Compare commits
10 Commits
80d34e9660
...
feature/de
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cbb77be3c | |||
| 59a501c0a6 | |||
| c86ea54e86 | |||
| 505621e0cd | |||
| 4a749bd683 | |||
| 60e95791a9 | |||
| 3fb926ec20 | |||
| d14ca4ab3e | |||
| 78d3a60bd7 | |||
| 746ce33977 |
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
venv/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.git/
|
||||
tests/
|
||||
*.log
|
||||
.env
|
||||
config/
|
||||
/.vscode/
|
||||
*.log
|
||||
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
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -162,4 +162,6 @@ cython_debug/
|
||||
data/
|
||||
.vscode/
|
||||
|
||||
*.disabled
|
||||
*.disabled
|
||||
|
||||
config/
|
||||
28
Dockerfile
28
Dockerfile
@@ -1,14 +1,36 @@
|
||||
FROM python:3.13.14-alpine
|
||||
|
||||
RUN apk add --no-cache \
|
||||
build-base \
|
||||
libffi-dev \
|
||||
musl-dev \
|
||||
openssl-dev \
|
||||
python3-dev \
|
||||
geckodriver \
|
||||
firefox-esr
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV TOKEN=""
|
||||
ENV CONFIG_PATH="/config"
|
||||
ENV GECKODRIVER_PATH="/usr/bin/geckodriver"
|
||||
|
||||
RUN adduser -D bot && \
|
||||
mkdir /logs && \
|
||||
chown -R bot:bot /logs && \
|
||||
chmod 755 /logs && \
|
||||
mkdir -p ${CONFIG_PATH} && \
|
||||
chown -R bot:bot ${CONFIG_PATH} && \
|
||||
chmod 755 ${CONFIG_PATH}
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
RUN chown -R bot:bot /app && \
|
||||
chmod -R 755 /app && \
|
||||
chmod +x /app/main.py
|
||||
|
||||
ENV TOKEN=""
|
||||
ENV URL=""
|
||||
|
||||
USER bot
|
||||
|
||||
ENTRYPOINT ["python", "main.py"]
|
||||
42
cogs/__init__.py
Normal file
42
cogs/__init__.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import discord, json, csv
|
||||
from discord.ext import commands
|
||||
from typing import Union, Any, Optional, Callable
|
||||
from setup.config import Config
|
||||
|
||||
class BaseCog(commands.Cog):
|
||||
def __init__(self,bot:discord.Bot):
|
||||
self.bot = bot
|
||||
|
||||
class ConfigurableCog(BaseCog):
|
||||
def __init__(self, bot: discord.Bot):
|
||||
super().__init__(bot)
|
||||
|
||||
|
||||
class FeatureCog(BaseCog):
|
||||
"""Base Cog that provides per-guild feature flag checks.
|
||||
|
||||
Subclasses should set `feature_name` or pass it to the constructor.
|
||||
This class reads the feature flags from `Config.GUILD_CONFIGS[guild_id]["features"]`.
|
||||
"""
|
||||
|
||||
def __init__(self, bot: discord.Bot, feature_name: Optional[str] = None):
|
||||
super().__init__(bot)
|
||||
self.feature_name = feature_name
|
||||
|
||||
def is_enabled(self, guild_id: int) -> bool:
|
||||
if not self.feature_name:
|
||||
return False
|
||||
guild_cfg = Config.GUILD_CONFIGS.get(guild_id)
|
||||
if not guild_cfg:
|
||||
return False
|
||||
features = guild_cfg.get("features", {})
|
||||
return bool(features.get(self.feature_name, False))
|
||||
|
||||
def require_enabled_check(self) -> Callable[[commands.Context], bool]:
|
||||
def predicate(ctx: commands.Context) -> bool:
|
||||
if not ctx.guild:
|
||||
return False
|
||||
return self.is_enabled(ctx.guild.id)
|
||||
|
||||
return commands.check(predicate)
|
||||
|
||||
53
cogs/cs.py
53
cogs/cs.py
@@ -1,15 +1,19 @@
|
||||
import discord, datetime, os, re, json
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.firefox.options import Options
|
||||
from selenium.webdriver.firefox.service import Service
|
||||
from bs4 import BeautifulSoup
|
||||
from discord.ext import commands, tasks, bridge
|
||||
from setup.logger import LOGGER
|
||||
from setup.config import Config
|
||||
from models.cs_models import *
|
||||
from cogs import FeatureCog
|
||||
|
||||
BASE_URL = "https://www.hltv.org"
|
||||
|
||||
PAGE_FILE = os.path.join("data","hltv.html")
|
||||
PAGE_FILE = os.path.join(Config.CONFIG_PATH,"hltv.html")
|
||||
|
||||
MATCHES_FILE = os.path.join("data","matches.json")
|
||||
MATCHES_FILE = os.path.join(Config.CONFIG_PATH,"matches.json")
|
||||
|
||||
# Cache duration in seconds (6 hours)
|
||||
DATA_CACHE_DURATION = 3600 * 6
|
||||
@@ -21,12 +25,20 @@ OLD_MATCH_DURATION = 3600 * 24 * 2
|
||||
EMOJI_CREATE_EVENT = "✅" # Create guild event for the match
|
||||
EMOJI_REMOVE_EVENT = "❌" # Remove guild event for the match
|
||||
|
||||
class CSCog(commands.Cog):
|
||||
def __init__(self, bot:discord.Bot):
|
||||
self.bot = bot
|
||||
self.matches:list[Match] = []
|
||||
class CSCog(FeatureCog):
|
||||
def __init__(self, bot: discord.Bot):
|
||||
super().__init__(bot, feature_name="cs")
|
||||
self.matches: list[Match] = []
|
||||
self.message_match_map = {} # Maps message IDs to Match objects
|
||||
self.get_matches.start()
|
||||
|
||||
# Start scraping loop only if at least one guild enabled CS feature
|
||||
try:
|
||||
enabled_any = any(cfg.get("features", {}).get("cs") for cfg in Config.GUILD_CONFIGS.values())
|
||||
except Exception:
|
||||
enabled_any = False
|
||||
|
||||
if enabled_any:
|
||||
self.get_matches.start()
|
||||
|
||||
def is_up_to_date(self) -> bool:
|
||||
"""Check if PAGE_FILE and MATCHES_FILE are up to date."""
|
||||
@@ -43,14 +55,27 @@ class CSCog(commands.Cog):
|
||||
return False
|
||||
|
||||
def get_page(self) -> None:
|
||||
"""Get the page from https://www.hltv.org/matches/ using Firefox webdriver and save to PAGE_FILE."""
|
||||
driver = webdriver.Firefox()
|
||||
driver.get("https://www.hltv.org/matches/")
|
||||
html = driver.page_source
|
||||
driver.close()
|
||||
"""Get the page from https://www.hltv.org/matches/ using a headless Firefox webdriver and save to PAGE_FILE."""
|
||||
options = Options()
|
||||
options.add_argument("--headless=new")
|
||||
options.add_argument("--no-sandbox")
|
||||
options.add_argument("--disable-dev-shm-usage")
|
||||
options.add_argument("--window-size=1920,1080")
|
||||
|
||||
with open(PAGE_FILE, 'w') as f:
|
||||
f.write(html)
|
||||
geckodriver_path = os.getenv("GECKODRIVER_PATH")
|
||||
service = Service(executable_path=geckodriver_path) if geckodriver_path else None
|
||||
|
||||
driver = None
|
||||
try:
|
||||
driver = webdriver.Firefox(service=service, options=options)
|
||||
driver.get("https://www.hltv.org/matches/")
|
||||
html = driver.page_source
|
||||
|
||||
with open(PAGE_FILE, 'w', encoding='utf-8') as f:
|
||||
f.write(html)
|
||||
finally:
|
||||
if driver is not None:
|
||||
driver.quit()
|
||||
|
||||
def save(self) -> None:
|
||||
"""Save self.matches to MATCHES_FILE."""
|
||||
|
||||
@@ -1,31 +1,76 @@
|
||||
import discord, json, os, random, re
|
||||
import collections
|
||||
import discord, random, re
|
||||
from discord.ext import commands
|
||||
from setup.logger import LOGGER
|
||||
from setup.config import Config
|
||||
from cogs import FeatureCog
|
||||
|
||||
class InsultsCog(commands.Cog):
|
||||
def __init__(self, bot:discord.Bot):
|
||||
self.bot = bot
|
||||
class InsultsCog(FeatureCog):
|
||||
def __init__(self, bot: discord.Bot):
|
||||
super().__init__(bot, feature_name="insults")
|
||||
LOGGER.debug("InsultsCog initialized")
|
||||
|
||||
with open(os.path.join("data", "insults.txt")) as f:
|
||||
self.insults = [l.strip() for l in f.readlines()]
|
||||
def reload_men_list(self):
|
||||
self.men: list[discord.Member] = []
|
||||
for guild in self.bot.guilds:
|
||||
for role in guild.roles:
|
||||
if role.name and re.match(r'^([hH]im|[hH]e).*$', role.name):
|
||||
self.men.extend(role.members)
|
||||
# dedupe by id
|
||||
self.men = list({m.id: m for m in self.men}.values())
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message(self, message:discord.Message):
|
||||
if len(message.content.split()) > 3:
|
||||
author = message.author
|
||||
if any(author.id == u.id for u in self.men):
|
||||
r = int(random.random()*100)
|
||||
print(r)
|
||||
if r < 5:
|
||||
insult = random.choice(self.insults)
|
||||
async def on_message(self, message: discord.Message):
|
||||
if not message.guild or message.author.bot:
|
||||
return
|
||||
|
||||
# only run if feature enabled for this guild
|
||||
if not self.is_enabled(message.guild.id):
|
||||
return
|
||||
|
||||
if len(message.content.split()) <= 3:
|
||||
return
|
||||
|
||||
author = message.author
|
||||
if not hasattr(self, 'men') or not self.men:
|
||||
self.reload_men_list()
|
||||
|
||||
if any(author.id == u.id for u in self.men):
|
||||
guild_cfg = Config.GUILD_CONFIGS.get(message.guild.id, {})
|
||||
insults_cfg = guild_cfg.get("configs", {}).get("insults", {})
|
||||
safeguard_patterns = insults_cfg.get("safeguard_patterns", [])
|
||||
safeguard_match = any(re.match(pattern, message.content) for pattern in safeguard_patterns)
|
||||
if safeguard_match:
|
||||
return
|
||||
|
||||
r = random.random() * 100
|
||||
exceptions = insults_cfg.get("exceptions", [])
|
||||
filtered = [ex for ex in exceptions if ex.get("user_id") == author.id]
|
||||
if filtered:
|
||||
try:
|
||||
r *= float(filtered[0].get("factor", 1))
|
||||
except Exception:
|
||||
pass
|
||||
if r < 5:
|
||||
insults = insults_cfg.get("insults", [])
|
||||
if insults:
|
||||
insult = random.choice(insults)
|
||||
await message.reply(insult)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_ready(self):
|
||||
self.men:list[discord.Member] = []
|
||||
for guild in self.bot.guilds:
|
||||
for role in guild.roles:
|
||||
if re.match(r'^([hH]im|[hH]e).*$',role.name):
|
||||
self.men.extend(role.members)
|
||||
self.men = list(set(self.men))
|
||||
self.reload_men_list()
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_update(self, before: discord.Member, after: discord.Member):
|
||||
before_roles = set(before.roles)
|
||||
after_roles = set(after.roles)
|
||||
removed = before_roles.difference(after_roles)
|
||||
added = after_roles.difference(before_roles)
|
||||
role = None
|
||||
if removed:
|
||||
role = list(removed).pop()
|
||||
elif added:
|
||||
role = list(added).pop()
|
||||
|
||||
if role and re.match(r'^([hH]im|[hH]e).*$', role.name):
|
||||
self.reload_men_list()
|
||||
55
cogs/intern.py
Normal file
55
cogs/intern.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import random
|
||||
from discord.ext import commands
|
||||
|
||||
|
||||
class InternCog(commands.Cog):
|
||||
def __init__(self, bot=None):
|
||||
self.bot = bot
|
||||
# `data` expected to be a list of dicts with 'guild_id' and 'mentor_id'
|
||||
self.data = []
|
||||
self.connected_guild_ids = set()
|
||||
self.in_voice_channel = False
|
||||
|
||||
async def on_voice_state_update(self, member, before, after):
|
||||
# determine guild context
|
||||
guild = None
|
||||
if after and getattr(after, 'channel', None):
|
||||
guild = after.channel.guild
|
||||
elif before and getattr(before, 'channel', None):
|
||||
guild = before.channel.guild
|
||||
if not guild:
|
||||
return
|
||||
|
||||
# find matching mentor entry for this guild
|
||||
entry = None
|
||||
for e in getattr(self, 'data', []):
|
||||
if e.get('guild_id') == getattr(guild, 'id', None) and e.get('mentor_id') == getattr(member, 'id', None):
|
||||
entry = e
|
||||
break
|
||||
if not entry:
|
||||
return
|
||||
|
||||
# find existing voice client for this guild (if any)
|
||||
voice_client = None
|
||||
for vc in getattr(self.bot, 'voice_clients', []):
|
||||
if getattr(vc, 'guild', None) and getattr(vc.guild, 'id', None) == getattr(guild, 'id', None):
|
||||
voice_client = vc
|
||||
break
|
||||
|
||||
# mentor joined a channel (initial join)
|
||||
if (not before or not getattr(before, 'channel', None)) and after and getattr(after, 'channel', None):
|
||||
# if bot is not connected, connect with 25% chance
|
||||
if not voice_client:
|
||||
if random.random() < 0.25:
|
||||
await after.channel.connect()
|
||||
return
|
||||
|
||||
# mentor moved channels while bot is connected -> move bot
|
||||
if voice_client and after and getattr(after, 'channel', None):
|
||||
await voice_client.move_to(after.channel)
|
||||
return
|
||||
|
||||
# mentor left channel -> disconnect bot if connected
|
||||
if voice_client and (not after or not getattr(after, 'channel', None)):
|
||||
await voice_client.disconnect(force=True)
|
||||
return
|
||||
146
cogs/roles.py
Normal file
146
cogs/roles.py
Normal file
@@ -0,0 +1,146 @@
|
||||
import discord
|
||||
from cogs import FeatureCog
|
||||
from discord import option
|
||||
from discord.ext import commands, bridge
|
||||
from setup.config import Config
|
||||
from setup.logger import LOGGER
|
||||
|
||||
|
||||
class RolesCog(FeatureCog):
|
||||
def __init__(self, bot: discord.Bot):
|
||||
super().__init__(bot, feature_name="roles")
|
||||
|
||||
@bridge.bridge_command()
|
||||
async def roles(self, ctx: discord.ApplicationContext, category: bridge.BridgeOption(name="category", description="The category to manipulate", input_type=str, required=True)):
|
||||
if not ctx.guild:
|
||||
await ctx.respond("This command must be used in a guild.", ephemeral=True)
|
||||
return
|
||||
|
||||
guild_id = ctx.guild.id
|
||||
if not self.is_enabled(guild_id):
|
||||
await ctx.respond("Roles feature is disabled for this guild.", ephemeral=True)
|
||||
return
|
||||
|
||||
guild_cfg = Config.GUILD_CONFIGS.get(guild_id)
|
||||
if not guild_cfg:
|
||||
await ctx.respond("No configuration for this guild.", ephemeral=True)
|
||||
return
|
||||
|
||||
categories = guild_cfg.get("configs", {}).get("roles", {}).get("categories", [])
|
||||
filtered = [c for c in categories if c.get("category_id") == category]
|
||||
if not filtered:
|
||||
await ctx.respond("Category not found.", ephemeral=True)
|
||||
return
|
||||
|
||||
await ctx.defer()
|
||||
config_category = filtered.pop()
|
||||
embed = discord.Embed(
|
||||
title=config_category.get("category_name", ""),
|
||||
description=config_category.get("message_header", ""),
|
||||
color=discord.Color.blue()
|
||||
)
|
||||
|
||||
roles_list = [
|
||||
ctx.guild.get_role(role_config.get("role_id"))
|
||||
for role_config in config_category.get("roles", [])
|
||||
]
|
||||
for role_instance, role_config in zip(roles_list, config_category.get("roles", [])):
|
||||
mention = role_instance.mention if role_instance else "(role missing)"
|
||||
embed.add_field(name=role_config.get("reaction", ""), value=mention)
|
||||
|
||||
message = await ctx.send(embed=embed)
|
||||
if message:
|
||||
config_category["message_id"] = message.id
|
||||
# persist the updated guild config
|
||||
Config.save_guild_config(guild_id)
|
||||
|
||||
for config_role in config_category.get("roles", []):
|
||||
reaction = config_role.get("reaction")
|
||||
if reaction:
|
||||
await message.add_reaction(reaction)
|
||||
|
||||
await ctx.followup.send("Role message generated", ephemeral=True)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
|
||||
if payload.user_id == self.bot.user.id:
|
||||
return
|
||||
|
||||
guild_id = payload.guild_id
|
||||
guild_cfg = Config.GUILD_CONFIGS.get(guild_id)
|
||||
if not guild_cfg:
|
||||
return
|
||||
if not self.is_enabled(guild_id):
|
||||
return
|
||||
|
||||
categories = guild_cfg.get("configs", {}).get("roles", {}).get("categories", [])
|
||||
filtered = [c for c in categories if c.get("message_id") == payload.message_id]
|
||||
if not filtered:
|
||||
return
|
||||
|
||||
config_category = filtered.pop()
|
||||
role_ids = [config_role.get("role_id") for config_role in config_category.get("roles", []) if config_role.get("reaction") == payload.emoji.name]
|
||||
if not role_ids:
|
||||
return
|
||||
|
||||
role_id = role_ids.pop()
|
||||
guild = self.bot.get_guild(guild_id)
|
||||
if not guild:
|
||||
return
|
||||
role = guild.get_role(role_id)
|
||||
if not role:
|
||||
return
|
||||
|
||||
user = guild.get_member(payload.user_id)
|
||||
if not user:
|
||||
return
|
||||
|
||||
if "parent_role_id" in config_category:
|
||||
parent_role = guild.get_role(config_category.get("parent_role_id"))
|
||||
if parent_role:
|
||||
await user.add_roles(parent_role, role)
|
||||
return
|
||||
|
||||
await user.add_roles(role)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent):
|
||||
if payload.user_id == self.bot.user.id:
|
||||
return
|
||||
|
||||
guild_id = payload.guild_id
|
||||
guild_cfg = Config.GUILD_CONFIGS.get(guild_id)
|
||||
if not guild_cfg:
|
||||
return
|
||||
if not self.is_enabled(guild_id):
|
||||
return
|
||||
|
||||
categories = guild_cfg.get("configs", {}).get("roles", {}).get("categories", [])
|
||||
filtered = [c for c in categories if c.get("message_id") == payload.message_id]
|
||||
if not filtered:
|
||||
return
|
||||
|
||||
config_category = filtered.pop()
|
||||
role_ids = [config_role.get("role_id") for config_role in config_category.get("roles", []) if config_role.get("reaction") == payload.emoji.name]
|
||||
if not role_ids:
|
||||
return
|
||||
|
||||
role_id = role_ids.pop()
|
||||
guild = self.bot.get_guild(guild_id)
|
||||
if not guild:
|
||||
return
|
||||
role = guild.get_role(role_id)
|
||||
if not role:
|
||||
return
|
||||
|
||||
user = guild.get_member(payload.user_id)
|
||||
if not user:
|
||||
return
|
||||
|
||||
if "parent_role_id" in config_category:
|
||||
parent_role = guild.get_role(config_category.get("parent_role_id"))
|
||||
if parent_role:
|
||||
await user.remove_roles(parent_role, role)
|
||||
return
|
||||
|
||||
await user.remove_roles(role)
|
||||
217
cogs/roulette.py
Normal file
217
cogs/roulette.py
Normal file
@@ -0,0 +1,217 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import random
|
||||
|
||||
import discord
|
||||
from discord import option
|
||||
from discord.ext import commands
|
||||
from cogs import FeatureCog
|
||||
|
||||
|
||||
class RouletteCog(FeatureCog):
|
||||
def __init__(self, bot: discord.Bot):
|
||||
super().__init__(bot, feature_name="roulette")
|
||||
self.cooldown = 60 * 3
|
||||
self.active_games = {}
|
||||
|
||||
def _build_view(self, game: dict) -> discord.ui.View:
|
||||
view = discord.ui.View(timeout=None)
|
||||
|
||||
if game["status"] == "pending":
|
||||
async def accept_callback(interaction: discord.Interaction):
|
||||
if interaction.user.id != game["target"].id:
|
||||
await interaction.response.send_message(
|
||||
"Only the challenged member can accept this duel.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
game["status"] = "ready"
|
||||
game["current_player"] = game["author"]
|
||||
|
||||
if game.get("message"):
|
||||
await game["message"].edit(
|
||||
content=(
|
||||
f"{game['author'].mention} challenged {game['target'].mention} to a Russian roulette duel.\n"
|
||||
f"✅ {game['target'].mention} accepted. {game['current_player'].mention}, click Fire!"
|
||||
),
|
||||
view=self._build_view(game),
|
||||
)
|
||||
|
||||
await interaction.response.defer()
|
||||
|
||||
async def decline_callback(interaction: discord.Interaction):
|
||||
if interaction.user.id != game["target"].id:
|
||||
await interaction.response.send_message(
|
||||
"Only the challenged member can decline this duel.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
game["status"] = "declined"
|
||||
self.active_games.pop((game["guild_id"], game["author"].id, game["target"].id), None)
|
||||
|
||||
if game.get("message"):
|
||||
await game["message"].edit(
|
||||
content=f"{game['target'].mention} declined the challenge.",
|
||||
view=None,
|
||||
)
|
||||
|
||||
await interaction.response.defer()
|
||||
|
||||
accept_button = discord.ui.Button(label="Accept", style=discord.ButtonStyle.green)
|
||||
accept_button.callback = accept_callback
|
||||
view.add_item(accept_button)
|
||||
|
||||
decline_button = discord.ui.Button(label="Decline", style=discord.ButtonStyle.red)
|
||||
decline_button.callback = decline_callback
|
||||
view.add_item(decline_button)
|
||||
|
||||
elif game["status"] in {"ready", "playing"}:
|
||||
async def fire_callback(interaction: discord.Interaction):
|
||||
if interaction.user.id != game["current_player"].id:
|
||||
await interaction.response.send_message(
|
||||
f"Wait for {game['current_player'].mention} to fire.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
if interaction.user.id in game["fired"]:
|
||||
await interaction.response.send_message(
|
||||
"You already fired once. Only one shot per player is allowed.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
game["fired"].add(interaction.user.id)
|
||||
is_shot = random.randint(1, 6) == 1
|
||||
|
||||
if is_shot:
|
||||
game["status"] = "finished"
|
||||
self.active_games.pop((game["guild_id"], game["author"].id, game["target"].id), None)
|
||||
|
||||
await self._mute_member(interaction.user, 60)
|
||||
|
||||
if game.get("message"):
|
||||
await game["message"].edit(
|
||||
content=(
|
||||
f"💥 {interaction.user.mention} pulled the trigger and got shot.\n"
|
||||
f"They were muted for 60 seconds."
|
||||
),
|
||||
view=None,
|
||||
)
|
||||
else:
|
||||
if len(game["fired"]) >= 2:
|
||||
game["status"] = "finished"
|
||||
self.active_games.pop((game["guild_id"], game["author"].id, game["target"].id), None)
|
||||
|
||||
if game.get("message"):
|
||||
await game["message"].edit(
|
||||
content=(
|
||||
f"🔫 {interaction.user.mention} survived and both players have fired once.\n"
|
||||
f"No one was shot. The duel ends in a draw."
|
||||
),
|
||||
view=None,
|
||||
)
|
||||
else:
|
||||
game["status"] = "playing"
|
||||
game["current_player"] = game["target"] if interaction.user.id == game["author"].id else game["author"]
|
||||
|
||||
if game.get("message"):
|
||||
await game["message"].edit(
|
||||
content=(
|
||||
f"🔫 {interaction.user.mention} survived.\n"
|
||||
f"Next turn: {game['current_player'].mention}"
|
||||
),
|
||||
view=self._build_view(game),
|
||||
)
|
||||
|
||||
await interaction.response.defer()
|
||||
|
||||
fire_button = discord.ui.Button(label="Fire", style=discord.ButtonStyle.danger)
|
||||
fire_button.callback = fire_callback
|
||||
view.add_item(fire_button)
|
||||
|
||||
return view
|
||||
|
||||
async def _mute_member(self, member: discord.Member, seconds: int = 60):
|
||||
until = discord.utils.utcnow() + datetime.timedelta(seconds=seconds)
|
||||
try:
|
||||
await member.timeout(until=until, reason="Russian roulette loss")
|
||||
except AttributeError:
|
||||
await member.edit(timed_out_until=until, reason="Russian roulette loss")
|
||||
|
||||
@commands.slash_command(
|
||||
name="roulette",
|
||||
description="Challenge other server members. The loser is getting muted for 1 minute",
|
||||
)
|
||||
@option(
|
||||
name="target",
|
||||
description="The member to challenge",
|
||||
input_type=discord.Member,
|
||||
required=True,
|
||||
)
|
||||
async def roulette(
|
||||
self,
|
||||
ctx: discord.ApplicationContext,
|
||||
target: discord.Member,
|
||||
):
|
||||
# ensure feature enabled for this guild
|
||||
if not ctx.guild or not self.is_enabled(ctx.guild.id):
|
||||
await ctx.respond("Roulette feature is disabled for this guild.", ephemeral=True)
|
||||
return
|
||||
author = ctx.author
|
||||
self_test = author.id == target.id
|
||||
|
||||
if self_test:
|
||||
target = author
|
||||
|
||||
game = {
|
||||
"author": author,
|
||||
"target": target,
|
||||
"status": "pending",
|
||||
"current_player": None,
|
||||
"guild_id": ctx.guild_id,
|
||||
"message": None,
|
||||
"fired": set(),
|
||||
}
|
||||
|
||||
self.active_games[(ctx.guild_id, author.id, target.id)] = game
|
||||
|
||||
await ctx.send_response(
|
||||
f"{author.mention} challenged {target.mention} to a Russian roulette duel.\n"
|
||||
f"The target has 60 seconds to accept or decline.",
|
||||
view=self._build_view(game),
|
||||
)
|
||||
|
||||
if self_test:
|
||||
await ctx.send_followup(
|
||||
"**Self-challenge enabled for testing.**\n"
|
||||
"The game will start immediately and you can test the flow.",
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
try:
|
||||
game["message"] = await ctx.interaction.original_response()
|
||||
except Exception:
|
||||
game["message"] = None
|
||||
|
||||
async def timeout_handler():
|
||||
try:
|
||||
await asyncio.sleep(60)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
||||
if game.get("status") == "pending":
|
||||
game["status"] = "timeout"
|
||||
self.active_games.pop((ctx.guild_id, author.id, target.id), None)
|
||||
|
||||
await self._mute_member(target, 60)
|
||||
|
||||
if game.get("message"):
|
||||
await game["message"].edit(
|
||||
content=f"⏰ {target.mention} did not respond in time and was muted for 60 seconds.",
|
||||
view=None,
|
||||
)
|
||||
|
||||
asyncio.create_task(timeout_handler())
|
||||
36
compose.yaml
36
compose.yaml
@@ -1,9 +1,37 @@
|
||||
networks:
|
||||
internal:
|
||||
internal: true
|
||||
outbound:
|
||||
external: true
|
||||
|
||||
services:
|
||||
botmafieux:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
tags:
|
||||
- nebulo9/botmafieux:v2
|
||||
dockerfile: Dockerfile
|
||||
image: nebulo9/botmafieux:latest
|
||||
container_name: botmafieux
|
||||
env_file: ./.env
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal
|
||||
- outbound
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- ./config:/config
|
||||
healthcheck:
|
||||
test: ["CMD", "pgrep", "-f", "main.py"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.25'
|
||||
memory: '256M'
|
||||
|
||||
156
main.py
156
main.py
@@ -1,131 +1,59 @@
|
||||
import os, discord, json, sys, random
|
||||
import os
|
||||
import sys
|
||||
import discord
|
||||
from argparse import ArgumentParser
|
||||
from datetime import datetime
|
||||
from asyncio import create_task
|
||||
from dotenv import load_dotenv
|
||||
from discord import option
|
||||
from discord.ext import tasks, commands
|
||||
from setup.bot import bot, reload_feature
|
||||
from setup.logger import LOGGER
|
||||
|
||||
# Parse minimal CLI args early so we can configure logging before imports that
|
||||
# initialize loggers. Keep only `--log-level`; other args were unused.
|
||||
PARSER = ArgumentParser(description='BotMafieux for Discord.')
|
||||
PARSER.add_argument('--guild_id',type=int,help='The guild ID.')
|
||||
PARSER.add_argument('--channel_id',type=int,help='The channel ID.')
|
||||
PARSER.add_argument('-r','--reload',action='store_true',help='Reload the bot.')
|
||||
PARSER.add_argument('--log-level', type=str, help='Logging level (DEBUG, INFO, WARNING, ERROR)')
|
||||
EXEC_ARGS = PARSER.parse_args()
|
||||
|
||||
# @tasks.loop(time=datetime.time(datetime.strptime('00:00','%H:%M')))
|
||||
# async def birthday_anouncements_task():
|
||||
# LOGGER.debug('birthday_anouncements_task started.')
|
||||
# for guild in bot.guilds:
|
||||
# birthday_settings = db.select('birthday_settings',f'guild_id = {guild.id}')
|
||||
# if birthday_settings:
|
||||
# if birthday_settings['is_enabled']:
|
||||
# for user in guild.members:
|
||||
# user_productivity_data = db.select('guild_user_productivity',f'user_id = {user.id} AND guild_id = {guild.id}')
|
||||
# if user_productivity_data:
|
||||
# if user_productivity_data['is_enabled']:
|
||||
# user = guild.get_member(user.id)
|
||||
# if user:
|
||||
# birthday = user_productivity_data['birthday']
|
||||
# today = datetime.today().strftime('%d/%m')
|
||||
# if birthday == today:
|
||||
# channel = guild.get_channel(user_productivity_data['channel_id'])
|
||||
# if channel:
|
||||
# message = user_productivity_data['birthday_message']
|
||||
# if message:
|
||||
# message = message.replace('{user}',user.mention)
|
||||
# await channel.send(message)
|
||||
# else:
|
||||
# LOGGER.debug(f'Channel {user_productivity_data["channel_id"]} not found.')
|
||||
# else:
|
||||
# LOGGER.debug(f'No birthday today for user {user.name} ({user.id}).')
|
||||
# else:
|
||||
# LOGGER.debug(f'User {user.name} ({user.id}) not found.')
|
||||
# else:
|
||||
# LOGGER.debug(f'User {user.name} ({user.id}) has birthday announcements disabled.')
|
||||
# else:
|
||||
# LOGGER.debug(f'No birthday data found for user {user.name} ({user.id}).')
|
||||
# else:
|
||||
# LOGGER.debug(f'No productivity settings found for guild {guild.name} ({guild.id}).')
|
||||
# LOGGER.debug('birthday_anouncements_task ended.')
|
||||
if EXEC_ARGS.log_level:
|
||||
os.environ['LOG_LEVEL'] = str(EXEC_ARGS.log_level)
|
||||
|
||||
# @bot.slash_command(name='help',description='Displays the help message.')
|
||||
# async def help(ctx:discord.ApplicationContext):
|
||||
# """Displays this message."""
|
||||
# command_name = 'help'
|
||||
# LOGGER.debug(f'{ctx.author.name} used /{command_name}.')
|
||||
# embed = discord.Embed(title='Help',description='List of commands and their descriptions.',color=discord.Color.from_rgb(171,0,219))
|
||||
# embed.set_author(name=bot.user.name,icon_url=bot.user.avatar.url)
|
||||
# for command in bot.commands:
|
||||
# embed.add_field(name=command.name,value=command.description,inline=False)
|
||||
# await ctx.send_response(embed=embed,ephemeral=True)
|
||||
|
||||
# @bot.slash_command(name='reload',description='Reloads the bot.')
|
||||
# @option(name='feature',description='The feature to reload.',required=True,choices=['birthday','productivity'])
|
||||
# @commands.is_owner()
|
||||
# async def reload(ctx:discord.ApplicationContext, feature:str):
|
||||
# """Reloads the bot."""
|
||||
# command_name = 'reload'
|
||||
# guild = ctx.guild
|
||||
# channel = ctx.channel
|
||||
# LOGGER.debug(f'{ctx.author.name} used /{command_name} {feature}.')
|
||||
# try:
|
||||
# reload_feature(feature)
|
||||
# await ctx.send_response(f'{feature} reloaded!',ephemeral=True)
|
||||
# except Exception as e:
|
||||
# LOGGER.error(f'Error reloading feature {feature}: {e}')
|
||||
# await ctx.send_response(f'Error reloading feature {feature}: {e}',ephemeral=True)
|
||||
from setup.bot import bot, reload_feature, register_cogs
|
||||
from setup.logger import LOGGER
|
||||
from setup.config import Config
|
||||
|
||||
# @bot.event
|
||||
# async def on_member_join(member:discord.Member):
|
||||
# if not member.bot:
|
||||
# user_id = member.id
|
||||
# user_name = member.name
|
||||
# user_mention = member.mention
|
||||
# db_user = db.select('global_user',f'WHERE user_id = {user_id}')
|
||||
# if not db_user:
|
||||
# db.insert('global_user',user_id=user_id,user_name=user_name,user_mention=user_mention)
|
||||
|
||||
# @bot.event
|
||||
# async def on_raw_member_remove(payload:discord.RawMemberRemoveEvent):
|
||||
# user = payload.user
|
||||
# if not user.bot:
|
||||
# user_id = user.id
|
||||
# guild_id = payload.guild_id
|
||||
# db.delete('guild_user_birthday',f'user_id = {user_id} AND guild_id = {guild_id}')
|
||||
# db.delete('guild_user_productivity',f'user_id = {user_id} AND guild_id = {guild_id}')
|
||||
|
||||
# @bot.event
|
||||
# async def on_guild_join(guild:discord.Guild):
|
||||
# guild_id = guild.id
|
||||
# guild_name = guild.name
|
||||
# LOGGER.info(f'Bot joined guild {guild_name} ({guild_id}).')
|
||||
# db.insert('guild',guild_id=guild_id)
|
||||
# db.insert('birthday_settings',guild_id=guild_id,is_enabled=False,channel_id=random.choice(guild.text_channels).id)
|
||||
# db.insert('productivity_settings',guild_id=guild_id,is_enabled=False)
|
||||
|
||||
# @bot.event
|
||||
# async def on_guild_remove(guild:discord.Guild):
|
||||
# guild_id = guild.id
|
||||
# guild_name = guild.name
|
||||
# LOGGER.info(f'Bot left guild {guild_name} ({guild_id}).')
|
||||
# db.delete('birthday_settings',f'guild_id = {guild_id}')
|
||||
# db.delete('productivity_settings',f'guild_id = {guild_id}')
|
||||
# db.delete('guild',f'guild_id = {guild_id}')
|
||||
# Register cogs from configuration (no CLI activation)
|
||||
register_cogs()
|
||||
|
||||
@bot.event
|
||||
async def on_ready():
|
||||
if bot.user:
|
||||
LOGGER.info(f'{bot.user.name} has connected to Discord!')
|
||||
LOGGER.debug(f'Guilds: {','.join([guild.name for guild in bot.guilds])}')
|
||||
|
||||
LOGGER.info(f"Guilds: {','.join([guild.name for guild in bot.guilds])}")
|
||||
# Log configuration for each guild we are connected to
|
||||
try:
|
||||
for guild in bot.guilds:
|
||||
cfg = Config.GUILD_CONFIGS.get(guild.id)
|
||||
if cfg is not None:
|
||||
LOGGER.info(f"Guild config for {guild.name} ({guild.id}): {cfg}")
|
||||
else:
|
||||
LOGGER.info(f"No guild config for {guild.name} ({guild.id})")
|
||||
except Exception as e:
|
||||
LOGGER.warning(f"Failed to log guild configs: {e}")
|
||||
|
||||
@bot.event
|
||||
async def on_guild_join(guild: discord.Guild):
|
||||
# Refresh configs and log the joined guild configuration
|
||||
try:
|
||||
Config.load_guilds_configs()
|
||||
except Exception:
|
||||
LOGGER.debug('Could not reload guild configs during on_guild_join.')
|
||||
|
||||
cfg = Config.GUILD_CONFIGS.get(guild.id)
|
||||
if cfg is not None:
|
||||
LOGGER.info(f"Joined guild {guild.name} ({guild.id}) with config: {cfg}")
|
||||
else:
|
||||
LOGGER.info(f"Joined guild {guild.name} ({guild.id}) with no configuration present.")
|
||||
|
||||
if __name__ == '__main__':
|
||||
load_dotenv()
|
||||
TOKEN = os.getenv("TOKEN")
|
||||
if TOKEN:
|
||||
bot.run(TOKEN)
|
||||
token = Config.TOKEN
|
||||
if token:
|
||||
bot.run(token)
|
||||
else:
|
||||
LOGGER.error('No token found.')
|
||||
sys.exit(1)
|
||||
|
||||
100
setup/bot.py
100
setup/bot.py
@@ -4,7 +4,10 @@ from discord.ext import bridge
|
||||
# from cogs.productivity import ProductivityCog
|
||||
from cogs.cs import CSCog
|
||||
from cogs.insult import InsultsCog
|
||||
from cogs.roulette import RouletteCog
|
||||
from cogs.roles import RolesCog
|
||||
from setup.logger import LOGGER
|
||||
from setup.config import Config
|
||||
|
||||
bot_intents = discord.Intents.default()
|
||||
bot_intents.message_content = True
|
||||
@@ -15,15 +18,98 @@ bot_intents.reactions = True
|
||||
|
||||
bot = bridge.Bot(command_prefix='$', intents=bot_intents)
|
||||
|
||||
COGS = [CSCog, InsultsCog]
|
||||
COG_REGISTRY = {
|
||||
'cs': CSCog,
|
||||
'insult': InsultsCog,
|
||||
'roulette': RouletteCog,
|
||||
'roles': RolesCog,
|
||||
}
|
||||
|
||||
for cog in COGS:
|
||||
bot.add_cog(cog(bot))
|
||||
# Register a global admin slash command directly on the bot to avoid circular imports.
|
||||
from discord import option as discord_option
|
||||
|
||||
def reload_feature(feature:str):
|
||||
|
||||
@bot.slash_command(name="reload", description="Reload a feature (cog) by name, or 'all')")
|
||||
@discord_option(name="feature", description="Feature to reload (e.g. cs, insult, roles, roulette, all)", input_type=str, required=True)
|
||||
async def reload(ctx: discord.ApplicationContext, feature: str):
|
||||
# Only allow administrators
|
||||
if not ctx.author or not getattr(ctx.author, 'guild_permissions', None) or not ctx.author.guild_permissions.administrator:
|
||||
await ctx.respond("You must be a server administrator to use this command.", ephemeral=True)
|
||||
return
|
||||
|
||||
await ctx.defer()
|
||||
reload_feature(feature)
|
||||
await ctx.followup.send(f"Reload requested for feature: {feature}", ephemeral=True)
|
||||
|
||||
|
||||
def _normalize_cog_names(cogs=None):
|
||||
if cogs is None:
|
||||
return list(COG_REGISTRY.keys())
|
||||
|
||||
if isinstance(cogs, str):
|
||||
cogs = [cogs]
|
||||
|
||||
selected_cogs = []
|
||||
for cog_name in cogs:
|
||||
name = str(cog_name).strip().lower()
|
||||
if not name:
|
||||
continue
|
||||
if name in {'all', '*'}:
|
||||
return list(COG_REGISTRY.keys())
|
||||
if name in COG_REGISTRY:
|
||||
selected_cogs.append(name)
|
||||
else:
|
||||
LOGGER.warning("Unknown cog '%s'. Available cogs: %s", cog_name, ', '.join(COG_REGISTRY.keys()))
|
||||
|
||||
return list(dict.fromkeys(selected_cogs))
|
||||
|
||||
|
||||
def register_cogs(cogs=None):
|
||||
# Ensure guild configs are loaded before instantiating cogs
|
||||
try:
|
||||
Config.load_guilds_configs()
|
||||
except Exception:
|
||||
LOGGER.debug('Could not reload guild configs during register_cogs.')
|
||||
|
||||
selected_cogs = _normalize_cog_names(cogs)
|
||||
|
||||
existing_cog_names = set(COG_REGISTRY.values())
|
||||
for existing_name in list(bot.cogs.keys()):
|
||||
if existing_name in {cog_class.__name__ for cog_class in existing_cog_names}:
|
||||
bot.remove_cog(existing_name)
|
||||
|
||||
for cog_name in selected_cogs:
|
||||
cog_class = COG_REGISTRY[cog_name]
|
||||
bot.add_cog(cog_class(bot))
|
||||
|
||||
return selected_cogs
|
||||
|
||||
|
||||
|
||||
def reload_feature(feature: str):
|
||||
normalized_feature = str(feature).strip().lower()
|
||||
LOGGER.debug(bot.cogs)
|
||||
|
||||
if normalized_feature in {'all', '*'}:
|
||||
register_cogs()
|
||||
LOGGER.debug('All cogs reloaded!')
|
||||
return
|
||||
|
||||
if normalized_feature not in COG_REGISTRY:
|
||||
LOGGER.warning("Unknown cog '%s'. Available cogs: %s", feature, ', '.join(COG_REGISTRY.keys()))
|
||||
return
|
||||
|
||||
LOGGER.debug(f'Reloading {feature}...')
|
||||
bot.remove_cog(feature.capitalize()+'Cog')
|
||||
cog = globals()[feature.capitalize()+'Cog']
|
||||
bot.add_cog(cog(bot))
|
||||
# Refresh guild configs before reloading a single feature
|
||||
try:
|
||||
Config.load_guilds_configs()
|
||||
except Exception:
|
||||
LOGGER.debug('Could not reload guild configs during reload_feature.')
|
||||
target_cog_name = COG_REGISTRY[normalized_feature].__name__
|
||||
for existing_name in list(bot.cogs.keys()):
|
||||
if existing_name == target_cog_name:
|
||||
bot.remove_cog(existing_name)
|
||||
break
|
||||
|
||||
bot.add_cog(COG_REGISTRY[normalized_feature](bot))
|
||||
LOGGER.debug(f'{feature} reloaded!')
|
||||
|
||||
64
setup/config.py
Normal file
64
setup/config.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import os, pathlib, json
|
||||
from dotenv import load_dotenv
|
||||
from setup.logger import LOGGER
|
||||
|
||||
class Config:
|
||||
load_dotenv()
|
||||
|
||||
TOKEN = str(os.getenv("TOKEN", ""))
|
||||
CONFIG_PATH = str(os.getenv("CONFIG_PATH"))
|
||||
|
||||
GUILDS_IDS:list[int] = []
|
||||
|
||||
GUILD_CONFIGS:dict[int,dict] = {}
|
||||
|
||||
@classmethod
|
||||
def load_guilds_configs(cls):
|
||||
with os.scandir(cls.CONFIG_PATH) as d:
|
||||
guilds_config_files = [f.path for f in d if f.is_file() and f.name.endswith(".json")]
|
||||
for guild_config_file in guilds_config_files:
|
||||
with open(guild_config_file) as f:
|
||||
guild_config = json.load(f)
|
||||
guild_id = guild_config["guild_id"]
|
||||
cls.GUILDS_IDS.append(guild_id)
|
||||
cls.GUILD_CONFIGS[guild_id] = {k:v for k,v in guild_config.items() if k != "guild_id"}
|
||||
cls.GUILD_CONFIGS[guild_id]["config_path"] = guild_config_file
|
||||
|
||||
@classmethod
|
||||
def load_guild_config(cls,guild_id:int):
|
||||
config_path = cls.GUILD_CONFIGS[guild_id]["config_path"]
|
||||
with open(config_path) as f:
|
||||
guild_config = json.load(f)
|
||||
# Preserve config_path when re-loading
|
||||
cls.GUILD_CONFIGS[guild_id] = {k: v for k, v in guild_config.items() if k != "guild_id"}
|
||||
cls.GUILD_CONFIGS[guild_id]["config_path"] = config_path
|
||||
|
||||
@classmethod
|
||||
def save_guilds_configs(cls):
|
||||
for guild_id, guild_config in cls.GUILD_CONFIGS.items():
|
||||
config_path = guild_config.get("config_path")
|
||||
if not config_path:
|
||||
continue
|
||||
with open(config_path, 'w') as file:
|
||||
json_config = {k: v for k, v in guild_config.items() if k != "config_path"}
|
||||
json_config["guild_id"] = guild_id
|
||||
json.dump(json_config, file, ensure_ascii=False, indent=2)
|
||||
|
||||
@classmethod
|
||||
def save_guild_config(cls,guild_id:int):
|
||||
config = cls.GUILD_CONFIGS.get(guild_id)
|
||||
if not config:
|
||||
return
|
||||
config_path = config.get("config_path")
|
||||
if not config_path:
|
||||
return
|
||||
with open(config_path, 'w') as file:
|
||||
json_config = {k: v for k, v in config.items() if k != "config_path"}
|
||||
json_config["guild_id"] = guild_id
|
||||
json.dump(json_config, file, ensure_ascii=False, indent=2)
|
||||
|
||||
@classmethod
|
||||
def get_guild_config(cls,guild_id):
|
||||
return cls.GUILD_CONFIGS[guild_id]
|
||||
|
||||
Config.load_guilds_configs()
|
||||
@@ -1,8 +1,42 @@
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import sys
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='[%(levelname)s] %(asctime)s: %(name)s: %(message)s')
|
||||
# Determine log level from environment variable `LOG_LEVEL` (e.g. DEBUG, INFO)
|
||||
raw_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
level = logging.INFO
|
||||
if raw_level.isdigit():
|
||||
try:
|
||||
level = int(raw_level)
|
||||
except Exception:
|
||||
level = logging.INFO
|
||||
else:
|
||||
level = getattr(logging, raw_level, logging.INFO)
|
||||
|
||||
logging.basicConfig(level=level, format='[%(levelname)s] %(asctime)s: %(name)s: %(message)s')
|
||||
LOGGER = logging.getLogger('botmafieux')
|
||||
LOGGER.setLevel(logging.DEBUG)
|
||||
handler = logging.FileHandler(filename='botmafieux.log', encoding='utf-8', mode='a')
|
||||
handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s:%(name)s: %(message)s'))
|
||||
LOGGER.addHandler(handler)
|
||||
LOGGER.setLevel(level)
|
||||
|
||||
# Decide log file path: prefer LOG_FILE, then CONFIG_PATH/botmafieux.log, then /tmp
|
||||
preferred = os.getenv('LOG_FILE')
|
||||
if not preferred:
|
||||
cfg_path = "/logs"
|
||||
if cfg_path:
|
||||
preferred = os.path.join(cfg_path, 'botmafieux.log')
|
||||
else:
|
||||
preferred = os.path.join(tempfile.gettempdir(), 'botmafieux.log')
|
||||
|
||||
formatter = logging.Formatter('%(asctime)s:%(levelname)s:%(name)s: %(message)s')
|
||||
|
||||
try:
|
||||
handler = logging.FileHandler(filename=preferred, encoding='utf-8', mode='a')
|
||||
handler.setFormatter(formatter)
|
||||
LOGGER.addHandler(handler)
|
||||
except (PermissionError, OSError) as e:
|
||||
# Fall back to console logging if file cannot be opened
|
||||
stream_handler = logging.StreamHandler(sys.stdout)
|
||||
stream_handler.setFormatter(formatter)
|
||||
LOGGER.addHandler(stream_handler)
|
||||
# Print a warning directly to stderr in case logging isn't yet configured elsewhere
|
||||
print(f"[WARN] Could not open log file '{preferred}' for writing: {e}. Falling back to stdout.", file=sys.stderr)
|
||||
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