7 Commits

Author SHA1 Message Date
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
d14ca4ab3e feat: Add Russian Roulette feature betwwen two players
Some checks failed
CD / Deploy to VPS (push) Has been cancelled
2026-07-14 16:12:16 +02:00
78d3a60bd7 Merge pull request 'feat: Add CD pipeline' (#1) from pipelines into main
Some checks failed
CD / Deploy to VPS (push) Has been cancelled
Reviewed-on: #1
2026-07-05 07:57:53 +00:00
12 changed files with 796 additions and 160 deletions

4
.gitignore vendored
View File

@@ -162,4 +162,6 @@ cython_debug/
data/
.vscode/
*.disabled
*.disabled
config/

View File

@@ -3,12 +3,17 @@ FROM python:3.13.14-alpine
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r 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}
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

@@ -1,15 +1,19 @@
import discord, datetime, os, re, json
from selenium import webdriver
from selenium.webdriver.firefox.options import Options
from selenium.webdriver.firefox.service import Service
from bs4 import BeautifulSoup
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"
PAGE_FILE = os.path.join("data","hltv.html")
PAGE_FILE = os.path.join(Config.CONFIG_PATH,"hltv.html")
MATCHES_FILE = os.path.join("data","matches.json")
MATCHES_FILE = os.path.join(Config.CONFIG_PATH,"matches.json")
# Cache duration in seconds (6 hours)
DATA_CACHE_DURATION = 3600 * 6
@@ -21,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."""
@@ -43,14 +55,27 @@ class CSCog(commands.Cog):
return False
def get_page(self) -> None:
"""Get the page from https://www.hltv.org/matches/ using Firefox webdriver and save to PAGE_FILE."""
driver = webdriver.Firefox()
driver.get("https://www.hltv.org/matches/")
html = driver.page_source
driver.close()
"""Get the page from https://www.hltv.org/matches/ using a headless Firefox webdriver and save to PAGE_FILE."""
options = Options()
options.add_argument("--headless=new")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--window-size=1920,1080")
with open(PAGE_FILE, 'w') as f:
f.write(html)
geckodriver_path = os.getenv("GECKODRIVER_PATH")
service = Service(executable_path=geckodriver_path) if geckodriver_path else None
driver = None
try:
driver = webdriver.Firefox(service=service, options=options)
driver.get("https://www.hltv.org/matches/")
html = driver.page_source
with open(PAGE_FILE, 'w', encoding='utf-8') as f:
f.write(html)
finally:
if driver is not None:
driver.quit()
def save(self) -> None:
"""Save self.matches to MATCHES_FILE."""

View File

