10 Commits

Author SHA1 Message Date
60e95791a9 Merge pull request 'fix: Fix pipeline, add ci and adapted compose' (#2) from pipelines into main
Some checks failed
CD / Build and push Docker image (push) Failing after 1s
CD / Deploy to VPS (push) Has been skipped
CI / Run tests (push) Failing after 1s
Reviewed-on: #2
2026-07-16 19:18:52 +00:00
3fb926ec20 fix: Fix pipeline, add ci and adapted compose
Some checks failed
CI / Run tests (pull_request) Failing after 20s
2026-07-16 21:15:02 +02:00
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
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
24 changed files with 7972 additions and 748 deletions

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

@@ -0,0 +1,67 @@
name: CD
on:
push:
branches: [main]
concurrency:
group: cd-${{ github.ref_name }}
cancel-in-progress: true
jobs:
build-and-push:
name: Build and push Docker image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Restore Docker build cache
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Build and push image
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: |
${{ secrets.DOCKER_USERNAME }}/botmafieux:latest
${{ secrets.DOCKER_USERNAME }}/botmafieux:${{ github.sha }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache,mode=max
deploy:
name: Deploy to VPS
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Copy compose file 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: |
set -euo pipefail
cd /opt/docker/botmafieux
docker compose -f compose.yaml --env-file .env pull
docker compose -f compose.yaml --env-file .env up -d --remove-orphans

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

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

6
.gitignore vendored
View File

@@ -160,4 +160,8 @@ cython_debug/
#.idea/
data/
.vscode/
.vscode/
*.disabled
config/

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
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
from setup.logger import LOGGER
from setup.config import Config
class InsultsCog(commands.Cog):
def __init__(self, bot:discord.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))

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())

22
compose.yaml Normal file
View File

@@ -0,0 +1,22 @@
networks:
internal:
internal: true
outbound:
external: true
services:
botmafieux:
image: nebulo9/botmafieux:latest
container_name: botmafieux
restart: unless-stopped
networks:
- internal
- outbound
env_file: ./.env
volumes:
- ./config:/config
deploy:
resources:
limits:
cpus: '0.25'
memory: '256M'

6778
config/hltv.html Normal file

File diff suppressed because one or more lines are too long

27
config/insults.txt Normal file
View File

@@ -0,0 +1,27 @@
Ta gueule.
Qui a demandé ?
Mais tais-toi!
zZzZzZzZ......
Tout ça ?
On peut se passer du message je pense.
C'est chiant le message là.
Mdr on s'en fout.
Pas lu.
Trop long j'ai décroché.
Il faut quitter le serveur, vous gênez l'espace s'il vous plaît.
Gênant...
Si je devais noter la pertinence du message ce serait genre 1/10.
Tu es ridicule, arrête d'écrire.
Ça a intéréssé quelqu'un ?
Franchement c'était nécessaire d'écrire ça ?
Honestly the most useless thing I read today.
Dommage que les hommes soient cons…
Ta mère mérite la prison pour avoir mis au monde un HOMME.
T'as pas beaucoup de neurones.
Arrêtons de normaliser les hommes svp.
🚮🚮
Ton message me donne envie de vomir.
J'ai pas ri perso.
Tiens tiens encore une raison de détester les hommes !
Y'a moyen ton père c'est Macron.
Tu préfères devenir un être humain sympa ou rester un homme ?

1
config/intern.json Normal file
View File

@@ -0,0 +1 @@
[{"mentor_id": 264189554582355969, "guild_id": 897157935006900254, "next_roll": "2026-07-22T00:00:00"}]

22
config/intern_takes.csv Normal file
View File

