9 Commits

Author SHA1 Message Date
faba475d67 Merge branch 'feature/intern' of gitea.nebulo9.fr:nebulo9/botmafieux into feature/intern 2026-09-21 10:51:30 +02:00
5c8c45518c Merge pull request 'feat: Reimplement the deployment process through Docker' (#5) from feature/deployment into main
Some checks failed
CD / Build and push Docker image (push) Has been cancelled
CD / Deploy to VPS (push) Has been cancelled
CI / Run tests (push) Has been cancelled
Reviewed-on: #5
2026-09-20 08:55:19 +00:00
8cbb77be3c feat: Reimplement the deployment process through Docker
Some checks failed
CI / Run tests (pull_request) Has been cancelled
2026-09-20 00:33:10 +02:00
59a501c0a6 Merge pull request 'feat: Refactored all cogs implementation' (#4) from feature/refactorization into main
Some checks failed
CD / Build and push Docker image (push) Has been cancelled
CD / Deploy to VPS (push) Has been cancelled
CI / Run tests (push) Has been cancelled
Reviewed-on: #4
2026-09-12 21:18:15 +00:00
c86ea54e86 feat: Refactored all cogs implementation
Some checks failed
CI / Run tests (pull_request) Has been cancelled
2026-09-12 23:16:43 +02:00
505621e0cd Merge pull request 'feat: Add Cog to perform role addition and deletion to a user from predefined config file' (#3) from feature/role-reaction into main
Some checks failed
CD / Build and push Docker image (push) Failing after 1s
CD / Deploy to VPS (push) Has been skipped
CI / Run tests (push) Has been cancelled
Reviewed-on: #3
2026-09-09 18:26:18 +00:00
4a749bd683 feat: Add Cog to perform role addition and deletion to a user from predefined config file
Some checks failed
CI / Run tests (pull_request) Failing after 1s
2026-09-09 19:40:37 +02:00
60e95791a9 Merge pull request 'fix: Fix pipeline, add ci and adapted compose' (#2) from pipelines into main
Some checks failed
CD / Build and push Docker image (push) Failing after 1s
CD / Deploy to VPS (push) Has been skipped
CI / Run tests (push) Failing after 1s
Reviewed-on: #2
2026-07-16 19:18:52 +00:00
3fb926ec20 fix: Fix pipeline, add ci and adapted compose
Some checks failed
CI / Run tests (pull_request) Failing after 20s
2026-07-16 21:15:02 +02:00
16 changed files with 705 additions and 225 deletions

View File

@@ -1,4 +1,11 @@
.gitea/ venv/
.venv/
__pycache__/ __pycache__/
.vscode *.pyc
.git/
tests/ tests/
*.log
.env
config/
/.vscode/
*.log

View File

@@ -2,15 +2,49 @@ name: CD
on: on:
push: push:
branches: [main, v2] branches: [main]
concurrency:
group: cd-${{ github.ref_name }}
cancel-in-progress: true
jobs: jobs:
build-and-push:
name: Build and push Docker image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Restore Docker build cache
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Build and push image
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: |
${{ secrets.DOCKER_USERNAME }}/botmafieux:latest
${{ secrets.DOCKER_USERNAME }}/botmafieux:${{ github.sha }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache,mode=max
deploy: deploy:
name: Deploy to VPS name: Deploy to VPS
needs: build-and-push needs: build-and-push
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Copy files to VPS - name: Copy compose file to VPS
uses: appleboy/scp-action@v1 uses: appleboy/scp-action@v1
with: with:
host: ${{ secrets.VPS_HOST }} host: ${{ secrets.VPS_HOST }}
@@ -19,7 +53,6 @@ jobs:
port: ${{ secrets.VPS_SSH_PORT }} port: ${{ secrets.VPS_SSH_PORT }}
source: "compose.yaml" source: "compose.yaml"
target: "/opt/docker/botmafieux" target: "/opt/docker/botmafieux"
- name: Deploy on VPS - name: Deploy on VPS
uses: appleboy/ssh-action@v1 uses: appleboy/ssh-action@v1
with: with:
@@ -28,12 +61,7 @@ jobs:
key: ${{ secrets.VPS_SSH_KEY }} key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_SSH_PORT }} port: ${{ secrets.VPS_SSH_PORT }}
script: | script: |
set -euo pipefail
cd /opt/docker/botmafieux cd /opt/docker/botmafieux
docker compose -f compose.yaml --env-file .env pull
docker compose -f compose.yaml --env-file .env down docker compose -f compose.yaml --env-file .env up -d --remove-orphans
docker compose -f compose.yaml --env-file .env build
docker compose -f compose.yaml --env-file .env up -d
docker compose -f compose.yaml --env-file .env.prod ps

22
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,22 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Run tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.13
- name: Install dependencies
run: python -m pip install --no-cache-dir -r requirements.txt
- name: Run pytest
run: pytest -q

View File

@@ -1,19 +1,36 @@
FROM python:3.13.14-alpine FROM python:3.13.14-alpine
RUN apk add --no-cache \
build-base \
libffi-dev \
musl-dev \
openssl-dev \
python3-dev \
geckodriver \
firefox-esr
WORKDIR /app WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt && \
apk add --no-cache firefox geckodriver
COPY . .
ENV TOKEN="" ENV TOKEN=""
ENV URL=""
ENV CONFIG_PATH="/config" ENV CONFIG_PATH="/config"
ENV COGS=""
ENV GECKODRIVER_PATH="/usr/bin/geckodriver" ENV GECKODRIVER_PATH="/usr/bin/geckodriver"
RUN mkdir ${CONFIG_PATH} RUN adduser -D bot && \
mkdir /logs && \
chown -R bot:bot /logs && \
chmod 755 /logs && \
mkdir -p ${CONFIG_PATH} && \
chown -R bot:bot ${CONFIG_PATH} && \
chmod 755 ${CONFIG_PATH}
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN chown -R bot:bot /app && \
chmod -R 755 /app && \
chmod +x /app/main.py
USER bot
ENTRYPOINT ["python", "main.py"] ENTRYPOINT ["python", "main.py"]

42
cogs/__init__.py Normal file
View File

@@ -0,0 +1,42 @@
import discord, json, csv
from discord.ext import commands
from typing import Union, Any, Optional, Callable
from setup.config import Config
class BaseCog(commands.Cog):
def __init__(self,bot:discord.Bot):
self.bot = bot
class ConfigurableCog(BaseCog):
def __init__(self, bot: discord.Bot):
super().__init__(bot)
class FeatureCog(BaseCog):
"""Base Cog that provides per-guild feature flag checks.
Subclasses should set `feature_name` or pass it to the constructor.
This class reads the feature flags from `Config.GUILD_CONFIGS[guild_id]["features"]`.
"""
def __init__(self, bot: discord.Bot, feature_name: Optional[str] = None):
super().__init__(bot)
self.feature_name = feature_name
def is_enabled(self, guild_id: int) -> bool:
if not self.feature_name:
return False
guild_cfg = Config.GUILD_CONFIGS.get(guild_id)
if not guild_cfg:
return False
features = guild_cfg.get("features", {})
return bool(features.get(self.feature_name, False))
def require_enabled_check(self) -> Callable[[commands.Context], bool]:
def predicate(ctx: commands.Context) -> bool:
if not ctx.guild:
return False
return self.is_enabled(ctx.guild.id)
return commands.check(predicate)

View File

@@ -7,6 +7,7 @@ from discord.ext import commands, tasks, bridge
from setup.logger import LOGGER from setup.logger import LOGGER
from setup.config import Config from setup.config import Config
from models.cs_models import * from models.cs_models import *
from cogs import FeatureCog
BASE_URL = "https://www.hltv.org" BASE_URL = "https://www.hltv.org"
@@ -24,11 +25,19 @@ OLD_MATCH_DURATION = 3600 * 24 * 2
EMOJI_CREATE_EVENT = "✅" # Create guild event for the match EMOJI_CREATE_EVENT = "✅" # Create guild event for the match
EMOJI_REMOVE_EVENT = "❌" # Remove guild event for the match EMOJI_REMOVE_EVENT = "❌" # Remove guild event for the match
class CSCog(commands.Cog): class CSCog(FeatureCog):
def __init__(self, bot:discord.Bot): def __init__(self, bot: discord.Bot):
self.bot = bot super().__init__(bot, feature_name="cs")
self.matches:list[Match] = [] self.matches: list[Match] = []
self.message_match_map = {} # Maps message IDs to Match objects self.message_match_map = {} # Maps message IDs to Match objects
# Start scraping loop only if at least one guild enabled CS feature
try:
enabled_any = any(cfg.get("features", {}).get("cs") for cfg in Config.GUILD_CONFIGS.values())
except Exception:
enabled_any = False
if enabled_any:
self.get_matches.start() self.get_matches.start()
def is_up_to_date(self) -> bool: def is_up_to_date(self) -> bool:

View File

@@ -1,32 +1,76 @@
import discord, json, os, random, re import discord, random, re
import collections from discord.ext import commands
from discord.ext import commands, bridge
from setup.logger import LOGGER from setup.logger import LOGGER
from setup.config import Config from setup.config import Config
from cogs import FeatureCog
class InsultsCog(commands.Cog): class InsultsCog(FeatureCog):
def __init__(self, bot:bridge.Bot): def __init__(self, bot: discord.Bot):
self.bot = bot super().__init__(bot, feature_name="insults")
LOGGER.debug("InsultsCog initialized")
with open(os.path.join(Config.CONFIG_PATH, "insults.txt")) as f: def reload_men_list(self):
self.insults = [l.strip() for l in f.readlines()] self.men: list[discord.Member] = []
for guild in self.bot.guilds:
for role in guild.roles:
if role.name and re.match(r'^([hH]im|[hH]e).*$', role.name):
self.men.extend(role.members)
# dedupe by id
self.men = list({m.id: m for m in self.men}.values())
@commands.Cog.listener() @commands.Cog.listener()
async def on_message(self, message:discord.Message): async def on_message(self, message: discord.Message):
if len(message.content.split()) > 3: if not message.guild or message.author.bot:
return
# only run if feature enabled for this guild
if not self.is_enabled(message.guild.id):
return
if len(message.content.split()) <= 3:
return
author = message.author author = message.author
if not hasattr(self, 'men') or not self.men:
self.reload_men_list()
if any(author.id == u.id for u in self.men): if any(author.id == u.id for u in self.men):
r = int(random.random()*100) guild_cfg = Config.GUILD_CONFIGS.get(message.guild.id, {})
print(r) insults_cfg = guild_cfg.get("configs", {}).get("insults", {})
safeguard_patterns = insults_cfg.get("safeguard_patterns", [])
safeguard_match = any(re.match(pattern, message.content) for pattern in safeguard_patterns)
if safeguard_match:
return
r = random.random() * 100
exceptions = insults_cfg.get("exceptions", [])
filtered = [ex for ex in exceptions if ex.get("user_id") == author.id]
if filtered:
try:
r *= float(filtered[0].get("factor", 1))
except Exception:
pass
if r < 5: if r < 5:
insult = random.choice(self.insults) insults = insults_cfg.get("insults", [])
if insults:
insult = random.choice(insults)
await message.reply(insult) await message.reply(insult)
@commands.Cog.listener() @commands.Cog.listener()
async def on_ready(self): async def on_ready(self):
self.men:list[discord.Member] = [] self.reload_men_list()
for guild in self.bot.guilds:
for role in guild.roles: @commands.Cog.listener()
if re.match(r'^([hH]im|[hH]e).*$',role.name): async def on_member_update(self, before: discord.Member, after: discord.Member):
self.men.extend(role.members) before_roles = set(before.roles)
self.men = list(set(self.men)) after_roles = set(after.roles)
removed = before_roles.difference(after_roles)
added = after_roles.difference(before_roles)
role = None
if removed:
role = list(removed).pop()
elif added:
role = list(added).pop()
if role and re.match(r'^([hH]im|[hH]e).*$', role.name):
self.reload_men_list()

View File

@@ -1,149 +1,55 @@
import discord, json, os, random, re, datetime import random
import collections from discord.ext import commands
from discord.ext import commands, bridge, tasks
from setup.logger import LOGGER
from setup.config import Config
class InternCog(commands.Cog): class InternCog(commands.Cog):
def __init__(self, bot:commands.Bot) -> None: def __init__(self, bot=None):
self.bot = bot self.bot = bot
self.in_voice_channel = False # `data` expected to be a list of dicts with 'guild_id' and 'mentor_id'
self.data = []
self.connected_guild_ids = set() self.connected_guild_ids = set()
def _get_guild_data(self, guild_id):
return next((data for data in self.data if data["guild_id"] == guild_id), None)
def _get_guild_voice_client(self, guild_id):
return next(
(
voice_client
for voice_client in self.bot.voice_clients
if getattr(getattr(voice_client, "guild", None), "id", None) == guild_id
),
None,
)
def _resolve_voice_action(self, before_channel, after_channel, guild_voice_client):
if after_channel is None and before_channel is not None:
return "disconnect"
if before_channel is not None and after_channel is not None:
if before_channel.id != after_channel.id:
return "move"
return None
if after_channel is not None and before_channel is None:
if guild_voice_client is not None:
return "move"
return "connect" if random.random() <= 0.25 else None
return None
def load_from_file(self):
try:
with open(Config.INTERN_MEMORY_PATH) as f:
self.data = json.load(f)
if len(self.data) == 0: raise FileNotFoundError
for guild_data in self.data:
guild = self.bot.get_guild(guild_data["guild_id"])
if guild:
mentor = guild.get_member(guild_data["mentor_id"])
LOGGER.debug(f"Loaded mentor: {mentor.name} for guild {guild.name}")
except FileNotFoundError:
today = datetime.datetime.now()
next_roll = datetime.datetime(year=today.year,month=today.month,day=today.day) + datetime.timedelta(days=7)
self.data = [
{
"mentor_id": random.choice(guild.members).id,
"guild_id": guild.id,
"next_roll": next_roll.isoformat()
}
for guild in self.bot.guilds
]
def load_from_database(self):
pass
def save_to_file(self):
with open(Config.INTERN_MEMORY_PATH, 'w') as f:
json.dump(self.data,f)
def save_to_database(self):
pass
@tasks.loop(hours=24)
async def update(self):
today = datetime.datetime.now()
for guild_data in self.data:
next_roll = datetime.datetime.fromisoformat(guild_data["next_roll"])
if today > next_roll:
guild = self.bot.get_guild(guild_data["guild_id"])
if guild:
members = [
member
for member in guild.get_role(1516232292576788530).members
if member not in guild.get_role(1526911990495445143).members
and member.id != guild_data["mentor_id"]
]
new_mentor = random.choice(members)
new_mentor_id = new_mentor.id
guild_data["mentor_id"] = new_mentor_id
new_next_roll = datetime.datetime(year=today.year,month=today.month,day=today.day) + datetime.timedelta(days=7)
guild_data["next_roll"] = new_next_roll.isoformat()
LOGGER.debug(f"New mentor: {new_mentor.name} for guild {guild.name}")
self.save_to_file()
@commands.Cog.listener()
async def on_ready(self):
self.load_from_file()
self.update.start()
@commands.Cog.listener()
async def on_voice_state_update(self, member:discord.Member, before:discord.VoiceState, after:discord.VoiceState):
if not self.data:
return
guild_id = None
if before and before.channel and before.channel.guild:
guild_id = before.channel.guild.id
elif after and after.channel and after.channel.guild:
guild_id = after.channel.guild.id
if guild_id is None:
return
guild_data = self._get_guild_data(guild_id)
if not guild_data or member.id != guild_data["mentor_id"]:
return
if before and before.channel:
channel_name = getattr(before.channel, "name", "unknown")
LOGGER.debug(f"Mentor left channel {channel_name}")
if after and after.channel:
channel_name = getattr(after.channel, "name", "unknown")
LOGGER.debug(f"Mentor joined channel {channel_name}")
guild_voice_client = self._get_guild_voice_client(guild_id)
action = self._resolve_voice_action(before.channel if before else None, after.channel if after else None, guild_voice_client)
if action == "connect":
try:
await after.channel.connect()
self.connected_guild_ids.add(guild_id)
self.in_voice_channel = True
except discord.ClientException as exc:
LOGGER.warning(f"Could not connect to voice channel: {exc}")
elif action == "move":
if guild_voice_client:
await guild_voice_client.move_to(after.channel)
else:
await after.channel.connect()
self.connected_guild_ids.add(guild_id)
self.in_voice_channel = True
elif action == "disconnect":
if guild_voice_client:
await guild_voice_client.disconnect(force=True)
self.connected_guild_ids.discard(guild_id)
self.in_voice_channel = False self.in_voice_channel = False
async def on_voice_state_update(self, member, before, after):
# determine guild context
guild = None
if after and getattr(after, 'channel', None):
guild = after.channel.guild
elif before and getattr(before, 'channel', None):
guild = before.channel.guild
if not guild:
return
# find matching mentor entry for this guild
entry = None
for e in getattr(self, 'data', []):
if e.get('guild_id') == getattr(guild, 'id', None) and e.get('mentor_id') == getattr(member, 'id', None):
entry = e
break
if not entry:
return
# find existing voice client for this guild (if any)
voice_client = None
for vc in getattr(self.bot, 'voice_clients', []):
if getattr(vc, 'guild', None) and getattr(vc.guild, 'id', None) == getattr(guild, 'id', None):
voice_client = vc
break
# mentor joined a channel (initial join)
if (not before or not getattr(before, 'channel', None)) and after and getattr(after, 'channel', None):
# if bot is not connected, connect with 25% chance
if not voice_client:
if random.random() < 0.25:
await after.channel.connect()
return
# mentor moved channels while bot is connected -> move bot
if voice_client and after and getattr(after, 'channel', None):
await voice_client.move_to(after.channel)
return
# mentor left channel -> disconnect bot if connected
if voice_client and (not after or not getattr(after, 'channel', None)):
await voice_client.disconnect(force=True)
return

146
cogs/roles.py Normal file
View File

@@ -0,0 +1,146 @@
import discord
from cogs import FeatureCog
from discord import option
from discord.ext import commands, bridge
from setup.config import Config
from setup.logger import LOGGER
class RolesCog(FeatureCog):
def __init__(self, bot: discord.Bot):
super().__init__(bot, feature_name="roles")
@bridge.bridge_command()
async def roles(self, ctx: discord.ApplicationContext, category: bridge.BridgeOption(name="category", description="The category to manipulate", input_type=str, required=True)):
if not ctx.guild:
await ctx.respond("This command must be used in a guild.", ephemeral=True)
return
guild_id = ctx.guild.id
if not self.is_enabled(guild_id):
await ctx.respond("Roles feature is disabled for this guild.", ephemeral=True)
return
guild_cfg = Config.GUILD_CONFIGS.get(guild_id)
if not guild_cfg:
await ctx.respond("No configuration for this guild.", ephemeral=True)
return
categories = guild_cfg.get("configs", {}).get("roles", {}).get("categories", [])
filtered = [c for c in categories if c.get("category_id") == category]
if not filtered:
await ctx.respond("Category not found.", ephemeral=True)
return
await ctx.defer()
config_category = filtered.pop()
embed = discord.Embed(
title=config_category.get("category_name", ""),
description=config_category.get("message_header", ""),
color=discord.Color.blue()
)
roles_list = [
ctx.guild.get_role(role_config.get("role_id"))
for role_config in config_category.get("roles", [])
]
for role_instance, role_config in zip(roles_list, config_category.get("roles", [])):
mention = role_instance.mention if role_instance else "(role missing)"
embed.add_field(name=role_config.get("reaction", ""), value=mention)
message = await ctx.send(embed=embed)
if message:
config_category["message_id"] = message.id
# persist the updated guild config
Config.save_guild_config(guild_id)
for config_role in config_category.get("roles", []):
reaction = config_role.get("reaction")
if reaction:
await message.add_reaction(reaction)
await ctx.followup.send("Role message generated", ephemeral=True)
@commands.Cog.listener()
async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
if payload.user_id == self.bot.user.id:
return
guild_id = payload.guild_id
guild_cfg = Config.GUILD_CONFIGS.get(guild_id)
if not guild_cfg:
return
if not self.is_enabled(guild_id):
return
categories = guild_cfg.get("configs", {}).get("roles", {}).get("categories", [])
filtered = [c for c in categories if c.get("message_id") == payload.message_id]
if not filtered:
return
config_category = filtered.pop()
role_ids = [config_role.get("role_id") for config_role in config_category.get("roles", []) if config_role.get("reaction") == payload.emoji.name]
if not role_ids:
return
role_id = role_ids.pop()
guild = self.bot.get_guild(guild_id)
if not guild:
return
role = guild.get_role(role_id)
if not role:
return
user = guild.get_member(payload.user_id)
if not user:
return
if "parent_role_id" in config_category:
parent_role = guild.get_role(config_category.get("parent_role_id"))
if parent_role:
await user.add_roles(parent_role, role)
return
await user.add_roles(role)
@commands.Cog.listener()
async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent):
if payload.user_id == self.bot.user.id:
return
guild_id = payload.guild_id
guild_cfg = Config.GUILD_CONFIGS.get(guild_id)
if not guild_cfg:
return
if not self.is_enabled(guild_id):
return
categories = guild_cfg.get("configs", {}).get("roles", {}).get("categories", [])
filtered = [c for c in categories if c.get("message_id") == payload.message_id]
if not filtered:
return
config_category = filtered.pop()
role_ids = [config_role.get("role_id") for config_role in config_category.get("roles", []) if config_role.get("reaction") == payload.emoji.name]
if not role_ids:
return
role_id = role_ids.pop()
guild = self.bot.get_guild(guild_id)
if not guild:
return
role = guild.get_role(role_id)
if not role:
return
user = guild.get_member(payload.user_id)
if not user:
return
if "parent_role_id" in config_category:
parent_role = guild.get_role(config_category.get("parent_role_id"))
if parent_role:
await user.remove_roles(parent_role, role)
return
await user.remove_roles(role)

View File

@@ -5,11 +5,12 @@ import random
import discord import discord
from discord import option from discord import option
from discord.ext import commands from discord.ext import commands
from cogs import FeatureCog
class RouletteCog(commands.Cog): class RouletteCog(FeatureCog):
def __init__(self, bot: discord.Bot): def __init__(self, bot: discord.Bot):
self.bot = bot super().__init__(bot, feature_name="roulette")
self.cooldown = 60 * 3 self.cooldown = 60 * 3
self.active_games = {} self.active_games = {}
@@ -155,6 +156,10 @@ class RouletteCog(commands.Cog):
ctx: discord.ApplicationContext, ctx: discord.ApplicationContext,
target: discord.Member, target: discord.Member,
): ):
# ensure feature enabled for this guild
if not ctx.guild or not self.is_enabled(ctx.guild.id):
await ctx.respond("Roulette feature is disabled for this guild.", ephemeral=True)
return
author = ctx.author author = ctx.author
self_test = author.id == target.id self_test = author.id == target.id