@@ -1,31 +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("data", "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)

217
cogs/roulette.py Normal file
View File

@@ -0,0 +1,217 @@
import asyncio
import datetime
import random
import discord
from discord import option
from discord.ext import commands
from cogs import FeatureCog
class RouletteCog(FeatureCog):
def __init__(self, bot: discord.Bot):
super().__init__(bot, feature_name="roulette")
self.cooldown = 60 * 3
self.active_games = {}
def _build_view(self, game: dict) -> discord.ui.View:
view = discord.ui.View(timeout=None)
if game["status"] == "pending":
async def accept_callback(interaction: discord.Interaction):
if interaction.user.id != game["target"].id:
await interaction.response.send_message(
"Only the challenged member can accept this duel.",
ephemeral=True,
)
return
game["status"] = "ready"
game["current_player"] = game["author"]
if game.get("message"):
await game["message"].edit(
content=(
f"{game['author'].mention} challenged {game['target'].mention} to a Russian roulette duel.\n"
f"✅ {game['target'].mention} accepted. {game['current_player'].mention}, click Fire!"
),
view=self._build_view(game),
)
await interaction.response.defer()
async def decline_callback(interaction: discord.Interaction):
if interaction.user.id != game["target"].id:
await interaction.response.send_message(
"Only the challenged member can decline this duel.",
ephemeral=True,
)
return
game["status"] = "declined"
self.active_games.pop((game["guild_id"], game["author"].id, game["target"].id), None)
if game.get("message"):
await game["message"].edit(
content=f"{game['target'].mention} declined the challenge.",
view=None,
)
await interaction.response.defer()
accept_button = discord.ui.Button(label="Accept", style=discord.ButtonStyle.green)
accept_button.callback = accept_callback
view.add_item(accept_button)
decline_button = discord.ui.Button(label="Decline", style=discord.ButtonStyle.red)
decline_button.callback = decline_callback
view.add_item(decline_button)
elif game["status"] in {"ready", "playing"}:
async def fire_callback(interaction: discord.Interaction):
if interaction.user.id != game["current_player"].id:
await interaction.response.send_message(
f"Wait for {game['current_player'].mention} to fire.",
ephemeral=True,
)
return
if interaction.user.id in game["fired"]:
await interaction.response.send_message(
"You already fired once. Only one shot per player is allowed.",
ephemeral=True,
)
return
game["fired"].add(interaction.user.id)
is_shot = random.randint(1, 6) == 1
if is_shot:
game["status"] = "finished"
self.active_games.pop((game["guild_id"], game["author"].id, game["target"].id), None)
await self._mute_member(interaction.user, 60)
if game.get("message"):
await game["message"].edit(
content=(
f"💥 {interaction.user.mention} pulled the trigger and got shot.\n"
f"They were muted for 60 seconds."
),
view=None,
)
else:
if len(game["fired"]) >= 2:
game["status"] = "finished"
self.active_games.pop((game["guild_id"], game["author"].id, game["target"].id), None)
if game.get("message"):
await game["message"].edit(
content=(
f"🔫 {interaction.user.mention} survived and both players have fired once.\n"
f"No one was shot. The duel ends in a draw."
),
view=None,
)
else:
game["status"] = "playing"
game["current_player"] = game["target"] if interaction.user.id == game["author"].id else game["author"]
if game.get("message"):
await game["message"].edit(
content=(
f"🔫 {interaction.user.mention} survived.\n"
f"Next turn: {game['current_player'].mention}"
),
view=self._build_view(game),
)
await interaction.response.defer()
fire_button = discord.ui.Button(label="Fire", style=discord.ButtonStyle.danger)
fire_button.callback = fire_callback
view.add_item(fire_button)
return view
async def _mute_member(self, member: discord.Member, seconds: int = 60):
until = discord.utils.utcnow() + datetime.timedelta(seconds=seconds)
try:
await member.timeout(until=until, reason="Russian roulette loss")
except AttributeError:
await member.edit(timed_out_until=until, reason="Russian roulette loss")
@commands.slash_command(
name="roulette",
description="Challenge other server members. The loser is getting muted for 1 minute",
)
@option(
name="target",
description="The member to challenge",
input_type=discord.Member,
required=True,
)
async def roulette(
self,
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
if self_test:
target = author
game = {
"author": author,
"target": target,
"status": "pending",
"current_player": None,
"guild_id": ctx.guild_id,
"message": None,
"fired": set(),
}
self.active_games[(ctx.guild_id, author.id, target.id)] = game
await ctx.send_response(
f"{author.mention} challenged {target.mention} to a Russian roulette duel.\n"
f"The target has 60 seconds to accept or decline.",
view=self._build_view(game),
)
if self_test:
await ctx.send_followup(
"**Self-challenge enabled for testing.**\n"
"The game will start immediately and you can test the flow.",
ephemeral=True,
)
try:
game["message"] = await ctx.interaction.original_response()
except Exception:
game["message"] = None
async def timeout_handler():
try:
await asyncio.sleep(60)
except asyncio.CancelledError:
return
if game.get("status") == "pending":
game["status"] = "timeout"
self.active_games.pop((ctx.guild_id, author.id, target.id), None)
await self._mute_member(target, 60)
if game.get("message"):
await game["message"].edit(
content=f"⏰ {target.mention} did not respond in time and was muted for 60 seconds.",
view=None,
)
asyncio.create_task(timeout_handler())

156
main.py
View File

@@ -1,131 +1,59 @@
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 dotenv import load_dotenv
from discord import option
from discord.ext import tasks, commands
from setup.bot import bot, reload_feature
from setup.logger import LOGGER
# 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('--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('--log-level', type=str, help='Logging level (DEBUG, INFO, WARNING, ERROR)')
EXEC_ARGS = PARSER.parse_args()
# @tasks.loop(time=datetime.time(datetime.strptime('00:00','%H:%M')))
# async def birthday_anouncements_task():
# LOGGER.debug('birthday_anouncements_task started.')
# for guild in bot.guilds:
# birthday_settings = db.select('birthday_settings',f'guild_id = {guild.id}')
# if birthday_settings:
# if birthday_settings['is_enabled']:
# for user in guild.members:
# user_productivity_data = db.select('guild_user_productivity',f'user_id = {user.id} AND guild_id = {guild.id}')
# if user_productivity_data:
# if user_productivity_data['is_enabled']:
# user = guild.get_member(user.id)
# if user:
# birthday = user_productivity_data['birthday']
# today = datetime.today().strftime('%d/%m')
# if birthday == today:
# channel = guild.get_channel(user_productivity_data['channel_id'])
# if channel:
# message = user_productivity_data['birthday_message']
# if message:
# message = message.replace('{user}',user.mention)
# await channel.send(message)
# else:
# LOGGER.debug(f'Channel {user_productivity_data["channel_id"]} not found.')
# else:
# LOGGER.debug(f'No birthday today for user {user.name} ({user.id}).')
# else:
# LOGGER.debug(f'User {user.name} ({user.id}) not found.')
# else:
# LOGGER.debug(f'User {user.name} ({user.id}) has birthday announcements disabled.')
# else:
# LOGGER.debug(f'No birthday data found for user {user.name} ({user.id}).')
# else:
# LOGGER.debug(f'No productivity settings found for guild {guild.name} ({guild.id}).')
# LOGGER.debug('birthday_anouncements_task ended.')
if EXEC_ARGS.log_level:
os.environ['LOG_LEVEL'] = str(EXEC_ARGS.log_level)
# @bot.slash_command(name='help',description='Displays the help message.')
# async def help(ctx:discord.ApplicationContext):
# """Displays this message."""
# command_name = 'help'
# LOGGER.debug(f'{ctx.author.name} used /{command_name}.')
# embed = discord.Embed(title='Help',description='List of commands and their descriptions.',color=discord.Color.from_rgb(171,0,219))
# embed.set_author(name=bot.user.name,icon_url=bot.user.avatar.url)
# for command in bot.commands:
# embed.add_field(name=command.name,value=command.description,inline=False)
# await ctx.send_response(embed=embed,ephemeral=True)
# @bot.slash_command(name='reload',description='Reloads the bot.')
# @option(name='feature',description='The feature to reload.',required=True,choices=['birthday','productivity'])
# @commands.is_owner()
# async def reload(ctx:discord.ApplicationContext, feature:str):
# """Reloads the bot."""
# command_name = 'reload'
# guild = ctx.guild
# channel = ctx.channel
# LOGGER.debug(f'{ctx.author.name} used /{command_name} {feature}.')
# try:
# reload_feature(feature)
# await ctx.send_response(f'{feature} reloaded!',ephemeral=True)
# except Exception as e:
# LOGGER.error(f'Error reloading feature {feature}: {e}')
# await ctx.send_response(f'Error reloading feature {feature}: {e}',ephemeral=True)
from setup.bot import bot, reload_feature, register_cogs
from setup.logger import LOGGER
from setup.config import Config
# @bot.event
# async def on_member_join(member:discord.Member):
# if not member.bot:
# user_id = member.id
# user_name = member.name
# user_mention = member.mention
# db_user = db.select('global_user',f'WHERE user_id = {user_id}')
# if not db_user:
# db.insert('global_user',user_id=user_id,user_name=user_name,user_mention=user_mention)
# @bot.event
# async def on_raw_member_remove(payload:discord.RawMemberRemoveEvent):
# user = payload.user
# if not user.bot:
# user_id = user.id
# guild_id = payload.guild_id
# db.delete('guild_user_birthday',f'user_id = {user_id} AND guild_id = {guild_id}')
# db.delete('guild_user_productivity',f'user_id = {user_id} AND guild_id = {guild_id}')
# @bot.event
# async def on_guild_join(guild:discord.Guild):
# guild_id = guild.id
# guild_name = guild.name
# LOGGER.info(f'Bot joined guild {guild_name} ({guild_id}).')
# db.insert('guild',guild_id=guild_id)
# db.insert('birthday_settings',guild_id=guild_id,is_enabled=False,channel_id=random.choice(guild.text_channels).id)
# db.insert('productivity_settings',guild_id=guild_id,is_enabled=False)
# @bot.event
# async def on_guild_remove(guild:discord.Guild):
# guild_id = guild.id
# guild_name = guild.name
# LOGGER.info(f'Bot left guild {guild_name} ({guild_id}).')
# db.delete('birthday_settings',f'guild_id = {guild_id}')
# db.delete('productivity_settings',f'guild_id = {guild_id}')
# db.delete('guild',f'guild_id = {guild_id}')
# 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__':
load_dotenv()
TOKEN = os.getenv("TOKEN")
if TOKEN:
bot.run(TOKEN)
token = Config.TOKEN
if token:
bot.run(token)
else:
LOGGER.error('No token found.')
sys.exit(1)

View File

@@ -4,7 +4,10 @@ from discord.ext import bridge
# from cogs.productivity import ProductivityCog
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
@@ -15,15 +18,98 @@ bot_intents.reactions = True
bot = bridge.Bot(command_prefix='$', intents=bot_intents)
COGS = [CSCog, InsultsCog]
COG_REGISTRY = {
'cs': CSCog,
'insult': InsultsCog,
'roulette': RouletteCog,
'roles': RolesCog,
}
for cog in COGS:
bot.add_cog(cog(bot))
# Register a global admin slash command directly on the bot to avoid circular imports.
from discord import option as discord_option
def reload_feature(feature:str):
@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:
return list(COG_REGISTRY.keys())
if isinstance(cogs, str):
cogs = [cogs]
selected_cogs = []
for cog_name in cogs:
name = str(cog_name).strip().lower()
if not name:
continue
if name in {'all', '*'}:
return list(COG_REGISTRY.keys())
if name in COG_REGISTRY:
selected_cogs.append(name)
else:
LOGGER.warning("Unknown cog '%s'. Available cogs: %s", cog_name, ', '.join(COG_REGISTRY.keys()))
return list(dict.fromkeys(selected_cogs))
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())
for existing_name in list(bot.cogs.keys()):
if existing_name in {cog_class.__name__ for cog_class in existing_cog_names}:
bot.remove_cog(existing_name)
for cog_name in selected_cogs:
cog_class = COG_REGISTRY[cog_name]
bot.add_cog(cog_class(bot))
return selected_cogs
def reload_feature(feature: str):
normalized_feature = str(feature).strip().lower()
LOGGER.debug(bot.cogs)
if normalized_feature in {'all', '*'}:
register_cogs()
LOGGER.debug('All cogs reloaded!')
return
if normalized_feature not in COG_REGISTRY:
LOGGER.warning("Unknown cog '%s'. Available cogs: %s", feature, ', '.join(COG_REGISTRY.keys()))
return
LOGGER.debug(f'Reloading {feature}...')
bot.remove_cog(feature.capitalize()+'Cog')
cog = globals()[feature.capitalize()+'Cog']
bot.add_cog(cog(bot))
# 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:
bot.remove_cog(existing_name)
break
bot.add_cog(COG_REGISTRY[normalized_feature](bot))
LOGGER.debug(f'{feature} reloaded!')

72
setup/config.py Normal file
View File

@@ -0,0 +1,72 @@
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()]
@classmethod
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,21 @@
import logging
import os
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)
LOGGER.setLevel(level)
# File handler (append)
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)