Compare commits
9 Commits
birthday-f
...
pipelines
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fb926ec20 | |||
| 746ce33977 | |||
| 80d34e9660 | |||
| 2cf7cc94bb | |||
| 425ce22e2c | |||
| b276afd6bb | |||
| 08da73fbda | |||
| b4b6db683a | |||
| 23c4047bf7 |
67
.gitea/workflows/cd.yaml
Normal file
67
.gitea/workflows/cd.yaml
Normal 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
22
.gitea/workflows/ci.yaml
Normal 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
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -160,3 +160,6 @@ cython_debug/
|
|||||||
#.idea/
|
#.idea/
|
||||||
|
|
||||||
data/
|
data/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
*.disabled
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal 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"]
|
||||||
31
README.md
31
README.md
@@ -1,2 +1,31 @@
|
|||||||
# botmafieux
|
# botmafieux
|
||||||
Le Bot Discord du serveur des Mafieux
|
Le Bot Discord du serveur des Mafieux.
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
### Annonces d'anniversaire
|
||||||
|
|
||||||
|
Chaque utilisateur peut paramétrer sa date d'anniversaire, un message personnalisé qui sera envoyé à minuit le jour J et l'activation de l'envoi à l'aide de la commande `birthday_set` dans le salon défini.
|
||||||
|
La fonctionnalité peut être configurée par un administrateur avec la commande `birthday_config` qui peut activer/désactiver la fonctionnalité sur le serveur courant et choisir le salon dans lequel les annonces d'anniversaires seront envoyées.
|
||||||
|
|
||||||
|
### Rappels de productivité
|
||||||
|
|
||||||
|
Chaque utilisateur peut paramétrer un message à envoyer tous les $x$ jours dans le salon choisi et son activation à l'aide de la commande `productivity_set`. Un message sera envoyé tous les $x$ jours après le dernier message de l'utilisateur dans le salon défini.
|
||||||
|
La fonctionnalité peut être configurée par un administrateur avec la commande `productivity_config` qui peut activer/désactiver la fonctionnalité sur le serveur courant.
|
||||||
|
|
||||||
|
## Commandes
|
||||||
|
|
||||||
|
### Commandes utilisateurs
|
||||||
|
|
||||||
|
| Commande | Description | Options requises | Options facultatives |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `help` | Afficher la description des commandes | | |
|
||||||
|
| `birthday_set` | Paramétrer son anniversaire | `date`: date d'anniversaire au format "*JJ/MM*"<br>`announcements`: activation/désactivation des annonces d'anniversaire | `message`: message personnalisé à envoyer à minuit le jour J<br>`for_user`: utilisateur à paramétrer (administrateurs uniquement) |
|
||||||
|
| `productivity_set` | Paramétrer ses rappels de productivité | `days`: nombre de jours entre chaque rappel<br>`channel`: salon dans lequel envoyer les rappels<br>`enable`: activation/désactivation des rappels de productivité | `message`: message à envoyer<br>`for_user`: utilisateur à paramétrer (administrateurs uniquement) |
|
||||||
|
|
||||||
|
### Commandes administrateurs
|
||||||
|
| Commande | Description | Options requises | Options facultatives |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `birthday_config` | Configurer les annonces d'anniversaire sur le serveur | `enable`: activation/désactivation des annonces d'anniversaire<br>`channel`: salon dans lequel envoyer les annonces d'anniversaire | |
|
||||||
|
| `productivity_config` | Configurer les rappels de productivité sur le serveur | `enable`: activation/désactivation des rappels de productivité | |
|
||||||
|
| `reload` | Recharger une fonctionnalité | `feature`: fonctionnalité à recharger | |
|
||||||
375
cogs/cs.py
Normal file
375
cogs/cs.py
Normal 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
31
cogs/insult.py
Normal 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))
|
||||||
22
compose.yaml
Normal file
22
compose.yaml
Normal 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
6778
config/hltv.html
Normal file
File diff suppressed because one or more lines are too long
27
config/insults.txt
Normal file
27
config/insults.txt
Normal 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
1
config/intern.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[{"mentor_id": 264189554582355969, "guild_id": 897157935006900254, "next_roll": "2026-07-22T00:00:00"}]
|
||||||
22
config/intern_takes.csv
Normal file
22
config/intern_takes.csv
Normal 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 ?
|
||||||
|
187
main.py
187
main.py
@@ -1,98 +1,131 @@
|
|||||||
import os, discord, json, sys
|
import os, discord, json, sys, random
|
||||||
from argparse import ArgumentParser
|
from argparse import ArgumentParser
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from asyncio import create_task
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
from discord import option
|
||||||
from discord.ext import tasks, commands
|
from discord.ext import tasks, commands
|
||||||
from modules.setup.bot import bot
|
from setup.bot import bot, reload_feature
|
||||||
from modules.setup.logger import LOGGER
|
from setup.logger import LOGGER
|
||||||
from modules.setup.data import DATA_DIR, get_guild_data
|
|
||||||
|
|
||||||
PARSER = ArgumentParser(description='BotMafieux for Discord.')
|
PARSER = ArgumentParser(description='BotMafieux for Discord.')
|
||||||
PARSER.add_argument('--guild_id',type=int,help='The guild ID.')
|
PARSER.add_argument('--guild_id',type=int,help='The guild ID.')
|
||||||
PARSER.add_argument('--channel_id',type=int,help='The channel ID.')
|
PARSER.add_argument('--channel_id',type=int,help='The channel ID.')
|
||||||
PARSER.add_argument('-r','--reload',action='store_true',help='Reload the bot.')
|
PARSER.add_argument('-r','--reload',action='store_true',help='Reload the bot.')
|
||||||
EXEC_ARGS = PARSER.parse_args()
|
EXEC_ARGS = PARSER.parse_args()
|
||||||
|
|
||||||
@tasks.loop(time=datetime.time(datetime.strptime('00:00','%H:%M')))
|
# @tasks.loop(time=datetime.time(datetime.strptime('00:00','%H:%M')))
|
||||||
async def birthday_anouncements_task():
|
# async def birthday_anouncements_task():
|
||||||
LOGGER.debug('birthday_anouncements_task started.')
|
# LOGGER.debug('birthday_anouncements_task started.')
|
||||||
for guild in bot.guilds:
|
# for guild in bot.guilds:
|
||||||
guild_data = get_guild_data(guild.id)
|
# birthday_settings = db.select('birthday_settings',f'guild_id = {guild.id}')
|
||||||
if 'birthday_announcements_channel' in guild_data['features']['birthday'].keys():
|
# if birthday_settings:
|
||||||
channel = guild.get_channel(guild_data['features']['birthday']['birthday_announcements_channel'])
|
# if birthday_settings['is_enabled']:
|
||||||
if 'birthdays' in guild_data.keys():
|
# for user in guild.members:
|
||||||
for user_id in guild_data['features']['birthday']['birthdays'].keys():
|
# user_productivity_data = db.select('guild_user_productivity',f'user_id = {user.id} AND guild_id = {guild.id}')
|
||||||
user = guild.get_member(int(user_id))
|
# if user_productivity_data:
|
||||||
if user:
|
# if user_productivity_data['is_enabled']:
|
||||||
if guild_data['features']['birthday']['birthdays'][user_id]['announcements']:
|
# user = guild.get_member(user.id)
|
||||||
date = guild_data['features']['birthday']['birthdays'][user_id]['date']
|
# if user:
|
||||||
today = datetime.today().strftime('%d/%m')
|
# birthday = user_productivity_data['birthday']
|
||||||
if date == today:
|
# today = datetime.today().strftime('%d/%m')
|
||||||
await channel.send(f'Joyeux anniversaire {user.mention}!')
|
# if birthday == today:
|
||||||
else:
|
# channel = guild.get_channel(user_productivity_data['channel_id'])
|
||||||
LOGGER.debug(f'No birthday_announcements_channel set for guild {guild.name} ({guild.id}).')
|
# if channel:
|
||||||
LOGGER.debug('birthday_anouncements_task ended.')
|
# 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.')
|
# @bot.slash_command(name='help',description='Displays the help message.')
|
||||||
async def help(ctx:discord.ApplicationContext):
|
# async def help(ctx:discord.ApplicationContext):
|
||||||
"""Displays this message."""
|
# """Displays this message."""
|
||||||
command_name = 'help'
|
# command_name = 'help'
|
||||||
LOGGER.debug(f'{ctx.author.name} used /{command_name}.')
|
# 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 = 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)
|
# embed.set_author(name=bot.user.name,icon_url=bot.user.avatar.url)
|
||||||
for command in bot.commands:
|
# for command in bot.commands:
|
||||||
embed.add_field(name=command.name,value=command.description,inline=False)
|
# embed.add_field(name=command.name,value=command.description,inline=False)
|
||||||
await ctx.send_response(embed=embed,ephemeral=True)
|
# await ctx.send_response(embed=embed,ephemeral=True)
|
||||||
|
|
||||||
@bot.slash_command(name='reload',description='Reloads the bot.')
|
# @bot.slash_command(name='reload',description='Reloads the bot.')
|
||||||
@commands.is_owner()
|
# @option(name='feature',description='The feature to reload.',required=True,choices=['birthday','productivity'])
|
||||||
async def reload(ctx:discord.ApplicationContext):
|
# @commands.is_owner()
|
||||||
"""Reloads the bot."""
|
# async def reload(ctx:discord.ApplicationContext, feature:str):
|
||||||
command_name = 'reload'
|
# """Reloads the bot."""
|
||||||
guild = ctx.guild
|
# command_name = 'reload'
|
||||||
channel = ctx.channel
|
# guild = ctx.guild
|
||||||
LOGGER.debug(f'{ctx.author.name} used /{command_name}.')
|
# channel = ctx.channel
|
||||||
await ctx.send_response('Reloading...',ephemeral=True)
|
# LOGGER.debug(f'{ctx.author.name} used /{command_name} {feature}.')
|
||||||
args = [f'--guild_id={guild.id}',f'--channel_id={channel.id}','--reload']
|
# try:
|
||||||
os.execl(sys.executable, sys.executable, __file__, *args)
|
# 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
|
# @bot.event
|
||||||
async def on_guild_join(guild:discord.Guild):
|
# async def on_member_join(member:discord.Member):
|
||||||
LOGGER.info(f'Bot joined guild {guild.name} ({guild.id}).')
|
# if not member.bot:
|
||||||
path = os.path.join(DATA_DIR, f'{guild.id}.json')
|
# user_id = member.id
|
||||||
if not os.path.exists(path):
|
# user_name = member.name
|
||||||
LOGGER.info(f'Creating data file for guild {guild.id}')
|
# user_mention = member.mention
|
||||||
with open(path, 'x') as f:
|
# db_user = db.select('global_user',f'WHERE user_id = {user_id}')
|
||||||
json.dump({}, f, indent=2)
|
# if not db_user:
|
||||||
|
# db.insert('global_user',user_id=user_id,user_name=user_name,user_mention=user_mention)
|
||||||
|
|
||||||
@bot.event
|
# @bot.event
|
||||||
async def on_guild_remove(guild:discord.Guild):
|
# async def on_raw_member_remove(payload:discord.RawMemberRemoveEvent):
|
||||||
LOGGER.info(f'Bot left guild {guild.name} ({guild.id}).')
|
# user = payload.user
|
||||||
path = os.path.join(DATA_DIR, f'{guild.id}.json')
|
# if not user.bot:
|
||||||
if os.path.exists(path):
|
# user_id = user.id
|
||||||
LOGGER.info(f'Deleting data file for guild {guild.id}.')
|
# guild_id = payload.guild_id
|
||||||
os.remove(path)
|
# 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
|
@bot.event
|
||||||
async def on_ready():
|
async def on_ready():
|
||||||
LOGGER.info(f'{bot.user.name} has connected to Discord!')
|
if bot.user:
|
||||||
LOGGER.debug(f'Guilds: {bot.guilds}')
|
LOGGER.info(f'{bot.user.name} has connected to Discord!')
|
||||||
if EXEC_ARGS.reload and EXEC_ARGS.guild_id and EXEC_ARGS.channel_id:
|
LOGGER.debug(f'Guilds: {','.join([guild.name for guild in bot.guilds])}')
|
||||||
guild = bot.get_guild(EXEC_ARGS.guild_id)
|
|
||||||
channel = guild.get_channel_or_thread(EXEC_ARGS.channel_id)
|
|
||||||
await channel.send(f'{bot.user.name} Bot reloaded!',silent=True)
|
|
||||||
# Creates guild data files if they don't exist
|
|
||||||
for guild in bot.guilds:
|
|
||||||
guild_id = guild.id
|
|
||||||
path = os.path.join(DATA_DIR, f'{guild_id}.json')
|
|
||||||
if not os.path.exists(path):
|
|
||||||
LOGGER.info(f'Creating data file for guild {guild_id}')
|
|
||||||
with open(path, 'x') as f:
|
|
||||||
json.dump({'features': {}}, f, indent=2)
|
|
||||||
birthday_anouncements_task.start()
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
TOKEN = os.getenv('TOKEN')
|
TOKEN = os.getenv("TOKEN")
|
||||||
bot.run(TOKEN)
|
if TOKEN:
|
||||||
|
bot.run(TOKEN)
|
||||||
|
else:
|
||||||
|
LOGGER.error('No token found.')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
119
models/cs_models.py
Normal file
119
models/cs_models.py
Normal 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)}'>"
|
||||||
@@ -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)
|
|
||||||
|
|
||||||
@@ -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))
|
|
||||||
@@ -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)
|
|
||||||
@@ -1,2 +1,31 @@
|
|||||||
python-dotenv
|
aiohappyeyeballs==2.6.2
|
||||||
py-cord
|
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
29
setup/bot.py
Normal 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!')
|
||||||
@@ -3,6 +3,6 @@ import logging
|
|||||||
logging.basicConfig(level=logging.INFO, format='[%(levelname)s] %(asctime)s: %(name)s: %(message)s')
|
logging.basicConfig(level=logging.INFO, format='[%(levelname)s] %(asctime)s: %(name)s: %(message)s')
|
||||||
LOGGER = logging.getLogger('botmafieux')
|
LOGGER = logging.getLogger('botmafieux')
|
||||||
LOGGER.setLevel(logging.DEBUG)
|
LOGGER.setLevel(logging.DEBUG)
|
||||||
handler = logging.FileHandler(filename='botmafieux.log', encoding='utf-8', mode='w')
|
handler = logging.FileHandler(filename='botmafieux.log', encoding='utf-8', mode='a')
|
||||||
handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s:%(name)s: %(message)s'))
|
handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s:%(name)s: %(message)s'))
|
||||||
LOGGER.addHandler(handler)
|
LOGGER.addHandler(handler)
|
||||||
78
tests/test_intern_voice.py
Normal file
78
tests/test_intern_voice.py
Normal 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()
|
||||||
Reference in New Issue
Block a user