View File

@@ -1,9 +1,37 @@
networks:
internal:
internal: true
outbound:
external: true
services: services:
botmafieux: botmafieux:
build: build:
context: . context: .
dockerfile: ./Dockerfile dockerfile: Dockerfile
tags: image: nebulo9/botmafieux:latest
- nebulo9/botmafieux:v2
container_name: botmafieux container_name: botmafieux
env_file: ./.env restart: unless-stopped
networks:
- internal
- outbound
env_file:
- ./.env
volumes:
- ./config:/config
healthcheck:
test: ["CMD", "pgrep", "-f", "main.py"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
deploy:
resources:
limits:
cpus: '0.25'
memory: '256M'

54
main.py
View File

@@ -1,28 +1,54 @@
import os, discord, json, sys, random import os
import sys
import discord
from argparse import ArgumentParser from argparse import ArgumentParser
from datetime import datetime
from asyncio import create_task # Parse minimal CLI args early so we can configure logging before imports that
from discord.ext import tasks, commands # initialize loggers. Keep only `--log-level`; other args were unused.
PARSER = ArgumentParser(description='BotMafieux for Discord.')
PARSER.add_argument('--log-level', type=str, help='Logging level (DEBUG, INFO, WARNING, ERROR)')
EXEC_ARGS = PARSER.parse_args()
if EXEC_ARGS.log_level:
os.environ['LOG_LEVEL'] = str(EXEC_ARGS.log_level)
from setup.bot import bot, reload_feature, register_cogs from setup.bot import bot, reload_feature, register_cogs
from setup.logger import LOGGER from setup.logger import LOGGER
from setup.config import Config from setup.config import Config
# Register cogs from configuration (no CLI activation)
PARSER = ArgumentParser(description='BotMafieux for Discord.') register_cogs()
PARSER.add_argument('--guild_id',type=int,help='The guild ID.')
PARSER.add_argument('--channel_id',type=int,help='The channel ID.')
PARSER.add_argument('-r','--reload',action='store_true',help='Reload the bot.')
PARSER.add_argument('--cogs',nargs='+',default=None,help='Select which cogs to load (for example: --cogs cs insult).')
EXEC_ARGS = PARSER.parse_args()
selected_cogs = EXEC_ARGS.cogs if EXEC_ARGS.cogs is not None else Config.get_cogs()
register_cogs(selected_cogs)
@bot.event @bot.event
async def on_ready(): async def on_ready():
if bot.user: if bot.user:
LOGGER.info(f'{bot.user.name} has connected to Discord!') LOGGER.info(f'{bot.user.name} has connected to Discord!')
LOGGER.debug(f"Guilds: {','.join([guild.name for guild in bot.guilds])}") LOGGER.info(f"Guilds: {','.join([guild.name for guild in bot.guilds])}")
# Log configuration for each guild we are connected to
try:
for guild in bot.guilds:
cfg = Config.GUILD_CONFIGS.get(guild.id)
if cfg is not None:
LOGGER.info(f"Guild config for {guild.name} ({guild.id}): {cfg}")
else:
LOGGER.info(f"No guild config for {guild.name} ({guild.id})")
except Exception as e:
LOGGER.warning(f"Failed to log guild configs: {e}")
@bot.event
async def on_guild_join(guild: discord.Guild):
# Refresh configs and log the joined guild configuration
try:
Config.load_guilds_configs()
except Exception:
LOGGER.debug('Could not reload guild configs during on_guild_join.')
cfg = Config.GUILD_CONFIGS.get(guild.id)
if cfg is not None:
LOGGER.info(f"Joined guild {guild.name} ({guild.id}) with config: {cfg}")
else:
LOGGER.info(f"Joined guild {guild.name} ({guild.id}) with no configuration present.")
if __name__ == '__main__': if __name__ == '__main__':
token = Config.TOKEN token = Config.TOKEN

View File

@@ -3,8 +3,9 @@ from discord.ext import commands
from cogs.cs import CSCog from cogs.cs import CSCog
from cogs.insult import InsultsCog from cogs.insult import InsultsCog
from cogs.roulette import RouletteCog from cogs.roulette import RouletteCog
from cogs.intern import InternCog from cogs.roles import RolesCog
from setup.logger import LOGGER from setup.logger import LOGGER
from setup.config import Config
bot_intents = discord.Intents.default() bot_intents = discord.Intents.default()
bot_intents.message_content = True bot_intents.message_content = True
@@ -20,9 +21,41 @@ COG_REGISTRY = {
'cs': CSCog, 'cs': CSCog,
'insult': InsultsCog, 'insult': InsultsCog,
'roulette': RouletteCog, 'roulette': RouletteCog,
'intern': InternCog 'roles': RolesCog,
} }
# Register a global admin slash command directly on the bot to avoid circular imports.
from discord import option as discord_option
@bot.slash_command(name="reload", description="Reload a feature (cog) by name, or 'all')")
@discord_option(name="feature", description="Feature to reload (e.g. cs, insult, roles, roulette, all)", input_type=str, required=True)
async def reload(ctx: discord.ApplicationContext, feature: str):
# Only allow administrators
if not ctx.author or not getattr(ctx.author, 'guild_permissions', None) or not ctx.author.guild_permissions.administrator:
await ctx.respond("You must be a server administrator to use this command.", ephemeral=True)
return
await ctx.defer()
reload_feature(feature)
await ctx.followup.send(f"Reload requested for feature: {feature}", ephemeral=True)
# Register a global admin slash command directly on the bot to avoid circular imports.
from discord import option as discord_option
@bot.slash_command(name="reload", description="Reload a feature (cog) by name, or 'all')")
@discord_option(name="feature", description="Feature to reload (e.g. cs, insult, roles, roulette, all)", input_type=str, required=True)
async def reload(ctx: discord.ApplicationContext, feature: str):
# Only allow administrators
if not ctx.author or not getattr(ctx.author, 'guild_permissions', None) or not ctx.author.guild_permissions.administrator:
await ctx.respond("You must be a server administrator to use this command.", ephemeral=True)
return
await ctx.defer()
reload_feature(feature)
await ctx.followup.send(f"Reload requested for feature: {feature}", ephemeral=True)
def _normalize_cog_names(cogs=None): def _normalize_cog_names(cogs=None):
if cogs is None: if cogs is None:
@@ -47,6 +80,12 @@ def _normalize_cog_names(cogs=None):
def register_cogs(cogs=None): def register_cogs(cogs=None):
# Ensure guild configs are loaded before instantiating cogs
try:
Config.load_guilds_configs()
except Exception:
LOGGER.debug('Could not reload guild configs during register_cogs.')
selected_cogs = _normalize_cog_names(cogs) selected_cogs = _normalize_cog_names(cogs)
existing_cog_names = set(COG_REGISTRY.values()) existing_cog_names = set(COG_REGISTRY.values())
@@ -76,6 +115,11 @@ def reload_feature(feature: str):
return return
LOGGER.debug(f'Reloading {feature}...') LOGGER.debug(f'Reloading {feature}...')
# Refresh guild configs before reloading a single feature
try:
Config.load_guilds_configs()
except Exception:
LOGGER.debug('Could not reload guild configs during reload_feature.')
target_cog_name = COG_REGISTRY[normalized_feature].__name__ target_cog_name = COG_REGISTRY[normalized_feature].__name__
for existing_name in list(bot.cogs.keys()): for existing_name in list(bot.cogs.keys()):
if existing_name == target_cog_name: if existing_name == target_cog_name:

View File

@@ -1,20 +1,64 @@
import os import os, pathlib, json
from dotenv import load_dotenv from dotenv import load_dotenv
from setup.logger import LOGGER
class Config: class Config:
load_dotenv() load_dotenv()
TOKEN = str(os.getenv("TOKEN", "")) TOKEN = str(os.getenv("TOKEN", ""))
CONFIG_PATH = str(os.getenv("CONFIG_PATH")) CONFIG_PATH = str(os.getenv("CONFIG_PATH"))
INTERN_MEMORY_PATH = os.path.join(CONFIG_PATH,"intern.json")
INTERN_TAKES_PATH = os.path.join(CONFIG_PATH,"intern_takes.csv") GUILDS_IDS:list[int] = []
COGS = os.getenv("COGS", "")
GUILD_CONFIGS:dict[int,dict] = {}
@classmethod @classmethod
def get_cogs(cls): def load_guilds_configs(cls):
raw_value = cls.COGS.strip() with os.scandir(cls.CONFIG_PATH) as d:
if not raw_value: guilds_config_files = [f.path for f in d if f.is_file() and f.name.endswith(".json")]
return None for guild_config_file in guilds_config_files:
return [item.strip().lower() for item in raw_value.split(",") if item.strip()] with open(guild_config_file) as f:
guild_config = json.load(f)
guild_id = guild_config["guild_id"]
cls.GUILDS_IDS.append(guild_id)
cls.GUILD_CONFIGS[guild_id] = {k:v for k,v in guild_config.items() if k != "guild_id"}
cls.GUILD_CONFIGS[guild_id]["config_path"] = guild_config_file
@classmethod
def load_guild_config(cls,guild_id:int):
config_path = cls.GUILD_CONFIGS[guild_id]["config_path"]
with open(config_path) as f:
guild_config = json.load(f)
# Preserve config_path when re-loading
cls.GUILD_CONFIGS[guild_id] = {k: v for k, v in guild_config.items() if k != "guild_id"}
cls.GUILD_CONFIGS[guild_id]["config_path"] = config_path
@classmethod
def save_guilds_configs(cls):
for guild_id, guild_config in cls.GUILD_CONFIGS.items():
config_path = guild_config.get("config_path")
if not config_path:
continue
with open(config_path, 'w') as file:
json_config = {k: v for k, v in guild_config.items() if k != "config_path"}
json_config["guild_id"] = guild_id
json.dump(json_config, file, ensure_ascii=False, indent=2)
@classmethod
def save_guild_config(cls,guild_id:int):
config = cls.GUILD_CONFIGS.get(guild_id)
if not config:
return
config_path = config.get("config_path")
if not config_path:
return
with open(config_path, 'w') as file:
json_config = {k: v for k, v in config.items() if k != "config_path"}
json_config["guild_id"] = guild_id
json.dump(json_config, file, ensure_ascii=False, indent=2)
@classmethod
def get_guild_config(cls,guild_id):
return cls.GUILD_CONFIGS[guild_id]
Config.load_guilds_configs()

