11 Commits

19 changed files with 1175 additions and 215 deletions

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
.gitea/
__pycache__/
.vscode
tests/

39
.gitea/workflows/cd.yaml Normal file
View File

@@ -0,0 +1,39 @@
name: CD
on:
push:
branches: [main, v2]
jobs:
deploy:
name: Deploy to VPS
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Copy files to VPS
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_SSH_PORT }}
source: "compose.yaml"
target: "/opt/docker/botmafieux"
- name: Deploy on VPS
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_SSH_PORT }}
script: |
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

9
.gitignore vendored
View File

@@ -159,4 +159,11 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
data/ data/
.vscode/
*.disabled
config/
tests/

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM python:3.13.14-alpine
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}
ENTRYPOINT ["python", "main.py"]

View File

@@ -1,2 +1,31 @@
# botmafieux # botmafieux
Le Bot Discord du serveur des Mafieux Le Bot Discord du serveur des Mafieux.
## Fonctionnalités
### Annonces d'anniversaire
Chaque utilisateur peut paramétrer sa date d'anniversaire, un message personnalisé qui sera envoyé à minuit le jour J et l'activation de l'envoi à l'aide de la commande `birthday_set` dans le salon défini.
La fonctionnalité peut être configurée par un administrateur avec la commande `birthday_config` qui peut activer/désactiver la fonctionnalité sur le serveur courant et choisir le salon dans lequel les annonces d'anniversaires seront envoyées.
### Rappels de productivité
Chaque utilisateur peut paramétrer un message à envoyer tous les $x$ jours dans le salon choisi et son activation à l'aide de la commande `productivity_set`. Un message sera envoyé tous les $x$ jours après le dernier message de l'utilisateur dans le salon défini.
La fonctionnalité peut être configurée par un administrateur avec la commande `productivity_config` qui peut activer/désactiver la fonctionnalité sur le serveur courant.
## Commandes
### Commandes utilisateurs
| Commande | Description | Options requises | Options facultatives |
| --- | --- | --- | --- |
| `help` | Afficher la description des commandes | | |
| `birthday_set` | Paramétrer son anniversaire | `date`: date d'anniversaire au format "*JJ/MM*"<br>`announcements`: activation/désactivation des annonces d'anniversaire | `message`: message personnalisé à envoyer à minuit le jour J<br>`for_user`: utilisateur à paramétrer (administrateurs uniquement) |
| `productivity_set` | Paramétrer ses rappels de productivité | `days`: nombre de jours entre chaque rappel<br>`channel`: salon dans lequel envoyer les rappels<br>`enable`: activation/désactivation des rappels de productivité | `message`: message à envoyer<br>`for_user`: utilisateur à paramétrer (administrateurs uniquement) |
### Commandes administrateurs
| Commande | Description | Options requises | Options facultatives |
| --- | --- | --- | --- |
| `birthday_config` | Configurer les annonces d'anniversaire sur le serveur | `enable`: activation/désactivation des annonces d'anniversaire<br>`channel`: salon dans lequel envoyer les annonces d'anniversaire | |
| `productivity_config` | Configurer les rappels de productivité sur le serveur | `enable`: activation/désactivation des rappels de productivité | |
| `reload` | Recharger une fonctionnalité | `feature`: fonctionnalité à recharger | |

391
cogs/cs.py Normal file
View File

