7 Commits

Author SHA1 Message Date
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 701 additions and 81 deletions

11
.dockerignore Normal file
View File

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

View File

@@ -2,15 +2,49 @@ name: CD
on:
push:
branches: [main, v2]
branches: [main]
concurrency:
group: cd-${{ github.ref_name }}
cancel-in-progress: true
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:
name: Deploy to VPS
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Copy files to VPS
- name: Copy compose file to VPS
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.VPS_HOST }}
@@ -19,7 +53,6 @@ jobs:
port: ${{ secrets.VPS_SSH_PORT }}
source: "compose.yaml"
target: "/opt/docker/botmafieux"
- name: Deploy on VPS
uses: appleboy/ssh-action@v1
with:
@@ -28,12 +61,7 @@ jobs:
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_SSH_PORT }}
script: |
set -euo pipefail
cd /opt/docker/botmafieux
docker compose -f compose.yaml --env-file .env down
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
docker compose -f compose.yaml --env-file .env pull
docker compose -f compose.yaml --env-file .env up -d --remove-orphans

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
RUN apk add --no-cache \
build-base \
libffi-dev \
musl-dev \
openssl-dev \
python3-dev \
geckodriver \
firefox-esr
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt && \
apk add --no-cache firefox geckodriver
COPY . .
ENV TOKEN=""
ENV URL=""
ENV CONFIG_PATH="/config"
ENV COGS=""
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"]

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.config import Config
from models.cs_models import *
from cogs import FeatureCog
BASE_URL = "https://www.hltv.org"
@@ -24,12 +25,20 @@ OLD_MATCH_DURATION = 3600 * 24 * 2
EMOJI_CREATE_EVENT = "✅" # Create guild event for the match
EMOJI_REMOVE_EVENT = "❌" # Remove guild event for the match
class CSCog(commands.Cog):
def __init__(self, bot:discord.Bot):
self.bot = bot
self.matches:list[Match] = []
class CSCog(FeatureCog):
def __init__(self, bot: discord.Bot):
super().__init__(bot, feature_name="cs")
self.matches: list[Match] = []
self.message_match_map = {} # Maps message IDs to Match objects
self.get_matches.start()
# 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()
def is_up_to_date(self) -> bool:
"""Check if PAGE_FILE and MATCHES_FILE are up to date."""

View File

@@ -1,32 +1,76 @@
import discord, json, os, random, re
import collections
import discord, random, re
from discord.ext import commands
from setup.logger import LOGGER
from setup.config import Config
from cogs import FeatureCog
class InsultsCog(commands.Cog):
def __init__(self, bot:discord.Bot):
self.bot = bot
class InsultsCog(FeatureCog):
def __init__(self, bot: discord.Bot):
super().__init__(bot, feature_name="insults")
LOGGER.debug("InsultsCog initialized")
with open(os.path.join(Config.CONFIG_PATH, "insults.txt")) as f:
self.insults = [l.strip() for l in f.readlines()]
def reload_men_list(self):
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()
async def on_message(self, message:discord.Message):
if len(message.content.split()) > 3:
author = message.author
if any(author.id == u.id for u in self.men):
r = int(random.random()*100)
print(r)
if r < 5:
insult = random.choice(self.insults)
async def on_message(self, message: discord.Message):
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
if not hasattr(self, 'men') or not self.men:
self.reload_men_list()
if any(author.id == u.id for u in self.men):
guild_cfg = Config.GUILD_CONFIGS.get(message.guild.id, {})
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:
insults = insults_cfg.get("insults", [])
if insults:
insult = random.choice(insults)
await message.reply(insult)
@commands.Cog.listener()
async def on_ready(self):
self.men:list[discord.Member] = []
for guild in self.bot.guilds:
for role in guild.roles:
if re.match(r'^([hH]im|[hH]e).*$',role.name):
self.men.extend(role.members)
self.men = list(set(self.men))
self.reload_men_list()
@commands.Cog.listener()
async def on_member_update(self, before: discord.Member, after: discord.Member):
before_roles = set(before.roles)
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()

