9 Commits

Author SHA1 Message Date
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
746ce33977 feat: Add CD pipeline 2026-07-05 00:31:54 +02:00
80d34e9660 feat: Add misandrist insults and big CS2 events matches fetch from HLTV.org 2026-07-04 22:36:07 +02:00
2cf7cc94bb Updated README 2023-11-22 12:40:26 +01:00
425ce22e2c Updated README 2023-11-22 12:39:25 +01:00
b276afd6bb Removed obsolete imports 2023-11-21 15:36:33 +01:00
08da73fbda Added psycopg2 module in requirements 2023-11-21 15:29:38 +01:00
b4b6db683a Changed data storage to a distant database 2023-11-21 12:06:09 +01:00
23c4047bf7 Added productivity feature 2023-11-19 13:47:16 +01:00
15 changed files with 792 additions and 208 deletions

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

3
.gitignore vendored
View File

@@ -160,3 +160,6 @@ cython_debug/
#.idea/
data/
.vscode/
*.disabled

14
Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM python:3.13.14-alpine
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV TOKEN=""
ENV URL=""
ENTRYPOINT ["python", "main.py"]

View File

@@ -1,2 +1,31 @@
# 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 | |

375
cogs/cs.py Normal file
View File

@@ -0,0 +1,375 @@
import discord, datetime, os, re, json
from selenium import webdriver
from bs4 import BeautifulSoup
from discord.ext import commands, tasks, bridge
from setup.logger import LOGGER
from models.cs_models import *
BASE_URL = "https://www.hltv.org"
PAGE_FILE = os.path.join("data","hltv.html")
MATCHES_FILE = os.path.join("data","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 Firefox webdriver and save to PAGE_FILE."""
driver = webdriver.Firefox()
driver.get("https://www.hltv.org/matches/")
html = driver.page_source
driver.close()
with open(PAGE_FILE, 'w') as f:
f.write(html)
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()

31
cogs/insult.py Normal file
View File

@@ -0,0 +1,31 @@
import discord, json, os, random, re
import collections
from discord.ext import commands
from setup.logger import LOGGER
class InsultsCog(commands.Cog):
def __init__(self, bot:discord.Bot):
self.bot = bot
with open(os.path.join("data", "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))

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

187
main.py
View File

@@ -1,98 +1,131 @@
import os, discord, json, sys
import os, discord, json, sys, random
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 modules.setup.bot import bot
from modules.setup.logger import LOGGER
from modules.setup.data import DATA_DIR, get_guild_data
from setup.bot import bot, reload_feature
from setup.logger import LOGGER
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.')
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:
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.')
# @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.')
@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='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.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)
@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_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_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
# 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}')
@bot.event
async def on_ready():
LOGGER.info(f'{bot.user.name} has connected to Discord!')
LOGGER.debug(f'Guilds: {bot.guilds}')
if EXEC_ARGS.reload and EXEC_ARGS.guild_id and EXEC_ARGS.channel_id:
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 bot.user:
LOGGER.info(f'{bot.user.name} has connected to Discord!')
LOGGER.debug(f'Guilds: {','.join([guild.name for guild in bot.guilds])}')
if __name__ == '__main__':
load_dotenv()
TOKEN = os.getenv('TOKEN')
bot.run(TOKEN)
TOKEN = os.getenv("TOKEN")
if 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,31 @@
python-dotenv
py-cord
aiohappyeyeballs==2.6.2
aiohttp==3.14.1
aiosignal==1.4.0
asarPy==1.0.1
attrs==26.1.0
beautifulsoup4==4.15.0
certifi==2026.5.20
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
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.15.0
urllib3==2.7.0
websocket-client==1.9.0
wsproto==1.3.2
yarl==1.24.2

29
setup/bot.py Normal file
View File

@@ -0,0 +1,29 @@
import discord
from discord.ext import bridge
# from cogs.birthday import BirthdayCog
# from cogs.productivity import ProductivityCog
from cogs.cs import CSCog
from cogs.insult import InsultsCog
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 = bridge.Bot(command_prefix='$', intents=bot_intents)
COGS = [CSCog, InsultsCog]
for cog in COGS:
bot.add_cog(cog(bot))
def reload_feature(feature:str):
LOGGER.debug(bot.cogs)
LOGGER.debug(f'Reloading {feature}...')
bot.remove_cog(feature.capitalize()+'Cog')
cog = globals()[feature.capitalize()+'Cog']
bot.add_cog(cog(bot))
LOGGER.debug(f'{feature} reloaded!')

View File

@@ -3,6 +3,6 @@ import logging
logging.basicConfig(level=logging.INFO, 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='w')
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)