@@ -0,0 +1,391 @@
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 *
BASE_URL = "https://www.hltv.org"
PAGE_FILE = os.path.join(Config.CONFIG_PATH,"hltv.html")
MATCHES_FILE = os.path.join(Config.CONFIG_PATH,"matches.json")
# Cache duration in seconds (6 hours)
DATA_CACHE_DURATION = 3600 * 6
# Match age threshold in seconds (2 days)
OLD_MATCH_DURATION = 3600 * 24 * 2
# Reaction emojis for match events
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] = []
self.message_match_map = {} # Maps message IDs to Match objects
self.get_matches.start()
def is_up_to_date(self) -> bool:
"""Check if PAGE_FILE and MATCHES_FILE are up to date."""
try:
page_modified = datetime.datetime.fromtimestamp(os.stat(PAGE_FILE).st_ctime)
matches_modified = datetime.datetime.fromtimestamp(os.stat(MATCHES_FILE).st_ctime)
now = datetime.datetime.now()
page_age = now.timestamp() - page_modified.timestamp()
matches_age = now.timestamp() - matches_modified.timestamp()
return page_age <= DATA_CACHE_DURATION and matches_age <= DATA_CACHE_DURATION
except FileNotFoundError:
return False
def get_page(self) -> None:
"""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")
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."""
matches_data = [match.to_dict() for match in self.matches]
with open(MATCHES_FILE, 'w') as f:
json.dump(matches_data, f, indent=2)
def remove_old_matches(self) -> None:
"""Remove matches that are older than OLD_MATCH_DURATION."""
now = datetime.datetime.now()
filtered_matches = []
for match in self.matches:
if match.date is None:
# Keep matches with no date
filtered_matches.append(match)
else:
match_age = now.timestamp() - match.date.timestamp()
if match_age <= OLD_MATCH_DURATION:
filtered_matches.append(match)
self.matches = filtered_matches
@tasks.loop(minutes=5)
async def get_matches(self):
if self.is_up_to_date():
# Load from MATCHES_FILE
try:
with open(MATCHES_FILE) as f:
matches_data = json.load(f)
self.matches = [Match.from_dict(match_data) for match_data in matches_data]
self.remove_old_matches()
except Exception as e:
LOGGER.error(f"Error loading matches from cache: {e}")
self.matches = []
else:
# Fetch new data
self.get_page()
# Read and parse the page
with open(PAGE_FILE) as f:
html = f.read()
page = BeautifulSoup(html)
all_matches = []
for section in page.find_all("div", {"class": "matches-chronologically"}):
all_matches.extend(section.find_all("div", {"class": "match-wrapper"}))
self.matches = []
for match_event in all_matches:
event = match_event.find("div", {"class": "match-event"})
event_name = event.attrs["data-event-headline"]
# Extract event logo
event_logo_container = event.find("div", {"class": "match-event-logo-container"})
event_logo_url = None
if event_logo_container:
logo_img = event_logo_container.find("img")
if logo_img and logo_img.attrs.get("src"):
event_logo_url = logo_img.attrs["src"]
event_obj = Event(int(event.attrs["data-event-id"]), event_name, event_logo_url)
href = str(match_event.find("a", {"class": "match-top"}).attrs["href"])
match_href = "https://hltv.org" + href
match_time_elt = match_event.find("div", {"class": "match-time"})
if match_time_elt:
match_timestamp = int(match_time_elt.attrs["data-unix"])
match_date = datetime.datetime.fromtimestamp(match_timestamp/1000)
else:
match_date = None
match_format = match_event.find("div", {"class": "match-meta"}).text
teams = match_event.find_all("div", {"class": "match-team"})
match_teams = []
for match_team in teams:
team = match_team.find("div", {"class": "match-teamname"})
team_obj = Team(team.text, match_team.find("img").attrs["src"])
match_teams.append(team_obj)
if len(match_teams) == 0:
continue
match_obj = Match(match_href, match_format, event_obj, match_teams, match_date)
self.matches.append(match_obj)
# Remove old matches and save to cache
self.remove_old_matches()
self.save()
if self.matches:
print(str(self.matches[0]))
def check_event(self, match: Match, event: discord.ScheduledEvent) -> bool:
"""
Check if a scheduled event matches the given match.
Returns True if both team names appear in the event name (case-insensitive).
"""
event_name_lower = event.name.lower()
team1_name_lower = match.teams[0].name.lower()
team2_name_lower = match.teams[1].name.lower()
# Check if both team names are in the event name
return team1_name_lower in event_name_lower and team2_name_lower in event_name_lower
async def create_guild_event(self, guild: discord.Guild, match: Match) -> discord.ScheduledEvent | None:
"""
Create a scheduled guild event for the match.
Returns the created event or None if it already exists or creation fails.
"""
try:
# Check if event already exists
for event in guild.scheduled_events:
if self.check_event(match, event):
return event
# Create event name
event_name = f"{match.teams[0].name} vs {match.teams[1].name}"
# Set start and end times - use UTC timezone to match the embed display
if match.date:
# Convert the timestamp to UTC-aware datetime
timestamp = int(match.date.timestamp())
start_time = datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc)
end_time = start_time + datetime.timedelta(hours=2) # Assume 2 hour match
else:
start_time = datetime.datetime.now(tz=datetime.timezone.utc)
end_time = start_time + datetime.timedelta(hours=2)
# Create the event
event = await guild.create_scheduled_event(
name=event_name,
start_time=start_time,
end_time=end_time,
description=f"Event: {match.event.name}\nFormat: {match.format}\nWatch on HLTV: {match.url}",
location=""
)
LOGGER.info(f"Created guild event for {event_name} in {guild.name}")
return event
except Exception as e:
LOGGER.error(f"Error creating guild event in {guild.name}: {e}")
return None
async def remove_guild_event(self, guild: discord.Guild, match: Match) -> bool:
"""
Remove the scheduled guild event for the match.
Returns True if an event was removed, False otherwise.
"""
try:
for event in guild.scheduled_events:
if self.check_event(match, event):
await event.delete()
LOGGER.info(f"Removed guild event {event.name} from {guild.name}")
return True
return False
except Exception as e:
LOGGER.error(f"Error removing guild event from {guild.name}: {e}")
return False
def create_match_embed(self, match: Match) -> discord.Embed:
"""
Create a Discord embed message containing match information.
Args:
match: The Match object to display
Returns:
discord.Embed: A formatted embed with match details
"""
# Create embed with title as the matchup
title = f"{match.teams[0].name} vs {match.teams[1].name}"
embed = discord.Embed(
title=title,
description=f"**Event:** {match.event.name}",
url=match.url,
color=discord.Color.blue()
)
# Add match format
embed.add_field(
name="Format",
value=match.format,
inline=True
)
# Add match date if available
if match.date:
embed.add_field(
name="Date & Time",
value=f"<t:{int(match.date.timestamp())}:F>",
inline=True
)
# Add team information with logos
teams_info = f"[{match.teams[0].name}]({match.url})\n[{match.teams[1].name}]({match.url})"
embed.add_field(
name="Teams",
value=teams_info,
inline=False
)
# Set thumbnail to event logo if available, otherwise use first team logo
if match.event.logo_url:
embed.set_thumbnail(url=match.event.logo_url)
elif match.teams[0].logo_url:
embed.set_thumbnail(url=match.teams[0].logo_url)
# Set footer with HLTV link
embed.set_footer(
text="HLTV.org",
icon_url="https://www.hltv.org/img/static/TopLogo2x.png"
)
return embed
@bridge.bridge_command()
async def test_match_embed(self, ctx:bridge.BridgeExtContext):
"""Send the first match as an embed to the test channel."""
TEST_CHANNEL_ID = 1305147782071451718
# Check if there are matches available
if not self.matches:
await ctx.send("❌ No matches available. Try running the scraper first.")
return
# Get the first match
first_match = self.matches[0]
# Create the embed
embed = self.create_match_embed(first_match)
# Send to test channel
try:
channel = self.bot.get_channel(TEST_CHANNEL_ID)
if channel is None:
await ctx.send(f"❌ Could not find channel with ID {TEST_CHANNEL_ID}")
return
message = await channel.send(embed=embed)
# Add reactions
await message.add_reaction(EMOJI_CREATE_EVENT)
await message.add_reaction(EMOJI_REMOVE_EVENT)
# Track the message -> match mapping
self.message_match_map[message.id] = first_match
await ctx.reply(f"✅ Sent embed for match: {first_match.teams[0].name} vs {first_match.teams[1].name}")
except Exception as e:
LOGGER.error(f"Error sending embed: {e}")
await ctx.send(f"❌ Error sending embed: {e}")
@commands.Cog.listener()
async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
"""Handle reactions added to match embed messages."""
# Ignore reactions from the bot itself
if payload.user_id == self.bot.user.id:
return
# Check if this is a tracked message
if payload.message_id not in self.message_match_map:
return
match = self.message_match_map[payload.message_id]
emoji = payload.emoji.name
# Get the guild
guild = self.bot.get_guild(payload.guild_id)
if guild is None:
return
# Get the message for replies
message = await self.get_reaction_message(payload)
if not message:
return
try:
if emoji == EMOJI_CREATE_EVENT:
# Create event
event = await self.create_guild_event(guild, match)
if event:
await message.reply(f"✅ Created guild event: **{event.name}**")
elif emoji == EMOJI_REMOVE_EVENT:
# Remove event
removed = await self.remove_guild_event(guild, match)
if removed:
await message.reply(
f"✅ Removed guild event for {match.teams[0].name} vs {match.teams[1].name}"
)
else:
await message.reply(
f"⚠️ No guild event found for this match"
)
except Exception as e:
LOGGER.error(f"Error handling reaction {emoji}: {e}")
async def get_reaction_message(self, payload: discord.RawReactionActionEvent) -> discord.Message | None:
"""Get the message object from a raw reaction payload."""
try:
channel = self.bot.get_channel(payload.channel_id)
if channel:
return await channel.fetch_message(payload.message_id)
except Exception as e:
LOGGER.error(f"Error fetching message: {e}")
return None
async def check_events(self):
"""Check scheduled events for matching CS matches."""
for match in self.matches:
for guild in self.bot.guilds:
events = [event for event in guild.scheduled_events if self.check_event(match, event)]
# print(guild.name, match)
# @commands.Cog.listener()
# async def on_ready(self):
# self.check_events.start()