55
cogs/intern.py Normal file
View File

@@ -0,0 +1,55 @@
import random
from discord.ext import commands
class InternCog(commands.Cog):
def __init__(self, bot=None):
self.bot = bot
# `data` expected to be a list of dicts with 'guild_id' and 'mentor_id'
self.data = []
self.connected_guild_ids = set()
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
from discord import option
from discord.ext import commands
from cogs import FeatureCog
class RouletteCog(commands.Cog):
class RouletteCog(FeatureCog):
def __init__(self, bot: discord.Bot):
self.bot = bot
super().__init__(bot, feature_name="roulette")
self.cooldown = 60 * 3
self.active_games = {}
@@ -155,6 +156,10 @@ class RouletteCog(commands.Cog):
ctx: discord.ApplicationContext,
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
self_test = author.id == target.id

View File

@@ -1,9 +1,37 @@
networks:
internal:
internal: true
outbound:
external: true
services:
botmafieux:
build:
context: .
dockerfile: ./Dockerfile
tags:
- nebulo9/botmafieux:v2
dockerfile: Dockerfile
image: nebulo9/botmafieux:latest
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'

55
main.py
View File

@@ -1,29 +1,54 @@
import os, discord, json, sys, random
import os
import sys
import discord
from argparse import ArgumentParser
from datetime import datetime
from asyncio import create_task
from discord import option
from discord.ext import tasks, commands
# Parse minimal CLI args early so we can configure logging before imports that
# 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.logger import LOGGER
from setup.config import Config
PARSER = ArgumentParser(description='BotMafieux for Discord.')
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)
# Register cogs from configuration (no CLI activation)
register_cogs()
@bot.event
async def on_ready():
if bot.user:
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__':
token = Config.TOKEN

View File

@@ -5,7 +5,9 @@ from discord.ext import bridge
from cogs.cs import CSCog
from cogs.insult import InsultsCog
from cogs.roulette import RouletteCog
from cogs.roles import RolesCog
from setup.logger import LOGGER
from setup.config import Config
bot_intents = discord.Intents.default()
bot_intents.message_content = True
@@ -19,9 +21,26 @@ bot = bridge.Bot(command_prefix='$', intents=bot_intents)
COG_REGISTRY = {
'cs': CSCog,
'insult': InsultsCog,
'roulette': RouletteCog
'roulette': RouletteCog,
'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)
def _normalize_cog_names(cogs=None):
if cogs is None:
@@ -46,6 +65,12 @@ def _normalize_cog_names(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)
existing_cog_names = set(COG_REGISTRY.values())
@@ -75,6 +100,11 @@ def reload_feature(feature: str):
return
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__
for existing_name in list(bot.cogs.keys()):
if existing_name == target_cog_name:

View File

@@ -1,18 +1,64 @@
import os
import os, pathlib, json
from dotenv import load_dotenv
from setup.logger import LOGGER
class Config:
load_dotenv()
TOKEN = str(os.getenv("TOKEN", ""))
CONFIG_PATH = str(os.getenv("CONFIG_PATH"))
COGS = os.getenv("COGS", "")
GUILDS_IDS:list[int] = []
GUILD_CONFIGS:dict[int,dict] = {}
@classmethod
def get_cogs(cls):
raw_value = cls.COGS.strip()
if not raw_value:
return None
return [item.strip().lower() for item in raw_value.split(",") if item.strip()]
def load_guilds_configs(cls):
with os.scandir(cls.CONFIG_PATH) as d:
guilds_config_files = [f.path for f in d if f.is_file() and f.name.endswith(".json")]
for guild_config_file in guilds_config_files:
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 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.setLevel(logging.DEBUG)
handler = logging.FileHandler(filename='botmafieux.log', encoding='utf-8', mode='a')
handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s:%(name)s: %(message)s'))
LOGGER.addHandler(handler)
LOGGER.setLevel(level)
# Decide log file path: prefer LOG_FILE, then CONFIG_PATH/botmafieux.log, then /tmp
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()