Files
MamieHenriette/discordbot/member_stats.py
T
2026-03-25 22:58:06 +01:00

184 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Compteurs messages / vocal par membre pour la webapp (table guild_member_stats)."""
import asyncio
import concurrent.futures
import logging
from datetime import datetime, timezone
import discord
from sqlalchemy import text
from webapp import webapp
from database import db
logger = logging.getLogger(__name__)
# (guild_id, user_id) -> datetime début session vocale (UTC)
_voice_join_at: dict[tuple[int, int], datetime] = {}
def _now_utc() -> datetime:
return datetime.now(timezone.utc)
def record_message(guild_id: int, user_id: int) -> None:
"""Incrémente message_count (hors bots)."""
try:
with webapp.app_context():
db.session.execute(
text("""
INSERT INTO guild_member_stats (guild_id, user_id, message_count, voice_seconds, updated_at)
VALUES (:gid, :uid, 1, 0, CURRENT_TIMESTAMP)
ON CONFLICT(guild_id, user_id) DO UPDATE SET
message_count = guild_member_stats.message_count + 1,
updated_at = CURRENT_TIMESTAMP
"""),
{"gid": str(guild_id), "uid": str(user_id)},
)
db.session.commit()
except Exception as e:
logger.warning("record_message: %s", e)
try:
db.session.rollback()
except Exception:
pass
def add_voice_seconds(guild_id: int, user_id: int, seconds: int) -> None:
if seconds <= 0:
return
try:
with webapp.app_context():
db.session.execute(
text("""
INSERT INTO guild_member_stats (guild_id, user_id, message_count, voice_seconds, updated_at)
VALUES (:gid, :uid, 0, :sec, CURRENT_TIMESTAMP)
ON CONFLICT(guild_id, user_id) DO UPDATE SET
voice_seconds = guild_member_stats.voice_seconds + :sec,
updated_at = CURRENT_TIMESTAMP
"""),
{"gid": str(guild_id), "uid": str(user_id), "sec": seconds},
)
db.session.commit()
except Exception as e:
logger.warning("add_voice_seconds: %s", e)
try:
db.session.rollback()
except Exception:
pass
def _finalize_voice_session(guild_id: int, user_id: int, end: datetime) -> None:
key = (guild_id, user_id)
started = _voice_join_at.pop(key, None)
if started is None:
return
delta = (end - started).total_seconds()
add_voice_seconds(guild_id, user_id, int(delta))
def on_voice_state_update_track_voice(member: discord.Member, before: discord.VoiceState, after: discord.VoiceState) -> None:
if member.bot:
return
guild_id = member.guild.id
uid = member.id
bc = before.channel
ac = after.channel
if bc == ac:
return
now = _now_utc()
if bc is not None:
_finalize_voice_session(guild_id, uid, now)
if ac is not None:
_voice_join_at[(guild_id, uid)] = now
async def fetch_guild_members_snapshot(bot: discord.Client, guild_id: int | None) -> tuple[bool, str | None, dict]:
"""
Retourne (ok, erreur, payload) avec payload =
{ guild_id, guild_name, members: [ { id, display_name, name, avatar_url, joined_at, nick, roles } ] }
"""
guilds = list(bot.guilds)
if not guilds:
return False, "Le bot n'est sur aucun serveur.", {}
chosen: discord.Guild | None = None
if guild_id is not None:
chosen = discord.utils.get(guilds, id=guild_id)
if chosen is None:
return False, "Serveur Discord introuvable pour ce bot.", {}
else:
if len(guilds) == 1:
chosen = guilds[0]
else:
return (
False,
"Plusieurs serveurs : précisez ?guild_id=… dans l'URL.",
{"guilds": [{"id": g.id, "name": g.name} for g in guilds]},
)
try:
await asyncio.wait_for(chosen.chunk(cache=True), timeout=120.0)
except asyncio.TimeoutError:
logger.warning(
"guild.chunk timeout (%s), suite avec le cache membres partiel",
chosen.name,
)
except Exception as e:
logger.warning("guild.chunk: %s", e, exc_info=True)
members_out = []
for m in chosen.members:
if m.bot:
continue
role_list = [r for r in m.roles if r.name != "@everyone"]
role_list.sort(key=lambda r: r.position, reverse=True)
roles = ", ".join(r.name for r in role_list[:8])
if len(role_list) > 8:
roles += f" (+{len(role_list) - 8})"
joined = m.joined_at.isoformat() if m.joined_at else None
members_out.append({
"id": str(m.id),
"display_name": m.display_name,
"name": m.name,
"avatar_url": m.display_avatar.url if m.display_avatar else "",
"joined_at": joined,
"nick": m.nick,
"roles": roles or "—",
})
members_out.sort(key=lambda x: (x["display_name"] or x["name"]).lower())
return True, None, {
"guild_id": str(chosen.id),
"guild_name": chosen.name,
"members": members_out,
}
def get_discord_members_snapshot_sync(bot: discord.Client, guild_id: int | None = None, timeout: float = 180.0) -> tuple[bool, str | None, dict]:
"""Appel thread-safe depuis Flask (run_coroutine_threadsafe sur la boucle du bot)."""
loop = getattr(bot, "loop", None)
if loop is None:
logger.error("get_discord_members_snapshot_sync: bot.loop est None (bot non démarré ?)")
return False, "Bot Discord non démarré (aucune boucle événements).", {}
if not bot.is_ready():
return False, "Bot Discord pas encore prêt (connexion en cours). Réessayez dans quelques secondes.", {}
try:
future = asyncio.run_coroutine_threadsafe(
fetch_guild_members_snapshot(bot, guild_id),
loop,
)
ok, err, payload = future.result(timeout=timeout)
return ok, err, payload
except (concurrent.futures.TimeoutError, TimeoutError) as e:
logger.error(
"get_discord_members_snapshot_sync: délai dépassé après %.0fs (%s)",
timeout,
type(e).__name__,
exc_info=True,
)
return (
False,
"Délai dépassé pendant le chargement des membres (serveur volumineux ou bot occupé). Réessayez dans un instant.",
{},
)
except Exception as e:
logger.exception("get_discord_members_snapshot_sync: %s", type(e).__name__)
msg = str(e) if str(e) else repr(e)
return False, msg or "Erreur lors de lappel au bot Discord.", {}