32
cogs/insult.py Normal file
View File

@@ -0,0 +1,32 @@
import discord, json, os, random, re
import collections
from discord.ext import commands, bridge
from setup.logger import LOGGER
from setup.config import Config
class InsultsCog(commands.Cog):
def __init__(self, bot:bridge.Bot):
self.bot = bot
with open(os.path.join(Config.CONFIG_PATH, "insults.txt")) as f:
self.insults = [l.strip() for l in f.readlines()]
@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)
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))

149
cogs/intern.py Normal file
View File

@@ -0,0 +1,149 @@
import discord, json, os, random, re, datetime
import collections
from discord.ext import commands, bridge, tasks
from setup.logger import LOGGER
from setup.config import Config
class InternCog(commands.Cog):
def __init__(self, bot:commands.Bot) -> None:
self.bot = bot
self.in_voice_channel = False
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

212
cogs/roulette.py Normal file
View File

@@ -0,0 +1,212 @@
import asyncio
import datetime
import random
import discord
from discord import option
from discord.ext import commands
class RouletteCog(commands.Cog):
def __init__(self, bot: discord.Bot):
self.bot = bot
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,
):
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())

9
compose.yaml Normal file
View File

@@ -0,0 +1,9 @@
services:
botmafieux:
build:
context: .
dockerfile: ./Dockerfile
tags:
- nebulo9/botmafieux:v2
container_name: botmafieux
env_file: ./.env

103
main.py
View File

@@ -1,98 +1,33 @@
import os, discord, json, sys import os, discord, json, sys, random
from argparse import ArgumentParser from argparse import ArgumentParser
from datetime import datetime from datetime import datetime
from dotenv import load_dotenv from asyncio import create_task
from discord.ext import tasks, commands from discord.ext import tasks, commands
from modules.setup.bot import bot from setup.bot import bot, reload_feature, register_cogs
from modules.setup.logger import LOGGER from setup.logger import LOGGER
from modules.setup.data import DATA_DIR, get_guild_data from setup.config import Config
PARSER = ArgumentParser(description='BotMafieux for Discord.') PARSER = ArgumentParser(description='BotMafieux for Discord.')
PARSER.add_argument('--guild_id',type=int,help='The guild ID.') 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('--channel_id',type=int,help='The channel ID.')
PARSER.add_argument('-r','--reload',action='store_true',help='Reload the bot.') 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() EXEC_ARGS = PARSER.parse_args()
selected_cogs = EXEC_ARGS.cogs if EXEC_ARGS.cogs is not None else Config.get_cogs()
@tasks.loop(time=datetime.time(datetime.strptime('00:00','%H:%M'))) register_cogs(selected_cogs)
async def birthday_anouncements_task():
LOGGER.debug('birthday_anouncements_task started.')
for guild in bot.guilds:
guild_data = get_guild_data(guild.id)
if 'birthday_announcements_channel' in guild_data['features']['birthday'].keys():
channel = guild.get_channel(guild_data['features']['birthday']['birthday_announcements_channel'])
if 'birthdays' in guild_data.keys():
for user_id in guild_data['features']['birthday']['birthdays'].keys():
user = guild.get_member(int(user_id))
if user:
if guild_data['features']['birthday']['birthdays'][user_id]['announcements']:
date = guild_data['features']['birthday']['birthdays'][user_id]['date']
today = datetime.today().strftime('%d/%m')
if date == today:
await channel.send(f'Joyeux anniversaire {user.mention}!')
else:
LOGGER.debug(f'No birthday_announcements_channel set for guild {guild.name} ({guild.id}).')
LOGGER.debug('birthday_anouncements_task ended.')
@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.')
@commands.is_owner()
async def reload(ctx:discord.ApplicationContext):
"""Reloads the bot."""
command_name = 'reload'
guild = ctx.guild
channel = ctx.channel
LOGGER.debug(f'{ctx.author.name} used /{command_name}.')
await ctx.send_response('Reloading...',ephemeral=True)
args = [f'--guild_id={guild.id}',f'--channel_id={channel.id}','--reload']
os.execl(sys.executable, sys.executable, __file__, *args)
@bot.event
async def on_guild_join(guild:discord.Guild):
LOGGER.info(f'Bot joined guild {guild.name} ({guild.id}).')
path = os.path.join(DATA_DIR, f'{guild.id}.json')
if not os.path.exists(path):
LOGGER.info(f'Creating data file for guild {guild.id}')
with open(path, 'x') as f:
json.dump({}, f, indent=2)
@bot.event
async def on_guild_remove(guild:discord.Guild):
LOGGER.info(f'Bot left guild {guild.name} ({guild.id}).')
path = os.path.join(DATA_DIR, f'{guild.id}.json')
if os.path.exists(path):
LOGGER.info(f'Deleting data file for guild {guild.id}.')
os.remove(path)
@bot.event @bot.event
async def on_ready(): async def on_ready():
LOGGER.info(f'{bot.user.name} has connected to Discord!') if bot.user:
LOGGER.debug(f'Guilds: {bot.guilds}') LOGGER.info(f'{bot.user.name} has connected to Discord!')
if EXEC_ARGS.reload and EXEC_ARGS.guild_id and EXEC_ARGS.channel_id: LOGGER.debug(f"Guilds: {','.join([guild.name for guild in bot.guilds])}")
guild = bot.get_guild(EXEC_ARGS.guild_id)
channel = guild.get_channel_or_thread(EXEC_ARGS.channel_id)
await channel.send(f'{bot.user.name} Bot reloaded!',silent=True)
# Creates guild data files if they don't exist
for guild in bot.guilds:
guild_id = guild.id
path = os.path.join(DATA_DIR, f'{guild_id}.json')
if not os.path.exists(path):
LOGGER.info(f'Creating data file for guild {guild_id}')
with open(path, 'x') as f:
json.dump({'features': {}}, f, indent=2)
birthday_anouncements_task.start()
if __name__ == '__main__': if __name__ == '__main__':
load_dotenv() token = Config.TOKEN
TOKEN = os.getenv('TOKEN') if token:
bot.run(TOKEN) bot.run(token)
else:
LOGGER.error('No token found.')
sys.exit(1)

