diff --git a/database/__init__.py b/database/__init__.py index 6ff0c6d..6226e9a 100644 --- a/database/__init__.py +++ b/database/__init__.py @@ -227,6 +227,30 @@ def _doAddColumnMigrations(cursor: Cursor): except Exception as e: logging.warning(f"Table webapp_page_permission: {e}") + if not _tableExists('guild_member_stats', cursor): + try: + cursor.execute(""" + CREATE TABLE guild_member_stats ( + guild_id VARCHAR(64) NOT NULL, + user_id VARCHAR(64) NOT NULL, + message_count INTEGER NOT NULL DEFAULT 0, + voice_seconds INTEGER NOT NULL DEFAULT 0, + updated_at DATETIME, + PRIMARY KEY (guild_id, user_id) + ) + """) + logging.info("Table guild_member_stats créée") + except Exception as e: + logging.warning(f"Table guild_member_stats: {e}") + + if _tableExists("webapp_page_permission", cursor): + try: + cursor.execute( + "INSERT OR IGNORE INTO webapp_page_permission (page_key, min_level, write_level) VALUES ('discord_members', 1, 1)" + ) + except Exception as e: + logging.warning(f"Permission page discord_members: {e}") + def _doSeedAuth(cursor: Cursor): """Seed rôles par défaut et permissions des pages si vides.""" @@ -270,6 +294,7 @@ def _doSeedAuth(cursor: Cursor): ("freeloot", 1, 2), ("patreon", 1, 2), ("moderation", 1, 2), + ("discord_members", 1, 1), ("users", 5, 5), ("settings", 5, 5), ] diff --git a/database/models.py b/database/models.py index 7b42a4c..04303f7 100644 --- a/database/models.py +++ b/database/models.py @@ -260,3 +260,12 @@ class ModShoutboxMessage(db.Model): message = db.Column(db.String(500), nullable=False) created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + +class GuildMemberStats(db.Model): + __tablename__ = 'guild_member_stats' + guild_id = db.Column(db.String(64), primary_key=True) + user_id = db.Column(db.String(64), primary_key=True) + message_count = db.Column(db.Integer, nullable=False, default=0) + voice_seconds = db.Column(db.Integer, nullable=False, default=0) + updated_at = db.Column(db.DateTime, nullable=True) + diff --git a/database/schema.sql b/database/schema.sql index 87a1926..a2ddfae 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -99,6 +99,15 @@ CREATE TABLE IF NOT EXISTS `member_invites` ( `join_date` DATETIME NOT NULL ); +CREATE TABLE IF NOT EXISTS `guild_member_stats` ( + `guild_id` VARCHAR(64) NOT NULL, + `user_id` VARCHAR(64) NOT NULL, + `message_count` INTEGER NOT NULL DEFAULT 0, + `voice_seconds` INTEGER NOT NULL DEFAULT 0, + `updated_at` DATETIME, + PRIMARY KEY (`guild_id`, `user_id`) +); + CREATE TABLE IF NOT EXISTS `twitch_link_filter` ( id INTEGER PRIMARY KEY AUTOINCREMENT, `enabled` BOOLEAN NOT NULL DEFAULT FALSE, diff --git a/discordbot/__init__.py b/discordbot/__init__.py index 64ac489..61cf539 100644 --- a/discordbot/__init__.py +++ b/discordbot/__init__.py @@ -29,6 +29,7 @@ from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInvit from discordbot.patreon import checkPatreonPosts from discordbot.youtube import checkYouTubeVideos from discordbot.auto_rooms import on_voice_state_update_auto_rooms, on_raw_reaction_add_auto_rooms, on_message_auto_rooms, cleanup_orphaned_auto_rooms +from discordbot.member_stats import record_message, on_voice_state_update_track_voice from protondb import searhProtonDb class DiscordBot(discord.Client): @@ -167,7 +168,10 @@ async def on_message(message: Message): # Gestion des messages dans les auto rooms (avant le check des commandes !) await on_message_auto_rooms(bot, message) - + + if message.guild and not message.author.bot: + record_message(message.guild.id, message.author.id) + if not message.content.startswith('!'): return command_name = message.content.split()[0] @@ -331,6 +335,7 @@ async def on_message(message: Message): @bot.event async def on_voice_state_update(member: Member, before, after): await on_voice_state_update_auto_rooms(bot, member, before, after) + on_voice_state_update_track_voice(member, before, after) @bot.event async def on_raw_reaction_add(payload: discord.RawReactionActionEvent): diff --git a/discordbot/member_stats.py b/discordbot/member_stats.py new file mode 100644 index 0000000..360b431 --- /dev/null +++ b/discordbot/member_stats.py @@ -0,0 +1,160 @@ +"""Compteurs messages / vocal par membre pour la webapp (table guild_member_stats).""" +import asyncio +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 chosen.chunk(cache=True) + except Exception as e: + logger.warning("guild.chunk: %s", e) + 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 = 90.0) -> tuple[bool, str | None, dict]: + """Appel thread-safe depuis Flask (run_coroutine_threadsafe sur la boucle du bot).""" + if bot.loop is None or not bot.is_ready(): + return False, "Bot Discord non connecté.", {} + try: + future = asyncio.run_coroutine_threadsafe( + fetch_guild_members_snapshot(bot, guild_id), + bot.loop, + ) + ok, err, payload = future.result(timeout=timeout) + return ok, err, payload + except Exception as e: + logger.error("get_discord_members_snapshot_sync: %s", e) + return False, str(e), {} diff --git a/webapp/__init__.py b/webapp/__init__.py index 67e43c2..6a1d033 100644 --- a/webapp/__init__.py +++ b/webapp/__init__.py @@ -41,7 +41,7 @@ def load_user(user_id): except (ValueError, TypeError): return None -from webapp import auth, commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, youtube, announcements, twitch_moderation, link_filter, twitch_events, users, settings, freeloot, patreon +from webapp import auth, commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, discord_members, youtube, announcements, twitch_moderation, link_filter, twitch_events, users, settings, freeloot, patreon from flask import request, redirect, url_for from flask_login import current_user diff --git a/webapp/discord_members.py b/webapp/discord_members.py new file mode 100644 index 0000000..8ab650b --- /dev/null +++ b/webapp/discord_members.py @@ -0,0 +1,157 @@ +from collections import defaultdict +from datetime import datetime + +from flask import render_template, request +from sqlalchemy import text + +from webapp import webapp +from webapp.auth import require_page +from database import db +from database.models import GuildMemberStats, ModerationEvent +from discordbot import bot +from discordbot.member_stats import get_discord_members_snapshot_sync + + +def _format_voice_seconds(sec: int) -> str: + if sec <= 0: + return "0 min" + h, sec = divmod(sec, 3600) + m, sec = divmod(sec, 60) + if h: + return f"{h}h {m}min" + if m: + return f"{m}min" + return f"{sec}s" + + +def _event_to_row(e: ModerationEvent) -> dict: + return { + "type": e.type or "—", + "created_at": e.created_at.strftime("%d/%m/%Y %H:%M") if e.created_at else "—", + "reason": (e.reason or "")[:500], + "staff_name": e.staff_name or "—", + "duration": e.duration, + } + + +def _load_latest_invites(guild_id_str: str) -> dict[str, dict]: + rows = db.session.execute( + text(""" + SELECT user_id, invite_code, inviter_name, join_date FROM ( + SELECT user_id, invite_code, inviter_name, join_date, + ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY join_date DESC, id DESC) AS rn + FROM member_invites WHERE guild_id = :gid + ) WHERE rn = 1 + """), + {"gid": guild_id_str}, + ).mappings().all() + out = {} + for r in rows: + jd = r["join_date"] + out[str(r["user_id"])] = { + "invite_code": r["invite_code"] or "—", + "inviter_name": r["inviter_name"] or "—", + "join_date": jd.strftime("%d/%m/%Y %H:%M") if jd else "—", + } + return out + + +def _load_sanctions_by_user(user_ids: list[str]) -> dict[str, list]: + by_user: dict[str, list] = defaultdict(list) + chunk = 400 + for i in range(0, len(user_ids), chunk): + part = user_ids[i : i + chunk] + if not part: + continue + q = ModerationEvent.query.filter(ModerationEvent.discord_id.in_(part)) + for e in q.all(): + by_user[e.discord_id].append(e) + for uid in by_user: + by_user[uid].sort(key=lambda x: x.created_at or datetime.min, reverse=True) + return by_user + + +@webapp.route("/discord-membres") +@require_page("discord_members") +def discord_members(): + status = webapp.config.get("BOT_STATUS", {}) + bot_connected = bool(status.get("discord_connected")) + + guild_param = request.args.get("guild_id", type=int) + + ok, err, payload = get_discord_members_snapshot_sync(bot, guild_id=guild_param) + + guild_choices = payload.get("guilds") if payload else None + if not ok and guild_choices: + return render_template( + "discord_members.html", + bot_connected=bot_connected, + load_error=err, + guild_choices=guild_choices, + guild_name=None, + members=[], + ) + + if not ok: + return render_template( + "discord_members.html", + bot_connected=bot_connected, + load_error=err or "Impossible de charger les membres.", + guild_choices=None, + guild_name=None, + members=[], + ) + + guild_id_str = payload["guild_id"] + guild_name = payload["guild_name"] + raw_members = payload["members"] + user_ids = [m["id"] for m in raw_members] + + stats_map = { + r.user_id: r + for r in GuildMemberStats.query.filter_by(guild_id=guild_id_str).all() + } + invites_map = _load_latest_invites(guild_id_str) + sanctions_by_user = _load_sanctions_by_user(user_ids) + + members = [] + for m in raw_members: + uid = m["id"] + joined_raw = m.get("joined_at") + joined_display = "—" + if joined_raw: + try: + dt = datetime.fromisoformat(joined_raw.replace("Z", "+00:00")) + joined_display = dt.strftime("%d/%m/%Y %H:%M") + " UTC" + except (ValueError, TypeError): + joined_display = joined_raw + st = stats_map.get(uid) + msg_c = st.message_count if st else 0 + voice_s = st.voice_seconds if st else 0 + inv = invites_map.get(uid, {"invite_code": "—", "inviter_name": "—", "join_date": "—"}) + events = sanctions_by_user.get(uid, []) + sanction_rows = [_event_to_row(e) for e in events] + search_blob = f"{m.get('display_name') or ''} {m.get('name') or ''} {uid}".lower() + members.append({ + **m, + "joined_display": joined_display, + "search_blob": search_blob, + "message_count": msg_c, + "voice_label": _format_voice_seconds(voice_s), + "voice_seconds": voice_s, + "invite_code": inv["invite_code"], + "inviter_name": inv["inviter_name"], + "invite_join_date": inv["join_date"], + "sanction_count": len(sanction_rows), + "sanctions": sanction_rows, + }) + + return render_template( + "discord_members.html", + bot_connected=bot_connected, + load_error=None, + guild_choices=None, + guild_name=guild_name, + guild_id=guild_id_str, + members=members, + ) diff --git a/webapp/settings.py b/webapp/settings.py index 52a4f72..21466de 100644 --- a/webapp/settings.py +++ b/webapp/settings.py @@ -63,6 +63,12 @@ PAGE_METADATA = { "description": "Historique de modération Discord", "icon": "shield" }, + "discord_members": { + "label": "Membres Discord", + "category": "moderation", + "description": "Liste des membres, activité et sanctions", + "icon": "users" + }, "twitch_moderation": { "label": "Modération Twitch", "category": "moderation", @@ -158,6 +164,7 @@ PAGE_KEYS = [ ("protondb", "ProtonDB"), ("freeloot", "FreeLoot"), ("moderation", "Modération Discord"), + ("discord_members", "Membres Discord"), ("users", "Utilisateurs"), ("settings", "Paramètres"), ] diff --git a/webapp/templates/discord_members.html b/webapp/templates/discord_members.html new file mode 100644 index 0000000..a1d6414 --- /dev/null +++ b/webapp/templates/discord_members.html @@ -0,0 +1,153 @@ +{% extends "template.html" %} + +{% block content %} +
+ Rapport par membre : arrivée sur le serveur, invitation enregistrée, activité (messages et vocal depuis le suivi activé) et sanctions. +
+ {% if guild_name %} +{{ guild_name }}
+ {% endif %} ++ Les compteurs messages et vocal sont cumulés à partir du déploiement du suivi (pas d’historique rétroactif). L’invitation provient du message de bienvenue si celui-ci était activé au moment du join. +
+@{{ m.name }} · {{ m.id }}
+{{ m.invite_code }}{{ s.reason }}
+Par {{ s.staff_name }}{% if s.duration is not none %} · durée {{ s.duration }}s{% endif %}
+Aucune sanction enregistrée en base pour ce membre.
+ {% endif %} +