View File

@@ -1,8 +1,42 @@
import logging import logging
import os
import tempfile
import sys
logging.basicConfig(level=logging.INFO, format='[%(levelname)s] %(asctime)s: %(name)s: %(message)s') # Determine log level from environment variable `LOG_LEVEL` (e.g. DEBUG, INFO)
raw_level = os.getenv("LOG_LEVEL", "INFO").upper()
level = logging.INFO
if raw_level.isdigit():
try:
level = int(raw_level)
except Exception:
level = logging.INFO
else:
level = getattr(logging, raw_level, logging.INFO)
logging.basicConfig(level=level, format='[%(levelname)s] %(asctime)s: %(name)s: %(message)s')
LOGGER = logging.getLogger('botmafieux') LOGGER = logging.getLogger('botmafieux')
LOGGER.setLevel(logging.DEBUG) LOGGER.setLevel(level)
handler = logging.FileHandler(filename='botmafieux.log', encoding='utf-8', mode='a')
handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s:%(name)s: %(message)s')) # Decide log file path: prefer LOG_FILE, then CONFIG_PATH/botmafieux.log, then /tmp
LOGGER.addHandler(handler) preferred = os.getenv('LOG_FILE')
if not preferred:
cfg_path = "/logs"
if cfg_path:
preferred = os.path.join(cfg_path, 'botmafieux.log')
else:
preferred = os.path.join(tempfile.gettempdir(), 'botmafieux.log')
formatter = logging.Formatter('%(asctime)s:%(levelname)s:%(name)s: %(message)s')
try:
handler = logging.FileHandler(filename=preferred, encoding='utf-8', mode='a')
handler.setFormatter(formatter)
LOGGER.addHandler(handler)
except (PermissionError, OSError) as e:
# Fall back to console logging if file cannot be opened
stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.setFormatter(formatter)
LOGGER.addHandler(stream_handler)
# Print a warning directly to stderr in case logging isn't yet configured elsewhere
print(f"[WARN] Could not open log file '{preferred}' for writing: {e}. Falling back to stdout.", file=sys.stderr)

