diff --git a/.env.example b/.env.example index cbb945a..a5c6b2b 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ DISCORD_TOKEN=your_bot_token_here APPLICATION_ID=your_application_id_here DEV=1 +DEV_GUILD_IDS=optional_test_guild_id FORGEJO_URL=https://your-forgejo-instance.com FORGEJO_OWNER=your_forgejo_owner FORGEJO_REPO=your_repo_name diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 1c6dc40..480739d 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -1,76 +1,46 @@ -name: CI/CD +name: ci on: push: - branches: [main, dev] + branches: [dev] pull_request: branches: [main] jobs: - lint: + ci: runs-on: ubuntu-latest steps: - - name: Checkout + - name: Recuperation du depot run: | - git clone --depth 1 https://omegakubeserv.tail951d2f.ts.net/Omega_Kube/VisionApp.git . - git fetch --all + set -euo pipefail + rm -rf repo + git clone https://omegakubeserv.tail951d2f.ts.net/Omega_Kube/VisionApp.git repo + cd repo + git checkout "${{ github.sha }}" + shell: bash - - name: Setup Python + - name: Installation de Python run: | + set -euo pipefail apt-get update && apt-get install -y python3 python3-pip python3-venv python3 -m venv .venv . .venv/bin/activate - pip install -r requirements.txt -r requirements-dev.txt + pip install -r repo/requirements.txt -r repo/requirements-dev.txt + shell: bash - - name: Ruff lint + - name: Lint (ruff) run: | + set -euo pipefail . .venv/bin/activate + cd repo ruff check . - - - name: Ruff format check - run: | - . .venv/bin/activate ruff format --check . + shell: bash - test: - runs-on: ubuntu-latest - steps: - - name: Checkout - run: | - git clone --depth 1 https://omegakubeserv.tail951d2f.ts.net/Omega_Kube/VisionApp.git . - - - name: Setup Python - run: | - apt-get update && apt-get install -y python3 python3-pip python3-venv - python3 -m venv .venv - . .venv/bin/activate - pip install -r requirements.txt -r requirements-dev.txt - - - name: Syntax check - run: | - . .venv/bin/activate - python3 -m py_compile bot.py - python3 -m py_compile vision.py - python3 -m py_compile config.py - - - name: Run tests + - name: Tests (pytest) run: | + set -euo pipefail . .venv/bin/activate + cd repo pytest tests/ -v || true - - deploy: - needs: [lint, test] - if: github.ref == 'refs/heads/main' && github.event_name == 'push' - runs-on: ubuntu-latest - steps: - - name: Deploy to production - run: | - apt-get update && apt-get install -y sshpass openssh-client - sshpass -p "${{ secrets.PROD_PASSWORD }}" ssh -o StrictHostKeyChecking=no ${{ secrets.PROD_USER }}@${{ secrets.PROD_HOST }} << 'DEPLOY' - cd /home/discord/Bots/VisionApp - git pull origin main --rebase - source venv/bin/activate - pip install -r requirements.txt - sudo systemctl restart vision-studio - echo "Deploy OK" - DEPLOY + shell: bash diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml new file mode 100644 index 0000000..d07ca77 --- /dev/null +++ b/.forgejo/workflows/deploy.yml @@ -0,0 +1,166 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# Vision Studio — Deploiement automatique via Forgejo Actions +# +# Declencheur : push sur `main`. +# Runner : forgejo-runner self-hosted (label omhk-deploy, sur .22). +# Cible : 192.168.1.117 -> conteneur Docker `vision-bot` +# (/home/discord/Bots/VisionApp, bind-mount .:/app). +# +# Pipeline : syntaxe -> rsync -> deploy -> health check -> rollback auto. +# Calque du deploy.yml de Kuby. +# ═══════════════════════════════════════════════════════════════════════════ +name: deploy + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: omhk-deploy + steps: + # ═══ CLONE ═════════════════════════════════════════════════════════ + - name: Recuperation du depot (clone interne Forgejo) + run: | + set -euo pipefail + rm -rf repo + git clone --depth 20 "http://192.168.1.22:3000/Omega_Kube/VisionApp.git" repo + shell: bash + + - name: Verification des outils de livraison + run: | + set -euo pipefail + command -v rsync && command -v ssh && command -v curl + shell: bash + + - name: Configuration de la cle SSH (secret VISION_SSH_KEY) + env: + VISION_SSH_KEY: ${{ secrets.VISION_SSH_KEY }} + run: | + set -euo pipefail + mkdir -p ~/.ssh + chmod 700 ~/.ssh + printf '%s\n' "$VISION_SSH_KEY" > ~/.ssh/id_vision + chmod 600 ~/.ssh/id_vision + ssh-keyscan -H 192.168.1.117 >> ~/.ssh/known_hosts 2>/dev/null + shell: bash + + # ═══ TEST SYNTAXE (sur .22, rapide) ══════════════════════════════ + - name: Test syntaxe Python + run: | + set -euo pipefail + cd repo + NPROC=$(nproc 2>/dev/null || echo 2) + ERR_FILE=$(mktemp) + find . -name "*.py" \ + -not -path "*/backups/*" \ + -not -path "*/__pycache__/*" \ + -not -path "*/.git/*" \ + -not -path "*/venv/*" \ + -not -path "*/.venv/*" \ + -print0 | xargs -0 -P "$NPROC" -I {} bash -c 'python3 -m py_compile "$1" 2>>"$2" || echo "ERREUR: $1" >>"$2"' _ {} "$ERR_FILE" + if [ -s "$ERR_FILE" ]; then + cat "$ERR_FILE" + rm -f "$ERR_FILE" + echo "DEPLOY ANNULE: erreurs de syntaxe" + exit 1 + fi + rm -f "$ERR_FILE" + echo "Syntaxe Python: OK" + shell: bash + + - name: Sauvegarde commit precedent (pour rollback) + run: | + set -euo pipefail + cd repo + PREV=$(git rev-parse HEAD~1 2>/dev/null || echo "") + echo "PREV_COMMIT=$PREV" >> "$GITHUB_ENV" + echo "Commit precedent: $PREV" + shell: bash + + # ═══ LIVRAISON ════════════════════════════════════════════════════ + - name: Livraison du code (rsync) + run: | + set -euo pipefail + cd repo + rsync -az --no-o --no-g \ + --exclude '.git/' --exclude '.env' --exclude '.env.*' \ + --exclude 'data/' --exclude 'logs/' --exclude '*.log' --exclude 'nohup.out' \ + --exclude '__pycache__/' --exclude 'venv/' --exclude '.venv/' \ + --exclude '*.db' --exclude '*.db-journal' --exclude '*.db-wal' --exclude '*.db-shm' \ + --exclude '.vscode/' --exclude '.idea/' \ + -e "ssh -i ~/.ssh/id_vision -o StrictHostKeyChecking=no -o UserKnownHostsFile=~/.ssh/known_hosts" \ + . \ + discord@192.168.1.117:/home/discord/Bots/VisionApp/ + shell: bash + + # ═══ DEPLOY ═══════════════════════════════════════════════════════ + - name: Deploiement du conteneur (docker compose, cote .117) + run: | + set -euo pipefail + cd repo + COMMIT="$(git rev-parse --short HEAD)" + CHANGED="$(git diff --name-only HEAD~20 HEAD 2>/dev/null || true)" + REBUILD="false" + if echo "$CHANGED" | grep -qE '(^|/)(requirements\.txt|Dockerfile)$'; then + REBUILD="true" + fi + ssh -i ~/.ssh/id_vision -o StrictHostKeyChecking=no -o UserKnownHostsFile=~/.ssh/known_hosts \ + discord@192.168.1.117 \ + "bash /home/discord/Bots/VisionApp/scripts/deploy_runner.sh main '${COMMIT}' '${REBUILD}'" + shell: bash + + # ═══ HEALTH CHECK ═════════════════════════════════════════════════ + - name: Health check post-deploy + id: health_check + run: | + set -euo pipefail + echo "Attente du demarrage du bot..." + for i in $(seq 1 10); do + sleep 3 + STATUS=$(curl -s -m 5 http://192.168.1.117:1890/health 2>/dev/null || echo "") + if echo "$STATUS" | grep -q '"status":"ok"'; then + echo "Health check OK (tentative $i)" + exit 0 + fi + echo "Tentative $i/10..." + done + echo "HEALTH CHECK FAILED apres 10 tentatives" + exit 1 + shell: bash + + # ═══ ROLLBACK ═════════════════════════════════════════════════════ + - name: Rollback vers commit precedent + if: failure() && steps.health_check.outcome == 'failure' + run: | + set -euo pipefail + if [ -z "${PREV_COMMIT:-}" ]; then + echo "Pas de commit precedent — rollback impossible" + exit 1 + fi + echo "═══ ROLLBACK vers ${PREV_COMMIT} ═══" + cd repo + git checkout "$PREV_COMMIT" + rsync -az --no-o --no-g \ + --exclude '.git/' --exclude '.env' --exclude '.env.*' \ + --exclude 'data/' --exclude 'logs/' --exclude '*.log' --exclude 'nohup.out' \ + --exclude '__pycache__/' --exclude 'venv/' --exclude '.venv/' \ + --exclude '*.db' --exclude '*.db-journal' --exclude '*.db-wal' --exclude '*.db-shm' \ + --exclude '.vscode/' --exclude '.idea/' \ + -e "ssh -i ~/.ssh/id_vision -o StrictHostKeyChecking=no -o UserKnownHostsFile=~/.ssh/known_hosts" \ + . \ + discord@192.168.1.117:/home/discord/Bots/VisionApp/ + ssh -i ~/.ssh/id_vision -o StrictHostKeyChecking=no -o UserKnownHostsFile=~/.ssh/known_hosts \ + discord@192.168.1.117 \ + "bash /home/discord/Bots/VisionApp/scripts/deploy_runner.sh main '${PREV_COMMIT}' false" + echo "ROLLBACK termine" + exit 1 + shell: bash + + - name: Verification finale + run: | + set -euo pipefail + ssh -i ~/.ssh/id_vision -o StrictHostKeyChecking=no -o UserKnownHostsFile=~/.ssh/known_hosts \ + discord@192.168.1.117 \ + "docker ps --filter name=vision-bot --format '{{.Names}} {{.Status}}' && curl -s -m 5 http://localhost:1890/health && echo" + shell: bash diff --git a/Dockerfile b/Dockerfile index 8bf24ca..c1ea67f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,6 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD python -c "import discord; print('ok')" || exit 1 + CMD curl -fsS http://localhost:1890/health || exit 1 CMD ["python", "vision.py"] diff --git a/bot.py b/bot.py index cb69a35..5539e65 100644 --- a/bot.py +++ b/bot.py @@ -1,5 +1,6 @@ import logging import os + import discord from discord.ext import commands @@ -27,17 +28,35 @@ class VisionBot(commands.Bot): await self.load_extension("commandes.hello") await self.load_extension("commandes.modmail") + self.tree.on_error = self.on_app_command_error + if config.DEV and DEV_GUILD_IDS: logger.info(f"Mode DEV — sync des commands sur les guilds: {DEV_GUILD_IDS}") for guild_id in DEV_GUILD_IDS: - self.tree.copy_global_to(guild=guild_id) - await self.tree.sync(guild=guild_id) - logger.info(f" ✓ Sync guild {guild_id}") + guild = discord.Object(id=guild_id) + self.tree.copy_global_to(guild=guild) + try: + await self.tree.sync(guild=guild) + logger.info(f" ✓ Sync guild {guild_id}") + except discord.HTTPException as e: + logger.warning(f" ✗ Sync guild {guild_id} impossible ({e}). Le bot est-il invité sur ce serveur ?") else: logger.info("Mode PROD — sync global des slash commands...") await self.tree.sync() logger.info("Slash commands synchronisées (global)") + async def on_app_command_error(self, interaction: discord.Interaction, error: discord.app_commands.AppCommandError): + logger.error( + f"Erreur commande /{interaction.command.name if interaction.command else '?'} : {error}", exc_info=error + ) + try: + if interaction.response.is_done(): + await interaction.followup.send(f"Erreur : `{error}`", ephemeral=True) + else: + await interaction.response.send_message(f"Erreur : `{error}`", ephemeral=True) + except discord.HTTPException: + pass + async def on_ready(self): instance = "DEV" if config.DEV else "PROD" logger.info(f"[{instance}] Connecté en tant que {self.user} (ID: {self.user.id})") diff --git a/commandes/modmail.py b/commandes/modmail.py index bb9191e..c5516b4 100644 --- a/commandes/modmail.py +++ b/commandes/modmail.py @@ -1,7 +1,8 @@ +import contextlib + import discord from discord import app_commands from discord.ext import commands -from datetime import datetime, timezone from utils import modmail_config as cfg from utils.modmail_transcript import generate_transcript, save_transcript @@ -45,9 +46,7 @@ class CategorySelect(discord.ui.Select): ) return - await interaction.response.send_modal( - TicketModal(category_key, cat.get("name", category_key)) - ) + await interaction.response.send_modal(TicketModal(category_key, cat.get("name", category_key))) class TicketModal(discord.ui.Modal, title="Nouveau Ticket"): @@ -106,13 +105,23 @@ class TicketModal(discord.ui.Modal, title="Nouveau Ticket"): await _send_ticket_panel(ticket_channel, interaction.user, self.category_name, self.reason.value) - try: + cog = interaction.client.get_cog("ModMail") + if cog is not None: + cog._dm_messages.setdefault(interaction.user.id, []).append( + { + "author": interaction.user, + "content": self.reason.value, + "timestamp": discord.utils.utcnow(), + "attachments": [], + "is_staff": False, + } + ) + + with contextlib.suppress(discord.HTTPException): await interaction.followup.send( f"Ticket cree: {ticket_channel.mention}", ephemeral=True, ) - except discord.HTTPException: - pass log_ch = guild.get_channel(data.get("log_channel_id")) if data.get("log_channel_id") else None if log_ch: @@ -181,6 +190,204 @@ class PersistentQuickOpen(discord.ui.View): await interaction.response.send_message("Erreur: reconfigurer le modmail.", ephemeral=True) +class TicketCategorySelect(discord.ui.ChannelSelect): + def __init__(self): + super().__init__( + placeholder="Categorie des tickets", + channel_types=[discord.ChannelType.category], + custom_id="modmail_cfg_ticket_category", + ) + + async def callback(self, interaction: discord.Interaction): + cfg.set_category(interaction.guild_id, self.values[0].id) + await interaction.response.edit_message(view=ModmailConfigView(interaction.guild)) + + +class StaffRoleSelect(discord.ui.RoleSelect): + def __init__(self): + super().__init__( + placeholder="Role du staff", + custom_id="modmail_cfg_staff_role", + ) + + async def callback(self, interaction: discord.Interaction): + cfg.set_staff_role(interaction.guild_id, self.values[0].id) + await interaction.response.edit_message(view=ModmailConfigView(interaction.guild)) + + +class LogChannelSelect(discord.ui.ChannelSelect): + def __init__(self): + super().__init__( + placeholder="Canal de logs", + channel_types=[discord.ChannelType.text], + custom_id="modmail_cfg_log_channel", + ) + + async def callback(self, interaction: discord.Interaction): + cfg.set_log_channel(interaction.guild_id, self.values[0].id) + await interaction.response.edit_message(view=ModmailConfigView(interaction.guild)) + + +class ToggleButton(discord.ui.Button): + def __init__(self, enabled: bool): + super().__init__( + label="Desactiver" if enabled else "Activer", + style=discord.ButtonStyle.danger if enabled else discord.ButtonStyle.success, + custom_id="modmail_cfg_toggle", + ) + + async def callback(self, interaction: discord.Interaction): + data = cfg.get_config() + cfg.set_enabled(interaction.guild_id, not data.get("enabled", False)) + await interaction.response.edit_message(view=ModmailConfigView(interaction.guild)) + + +class ManageCategoriesButton(discord.ui.Button): + def __init__(self): + super().__init__( + label="Gerer les categories", + style=discord.ButtonStyle.primary, + custom_id="modmail_cfg_manage_cat", + ) + + async def callback(self, interaction: discord.Interaction): + await interaction.response.edit_message(view=ModmailCategoriesView(interaction.guild)) + + +class CategoryRemoveSelect(discord.ui.Select): + def __init__(self, categories: dict): + options = [ + discord.SelectOption( + label=v["name"], + value=k, + emoji=v.get("emoji") or None, + description=v.get("description", "")[:100] or None, + ) + for k, v in list(categories.items())[:25] + ] + super().__init__( + placeholder="Supprimer une categorie", + options=options, + custom_id="modmail_cfg_remove_cat", + ) + + async def callback(self, interaction: discord.Interaction): + cfg.remove_category(interaction.guild_id, self.values[0]) + await interaction.response.edit_message(view=ModmailCategoriesView(interaction.guild)) + + +class AddCategoryButton(discord.ui.Button): + def __init__(self): + super().__init__( + label="Ajouter une categorie", + style=discord.ButtonStyle.success, + custom_id="modmail_cfg_add_cat", + ) + + async def callback(self, interaction: discord.Interaction): + await interaction.response.send_modal(AddCategoryModal()) + + +class BackButton(discord.ui.Button): + def __init__(self): + super().__init__( + label="Retour", + style=discord.ButtonStyle.secondary, + custom_id="modmail_cfg_back", + ) + + async def callback(self, interaction: discord.Interaction): + await interaction.response.edit_message(view=ModmailConfigView(interaction.guild)) + + +class AddCategoryModal(discord.ui.Modal, title="Ajouter une categorie"): + name = discord.ui.TextInput(label="Nom", max_length=50, required=True) + emoji = discord.ui.TextInput(label="Emoji", max_length=50, required=False) + description = discord.ui.TextInput( + label="Description", + max_length=100, + required=False, + style=discord.TextStyle.paragraph, + ) + + async def on_submit(self, interaction: discord.Interaction): + cfg.add_category( + interaction.guild_id, + self.name.value, + self.emoji.value or "🎫", + self.description.value or "", + ) + await interaction.response.edit_message(view=ModmailCategoriesView(interaction.guild)) + + +def _fmt_channel(cid) -> str: + return f"<#{cid}>" if cid else "`non defini`" + + +def _fmt_role(rid) -> str: + return f"<@&{rid}>" if rid else "`non defini`" + + +class ModmailConfigView(discord.ui.LayoutView): + def __init__(self, guild: discord.Guild): + super().__init__(timeout=None) + data = cfg.get_config() + categories = cfg.get_categories() + enabled = data.get("enabled", False) + + cat_list = ( + "\n".join(f"- {v.get('emoji', '')} **{v['name']}**" for v in categories.values()) or "*Aucune categorie*" + ) + + container = discord.ui.Container( + accent_colour=discord.Colour.green() if enabled else discord.Colour.red(), + ) + container.add_item( + discord.ui.TextDisplay( + f"## Configuration Modmail\n" + f"**Statut :** {'🟢 Actif' if enabled else '🔴 Inactif'}\n" + f"**Categorie des tickets :** {_fmt_channel(data.get('category_id'))}\n" + f"**Role staff :** {_fmt_role(data.get('staff_role_id'))}\n" + f"**Canal de logs :** {_fmt_channel(data.get('log_channel_id'))}\n" + f"### Categories ({len(categories)})\n{cat_list}" + ) + ) + container.add_item(discord.ui.Separator()) + container.add_item(discord.ui.ActionRow(TicketCategorySelect())) + container.add_item(discord.ui.ActionRow(StaffRoleSelect())) + container.add_item(discord.ui.ActionRow(LogChannelSelect())) + container.add_item(discord.ui.ActionRow(ToggleButton(enabled), ManageCategoriesButton())) + self.add_item(container) + + +class ModmailCategoriesView(discord.ui.LayoutView): + def __init__(self, guild: discord.Guild): + super().__init__(timeout=None) + categories = cfg.get_categories() + + cat_list = ( + "\n".join( + f"- {v.get('emoji', '')} **{v['name']}**" + (f" — {v['description']}" if v.get("description") else "") + for v in categories.values() + ) + or "*Aucune categorie*" + ) + + container = discord.ui.Container(accent_colour=discord.Colour.blurple()) + container.add_item( + discord.ui.TextDisplay( + f"## Gestion des categories\n" + f"{cat_list}\n\n" + "*Selectionne une categorie pour la supprimer, ou ajoute-en une nouvelle.*" + ) + ) + container.add_item(discord.ui.Separator()) + if categories: + container.add_item(discord.ui.ActionRow(CategoryRemoveSelect(categories))) + container.add_item(discord.ui.ActionRow(AddCategoryButton(), BackButton())) + self.add_item(container) + + class ModMail(commands.Cog): """Systeme de modmail / tickets via DM.""" @@ -221,7 +428,6 @@ class ModMail(commands.Cog): await self._send_ticket_creation_panel(message) async def _send_ticket_creation_panel(self, message: discord.Message): - data = cfg.get_config() categories = cfg.get_categories() if not categories: await message.author.send("Aucune categorie de ticket configuree.") @@ -233,10 +439,7 @@ class ModMail(commands.Cog): container = discord.ui.Container(accent_colour=discord.Colour.blurple()) container.add_item( - discord.ui.TextDisplay( - f"## Ouvrir un ticket\n" - f"Cliquez pour ouvrir un ticket **{cat['name']}**." - ) + discord.ui.TextDisplay(f"## Ouvrir un ticket\nCliquez pour ouvrir un ticket **{cat['name']}**.") ) open_button = discord.ui.Button( label=f"Ouvrir — {cat['name']}", @@ -252,10 +455,7 @@ class ModMail(commands.Cog): container = discord.ui.Container(accent_colour=discord.Colour.blurple()) container.add_item( - discord.ui.TextDisplay( - "## Ouvrir un ticket\n" - "Choisis une categorie pour ouvrir un ticket avec le staff." - ) + discord.ui.TextDisplay("## Ouvrir un ticket\nChoisis une categorie pour ouvrir un ticket avec le staff.") ) container.add_item(discord.ui.Separator()) container.add_item(CategorySelect(categories)) @@ -266,7 +466,6 @@ class ModMail(commands.Cog): await message.author.send(view=view) async def _relay_to_dm(self, message: discord.Message): - data = cfg.get_config() ticket = cfg.get_ticket_by_channel(message.channel.id) if not ticket: return @@ -279,15 +478,17 @@ class ModMail(commands.Cog): if not self._dm_messages.get(user_id): self._dm_messages[user_id] = [] - self._dm_messages[user_id].append({ - "author": message.author, - "content": message.content, - "timestamp": message.created_at, - "attachments": list(message.attachments), - "is_staff": True, - }) + self._dm_messages[user_id].append( + { + "author": message.author, + "content": message.content, + "timestamp": message.created_at, + "attachments": list(message.attachments), + "is_staff": True, + } + ) - try: + with contextlib.suppress(discord.HTTPException): embed = discord.Embed( description=message.content or "*Pieces jointes*", color=discord.Color.green(), @@ -299,26 +500,24 @@ class ModMail(commands.Cog): ) embed.set_footer(text=f"Staff — {message.guild.name}" if message.guild else "Staff") await user.send(embed=embed) - except discord.HTTPException: - pass for att in message.attachments: - try: + with contextlib.suppress(discord.HTTPException): await user.send(file=await att.to_file()) - except discord.HTTPException: - pass async def _send_to_ticket(self, channel, user, message): if not self._dm_messages.get(user.id): self._dm_messages[user.id] = [] - self._dm_messages[user.id].append({ - "author": user, - "content": message.content, - "timestamp": message.created_at, - "attachments": list(message.attachments), - "is_staff": False, - }) + self._dm_messages[user.id].append( + { + "author": user, + "content": message.content, + "timestamp": message.created_at, + "attachments": list(message.attachments), + "is_staff": False, + } + ) embed = discord.Embed( description=message.content or "*Pieces jointes*", @@ -333,116 +532,21 @@ class ModMail(commands.Cog): await channel.send(embed=embed) for att in message.attachments: - await channel.send(file=await discord.File.from_attachment(att)) + await channel.send(file=await att.to_file()) # --- COMMANDES --- - modmail_group = app_commands.Group(name="modmail", description="Configuration du modmail") + modmail_group = app_commands.Group(name="modmail", description="Systeme de modmail") - @modmail_group.command(name="setup", description="Setup le systeme de modmail") - @app_commands.describe( - category="Category pour les tickets", - staff_role="Role du staff", - log_channel="Canal de logs", - ) - @app_commands.checks.has_permissions(manage_guild=True) - async def setup( - self, - interaction: discord.Interaction, - category: discord.CategoryChannel, - staff_role: discord.Role, - log_channel: discord.TextChannel, - ): - cfg.set_category(interaction.guild_id, category.id) - cfg.set_staff_role(interaction.guild_id, staff_role.id) - cfg.set_log_channel(interaction.guild_id, log_channel.id) - cfg.set_enabled(interaction.guild_id, True) - - data = cfg.get_config() - data["guild_id"] = interaction.guild_id - from utils.modmail_config import _save - _save(data) - - container = discord.ui.Container(accent_colour=discord.Colour.green()) - container.add_item( - discord.ui.TextDisplay( - "## Modmail Configure\n" - f"**Category:** {category.mention}\n" - f"**Staff:** {staff_role.mention}\n" - f"**Logs:** {log_channel.mention}\n" - f"**Status:** Active" - ) - ) - view = discord.ui.LayoutView() - view.add_item(container) - - await interaction.response.send_message(view=view, ephemeral=True) - - @modmail_group.command(name="config", description="Voir la configuration modmail") + @modmail_group.command(name="config", description="Panneau de configuration du modmail") @app_commands.checks.has_permissions(manage_guild=True) async def config_cmd(self, interaction: discord.Interaction): - data = cfg.get_config() - categories = cfg.get_categories() - - cat_list = "\n".join( - f"- {v.get('emoji', '')} **{v['name']}**" for v in categories.values() - ) or "Aucune" - - status = "Active" if data.get("enabled") else "Desactivee" - color = discord.Colour.green() if data.get("enabled") else discord.Colour.red() - - container = discord.ui.Container(accent_colour=color) - container.add_item( - discord.ui.TextDisplay( - f"## Configuration Modmail\n" - f"**Status:** {status}\n" - f"**Category:** <#{data.get('category_id', 0)}>\n" - f"**Staff:** <@&{data.get('staff_role_id', 0)}>\n" - f"**Logs:** <#{data.get('log_channel_id', 0)}>\n\n" - f"### Categories\n{cat_list}" - ) - ) - view = discord.ui.LayoutView() - view.add_item(container) - - await interaction.response.send_message(view=view, ephemeral=True) - - @modmail_group.command(name="addcategory", description="Ajouter une categorie de ticket") - @app_commands.describe(name="Nom", emoji="Emoji", description="Description") - @app_commands.checks.has_permissions(manage_guild=True) - async def addcategory( - self, - interaction: discord.Interaction, - name: str, - emoji: str, - description: str = "", - ): - cfg.add_category(interaction.guild_id, name, emoji, description) + cfg.set_guild_id(interaction.guild_id) await interaction.response.send_message( - f"Categorie **{name}** ajoutee.", ephemeral=True + view=ModmailConfigView(interaction.guild), + ephemeral=True, ) - @modmail_group.command(name="removecategory", description="Supprimer une categorie") - @app_commands.describe(name="Nom de la categorie") - @app_commands.checks.has_permissions(manage_guild=True) - async def removecategory(self, interaction: discord.Interaction, name: str): - cfg.remove_category(interaction.guild_id, name) - await interaction.response.send_message( - f"Categorie **{name}** supprimee.", ephemeral=True - ) - - @modmail_group.command(name="enable", description="Activer le modmail") - @app_commands.checks.has_permissions(manage_guild=True) - async def enable(self, interaction: discord.Interaction): - cfg.set_enabled(interaction.guild_id, True) - await interaction.response.send_message("Modmail active.", ephemeral=True) - - @modmail_group.command(name="disable", description="Desactiver le modmail") - @app_commands.checks.has_permissions(manage_guild=True) - async def disable(self, interaction: discord.Interaction): - cfg.set_enabled(interaction.guild_id, False) - await interaction.response.send_message("Modmail desactive.", ephemeral=True) - # --- CLOSE --- @commands.command(name="close") @@ -475,13 +579,11 @@ class ModMail(commands.Cog): dm_user = self.bot.get_user(user_id) if dm_user: - try: + with contextlib.suppress(discord.HTTPException): await dm_user.send( "Ton ticket a ete ferme. Voici le transcript:", file=discord.File(transcript_path, filename=f"ticket_{channel.id}.html"), ) - except discord.HTTPException: - pass data = cfg.get_config() log_ch = channel.guild.get_channel(data.get("log_channel_id")) if data.get("log_channel_id") else None @@ -496,18 +598,14 @@ class ModMail(commands.Cog): container = discord.ui.Container(accent_colour=discord.Colour.red()) container.add_item( - discord.ui.TextDisplay( - f"## Ticket ferme\n**Ferme par:** {user.mention}\n**Categorie:** {category}" - ) + discord.ui.TextDisplay(f"## Ticket ferme\n**Ferme par:** {user.mention}\n**Categorie:** {category}") ) view = discord.ui.LayoutView() view.add_item(container) await channel.send(view=view) - try: + with contextlib.suppress(discord.HTTPException): await channel.delete(reason=f"Ticket ferme par {user}") - except discord.HTTPException: - pass async def setup(bot: commands.Bot): diff --git a/config.py b/config.py index ea17f0c..55cc57f 100644 --- a/config.py +++ b/config.py @@ -1,5 +1,6 @@ import os from pathlib import Path + from dotenv import load_dotenv load_dotenv() @@ -14,6 +15,7 @@ LOGS_DIR.mkdir(exist_ok=True) DISCORD_TOKEN = os.getenv("DISCORD_TOKEN", "") APPLICATION_ID = os.getenv("APPLICATION_ID", "") DEV = os.getenv("DEV", "").strip() in ("1", "true", "yes") +HEALTH_PORT = int(os.getenv("HEALTH_PORT", "1890")) FORGEJO_URL = os.getenv("FORGEJO_URL", "") FORGEJO_TOKEN = os.getenv("FORGEJO_TOKEN", "") diff --git a/docker-compose.yml b/docker-compose.yml index 018953e..07b2455 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,15 +3,20 @@ services: build: context: . dockerfile: Dockerfile - container_name: vision-studio + image: vision-vision + container_name: vision-bot restart: unless-stopped env_file: .env environment: - VISION_INSTANCE=prod + - HEALTH_PORT=1890 dns: - 1.1.1.1 - 8.8.8.8 + ports: + - "1890:1890" volumes: + - .:/app - vision-data:/app/data - vision-logs:/app/logs deploy: diff --git a/health.py b/health.py new file mode 100644 index 0000000..0e74efb --- /dev/null +++ b/health.py @@ -0,0 +1,27 @@ +import logging + +from aiohttp import web + +logger = logging.getLogger("vision") + + +async def start_health_server(port: int, host: str = "0.0.0.0"): + """Demarre un mini serveur HTTP expose par le conteneur Docker. + + Le runner de deploiement interroge /health pour valider le demarrage + (health check post-deploy, comme Kuby). + """ + + async def health(_request: web.Request) -> web.Response: + return web.Response(text='{"status":"ok"}', content_type="application/json") + + app = web.Application() + app.router.add_get("/health", health) + app.router.add_get("/api/health", health) + + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, host, port) + await site.start() + logger.info("Health server demarre sur %s:%s", host, port) + return runner diff --git a/scripts/deploy_runner.sh b/scripts/deploy_runner.sh new file mode 100755 index 0000000..4315fec --- /dev/null +++ b/scripts/deploy_runner.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# ══════════════════════════════════════════════════════════════════════════════ +# Vision Studio — Script de deploiement via Forgejo Actions (hote .117) +# +# Calque du deploy_runner.sh de Kuby. +# Tourne sur l'HOTE .117 (utilisateur discord), livre par rsync depuis +# .forgejo/workflows/deploy.yml puis execute a distance en SSH. +# +# Architecture prod .117 : +# - Vision Studio tourne dans Docker : service `vision` -> conteneur `vision-bot` +# (image `vision-vision`, restart: unless-stopped, code bind-mount .:/app). +# - Le code est livre par rsync sur /home/discord/Bots/VisionApp : +# un `docker compose restart` suffit pour recharger du code pur. +# - Si requirements.txt ou Dockerfile changent -> `docker compose build` +# est necessaire (pip install ne persiste pas). Le flag `rebuild` est +# calcule par le workflow (diff git) et passe en argument. +# +# Usage : deploy_runner.sh +# ══════════════════════════════════════════════════════════════════════════════ +set -uo pipefail + +ROOT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)" +BRANCH="${1:-main}" +COMMIT="${2:-inconnu}" +REBUILD="${3:-false}" +LOCK_FILE="/tmp/vision_deploy_runner.lock" +DEPLOY_LOG="${ROOT_DIR}/data/deploy.log" +CONTAINER="vision-bot" + +log() { echo "[deploy] $(date '+%F %T') $*"; } + +acquire_lock() { + if [ -f "$LOCK_FILE" ]; then + local pid + pid=$(cat "$LOCK_FILE" 2>/dev/null) + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + log "ERROR: deploy deja en cours (PID $pid)" + exit 1 + fi + log "WARN: lock orphelin, nettoyage" + rm -f "$LOCK_FILE" + fi + echo $$ > "$LOCK_FILE" +} +release_lock() { rm -f "$LOCK_FILE"; } +trap release_lock EXIT + +cd "$ROOT_DIR" +acquire_lock + +log "Deploy demarre — branch=$BRANCH commit=$COMMIT rebuild=$REBUILD" + +if ! command -v docker &>/dev/null; then + log "ERROR: docker introuvable sur l'hote" + exit 1 +fi + +# Transition systemd -> Docker (une seule fois) : arreter l'ancien service pour +# eviter deux instances du bot avec le meme token Discord. +if command -v systemctl &>/dev/null && systemctl list-unit-files 2>/dev/null | grep -q '^vision-studio\.service'; then + if systemctl is-active --quiet vision-studio 2>/dev/null; then + log "Transition: arret du service systemd vision-studio..." + if ! sudo -n systemctl stop vision-studio 2>/dev/null; then + log "WARN: arret systemd refuse (sudo sans mot de passe). A faire une fois manuellement:" + log " sudo systemctl stop vision-studio && sudo systemctl disable vision-studio" + fi + fi +fi + +if [ "$REBUILD" = "true" ]; then + log "requirements/Dockerfile modifies -> rebuild image Docker..." + if ! docker compose build --no-cache vision; then + log "ERROR: docker compose build echoue" + exit 1 + fi + docker compose up -d --force-recreate vision +else + log "Rechargement du code (bind-mount) -> redemarrage conteneur..." + docker compose up -d vision + docker compose restart vision +fi + +sleep 5 + +if ! docker ps --filter "name=${CONTAINER}" --filter "status=running" --format '{{.Names}}' | grep -q "${CONTAINER}"; then + log "ERROR: conteneur ${CONTAINER} non demarre" + docker logs --tail 40 "${CONTAINER}" 2>&1 || true + exit 1 +fi + +mkdir -p "$(dirname "$DEPLOY_LOG")" +echo "$(date '+%F %T') | $BRANCH | $COMMIT | rebuild=$REBUILD | auto" >> "$DEPLOY_LOG" + +log "Deploy termine — conteneur ${CONTAINER} en marche" diff --git a/utils/modmail_config.py b/utils/modmail_config.py index 035a6e1..b840cc4 100644 --- a/utils/modmail_config.py +++ b/utils/modmail_config.py @@ -1,5 +1,4 @@ import json -from pathlib import Path from config import DATA_DIR @@ -32,6 +31,12 @@ def get_config() -> dict: return _load() +def set_guild_id(guild_id: int): + data = _load() + data["guild_id"] = guild_id + _save(data) + + def set_enabled(guild_id: int, enabled: bool): data = _load() data["enabled"] = enabled diff --git a/utils/modmail_transcript.py b/utils/modmail_transcript.py index 38185bc..f5d4a98 100644 --- a/utils/modmail_transcript.py +++ b/utils/modmail_transcript.py @@ -1,5 +1,4 @@ import html -import os from datetime import datetime, timezone @@ -35,12 +34,16 @@ def generate_transcript(messages: list[dict], ticket_info: dict) -> str: attachments_html = "" for att in msg.get("attachments", []): if att.url.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".webp")): - attachments_html += f'
attachment
' + attachments_html += ( + f'
attachment
' + ) else: - attachments_html += f'' + attachments_html += ( + f'' + ) messages_html += f""" -
+
{html.escape(str(author))} {html.escape(str(author))} @@ -60,7 +63,7 @@ def generate_transcript(messages: list[dict], ticket_info: dict) -> str: -Transcript — Ticket #{ticket_info.get('channel_id', 0)} +Transcript — Ticket #{ticket_info.get("channel_id", 0)}