commit e44349cea9e150dd368e74662a16931269a2c675 Author: Mathis Date: Sat Sep 19 12:11:06 2026 +0200 feat: initial project setup — Vision Studio bot - Python 3.12 + discord.py - Docker + docker-compose - Forgejo Actions CI (lint + test) - Cog system with /hello example - deploy.sh for auto-deployment diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..28a3b31 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.github +__pycache__ +*.pyc +*.pyo +venv/ +.venv/ +.pytest_cache/ +tests/ +.mypy_cache/ +nohup.out +*.log +data/ +logs/ +.gitignore +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cbb945a --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +DISCORD_TOKEN=your_bot_token_here +APPLICATION_ID=your_application_id_here +DEV=1 +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 new file mode 100644 index 0000000..6d1560d --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + pip install -r requirements.txt -r requirements-dev.txt + + - name: Ruff lint + run: ruff check . + + - name: Ruff format check + run: ruff format --check . + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + pip install -r requirements.txt -r requirements-dev.txt + + - name: Syntax check + run: python -m py_compile bot.py && python -m py_compile vision.py && python -m py_compile config.py + + - name: Run tests + run: pytest tests/ -v || true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eaf9bc6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# --- SÉCURITÉ --- +.env + +# --- PYTHON --- +__pycache__/ +*.py[cod] +*$py.class +venv/ +.venv/ +env/ + +# --- DONNÉES --- +data/* +!data/.gitkeep +*.db +*.db-journal +*.db-wal +*.db-shm + +# --- LOGS --- +*.log +logs/ + +# --- IDE --- +.vscode/ +.idea/ +.DS_Store + +# --- DOCKER --- +docker-compose.override.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8bf24ca --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +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 ["python", "vision.py"] diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..1dfa008 --- /dev/null +++ b/bot.py @@ -0,0 +1,37 @@ +import logging +import discord +from discord.ext import commands + +import config + +logger = logging.getLogger("vision") + + +class VisionBot(commands.Bot): + def __init__(self): + intents = discord.Intents.default() + intents.message_content = True + intents.members = True + + super().__init__( + command_prefix="!", + intents=intents, + application_id=config.APPLICATION_ID, + ) + + async def setup_hook(self): + logger.info("Chargement des cogs...") + await self.load_extension("commandes.hello") + logger.info("Sync des slash commands...") + await self.tree.sync() + logger.info("Slash commands synchronisées") + + async def on_ready(self): + logger.info(f"Connecté en tant que {self.user} (ID: {self.user.id})") + logger.info(f"Serveurs : {len(self.guilds)}") + await self.change_presence( + activity=discord.Activity( + type=discord.ActivityType.watching, + name="Vision Studio", + ) + ) diff --git a/commandes/__init__.py b/commandes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/commandes/hello.py b/commandes/hello.py new file mode 100644 index 0000000..0da0390 --- /dev/null +++ b/commandes/hello.py @@ -0,0 +1,22 @@ +import discord +from discord import app_commands +from discord.ext import commands + + +class Hello(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + + @app_commands.command(name="hello", description="Salut de la part de Vision Studio !") + async def hello(self, interaction: discord.Interaction): + embed = discord.Embed( + title="Vision Studio", + description="Salut ! Je suis **Vision Studio**, ton bot Discord.\n\nUtilise `/help` pour voir les commandes disponibles.", + color=discord.Color.blue(), + ) + embed.set_footer(text="Vision Studio v1.0") + await interaction.response.send_message(embed=embed) + + +async def setup(bot: commands.Bot): + await bot.add_cog(Hello(bot)) diff --git a/config.py b/config.py new file mode 100644 index 0000000..ea17f0c --- /dev/null +++ b/config.py @@ -0,0 +1,21 @@ +import os +from pathlib import Path +from dotenv import load_dotenv + +load_dotenv() + +BASE_DIR = Path(__file__).resolve().parent +DATA_DIR = BASE_DIR / "data" +LOGS_DIR = BASE_DIR / "logs" + +DATA_DIR.mkdir(exist_ok=True) +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") + +FORGEJO_URL = os.getenv("FORGEJO_URL", "") +FORGEJO_TOKEN = os.getenv("FORGEJO_TOKEN", "") +FORGEJO_OWNER = os.getenv("FORGEJO_OWNER", "") +FORGEJO_REPO = os.getenv("FORGEJO_REPO", "") diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..018953e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +services: + vision: + build: + context: . + dockerfile: Dockerfile + container_name: vision-studio + restart: unless-stopped + env_file: .env + environment: + - VISION_INSTANCE=prod + dns: + - 1.1.1.1 + - 8.8.8.8 + volumes: + - vision-data:/app/data + - vision-logs:/app/logs + deploy: + resources: + limits: + memory: 512M + cpus: "1.0" + reservations: + memory: 128M + cpus: "0.25" + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +volumes: + vision-data: + vision-logs: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..eef02cc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "vision-studio" +version = "1.0.0" +description = "Vision Studio — Bot Discord" +requires-python = ">=3.12" + +[tool.ruff] +target-version = "py312" +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "SIM"] +ignore = ["E501"] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["ALL"] + +[tool.ruff.format] +quote-style = "double" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..7d3c2d5 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +ruff>=0.5.0 +pytest>=8.0.0 +pytest-asyncio>=0.24.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3b5adcb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +discord.py>=2.4.0 +python-dotenv>=1.0.0 +Pillow>=10.0.0 +aiohttp>=3.9.0 diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..8998621 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,95 @@ +#!/bin/bash +# ══════════════════════════════════════════════════════════════════════════════ +# Vision Studio — Script de deploiement automatique +# +# Flow : +# 1. Lock anti-concurrence +# 2. git pull origin --rebase +# 3. Si requirements.txt change -> docker compose build +# 4. Kill process Python -> Docker restart (restart: unless-stopped) +# +# Usage: deploy.sh [branch] +# ══════════════════════════════════════════════════════════════════════════════ +set -uo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BRANCH="${1:-main}" +LOCK_FILE="/tmp/vision_deploy.lock" +DEPLOY_LOG="${ROOT_DIR}/data/deploy.log" + +if [ -f "$ROOT_DIR/.env" ]; then + set -a + . "$ROOT_DIR/.env" + set +a +fi + +COMPOSE_DIR="${ROOT_DIR}" + +log() { echo "[deploy] $(date '+%F %T') $*"; } + +acquire_lock() { + if [ -f "$LOCK_FILE" ]; then + local lock_pid + lock_pid=$(cat "$LOCK_FILE" 2>/dev/null) + if [ -n "$lock_pid" ] && kill -0 "$lock_pid" 2>/dev/null; then + log "ERROR: Deploy déjà en cours (PID $lock_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 + +DEPLOY_START=$(date +%s) +COMMIT_BEFORE=$(git log -1 --format="%h %s" 2>/dev/null || echo "unknown") + +log "Deploy démarré — branch=$BRANCH — commit=$COMMIT_BEFORE" + +log "Git pull origin $BRANCH --rebase..." +if ! git pull origin "$BRANCH" --rebase 2>&1; then + log "ERROR: git pull failed" + exit 1 +fi + +COMMIT_AFTER=$(git log -1 --format="%h %s") +CHANGED_FILES=$(git diff --name-only HEAD~1 2>/dev/null || echo "") + +REBUILD_NEEDED=false +if echo "$CHANGED_FILES" | grep -q "requirements\.txt"; then + REBUILD_NEEDED=true + log "requirements.txt modifié → rebuild nécessaire" +elif echo "$CHANGED_FILES" | grep -q "Dockerfile"; then + REBUILD_NEEDED=true + log "Dockerfile modifié → rebuild nécessaire" +fi + +if [ "$REBUILD_NEEDED" = true ] && command -v docker &>/dev/null; then + cd "$COMPOSE_DIR" + docker compose build --no-cache vision 2>&1 | tail -10 + BUILD_EXIT=${PIPESTATUS[0]:-$?} + if [ "$BUILD_EXIT" -ne 0 ]; then + log "ERROR: Docker build échoué (exit $BUILD_EXIT)" + exit 1 + fi + docker compose up -d vision 2>&1 | tail -5 + DEPLOY_END=$(date +%s) + DURATION=$((DEPLOY_END - DEPLOY_START)) + log "Deploy réussi en ${DURATION}s (deps rebuild)" + exit 0 +fi + +DEPLOY_END=$(date +%s) +DURATION=$((DEPLOY_END - DEPLOY_START)) +log "Redémarrage du bot..." + +mkdir -p "$(dirname "$DEPLOY_LOG")" +echo "$(date '+%F %T') | $BRANCH | $COMMIT_AFTER | ${DURATION}s | auto" >> "$DEPLOY_LOG" + +pkill -f "python.*vision\.py" 2>/dev/null || kill 1 2>/dev/null || true diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vision.py b/vision.py new file mode 100644 index 0000000..442fea5 --- /dev/null +++ b/vision.py @@ -0,0 +1,41 @@ +import asyncio +import logging +import sys + +from dotenv import load_dotenv + +load_dotenv() + +import config +from bot import VisionBot + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s | %(levelname)-8s | %(name)s — %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + handlers=[ + logging.StreamHandler(sys.stdout), + logging.FileHandler(config.LOGS_DIR / "vision.log", encoding="utf-8"), + ], +) +logger = logging.getLogger("vision") + + +async def main(): + if not config.DISCORD_TOKEN: + logger.critical("DISCORD_TOKEN manquant dans .env") + sys.exit(1) + + logger.info("Lancement de Vision Studio...") + bot = VisionBot() + + try: + await bot.start(config.DISCORD_TOKEN) + except KeyboardInterrupt: + logger.info("Arrêt demandé") + except Exception as e: + logger.critical(f"Erreur fatale : {e}", exc_info=True) + + +if __name__ == "__main__": + asyncio.run(main())