119
models/cs_models.py Normal file
View File

@@ -0,0 +1,119 @@
import datetime
class Event:
def __init__(self, event_id:int, name:str, logo_url:str = None) -> None:
self.__event_id = event_id
self.__name = name
self.__logo_url = logo_url
@property
def event_id(self):
return self.__event_id
@property
def name(self):
return self.__name
@property
def logo_url(self):
return self.__logo_url
def to_dict(self) -> dict:
return {
'event_id': self.event_id,
'name': self.name,
'logo_url': self.logo_url
}
@classmethod
def from_dict(cls, data: dict) -> 'Event':
return cls(data['event_id'], data['name'], data.get('logo_url'))
def __str__(self) -> str:
return f"<Event event_id={self.event_id} name='{self.name}'>"
class Team:
def __init__(self, name:str, logo_url:str) -> None:
self.__name = name
self.__logo_url = logo_url
@property
def name(self):
return self.__name
@property
def logo_url(self):
return self.__logo_url
def to_dict(self) -> dict:
return {
'name': self.name,
'logo_url': self.logo_url
}
@classmethod
def from_dict(cls, data: dict) -> 'Team':
return cls(data['name'], data['logo_url'])
def __str__(self) -> str:
return f"<Team logo_url='{self.logo_url}' name='{self.name}'>"
class Match:
def __init__(self, url:str, format:str, event:Event, teams:list[Team], date=None):
self.__date = date
self.__url = url
self.__format = format
self.__event = event
self.__teams = tuple(teams)
@property
def date(self):
return self.__date
@property
def url(self):
return self.__url
@property
def format(self):
return self.__format
@property
def event(self):
return self.__event
@property
def teams(self):
return self.__teams
@property
def date_iso(self):
return self.__date.isoformat()
@property
def teams_names(self):
return (self.__teams[0].name, self.__teams[1].name)
@property
def teams_logos(self):
return (self.__teams[0].logo_url, self.__teams[1].logo_url)
def to_dict(self) -> dict:
return {
'url': self.url,
'format': self.format,
'event': self.event.to_dict(),
'teams': [team.to_dict() for team in self.teams],
'date': self.date.isoformat() if self.date else None
}
@classmethod
def from_dict(cls, data: dict) -> 'Match':
event = Event.from_dict(data['event'])
teams = [Team.from_dict(team_data) for team_data in data['teams']]
date = datetime.datetime.fromisoformat(data['date']) if data['date'] else None
return cls(data['url'], data['format'], event, teams, date)
def __str__(self) -> str:
return f"<Match date='{self.date}' url='{self.url}' format='{self.url}' event={self.event} teams='{','.join(self.teams_names)}'>"

View File

@@ -1,98 +0,0 @@
import discord, re
from discord import option
from discord.ext import commands
from ..setup.logger import LOGGER
from ..setup.data import get_guild_data, save_guild_data
class BirthdayCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
@commands.slash_command(description='Sets birthday date. Must be in DAY/MONTH format.')
@option(name='date',description='Birthday date.',required=True)
@option(name='for_user',description='The user to set the birthday date for.',required=False,type=discord.Member)
async def birthday_set(self,ctx:discord.ApplicationContext, date:str, for_user:discord.Member):
"""Sets birthday date. Must be in DAY/MONTH format."""
command_name = 'birthday_set'
guild_id = ctx.guild.id
guild_data = get_guild_data(guild_id)
author = ctx.author
if re.match(r'\d{2}\/\d{2}',date): # Check if date is in DAY/MONTH format
if for_user:
if author.guild_permissions.administrator: # Check if author is an administrator in case they want to set the birthday for another user
LOGGER.debug(f'{author.name} used /{command_name} {date} for {for_user.name}.')
if 'birthday' not in guild_data['features'].keys():
guild_data['features']['birthday'] = dict()
if 'birthdays' not in guild_data['features']['birthday'].keys():
guild_data['features']['birthday']['birthdays'] = dict()
if str(for_user.id) not in guild_data['features']['birthday']['birthdays'].keys():
guild_data['features']['birthday'][str(for_user.id)] = dict()
guild_data['features']['birthday']['birthdays'][str(for_user.id)]['date'] = date
guild_data['features']['birthday']['birthdays'][str(for_user.id)]['announcements'] = True
save_guild_data(guild_id, guild_data)
await ctx.send_response(f'Birthday for {for_user.name} has been set to {date}.',ephemeral=True)
else:
LOGGER.debug(f'{author.name} tried to use /{command_name} {date} for {for_user.name} but is not an administrator.')
await ctx.send_response('You must be an administrator to run the command with "for_user".',ephemeral=True)
else:
# Set birthday for author
LOGGER.debug(f'{author.name} used /{command_name} {date}.')
if 'birthday' not in guild_data['features'].keys():
guild_data['features']['birthday'] = dict()
if 'birthdays' not in guild_data['features']['birthday'].keys():
guild_data['features']['birthday']['birthdays'] = dict()
if author.id not in guild_data['features']['birthday']['birthdays'].keys():
guild_data['features']['birthday']['birthdays'][str(author.id)] = dict()
guild_data['features']['birthday']['birthdays'][str(author.id)]['date'] = date
guild_data['features']['birthday']['birthdays'][str(author.id)]['announcements'] = True
save_guild_data(guild_id, guild_data)
# The status defines the human readable status of the announcements to be displayed in the response.
announcements_status = 'will be' if guild_data['features']['birthday']['birthdays'][str(author.id)]['announcements'] else 'will not be'
await ctx.send_response(f'{author.mention} has set their birthday to {date} and {announcements_status} announced.',ephemeral=True)
else:
LOGGER.debug(f'{author.name} used /set_birthday {date} but the format is not correct.')
await ctx.send_response('The date must is DAY/MONTH format.',ephemeral=True)
@commands.slash_command(description='Enable or disable birthday announcements.')
@option(name='enable',description='Enable birthday announcements.',required=True)
@option(name='for_user',description='The user to enable or disable birthday announcements for.',required=False,type=discord.Member)
async def birthday_announcements(self,ctx:discord.ApplicationContext, enable:bool, for_user:discord.Member):
"""Enable or disable birthday announcements."""
command_name = 'birthday_announcements'
guild_id = ctx.guild.id
guild_data = get_guild_data(guild_id) or dict()
author = ctx.author
if for_user:
if author.guild_permissions.administrator: # Check if author is an administrator in case they want to set the birthday for another user
LOGGER.debug(f'{author.name} used /{command_name} {enable} for {for_user.name}.')
guild_data['features']['birthday']['birthdays'][str(for_user.id)]['announcements'] = enable
save_guild_data(guild_id, guild_data)
await ctx.send_response(f'Announcements for {for_user.name} are set to {enable}',ephemeral=True)
else:
LOGGER.debug(f'{author.name} tried to use /{command_name} {enable} for {for_user.name} but is not an administrator.')
await ctx.send_response('You must be an administrator to run the command with "for_user".',ephemeral=True)
else:
LOGGER.debug(f'{author.name} used /{command_name} {enable}.')
guild_data['features']['birthday']['birthdays'][str(author.id)]['announcements'] = enable
save_guild_data(guild_id, guild_data)
await ctx.send_response(f'Your birthday announcements have been set to {enable}',ephemeral=True)
@commands.slash_command(description='Sets the channel to send birthday announcements to.')
@option(name='channel',description='The channel to send birthday announcements to.',required=True,type=discord.TextChannel)
async def birthday_announcements_channel(self,ctx:discord.ApplicationContext, channel:discord.TextChannel):
"""Sets the channel to send birthday announcements to."""
command_name = 'birthday_announcements_channel'
guild_id = ctx.guild.id
guild_data = get_guild_data(guild_id) or dict()
author = ctx.author
if author.guild_permissions.administrator:
LOGGER.debug(f'{author.name} used /{command_name} {channel.name}.')
guild_data['features']['birthday']['birthday_announcements_channel'] = channel.id
save_guild_data(guild_id, guild_data)
await ctx.send_response(f'Birthday announcements will be sent to {channel.mention}.',ephemeral=True)
else:
LOGGER.debug(f'{author.name} tried to use /{command_name} {channel.mention} but is not an administrator.')
await ctx.send_response('You must be an administrator to run this command.',ephemeral=True)

