Compare commits
9 commits
f505b6c552
...
4d2be145aa
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d2be145aa | |||
| a32dc723e9 | |||
| 21123b182f | |||
| 4ebbc7180f | |||
| 3face47d42 | |||
| e22a98a4a3 | |||
| e32ca9c8fb | |||
| 32513485c3 | |||
| b736f4cc8b |
13 changed files with 607 additions and 216 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
DISCORD_TOKEN=your_bot_token_here
|
DISCORD_TOKEN=your_bot_token_here
|
||||||
APPLICATION_ID=your_application_id_here
|
APPLICATION_ID=your_application_id_here
|
||||||
DEV=1
|
DEV=1
|
||||||
|
DEV_GUILD_IDS=optional_test_guild_id
|
||||||
FORGEJO_URL=https://your-forgejo-instance.com
|
FORGEJO_URL=https://your-forgejo-instance.com
|
||||||
FORGEJO_OWNER=your_forgejo_owner
|
FORGEJO_OWNER=your_forgejo_owner
|
||||||
FORGEJO_REPO=your_repo_name
|
FORGEJO_REPO=your_repo_name
|
||||||
|
|
|
||||||
|
|
@ -1,76 +1,46 @@
|
||||||
name: CI/CD
|
name: ci
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, dev]
|
branches: [dev]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
ci:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Recuperation du depot
|
||||||
run: |
|
run: |
|
||||||
git clone --depth 1 https://omegakubeserv.tail951d2f.ts.net/Omega_Kube/VisionApp.git .
|
set -euo pipefail
|
||||||
git fetch --all
|
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: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
apt-get update && apt-get install -y python3 python3-pip python3-venv
|
apt-get update && apt-get install -y python3 python3-pip python3-venv
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
. .venv/bin/activate
|
. .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: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
|
cd repo
|
||||||
ruff check .
|
ruff check .
|
||||||
|
|
||||||
- name: Ruff format check
|
|
||||||
run: |
|
|
||||||
. .venv/bin/activate
|
|
||||||
ruff format --check .
|
ruff format --check .
|
||||||
|
shell: bash
|
||||||
|
|
||||||
test:
|
- name: Tests (pytest)
|
||||||
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: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
|
cd repo
|
||||||
pytest tests/ -v || true
|
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
|
|
||||||
|
|
|
||||||
166
.forgejo/workflows/deploy.yml
Normal file
166
.forgejo/workflows/deploy.yml
Normal file
|
|
@ -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
|
||||||
|
|
@ -18,6 +18,6 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
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"]
|
CMD ["python", "vision.py"]
|
||||||
|
|
|
||||||
23
bot.py
23
bot.py
|
|
@ -1,5 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
|
|
@ -27,17 +28,35 @@ class VisionBot(commands.Bot):
|
||||||
await self.load_extension("commandes.hello")
|
await self.load_extension("commandes.hello")
|
||||||
await self.load_extension("commandes.modmail")
|
await self.load_extension("commandes.modmail")
|
||||||
|
|
||||||
|
self.tree.on_error = self.on_app_command_error
|
||||||
|
|
||||||
if config.DEV and DEV_GUILD_IDS:
|
if config.DEV and DEV_GUILD_IDS:
|
||||||
logger.info(f"Mode DEV — sync des commands sur les guilds: {DEV_GUILD_IDS}")
|
logger.info(f"Mode DEV — sync des commands sur les guilds: {DEV_GUILD_IDS}")
|
||||||
for guild_id in DEV_GUILD_IDS:
|
for guild_id in DEV_GUILD_IDS:
|
||||||
self.tree.copy_global_to(guild=guild_id)
|
guild = discord.Object(id=guild_id)
|
||||||
await self.tree.sync(guild=guild_id)
|
self.tree.copy_global_to(guild=guild)
|
||||||
|
try:
|
||||||
|
await self.tree.sync(guild=guild)
|
||||||
logger.info(f" ✓ Sync guild {guild_id}")
|
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:
|
else:
|
||||||
logger.info("Mode PROD — sync global des slash commands...")
|
logger.info("Mode PROD — sync global des slash commands...")
|
||||||
await self.tree.sync()
|
await self.tree.sync()
|
||||||
logger.info("Slash commands synchronisées (global)")
|
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):
|
async def on_ready(self):
|
||||||
instance = "DEV" if config.DEV else "PROD"
|
instance = "DEV" if config.DEV else "PROD"
|
||||||
logger.info(f"[{instance}] Connecté en tant que {self.user} (ID: {self.user.id})")
|
logger.info(f"[{instance}] Connecté en tant que {self.user} (ID: {self.user.id})")
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
|
import contextlib
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from discord import app_commands
|
from discord import app_commands
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from utils import modmail_config as cfg
|
from utils import modmail_config as cfg
|
||||||
from utils.modmail_transcript import generate_transcript, save_transcript
|
from utils.modmail_transcript import generate_transcript, save_transcript
|
||||||
|
|
@ -45,9 +46,7 @@ class CategorySelect(discord.ui.Select):
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
await interaction.response.send_modal(
|
await interaction.response.send_modal(TicketModal(category_key, cat.get("name", category_key)))
|
||||||
TicketModal(category_key, cat.get("name", category_key))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TicketModal(discord.ui.Modal, title="Nouveau Ticket"):
|
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)
|
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(
|
await interaction.followup.send(
|
||||||
f"Ticket cree: {ticket_channel.mention}",
|
f"Ticket cree: {ticket_channel.mention}",
|
||||||
ephemeral=True,
|
ephemeral=True,
|
||||||
)
|
)
|
||||||
except discord.HTTPException:
|
|
||||||
pass
|
|
||||||
|
|
||||||
log_ch = guild.get_channel(data.get("log_channel_id")) if data.get("log_channel_id") else None
|
log_ch = guild.get_channel(data.get("log_channel_id")) if data.get("log_channel_id") else None
|
||||||
if log_ch:
|
if log_ch:
|
||||||
|
|
@ -181,6 +190,204 @@ class PersistentQuickOpen(discord.ui.View):
|
||||||
await interaction.response.send_message("Erreur: reconfigurer le modmail.", ephemeral=True)
|
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):
|
class ModMail(commands.Cog):
|
||||||
"""Systeme de modmail / tickets via DM."""
|
"""Systeme de modmail / tickets via DM."""
|
||||||
|
|
||||||
|
|
@ -221,7 +428,6 @@ class ModMail(commands.Cog):
|
||||||
await self._send_ticket_creation_panel(message)
|
await self._send_ticket_creation_panel(message)
|
||||||
|
|
||||||
async def _send_ticket_creation_panel(self, message: discord.Message):
|
async def _send_ticket_creation_panel(self, message: discord.Message):
|
||||||
data = cfg.get_config()
|
|
||||||
categories = cfg.get_categories()
|
categories = cfg.get_categories()
|
||||||
if not categories:
|
if not categories:
|
||||||
await message.author.send("Aucune categorie de ticket configuree.")
|
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 = discord.ui.Container(accent_colour=discord.Colour.blurple())
|
||||||
container.add_item(
|
container.add_item(
|
||||||
discord.ui.TextDisplay(
|
discord.ui.TextDisplay(f"## Ouvrir un ticket\nCliquez pour ouvrir un ticket **{cat['name']}**.")
|
||||||
f"## Ouvrir un ticket\n"
|
|
||||||
f"Cliquez pour ouvrir un ticket **{cat['name']}**."
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
open_button = discord.ui.Button(
|
open_button = discord.ui.Button(
|
||||||
label=f"Ouvrir — {cat['name']}",
|
label=f"Ouvrir — {cat['name']}",
|
||||||
|
|
@ -252,10 +455,7 @@ class ModMail(commands.Cog):
|
||||||
|
|
||||||
container = discord.ui.Container(accent_colour=discord.Colour.blurple())
|
container = discord.ui.Container(accent_colour=discord.Colour.blurple())
|
||||||
container.add_item(
|
container.add_item(
|
||||||
discord.ui.TextDisplay(
|
discord.ui.TextDisplay("## Ouvrir un ticket\nChoisis une categorie pour ouvrir un ticket avec le staff.")
|
||||||
"## Ouvrir un ticket\n"
|
|
||||||
"Choisis une categorie pour ouvrir un ticket avec le staff."
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
container.add_item(discord.ui.Separator())
|
container.add_item(discord.ui.Separator())
|
||||||
container.add_item(CategorySelect(categories))
|
container.add_item(CategorySelect(categories))
|
||||||
|
|
@ -266,7 +466,6 @@ class ModMail(commands.Cog):
|
||||||
await message.author.send(view=view)
|
await message.author.send(view=view)
|
||||||
|
|
||||||
async def _relay_to_dm(self, message: discord.Message):
|
async def _relay_to_dm(self, message: discord.Message):
|
||||||
data = cfg.get_config()
|
|
||||||
ticket = cfg.get_ticket_by_channel(message.channel.id)
|
ticket = cfg.get_ticket_by_channel(message.channel.id)
|
||||||
if not ticket:
|
if not ticket:
|
||||||
return
|
return
|
||||||
|
|
@ -279,15 +478,17 @@ class ModMail(commands.Cog):
|
||||||
if not self._dm_messages.get(user_id):
|
if not self._dm_messages.get(user_id):
|
||||||
self._dm_messages[user_id] = []
|
self._dm_messages[user_id] = []
|
||||||
|
|
||||||
self._dm_messages[user_id].append({
|
self._dm_messages[user_id].append(
|
||||||
|
{
|
||||||
"author": message.author,
|
"author": message.author,
|
||||||
"content": message.content,
|
"content": message.content,
|
||||||
"timestamp": message.created_at,
|
"timestamp": message.created_at,
|
||||||
"attachments": list(message.attachments),
|
"attachments": list(message.attachments),
|
||||||
"is_staff": True,
|
"is_staff": True,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
with contextlib.suppress(discord.HTTPException):
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
description=message.content or "*Pieces jointes*",
|
description=message.content or "*Pieces jointes*",
|
||||||
color=discord.Color.green(),
|
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")
|
embed.set_footer(text=f"Staff — {message.guild.name}" if message.guild else "Staff")
|
||||||
await user.send(embed=embed)
|
await user.send(embed=embed)
|
||||||
except discord.HTTPException:
|
|
||||||
pass
|
|
||||||
|
|
||||||
for att in message.attachments:
|
for att in message.attachments:
|
||||||
try:
|
with contextlib.suppress(discord.HTTPException):
|
||||||
await user.send(file=await att.to_file())
|
await user.send(file=await att.to_file())
|
||||||
except discord.HTTPException:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def _send_to_ticket(self, channel, user, message):
|
async def _send_to_ticket(self, channel, user, message):
|
||||||
if not self._dm_messages.get(user.id):
|
if not self._dm_messages.get(user.id):
|
||||||
self._dm_messages[user.id] = []
|
self._dm_messages[user.id] = []
|
||||||
|
|
||||||
self._dm_messages[user.id].append({
|
self._dm_messages[user.id].append(
|
||||||
|
{
|
||||||
"author": user,
|
"author": user,
|
||||||
"content": message.content,
|
"content": message.content,
|
||||||
"timestamp": message.created_at,
|
"timestamp": message.created_at,
|
||||||
"attachments": list(message.attachments),
|
"attachments": list(message.attachments),
|
||||||
"is_staff": False,
|
"is_staff": False,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
description=message.content or "*Pieces jointes*",
|
description=message.content or "*Pieces jointes*",
|
||||||
|
|
@ -333,116 +532,21 @@ class ModMail(commands.Cog):
|
||||||
await channel.send(embed=embed)
|
await channel.send(embed=embed)
|
||||||
|
|
||||||
for att in message.attachments:
|
for att in message.attachments:
|
||||||
await channel.send(file=await discord.File.from_attachment(att))
|
await channel.send(file=await att.to_file())
|
||||||
|
|
||||||
# --- COMMANDES ---
|
# --- 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")
|
@modmail_group.command(name="config", description="Panneau de configuration du 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)
|
@app_commands.checks.has_permissions(manage_guild=True)
|
||||||
async def config_cmd(self, interaction: discord.Interaction):
|
async def config_cmd(self, interaction: discord.Interaction):
|
||||||
data = cfg.get_config()
|
cfg.set_guild_id(interaction.guild_id)
|
||||||
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(
|
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 ---
|
# --- CLOSE ---
|
||||||
|
|
||||||
@commands.command(name="close")
|
@commands.command(name="close")
|
||||||
|
|
@ -475,13 +579,11 @@ class ModMail(commands.Cog):
|
||||||
|
|
||||||
dm_user = self.bot.get_user(user_id)
|
dm_user = self.bot.get_user(user_id)
|
||||||
if dm_user:
|
if dm_user:
|
||||||
try:
|
with contextlib.suppress(discord.HTTPException):
|
||||||
await dm_user.send(
|
await dm_user.send(
|
||||||
"Ton ticket a ete ferme. Voici le transcript:",
|
"Ton ticket a ete ferme. Voici le transcript:",
|
||||||
file=discord.File(transcript_path, filename=f"ticket_{channel.id}.html"),
|
file=discord.File(transcript_path, filename=f"ticket_{channel.id}.html"),
|
||||||
)
|
)
|
||||||
except discord.HTTPException:
|
|
||||||
pass
|
|
||||||
|
|
||||||
data = cfg.get_config()
|
data = cfg.get_config()
|
||||||
log_ch = channel.guild.get_channel(data.get("log_channel_id")) if data.get("log_channel_id") else None
|
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 = discord.ui.Container(accent_colour=discord.Colour.red())
|
||||||
container.add_item(
|
container.add_item(
|
||||||
discord.ui.TextDisplay(
|
discord.ui.TextDisplay(f"## Ticket ferme\n**Ferme par:** {user.mention}\n**Categorie:** {category}")
|
||||||
f"## Ticket ferme\n**Ferme par:** {user.mention}\n**Categorie:** {category}"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
view = discord.ui.LayoutView()
|
view = discord.ui.LayoutView()
|
||||||
view.add_item(container)
|
view.add_item(container)
|
||||||
await channel.send(view=view)
|
await channel.send(view=view)
|
||||||
|
|
||||||
try:
|
with contextlib.suppress(discord.HTTPException):
|
||||||
await channel.delete(reason=f"Ticket ferme par {user}")
|
await channel.delete(reason=f"Ticket ferme par {user}")
|
||||||
except discord.HTTPException:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def setup(bot: commands.Bot):
|
async def setup(bot: commands.Bot):
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
@ -14,6 +15,7 @@ LOGS_DIR.mkdir(exist_ok=True)
|
||||||
DISCORD_TOKEN = os.getenv("DISCORD_TOKEN", "")
|
DISCORD_TOKEN = os.getenv("DISCORD_TOKEN", "")
|
||||||
APPLICATION_ID = os.getenv("APPLICATION_ID", "")
|
APPLICATION_ID = os.getenv("APPLICATION_ID", "")
|
||||||
DEV = os.getenv("DEV", "").strip() in ("1", "true", "yes")
|
DEV = os.getenv("DEV", "").strip() in ("1", "true", "yes")
|
||||||
|
HEALTH_PORT = int(os.getenv("HEALTH_PORT", "1890"))
|
||||||
|
|
||||||
FORGEJO_URL = os.getenv("FORGEJO_URL", "")
|
FORGEJO_URL = os.getenv("FORGEJO_URL", "")
|
||||||
FORGEJO_TOKEN = os.getenv("FORGEJO_TOKEN", "")
|
FORGEJO_TOKEN = os.getenv("FORGEJO_TOKEN", "")
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,20 @@ services:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: vision-studio
|
image: vision-vision
|
||||||
|
container_name: vision-bot
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
- VISION_INSTANCE=prod
|
- VISION_INSTANCE=prod
|
||||||
|
- HEALTH_PORT=1890
|
||||||
dns:
|
dns:
|
||||||
- 1.1.1.1
|
- 1.1.1.1
|
||||||
- 8.8.8.8
|
- 8.8.8.8
|
||||||
|
ports:
|
||||||
|
- "1890:1890"
|
||||||
volumes:
|
volumes:
|
||||||
|
- .:/app
|
||||||
- vision-data:/app/data
|
- vision-data:/app/data
|
||||||
- vision-logs:/app/logs
|
- vision-logs:/app/logs
|
||||||
deploy:
|
deploy:
|
||||||
|
|
|
||||||
27
health.py
Normal file
27
health.py
Normal file
|
|
@ -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
|
||||||
94
scripts/deploy_runner.sh
Executable file
94
scripts/deploy_runner.sh
Executable file
|
|
@ -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 <branch> <commit> <rebuild>
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
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"
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from config import DATA_DIR
|
from config import DATA_DIR
|
||||||
|
|
||||||
|
|
@ -32,6 +31,12 @@ def get_config() -> dict:
|
||||||
return _load()
|
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):
|
def set_enabled(guild_id: int, enabled: bool):
|
||||||
data = _load()
|
data = _load()
|
||||||
data["enabled"] = enabled
|
data["enabled"] = enabled
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import html
|
import html
|
||||||
import os
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -35,12 +34,16 @@ def generate_transcript(messages: list[dict], ticket_info: dict) -> str:
|
||||||
attachments_html = ""
|
attachments_html = ""
|
||||||
for att in msg.get("attachments", []):
|
for att in msg.get("attachments", []):
|
||||||
if att.url.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".webp")):
|
if att.url.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".webp")):
|
||||||
attachments_html += f'<div class="attachment"><img src="{html.escape(att.url)}" alt="attachment" /></div>'
|
attachments_html += (
|
||||||
|
f'<div class="attachment"><img src="{html.escape(att.url)}" alt="attachment" /></div>'
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
attachments_html += f'<div class="attachment"><a href="{html.escape(att.url)}">{html.escape(att.filename)}</a></div>'
|
attachments_html += (
|
||||||
|
f'<div class="attachment"><a href="{html.escape(att.url)}">{html.escape(att.filename)}</a></div>'
|
||||||
|
)
|
||||||
|
|
||||||
messages_html += f"""
|
messages_html += f"""
|
||||||
<div class="message {'staff' if is_staff else 'user'}">
|
<div class="message {"staff" if is_staff else "user"}">
|
||||||
<div class="msg-header">
|
<div class="msg-header">
|
||||||
<img class="avatar" src="{avatar}" alt="{html.escape(str(author))}" />
|
<img class="avatar" src="{avatar}" alt="{html.escape(str(author))}" />
|
||||||
<span class="author">{html.escape(str(author))}</span>
|
<span class="author">{html.escape(str(author))}</span>
|
||||||
|
|
@ -60,7 +63,7 @@ def generate_transcript(messages: list[dict], ticket_info: dict) -> str:
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Transcript — Ticket #{ticket_info.get('channel_id', 0)}</title>
|
<title>Transcript — Ticket #{ticket_info.get("channel_id", 0)}</title>
|
||||||
<style>
|
<style>
|
||||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||||
body {{
|
body {{
|
||||||
|
|
@ -184,10 +187,10 @@ def generate_transcript(messages: list[dict], ticket_info: dict) -> str:
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>Transcript — Ticket #{ticket_info.get('channel_id', 0)}</h1>
|
<h1>Transcript — Ticket #{ticket_info.get("channel_id", 0)}</h1>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<span>Catégorie: {html.escape(ticket_info.get('category', 'N/A'))}</span>
|
<span>Catégorie: {html.escape(ticket_info.get("category", "N/A"))}</span>
|
||||||
<span>Utilisateur: {ticket_info.get('user_id', 'N/A')}</span>
|
<span>Utilisateur: {ticket_info.get("user_id", "N/A")}</span>
|
||||||
<span>Généré le: {date_str}</span>
|
<span>Généré le: {date_str}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,9 @@ import asyncio
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
import config
|
import config
|
||||||
from bot import VisionBot
|
from bot import VisionBot
|
||||||
|
from health import start_health_server
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
|
|
@ -26,8 +25,10 @@ async def main():
|
||||||
|
|
||||||
instance = "DEV" if config.DEV else "PROD"
|
instance = "DEV" if config.DEV else "PROD"
|
||||||
logger.info(f"Lancement de Vision Studio [{instance}]...")
|
logger.info(f"Lancement de Vision Studio [{instance}]...")
|
||||||
bot = VisionBot()
|
|
||||||
|
|
||||||
|
await start_health_server(config.HEALTH_PORT)
|
||||||
|
|
||||||
|
bot = VisionBot()
|
||||||
try:
|
try:
|
||||||
await bot.start(config.DISCORD_TOKEN)
|
await bot.start(config.DISCORD_TOKEN)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue