diff --git a/.env.example b/.env.example index a5c6b2b..cbb945a 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,6 @@ 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 480739d..1c6dc40 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -1,46 +1,76 @@ -name: ci +name: CI/CD on: push: - branches: [dev] + branches: [main, dev] pull_request: branches: [main] jobs: - ci: + lint: runs-on: ubuntu-latest steps: - - name: Recuperation du depot + - name: Checkout run: | - 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 + git clone --depth 1 https://omegakubeserv.tail951d2f.ts.net/Omega_Kube/VisionApp.git . + git fetch --all - - name: Installation de Python + - name: Setup 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 repo/requirements.txt -r repo/requirements-dev.txt - shell: bash + pip install -r requirements.txt -r requirements-dev.txt - - name: Lint (ruff) + - name: Ruff lint run: | - set -euo pipefail . .venv/bin/activate - cd repo ruff check . - ruff format --check . - shell: bash - - name: Tests (pytest) + - name: Ruff format check + run: | + . .venv/bin/activate + ruff format --check . + + 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 run: | - set -euo pipefail . .venv/bin/activate - cd repo pytest tests/ -v || true - shell: bash + + 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 diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml deleted file mode 100644 index d07ca77..0000000 --- a/.forgejo/workflows/deploy.yml +++ /dev/null @@ -1,166 +0,0 @@ -# ═══════════════════════════════════════════════════════════════════════════ -# 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 c1ea67f..8bf24ca 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 curl -fsS http://localhost:1890/health || exit 1 + CMD python -c "import discord; print('ok')" || exit 1 CMD ["python", "vision.py"] diff --git a/bot.py b/bot.py index 5539e65..cb69a35 100644 --- a/bot.py +++ b/bot.py @@ -1,6 +1,5 @@ import logging import os - import discord from discord.ext import commands @@ -28,35 +27,17 @@ 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: - 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 ?") + self.tree.copy_global_to(guild=guild_id) + await self.tree.sync(guild=guild_id) + logger.info(f" ✓ Sync guild {guild_id}") 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 c5516b4..bb9191e 100644 --- a/commandes/modmail.py +++ b/commandes/modmail.py @@ -1,8 +1,7 @@ -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 @@ -46,7 +45,9 @@ 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"): @@ -105,23 +106,13 @@ class TicketModal(discord.ui.Modal, title="Nouveau Ticket"): await _send_ticket_panel(ticket_channel, interaction.user, self.category_name, self.reason.value) - 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): + try: 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: @@ -190,204 +181,6 @@ 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.""" @@ -428,6 +221,7 @@ 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.") @@ -439,7 +233,10 @@ class ModMail(commands.Cog): container = discord.ui.Container(accent_colour=discord.Colour.blurple()) container.add_item( - discord.ui.TextDisplay(f"## Ouvrir un ticket\nCliquez pour ouvrir un ticket **{cat['name']}**.") + discord.ui.TextDisplay( + f"## Ouvrir un ticket\n" + f"Cliquez pour ouvrir un ticket **{cat['name']}**." + ) ) open_button = discord.ui.Button( label=f"Ouvrir — {cat['name']}", @@ -455,7 +252,10 @@ class ModMail(commands.Cog): container = discord.ui.Container(accent_colour=discord.Colour.blurple()) container.add_item( - discord.ui.TextDisplay("## Ouvrir un ticket\nChoisis une categorie pour ouvrir un ticket avec le staff.") + discord.ui.TextDisplay( + "## Ouvrir un ticket\n" + "Choisis une categorie pour ouvrir un ticket avec le staff." + ) ) container.add_item(discord.ui.Separator()) container.add_item(CategorySelect(categories)) @@ -466,6 +266,7 @@ 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 @@ -478,17 +279,15 @@ 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, + }) - with contextlib.suppress(discord.HTTPException): + try: embed = discord.Embed( description=message.content or "*Pieces jointes*", color=discord.Color.green(), @@ -500,24 +299,26 @@ 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: - with contextlib.suppress(discord.HTTPException): + try: 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*", @@ -532,20 +333,115 @@ class ModMail(commands.Cog): await channel.send(embed=embed) for att in message.attachments: - await channel.send(file=await att.to_file()) + await channel.send(file=await discord.File.from_attachment(att)) # --- COMMANDES --- - modmail_group = app_commands.Group(name="modmail", description="Systeme de modmail") + modmail_group = app_commands.Group(name="modmail", description="Configuration du modmail") - @modmail_group.command(name="config", description="Panneau de configuration du 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") @app_commands.checks.has_permissions(manage_guild=True) async def config_cmd(self, interaction: discord.Interaction): - cfg.set_guild_id(interaction.guild_id) - await interaction.response.send_message( - view=ModmailConfigView(interaction.guild), - ephemeral=True, + 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) + await interaction.response.send_message( + f"Categorie **{name}** ajoutee.", 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 --- @@ -579,11 +475,13 @@ class ModMail(commands.Cog): dm_user = self.bot.get_user(user_id) if dm_user: - with contextlib.suppress(discord.HTTPException): + try: 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 @@ -598,14 +496,18 @@ 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) - with contextlib.suppress(discord.HTTPException): + try: 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 55cc57f..ea17f0c 100644 --- a/config.py +++ b/config.py @@ -1,6 +1,5 @@ import os from pathlib import Path - from dotenv import load_dotenv load_dotenv() @@ -15,7 +14,6 @@ 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 07b2455..018953e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,20 +3,15 @@ services: build: context: . dockerfile: Dockerfile - image: vision-vision - container_name: vision-bot + container_name: vision-studio 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 deleted file mode 100644 index 0e74efb..0000000 --- a/health.py +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100755 index 4315fec..0000000 --- a/scripts/deploy_runner.sh +++ /dev/null @@ -1,94 +0,0 @@ -#!/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 b840cc4..035a6e1 100644 --- a/utils/modmail_config.py +++ b/utils/modmail_config.py @@ -1,4 +1,5 @@ import json +from pathlib import Path from config import DATA_DIR @@ -31,12 +32,6 @@ 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 f5d4a98..38185bc 100644 --- a/utils/modmail_transcript.py +++ b/utils/modmail_transcript.py @@ -1,4 +1,5 @@ import html +import os from datetime import datetime, timezone @@ -34,16 +35,12 @@ 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))} @@ -63,7 +60,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)}