@@ -0,0 +1,22 @@
target;weight;take
any;1.0;Tu as besoin d'un café ?
any;1.0;Je peux te faire un café si tu veux ?
any;1.0;Tu as besoin d'un thé ?
any;1.0;Je peux te faire un thé si tu veux ?
any;1.0;Tu as besoin d'un matcha latte?
any;1.0;Je peux te faire un matcha latte si tu veux ?
any;1.0;Tu peux m'aider sur un truc ?
any;1.0;Désolé, j'ai pas compris.
any;1.0;Attends, tu peux réexpliquer ?
any;1.0;J'ai fait une bêtise je crois...
nebulo;2.0;J'ai un bug dans mon code tu peux regarder ?
bluebro;2.0;J'ai un bug dans mon code tu peux regarder ?
viebah;2.0;J'ai un bug dans mon code tu peux regarder ?
merdix;2.0;Mon modèle 3D est cassé, je fais quoi ?
goupzy;2.0;Mon modèle 3D est cassé, je fais quoi ?
viebah;2.0;Mon modèle 3D est cassé, je fais quoi ?
goupzy;2.0;Mon modèle 3D est cassé, je fais quoi ?
nino274;2.0;Mon modèle 3D est cassé, je fais quoi ?
cemesah;2.0;J'ai fait une erreur, il faut réimprimer les cartes...
cemesah;2.0;J'ai pas sauvegardé et InDesign a planté...
cemesah;2.0;Tu veux du bouillon ?
1 target weight take
2 any 1.0 Tu as besoin d'un café ?
3 any 1.0 Je peux te faire un café si tu veux ?
4 any 1.0 Tu as besoin d'un thé ?
5 any 1.0 Je peux te faire un thé si tu veux ?
6 any 1.0 Tu as besoin d'un matcha latte?
7 any 1.0 Je peux te faire un matcha latte si tu veux ?
8 any 1.0 Tu peux m'aider sur un truc ?
9 any 1.0 Désolé, j'ai pas compris.
10 any 1.0 Attends, tu peux réexpliquer ?
11 any 1.0 J'ai fait une bêtise je crois...
12 nebulo 2.0 J'ai un bug dans mon code tu peux regarder ?
13 bluebro 2.0 J'ai un bug dans mon code tu peux regarder ?
14 viebah 2.0 J'ai un bug dans mon code tu peux regarder ?
15 merdix 2.0 Mon modèle 3D est cassé, je fais quoi ?
16 goupzy 2.0 Mon modèle 3D est cassé, je fais quoi ?
17 viebah 2.0 Mon modèle 3D est cassé, je fais quoi ?
18 goupzy 2.0 Mon modèle 3D est cassé, je fais quoi ?
19 nino274 2.0 Mon modèle 3D est cassé, je fais quoi ?
20 cemesah 2.0 J'ai fait une erreur, il faut réimprimer les cartes...
21 cemesah 2.0 J'ai pas sauvegardé et InDesign a planté...
22 cemesah 2.0 Tu veux du bouillon ?

159
main.py
View File