View File

@@ -1,13 +0,0 @@
import discord
from ..cogs.birthday import BirthdayCog
bot_intents = discord.Intents.default()
bot_intents.message_content = True
bot_intents.members = True
bot_intents.presences = True
bot_intents.guilds = True
bot_intents.reactions = True
bot = discord.Bot(command_prefix='$', intents=bot_intents)
bot.add_cog(BirthdayCog(bot))

View File

@@ -1,15 +0,0 @@
import os, json
DATA_DIR = 'data/'
def get_guild_data(guild_id:int):
"""Reads and returns the dictionary of guild data from the guild's JSON data file."""
path = os.path.join(DATA_DIR, f'{guild_id}.json')
with open(path, 'r') as f:
return json.load(f)
def save_guild_data(guild_id:int, data):
"""Saves the dictionary of guild data to the guild's JSON data file."""
path = os.path.join(DATA_DIR, f'{guild_id}.json')
with open(path, 'w') as f:
json.dump(data, f, indent=2)

View File

@@ -1,2 +1,37 @@
python-dotenv aiohappyeyeballs==2.7.1
py-cord aiohttp==3.14.1
aiosignal==1.4.0
asarPy==1.0.1
async-timeout==5.0.1
attrs==26.1.0
audioop-lts==0.2.2
beautifulsoup4==4.15.0
certifi==2026.5.20
cffi==2.1.0
charset-normalizer==3.4.7
frozenlist==1.8.0
h11==0.16.0
idna==3.18
lxml==6.1.1
multidict==6.7.1
outcome==1.3.0.post0
propcache==0.5.2
psycopg2-binary==2.9.12
py-cord==2.8.0
pycparser==3.0
PyNaCl==1.6.2
PySocks==1.7.1
python-dotenv==1.2.2
requests==2.34.2
selenium==4.45.0
sniffio==1.3.1
sortedcontainers==2.4.0
soupsieve==2.8.4
trio==0.33.0
trio-websocket==0.12.2
typing_extensions==4.16.0
urllib3==2.7.0
wavelink==3.5.2
websocket-client==1.9.0
wsproto==1.3.2
yarl==1.24.2