View File

@@ -0,0 +1,78 @@
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from cogs.intern import InternCog
class InternVoiceBehaviorTests(unittest.TestCase):
def make_cog(self, voice_client=None):
cog = InternCog.__new__(InternCog)
cog.bot = SimpleNamespace(voice_clients=[voice_client] if voice_client else [])
cog.data = [{"guild_id": 123, "mentor_id": 456}]
cog.connected_guild_ids = set()
cog.in_voice_channel = False
return cog
def make_channel(self, channel_id, guild, connect_mock=None, name="test-channel"):
return SimpleNamespace(id=channel_id, guild=guild, connect=connect_mock or AsyncMock(), name=name)
def make_member(self, member_id):
return SimpleNamespace(id=member_id)
async def _run_handler(self, cog, before_channel, after_channel, voice_client):
before = SimpleNamespace(channel=before_channel)
after = SimpleNamespace(channel=after_channel)
member = self.make_member(456)
await cog.on_voice_state_update(member, before, after)
def test_initial_join_only_connects_with_25_percent_chance(self):
cog = self.make_cog()
connect_mock = AsyncMock()
guild = SimpleNamespace(id=123, voice_client=None)
channel = self.make_channel(1, guild, connect_mock=connect_mock)
before = None
after = SimpleNamespace(channel=channel)
member = self.make_member(456)
with patch("cogs.intern.random.random", return_value=0.1):
import asyncio
asyncio.run(cog.on_voice_state_update(member, before, after))
connect_mock.assert_awaited_once()
cog = self.make_cog()
connect_mock = AsyncMock()
guild = SimpleNamespace(id=123, voice_client=None)
channel = self.make_channel(1, guild, connect_mock=connect_mock)
before = None
after = SimpleNamespace(channel=channel)
member = self.make_member(456)
with patch("cogs.intern.random.random", return_value=0.9):
import asyncio
asyncio.run(cog.on_voice_state_update(member, before, after))
connect_mock.assert_not_awaited()
def test_move_always_moves_the_bot_when_connected(self):
guild = SimpleNamespace(id=123, voice_client=None)
voice_client = SimpleNamespace(move_to=AsyncMock(), disconnect=AsyncMock(), guild=guild)
cog = self.make_cog(voice_client=voice_client)
before_channel = self.make_channel(1, guild)
after_channel = self.make_channel(2, guild)
import asyncio
asyncio.run(self._run_handler(cog, before_channel, after_channel, voice_client))
voice_client.move_to.assert_awaited_once_with(after_channel)
def test_disconnect_always_disconnects_when_mentor_leaves(self):
guild = SimpleNamespace(id=123, voice_client=None)
voice_client = SimpleNamespace(move_to=AsyncMock(), disconnect=AsyncMock(), guild=guild)
cog = self.make_cog(voice_client=voice_client)
before_channel = self.make_channel(1, guild)
after_channel = None
import asyncio
asyncio.run(self._run_handler(cog, before_channel, after_channel, voice_client))
voice_client.disconnect.assert_awaited_once_with(force=True)
if __name__ == "__main__":
unittest.main()