@@ -1,167 +1,34 @@
import os, discord, json, sys, random
import modules.setup.db as db
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, reload_feature
from modules.setup.logger import LOGGER
from modules.setup.data import get_guild_data, create_guild_data, delete_guild_data, save_guild_data
from modules.cogs.productivity import TASKS as PRODUCTIVITY_TASKS, send_reminder
from setup.bot import bot, reload_feature, register_cogs
from setup.logger import LOGGER
from setup.config import Config
PARSER = ArgumentParser(description='BotMafieux for Discord.')
PARSER.add_argument('--guild_id',type=int,help='The guild ID.')
PARSER.add_argument('--channel_id',type=int,help='The channel ID.')
PARSER.add_argument('-r','--reload',action='store_true',help='Reload the bot.')
PARSER.add_argument('--cogs',nargs='+',default=None,help='Select which cogs to load (for example: --cogs cs insult).')
EXEC_ARGS = PARSER.parse_args()
@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='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_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}')
selected_cogs = EXEC_ARGS.cogs if EXEC_ARGS.cogs is not None else Config.get_cogs()
register_cogs(selected_cogs)
@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)
birthday_anouncements_task.start()
for guild in bot.guilds:
for user in guild.members:
user_id = user.id
db_user = db.select('global_user',f'user_id = {user_id}')
if not db_user:
db.insert('global_user',user_id=user_id,user_name=user.name,user_mention=user.mention)
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])}")
productivity_settings = db.select('productivity_settings',f'guild_id = {guild.id}')
if productivity_settings:
if productivity_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']:
next_reminder = user_productivity_data['next_reminder']
if next_reminder > datetime.now().timestamp():
cooldown = next_reminder - datetime.now().timestamp()
channel = guild.get_channel(user_productivity_data['channel_id'])
reminder_message = user_productivity_data['reminder_message']
PRODUCTIVITY_TASKS[str(user.id)] = create_task(send_reminder(cooldown,user,channel,reminder_message))
else:
user_productivity_data['next_reminder'] = datetime.now().timestamp() + user_productivity_data['cooldown']
db.update('guild_user_productivity',f'user_id = {user.id} AND guild_id = {guild.id}',next_reminder=user_productivity_data['next_reminder'])
channel = guild.get_channel(user_productivity_data['channel_id'])
reminder_message = user_productivity_data['reminder_message']
PRODUCTIVITY_TASKS[str(user.id)] = create_task(send_reminder(user_productivity_data['cooldown'],user,channel,reminder_message))
else:
LOGGER.debug(f'No productivity settings found for guild {guild.name} ({guild.id}).')
if __name__ == '__main__':
db.check_conn()
result = db.select('token','token_name = \'discord\'','token_value')
if result:
TOKEN = result['token_value']
bot.run(TOKEN)
token = Config.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,117 +0,0 @@
import discord, re
from discord import option
from discord.ext import commands
from datetime import datetime
from ..setup.logger import LOGGER
from ..setup.data import get_guild_data, save_guild_data, is_feature_enabled
from ..setup import db
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='announcements',description='Enable or disable birthday announcements.',required=True,type=bool)
@option(name='message',description='The message to send when it is the user\'s birthday.',required=False)
@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,announcements:bool,message:str,for_user:discord.Member):
"""Sets birthday date. Must be in DAY/MONTH format."""
command_name = 'birthday_set'
guild_id = ctx.guild.id
author = ctx.author
birthday_settings = db.select('birthday_settings',f'guild_id = {guild_id}')
if birthday_settings:
if birthday_settings['is_enabled']:
if for_user:
if author.guild_permissions.administrator:
if re.match(r'^\d{1,2}\/\d{1,2}$', date):
user_data = db.select('guild_user_birthday',f'guild_id = {guild_id} AND user_id = {for_user.id}')
if user_data:
# Update user data
if message:
LOGGER.debug(f'{author.name} used /{command_name} {date} {announcements} {message} for {for_user.name}.')
db.update('guild_user_birthday',f'guild_id = {guild_id} AND user_id = {for_user.id}',birthday=date,is_enabled=announcements,birthday_message=message)
else:
LOGGER.debug(f'{author.name} used /{command_name} {date} {announcements} for {for_user.name}.')
db.update('guild_user_birthday',f'guild_id = {guild_id} AND user_id = {for_user.id}',birthday=date,is_enabled=announcements)
await ctx.send_response(f'{for_user.name}\'s birthday has been set to {date}.',ephemeral=True)
else:
# Create user data
if message:
LOGGER.debug(f'{author.name} used /{command_name} {date} {announcements} {message} for {for_user.name}.')
db.insert('guild_user_birthday',guild_id=guild_id,user_id=for_user.id,birthday=date,is_enabled=announcements,birthday_message=message)
else:
LOGGER.debug(f'{author.name} used /{command_name} {date} {announcements} for {for_user.name}.')
db.insert('guild_user_birthday',guild_id=guild_id,user_id=for_user.id,birthday=date,is_enabled=announcements)
await ctx.send_response(f'{for_user.name}\'s birthday has been set to {date}.',ephemeral=True)
else:
LOGGER.debug(f'{author.name} tried to use /{command_name} {date} {announcements} {message} for {for_user.name} but the date format is invalid.')
await ctx.send_response('The date format must be DAY/MONTH.',ephemeral=True)
else:
LOGGER.debug(f'{author.name} tried to use /{command_name} {date} {announcements} {message} 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:
if re.match(r'^\d{1,2}\/\d{1,2}$', date):
user_data = db.select('guild_user_birthday',f'guild_id = {guild_id} AND user_id = {author.id}')
if user_data:
# Update user data
if message:
LOGGER.debug(f'{author.name} used /{command_name} {date} {announcements} {message}.')
db.update('guild_user_birthday',f'guild_id = {guild_id} AND user_id = {author.id}',birthday=date,is_enabled=announcements,birthday_message=message)
else:
LOGGER.debug(f'{author.name} used /{command_name} {date} {announcements}.')
db.update('guild_user_birthday',f'guild_id = {guild_id} AND user_id = {author.id}',birthday=date,is_enabled=announcements)
await ctx.send_response(f'Your birthday has been set to {date}.',ephemeral=True)
else:
# Create user data
if message:
LOGGER.debug(f'{author.name} used /{command_name} {date} {announcements} {message}.')
db.insert('guild_user_birthday',guild_id=guild_id,user_id=author.id,birthday=date,is_enabled=announcements,birthday_message=message)
else:
LOGGER.debug(f'{author.name} used /{command_name} {date} {announcements}.')
db.insert('guild_user_birthday',guild_id=guild_id,user_id=author.id,birthday=date,is_enabled=announcements)
await ctx.send_response(f'Your birthday has been set to {date}.',ephemeral=True)
else:
LOGGER.debug(f'{author.name} tried to use /{command_name} {date} {announcements} {message} but the date format is invalid.')
await ctx.send_response('The date format must be DAY/MONTH.',ephemeral=True)
else:
LOGGER.debug(f'{author.name} tried to use /{command_name} {date} {announcements} {message} but the birthday feature is not enabled.')
await ctx.send_response('The birthday feature is not enabled.',ephemeral=True)
else:
LOGGER.debug(f'{author.name} tried to use /{command_name} {date} {announcements} {message} but the birthday feature is not enabled.')
await ctx.send_response('The birthday feature is not enabled.',ephemeral=True)
@commands.slash_command(description='Configurate birthday announcements.')
@option(name='channel',description='The channel to send birthday announcements to.',required=True,type=discord.TextChannel)
@option(name='enable',description='Enable or disable birthday announcements.',required=True,type=bool)
async def birthday_config(self,ctx:discord.ApplicationContext, channel:discord.TextChannel,enable:bool):
"""Configurate birthday announcements."""
command_name = 'birthday_config'
guild_id = ctx.guild.id
author = ctx.author
birthday_settings = db.select('birthday_settings',f'guild_id = {guild_id}')
if birthday_settings:
# Update guild birthday settings
if birthday_settings['is_enabled']:
if author.guild_permissions.administrator:
LOGGER.debug(f'{author.name} used /{command_name} {channel.name} {enable}.')
db.update('birthday_settings',f'guild_id = {guild_id}',channel_id=channel.id,is_enabled=enable)
await ctx.send_response(f'Birthday announcements have been {"enabled" if enable else "disabled"} in {channel.mention}.',ephemeral=True)
else:
LOGGER.debug(f'{author.name} tried to use /{command_name} {channel.name} {enable} but is not an administrator.')
await ctx.send_response('You must be an administrator to run this command.',ephemeral=True)
else:
LOGGER.debug(f'{author.name} tried to use /{command_name} {channel.name} {enable} but the birthday feature is not enabled.')
await ctx.send_response('The birthday feature is not enabled.',ephemeral=True)
else:
# Create guild birthday settings
if author.guild_permissions.administrator:
LOGGER.debug(f'{author.name} used /{command_name} {channel.name} {enable}.')
db.insert('birthday_settings',guild_id=guild_id,channel_id=channel.id,is_enabled=enable)
await ctx.send_response(f'Birthday announcements have been {"enabled" if enable else "disabled"} in {channel.mention}.',ephemeral=True)
else:
LOGGER.debug(f'{author.name} tried to use /{command_name} {channel.name} {enable} but is not an administrator.')
await ctx.send_response('You must be an administrator to run this command.',ephemeral=True)

View File

@@ -1,151 +0,0 @@
import discord
from datetime import datetime
from discord.ext import commands, tasks
from discord import option
from typing import Union, Optional
from asyncio import sleep, create_task
from ..setup.logger import LOGGER
from ..setup import db
from ..setup.data import get_guild_data, save_guild_data, is_feature_enabled
TASKS = {}
async def send_reminder(cooldown:int,author:discord.Member,channel:discord.TextChannel,reminder_message:str):
while True:
await sleep(cooldown)
LOGGER.debug(f'Sending productivity reminder to {author.name} in {channel.name} of {channel.guild.name}.')
message = reminder_message.replace('{user}',author.mention)
await channel.send(message)
class ProductivityCog(commands.Cog):
def __init__(self,bot:discord.Bot) -> None:
self.bot = bot
@commands.slash_command(description='Configurate productivity reminder.')
@option(name='channel',description='The channel to send productivity reminders in.',required=True)
@option(name='enable',description='Enable or disable productivity reminders.',required=True)
@option(name='days',description='Number of days to wait before sending a reminder if the user has not been active in the channel.',required=True,type=int)
@option(name='custom_message',description='Custom message to send with the reminder.',required=False,type=str)
@option(name='for_user',description='The user to configurate productivity reminders for.',required=False,type=discord.Member)
async def productivity_set(self,ctx:discord.ApplicationContext, channel:Union[discord.TextChannel,discord.Thread], enable:bool, days:int, custom_message=None, for_user:discord.Member=None):
"""Configurate productivity reminder."""
command_name = 'productivity_set'
guild_id = ctx.guild.id
author = ctx.author
productivity_settings = db.select('productivity_settings',f'guild_id = {guild_id}')
if productivity_settings:
if productivity_settings['is_enabled']:
if for_user:
if author.guild_permissions.administrator:
user_data = db.select('guild_user_productivity',f'guild_id = {guild_id} AND user_id = {for_user.id}')
cooldown = days*60*60*24
next_reminder = int(datetime.now().timestamp() + cooldown)
if user_data:
# Update user data
if custom_message:
db.update('guild_user_productivity',f'guild_id = {guild_id} AND user_id = {for_user.id}',channel_id=channel.id,cooldown=cooldown,next_reminder=next_reminder,reminder_message=custom_message,is_enabled=enable)
LOGGER.debug(f'{author.name} used /{command_name} {channel.mention} {enable} {days} {custom_message} for {for_user.name}.')
await ctx.send_response(f'Productivity reminders for {for_user.name} have been set to {channel.mention}.',ephemeral=True)
else:
db.update('guild_user_productivity',f'guild_id = {guild_id} AND user_id = {for_user.id}',channel_id=channel.id,cooldown=cooldown,next_reminder=next_reminder,is_enabled=enable)
LOGGER.debug(f'{author.name} used /{command_name} {channel.mention} {enable} {days} for {for_user.name}.')
await ctx.send_response(f'Productivity reminders for {for_user.name} have been set to {channel.mention}.',ephemeral=True)
else:
# Create user data
if custom_message:
db.insert('guild_user_productivity',guild_id=guild_id,user_id=for_user.id,channel_id=channel.id,cooldown=cooldown,next_reminder=next_reminder,reminder_message=custom_message,is_enabled=enable)
LOGGER.debug(f'{author.name} used /{command_name} {channel.mention} {enable} {days} {custom_message} for {for_user.name}.')
await ctx.send_response(f'Productivity reminders for {for_user.name} have been set to {channel.mention}.',ephemeral=True)
else:
db.insert('guild_user_productivity',guild_id=guild_id,user_id=for_user.id,channel_id=channel.id,cooldown=cooldown,next_reminder=next_reminder,is_enabled=enable)
LOGGER.debug(f'{author.name} used /{command_name} {channel.mention} {enable} {days} for {for_user.name}.')
await ctx.send_response(f'Productivity reminders for {for_user.name} have been set to {channel.mention}.',ephemeral=True)
user_data = db.select('guild_user_productivity',f'guild_id = {guild_id} AND user_id = {for_user.id}')
if str(for_user.id) in TASKS.keys():
TASKS[str(for_user.id)].cancel()
TASKS[str(for_user.id)] = create_task(send_reminder(cooldown,for_user,channel,user_data['reminder_message']))
else:
LOGGER.debug(f'{author.name} tried to use /{command_name} {channel.mention} {enable} {days} {custom_message} 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:
user_data = db.select('guild_user_productivity',f'guild_id = {guild_id} AND user_id = {author.id}')
cooldown = days*60*60*24
next_reminder = int(datetime.now().timestamp() + cooldown)
if user_data:
# Update user data
if custom_message:
db.update('guild_user_productivity',f'guild_id = {guild_id} AND user_id = {author.id}',channel_id=channel.id,cooldown=cooldown,next_reminder=next_reminder,reminder_message=custom_message,is_enabled=enable)
LOGGER.debug(f'{author.name} used /{command_name} {channel.mention} {enable} {days} {custom_message}.')
await ctx.send_response(f'Productivity reminders for {author.name} have been set to {channel.mention}.',ephemeral=True)
else:
db.update('guild_user_productivity',f'guild_id = {guild_id} AND user_id = {author.id}',channel_id=channel.id,cooldown=cooldown,next_reminder=next_reminder,is_enabled=enable)
LOGGER.debug(f'{author.name} used /{command_name} {channel.mention} {enable} {days}.')
await ctx.send_response(f'Productivity reminders for {author.name} have been set to {channel.mention}.',ephemeral=True)
else:
# Create user data
if custom_message:
db.insert('guild_user_productivity',guild_id=guild_id,user_id=author.id,channel_id=channel.id,cooldown=cooldown,next_reminder=next_reminder,reminder_message=custom_message,is_enabled=enable)
LOGGER.debug(f'{author.name} used /{command_name} {channel.mention} {enable} {days} {custom_message}.')
await ctx.send_response(f'Productivity reminders for {author.name} have been set to {channel.mention}.',ephemeral=True)
else:
db.insert('guild_user_productivity',guild_id=guild_id,user_id=author.id,channel_id=channel.id,cooldown=cooldown,next_reminder=next_reminder,is_enabled=enable)
LOGGER.debug(f'{author.name} used /{command_name} {channel.mention} {enable} {days}.')
await ctx.send_response(f'Productivity reminders for {author.name} have been set to {channel.mention}.',ephemeral=True)
user_data = db.select('guild_user_productivity',f'guild_id = {guild_id} AND user_id = {author.id}')
if str(author.id) in TASKS.keys():
TASKS[str(author.id)].cancel()
TASKS[str(author.id)] = create_task(send_reminder(cooldown,author,channel,user_data['reminder_message']))
else:
LOGGER.debug(f'{author.name} tried to use /{command_name} {channel.mention} {enable} {days} {custom_message} but the productivity feature is not enabled.')
await ctx.send_response('The productivity feature is not enabled.',ephemeral=True)
else:
LOGGER.debug(f'{author.name} tried to use /{command_name} {channel.mention} {enable} {days} {custom_message} but no productivity settings were found.')
await ctx.send_response('No productivity settings were found.',ephemeral=True)
@commands.slash_command(description='Enable or disable productivity reminders on the server.')
@option(name='enable',description='Enable or disable productivity reminders.',required=True)
async def productivity_config(self,ctx:discord.ApplicationContext, enable:bool):
"""Enable or disable productivity reminders on the server."""
command_name = 'productivity_config'
guild_id = ctx.guild.id
author = ctx.author
productivity_settings = db.select('productivity_settings',f'guild_id = {guild_id}')
if productivity_settings:
# Update guild productivity settings
if author.guild_permissions.administrator:
LOGGER.debug(f'{author.name} used /{command_name} {enable}.')
db.update('productivity_settings',f'guild_id = {guild_id}',is_enabled=enable)
await ctx.send_response(f'Productivity reminders have been {"enabled" if enable else "disabled"}.',ephemeral=True)
else:
LOGGER.debug(f'{author.name} tried to use /{command_name} {enable} but is not an administrator.')
await ctx.send_response('You must be an administrator to run this command.',ephemeral=True)
else:
# Create guild productivity settings
if author.guild_permissions.administrator:
LOGGER.debug(f'{author.name} used /{command_name} {enable}.')
db.insert('productivity_settings',guild_id=guild_id,is_enabled=enable)
await ctx.send_response(f'Productivity reminders have been {"enabled" if enable else "disabled"}.',ephemeral=True)
else:
LOGGER.debug(f'{author.name} tried to use /{command_name} {enable} but is not an administrator.')
await ctx.send_response('You must be an administrator to run this command.',ephemeral=True)
@commands.Cog.listener()
async def on_message(self,message:discord.Message):
author = message.author
if author.id != self.bot.user.id: # Ignore messages from the bot
guild_id = message.guild.id
productivity_settings = db.select('productivity_settings',f'guild_id = {guild_id}')
if productivity_settings:
if productivity_settings['is_enabled']:
user_productivity_data = db.select('guild_user_productivity',f'user_id = {author.id} AND guild_id = {guild_id}')
if user_productivity_data:
if user_productivity_data['is_enabled']:
next_reminder = int(datetime.now().timestamp() + user_productivity_data['cooldown'])
db.update('guild_user_productivity',f'user_id = {author.id} AND guild_id = {guild_id}',next_reminder=next_reminder)
if str(author.id) in TASKS.keys():
TASKS[str(author.id)].cancel()
TASKS[str(author.id)] = create_task(send_reminder(user_productivity_data['cooldown'],author,message.channel,user_productivity_data['reminder_message']))

View File

@@ -1,26 +0,0 @@
import discord
from ..cogs.birthday import BirthdayCog
from ..cogs.productivity import ProductivityCog
from .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 = discord.Bot(command_prefix='$', intents=bot_intents)
COGS = [BirthdayCog, ProductivityCog]
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

@@ -1,304 +0,0 @@
import os, psycopg2
from dotenv import load_dotenv
from typing import Optional, Callable, Any, Union
from .logger import LOGGER
load_dotenv()
DB_NAME = os.getenv('DB_NAME')
DB_USER = os.getenv('DB_USER')
DB_PASSWORD = os.getenv('DB_PASSWORD')
DB_HOST = os.getenv('DB_HOST')
def check_conn():
"""Tests the database connection."""
connection = psycopg2.connect(
database=DB_NAME,
user=DB_USER,
password=DB_PASSWORD,
host=DB_HOST,
port="5432"
)
if connection.status:
LOGGER.debug('Established connection to database.')
LOGGER.debug(f'Connection status: {connection.status}')
else:
LOGGER.error('Failed to connect to database.')
connection.close()
LOGGER.debug('Closed connection to database.')
def insert(table:str,**kwargs):
"""
Inserts the given data into the given table.
Global_User
user_id: int
user_mention: str
user_mention: str
Guild
guild_id: int
Birthday_Settings
guild_id: int
is_enabled: bool
channel_id: int
Productivity_Settings
guild_id: int
is_enabled: bool
Guild_User_Birthday
user_id: int
guild_id: int
birthday: int
birthday_message: str | None
is_enabled: bool
Guild_User_Productivity
user_id: int
guild_id: int
is_enabled: bool
channel_id: int
cooldown: int
next_reminder: int
reminder_message: str | None
"""
connection = psycopg2.connect(
database=DB_NAME,
user=DB_USER,
password=DB_PASSWORD,
host=DB_HOST,
port="5432",
)
cursor = connection.cursor()
query = f"INSERT INTO {table} ("
params = []
for key,value in kwargs.items():
query += f"{key},"
params.append(value)
query = query.rstrip(",") # Remove trailing comma
query += ") VALUES ("
for _ in range(len(params)):
query += "%s,"
query = query.rstrip(",") # Remove trailing comma
query += ")"
query = cursor.mogrify(query, params)
cursor.execute(query)
connection.commit()
cursor.close()
connection.close()
LOGGER.debug(f'Inserted data into {table}.')
def update(table:str,where:str,**kwargs):
"""
Updates the given data in the given table.
Global_User
user_id: int
user_mention: str
user_mention: str
Guild
guild_id: int
Birthday_Settings
guild_id: int
is_enabled: bool
channel_id: int
Productivity_Settings
guild_id: int
is_enabled: bool
Guild_User_Birthday
user_id: int
guild_id: int
birthday: int
birthday_message: str | None
is_enabled: bool
Guild_User_Productivity
user_id: int
guild_id: int
is_enabled: bool
channel_id: int
cooldown: int
next_reminder: int
reminder_message: str | None
"""
connection = psycopg2.connect(
database=DB_NAME,
user=DB_USER,
password=DB_PASSWORD,
host=DB_HOST,
port="5432",
)
cursor = connection.cursor()
query = f"UPDATE {table} SET "
params = []
for key,value in kwargs.items():
query += f"{key} = %s,"
params.append(value)
query = query.rstrip(",") # Remove trailing comma
query += f" WHERE {where}"
query = cursor.mogrify(query, params)
cursor.execute(query)
connection.commit()
cursor.close()
connection.close()
LOGGER.debug(f'Updated data in {table}.')
def delete(table:str,where:str):
"""
Deletes the given data from the given table.
Global_User
user_id: int
user_mention: str
user_mention: str
Guild
guild_id: int
Birthday_Settings
guild_id: int
is_enabled: bool
channel_id: int
Productivity_Settings
guild_id: int
is_enabled: bool
Guild_User_Birthday
user_id: int
guild_id: int
birthday: int
birthday_message: str | None
is_enabled: bool
Guild_User_Productivity
user_id: int
guild_id: int
is_enabled: bool
channel_id: int
cooldown: int
next_reminder: int
reminder_message: str | None
"""
connection = psycopg2.connect(
database=DB_NAME,
user=DB_USER,
password=DB_PASSWORD,
host=DB_HOST,
port="5432",
)
cursor = connection.cursor()
query = f"DELETE FROM {table} WHERE {where}"
cursor.execute(query)
connection.commit()
cursor.close()
connection.close()
LOGGER.debug(f'Deleted data from {table}.')
def select(table:str,where:str,*columns:str) -> dict[str,Any] | None:
"""
Selects the given data from the given table, and returns it as a dictionary.
Global_User
user_id: int
user_mention: str
user_mention: str
Guild
guild_id: int
Birthday_Settings
guild_id: int
is_enabled: bool
channel_id: int
Productivity_Settings
guild_id: int
is_enabled: bool
Guild_User_Birthday
user_id: int
guild_id: int
birthday: int
birthday_message: str | None
is_enabled: bool
Guild_User_Productivity
user_id: int
guild_id: int
is_enabled: bool
channel_id: int
cooldown: int
next_reminder: int
reminder_message: str | None
"""
connection = psycopg2.connect(
database=DB_NAME,
user=DB_USER,
password=DB_PASSWORD,
host=DB_HOST,
port="5432"
)
connection.readonly = True
cursor = connection.cursor()
query = f"SELECT "
if columns:
for column in columns:
query += f"{column},"
query = query.rstrip(",")
else:
query += "*"
query += f" FROM {table} WHERE {where}"
cursor.execute(query)
record = cursor.fetchone()
result = None
if record is None:
LOGGER.debug(f'No data found in {table} for {where}.')
else:
result = {}
if len(columns) > 0:
for i in range(len(columns)):
result[columns[i]] = record[i]
else:
if table.lower() == 'global_user':
result['user_id'] = record[0]
result['user_name'] = record[1]
result['user_mention'] = record[2]
elif table.lower() == 'guild':
result['guild_id'] = record[0]
elif table.lower() == 'birthday_settings':
result['guild_id'] = record[0]
result['is_enabled'] = record[1]
result['channel_id'] = record[2]
elif table.lower() == 'productivity_settings':
result['guild_id'] = record[0]
result['is_enabled'] = record[1]
elif table.lower() == 'guild_user_birthday':
result['user_id'] = record[0]
result['guild_id'] = record[1]
result['birthday'] = record[2]
result['is_enabled'] = record[3]
elif table.lower() == 'guild_user_productivity':
result['user_id'] = record[0]
result['guild_id'] = record[1]
result['is_enabled'] = record[2]
result['channel_id'] = record[3]
result['cooldown'] = record[4]
result['next_reminder'] = record[5]
result['reminder_message'] = record[6]
else:
LOGGER.error(f'Unknown table {table}.')
cursor.close()
connection.close()
return result

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

85
setup/bot.py Normal file
View File

@@ -0,0 +1,85 @@
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 cogs.roulette import RouletteCog
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)
COG_REGISTRY = {
'cs': CSCog,
'insult': InsultsCog,
'roulette': RouletteCog
}
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!')

18
setup/config.py Normal file
View File

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

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