86
setup/bot.py Normal file
View File

@@ -0,0 +1,86 @@
import discord
from discord.ext import commands
from cogs.cs import CSCog
from cogs.insult import InsultsCog
from cogs.roulette import RouletteCog
from cogs.intern import InternCog
from setup.logger import LOGGER
bot_intents = discord.Intents.default()
bot_intents.message_content = True
bot_intents.members = True
bot_intents.presences = True
bot_intents.guilds = True
bot_intents.reactions = True
bot_intents.voice_states = True
bot = commands.Bot(command_prefix='$', intents=bot_intents)
COG_REGISTRY = {
'cs': CSCog,
'insult': InsultsCog,
'roulette': RouletteCog,
'intern': InternCog
}
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):
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}...')
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!')

20
setup/config.py Normal file
View File

@@ -0,0 +1,20 @@
import os
from dotenv import load_dotenv
class Config:
load_dotenv()
TOKEN = str(os.getenv("TOKEN", ""))
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")
COGS = os.getenv("COGS", "")
@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()]

View File

@@ -3,6 +3,6 @@ import logging
logging.basicConfig(level=logging.INFO, format='[%(levelname)s] %(asctime)s: %(name)s: %(message)s') logging.basicConfig(level=logging.INFO, format='[%(levelname)s] %(asctime)s: %(name)s: %(message)s')
LOGGER = logging.getLogger('botmafieux') LOGGER = logging.getLogger('botmafieux')
LOGGER.setLevel(logging.DEBUG) LOGGER.setLevel(logging.DEBUG)
handler = logging.FileHandler(filename='botmafieux.log', encoding='utf-8', mode='w') handler = logging.FileHandler(filename='botmafieux.log', encoding='utf-8', mode='a')
handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s:%(name)s: %(message)s')) handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s:%(name)s: %(message)s'))
LOGGER.addHandler(handler) LOGGER.addHandler(handler)