diff --git a/bot/src/discord/discordManager.js b/bot/src/discord/discordManager.js index 8460530..49f3bae 100644 --- a/bot/src/discord/discordManager.js +++ b/bot/src/discord/discordManager.js @@ -9,6 +9,8 @@ const messageFilter = require('./messageFilter') const scheduler = require('../scheduler/scheduler') const roleMenuHandler = require('./roleMenuHandler') const { handleGuildMemberAdd } = require('./guildMemberAdd') +const { handleGuildMemberRemove } = require('./guildMemberRemove') +const inviteTracker = require('./inviteTracker') const tempRoleSweeper = require('../roles/tempRoleSweeper') const inviteScheduler = require('../invites/inviteScheduler') @@ -58,13 +60,15 @@ async function start({ token, guildId: gid }) { // GuildMessages + MessageContent (Phase 3, filter) and GuildMembers // (Phase 5, auto-role + bulk role ops) are all privileged — must be enabled - // in the Discord Developer Portal, see the Phase 1 setup notes. + // in the Discord Developer Portal, see the Phase 1 setup notes. GuildInvites + // (Phase 6b, invite-usage attribution) is NOT privileged — no portal toggle. client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildInvites, ], }) @@ -74,6 +78,7 @@ async function start({ token, guildId: gid }) { await scheduler.start(client) tempRoleSweeper.start(client) inviteScheduler.start(client, guildId) + await inviteTracker.prime(client, guildId) status = 'connected' statusDetail = null lastConnectedAt = new Date() @@ -102,6 +107,10 @@ async function start({ token, guildId: gid }) { client.on('messageCreate', messageFilter.handleMessageCreate) client.on('guildMemberAdd', handleGuildMemberAdd) + client.on('guildMemberRemove', handleGuildMemberRemove) + // Keep the invite-use cache fresh so guildMemberAdd can attribute joins. + client.on('inviteCreate', inviteTracker.onInviteCreate) + client.on('inviteDelete', inviteTracker.onInviteDelete) client.on('error', (err) => { status = 'error' diff --git a/bot/src/discord/guildMemberAdd.js b/bot/src/discord/guildMemberAdd.js index 9932176..e4fb5dd 100644 --- a/bot/src/discord/guildMemberAdd.js +++ b/bot/src/discord/guildMemberAdd.js @@ -1,11 +1,37 @@ -// Auto-role on join. Requires the Server Members privileged intent (already -// enabled in the Discord Developer Portal per the Phase 1 setup notes). +// Member join handling: record the join event (with best-effort invite +// attribution, Phase 6b) then apply the configured auto-role. Requires the +// Server Members privileged intent (already enabled per the Phase 1 setup notes) +// and, for invite attribution, the GuildInvites intent. const guildConfig = require('../model/guildConfig') +const memberEvents = require('../model/memberEvents') +const inviteTracker = require('./inviteTracker') const createLogger = require('../utils/logger') -const log = createLogger('autorole') +const log = createLogger('members') async function handleGuildMemberAdd(member) { + // Attribute the invite first (diffs the invite-use cache), then record the join. + // Both are best-effort — a failure here must never block the auto-role below. + let invite = { code: null, inviterId: null, inviterTag: null } + try { + invite = await inviteTracker.attribute(member) + } catch (err) { + log.warn('invite attribution threw', { userId: member.id, message: err.message }) + } + try { + await memberEvents.record({ + guildId: member.guild.id, + eventType: 'join', + discordUserId: member.id, + username: member.user?.tag, + inviteCode: invite.code, + inviterId: invite.inviterId, + inviterTag: invite.inviterTag, + }) + } catch (err) { + log.warn('member join record failed', { userId: member.id, message: err.message }) + } + try { const roleId = await guildConfig.getAutoRoleId(member.guild.id) if (!roleId) return diff --git a/bot/src/discord/guildMemberRemove.js b/bot/src/discord/guildMemberRemove.js new file mode 100644 index 0000000..840501e --- /dev/null +++ b/bot/src/discord/guildMemberRemove.js @@ -0,0 +1,23 @@ +// Member leave handling (Phase 6b): record a leave event for the dashboard's +// members feed. Fires on both voluntary leaves and kicks/bans — Discord doesn't +// distinguish them on this event, and the mod-action (if any) is logged +// separately via mod_actions, so a leave row here is purely the lifecycle fact. +const memberEvents = require('../model/memberEvents') +const createLogger = require('../utils/logger') + +const log = createLogger('members') + +async function handleGuildMemberRemove(member) { + try { + await memberEvents.record({ + guildId: member.guild.id, + eventType: 'leave', + discordUserId: member.id, + username: member.user?.tag, + }) + } catch (err) { + log.warn('member leave record failed', { userId: member.id, message: err.message }) + } +} + +module.exports = { handleGuildMemberRemove } diff --git a/bot/src/discord/inviteTracker.js b/bot/src/discord/inviteTracker.js new file mode 100644 index 0000000..8886956 --- /dev/null +++ b/bot/src/discord/inviteTracker.js @@ -0,0 +1,74 @@ +// Best-effort invite-usage attribution (Phase 6b). Discord doesn't tell you +// which invite a member used, so the standard approach is to keep a cache of +// each invite's use-count and, on guildMemberAdd, re-fetch and find the one +// whose count went up. Requires the GuildInvites intent + Manage Guild (the bot +// already creates/deletes invites, so it has the permission). All calls are +// best-effort: any failure just yields a null attribution and the join is still +// recorded. Vanity-URL and bot-added joins are inherently unattributable. +const createLogger = require('../utils/logger') + +const log = createLogger('invites') + +// guildId -> Map +const cache = new Map() + +async function snapshot(guild) { + const map = new Map() + const invites = await guild.invites.fetch() + for (const inv of invites.values()) map.set(inv.code, inv.uses || 0) + return map +} + +// Populate the cache for a guild (call once the client is ready). +async function prime(client, guildId) { + try { + const guild = client.guilds.cache.get(guildId) || (await client.guilds.fetch(guildId)) + cache.set(guildId, await snapshot(guild)) + log.info('invite cache primed', { guildId, count: cache.get(guildId).size }) + } catch (err) { + log.warn('invite cache prime failed (missing Manage Guild / GuildInvites?)', { message: err.message }) + } +} + +function onInviteCreate(invite) { + if (!invite.guild) return + const g = cache.get(invite.guild.id) || new Map() + g.set(invite.code, invite.uses || 0) + cache.set(invite.guild.id, g) +} + +function onInviteDelete(invite) { + if (!invite.guild) return + const g = cache.get(invite.guild.id) + if (g) g.delete(invite.code) +} + +// Diff current invite uses against the cached snapshot to find which invite the +// joining member used, then refresh the cache. Returns { code, inviterId, +// inviterTag } with nulls when it can't be determined. +async function attribute(member) { + const empty = { code: null, inviterId: null, inviterTag: null } + try { + const guild = member.guild + const before = cache.get(guild.id) || new Map() + const current = await guild.invites.fetch() + + let found = empty + for (const inv of current.values()) { + const prev = before.get(inv.code) || 0 + if ((inv.uses || 0) > prev && found === empty) { + found = { code: inv.code, inviterId: inv.inviter?.id || null, inviterTag: inv.inviter?.tag || null } + } + } + + const next = new Map() + for (const inv of current.values()) next.set(inv.code, inv.uses || 0) + cache.set(guild.id, next) + return found + } catch (err) { + log.warn('invite attribution failed', { message: err.message }) + return empty + } +} + +module.exports = { prime, onInviteCreate, onInviteDelete, attribute } diff --git a/bot/src/discord/messageFilter.js b/bot/src/discord/messageFilter.js index 56d3575..5c4a085 100644 --- a/bot/src/discord/messageFilter.js +++ b/bot/src/discord/messageFilter.js @@ -8,6 +8,8 @@ const { findMatch } = require('../filter/normalize') const inviteFilter = require('../filter/inviteFilter') const spamFilter = require('../filter/spamFilter') const warnings = require('../model/warnings') +const filterHits = require('../model/filterHits') +const spamHits = require('../model/spamHits') const modLog = require('./modLog') const createLogger = require('../utils/logger') @@ -19,6 +21,48 @@ function botActor(client) { return { id: client.user.id, tag: client.user.tag } } +// Dashboard event capture (Phase 6b). Best-effort — recording a hit must never +// break the moderation action it accompanies, so failures are swallowed+logged. +async function recordFilterHit(message, hitType, matched, actionTaken) { + try { + await filterHits.record({ + guildId: message.guildId, + hitType, + discordUserId: message.author.id, + username: message.author.tag, + channelId: message.channelId, + matched, + actionTaken, + }) + } catch (err) { + log.warn('filter hit record failed', { message: err.message }) + } +} + +async function recordSpamHit(message, spamType) { + try { + await spamHits.record({ + guildId: message.guildId, + spamType, + discordUserId: message.author.id, + username: message.author.tag, + channelId: message.channelId, + }) + } catch (err) { + log.warn('spam hit record failed', { message: err.message }) + } +} + +// Which spam rule tripped (for the spam_hits row). isRateLimited has a side +// effect (records this message's timestamp) so it must be evaluated first, and +// exactly once — mirroring the original OR-order. +function detectSpam(message) { + if (spamFilter.isRateLimited(message.guildId, message.author.id)) return 'rate_limit' + if (spamFilter.isMassMention(message)) return 'mass_mention' + if (spamFilter.isMassEmoji(message.content)) return 'mass_emoji' + return null +} + async function isBypassed(message, cache) { if (cache.allowChannels.has(message.channelId)) return true const memberRoles = message.member ? message.member.roles.cache : null @@ -62,8 +106,10 @@ async function handleMessageCreate(message) { const cache = await filterCache.getOrLoad(message.guildId) if (await isBypassed(message, cache)) return - if (await inviteFilter.containsForeignInvite(message)) { + const foreignCode = await inviteFilter.foreignInviteCode(message) + if (foreignCode) { await message.delete().catch(() => {}) + await recordFilterHit(message, 'invite', foreignCode, 'warn') await applyWarnAction(message, 'Posted a Discord invite link') return } @@ -71,17 +117,16 @@ async function handleMessageCreate(message) { const match = findMatch(message.content, cache.words) if (match) { await message.delete().catch(() => {}) + await recordFilterHit(message, 'word', match.word, match.severity) if (match.severity === 'mute') await applyMuteAction(message, `Filtered word: ${match.word}`) else if (match.severity === 'warn') await applyWarnAction(message, `Filtered word: ${match.word}`) return } - if ( - spamFilter.isRateLimited(message.guildId, message.author.id) || - spamFilter.isMassMention(message) || - spamFilter.isMassEmoji(message.content) - ) { + const spamType = detectSpam(message) + if (spamType) { await message.delete().catch(() => {}) + await recordSpamHit(message, spamType) await applyWarnAction(message, 'Automated spam detection (rate limit / mass mention / mass emoji)') } } catch (err) { diff --git a/bot/src/filter/inviteFilter.js b/bot/src/filter/inviteFilter.js index 1805ea7..70c9925 100644 --- a/bot/src/filter/inviteFilter.js +++ b/bot/src/filter/inviteFilter.js @@ -4,20 +4,23 @@ // than silently letting an unresolvable link through. const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-zA-Z0-9-]+)/gi -async function containsForeignInvite(message) { +// Returns the first foreign (or unresolvable) invite code found in the message, +// or null if the message contains no foreign invites. Returning the code (rather +// than a bare boolean) lets the caller record which invite was blocked. +async function foreignInviteCode(message) { const matches = [...message.content.matchAll(INVITE_REGEX)] - if (matches.length === 0) return false + if (matches.length === 0) return null for (const match of matches) { const code = match[1] try { const invite = await message.client.fetchInvite(code) - if (invite.guild?.id !== message.guildId) return true + if (invite.guild?.id !== message.guildId) return code } catch { - return true + return code } } - return false + return null } -module.exports = { containsForeignInvite } +module.exports = { foreignInviteCode } diff --git a/bot/src/model/filterHits.js b/bot/src/model/filterHits.js new file mode 100644 index 0000000..9100231 --- /dev/null +++ b/bot/src/model/filterHits.js @@ -0,0 +1,15 @@ +// Automated content-filter hits (Phase 6b). Bot-owned; recorded whenever the +// word filter or foreign-invite filter deletes a message. mod_actions still +// records the resulting warn/mute separately. Schema: server/db/schema.sql +// (filter_hits). +const db = require('../db') + +async function record({ guildId, hitType, discordUserId, username, channelId, matched, actionTaken }) { + await db.query( + `INSERT INTO filter_hits (guild_id, hit_type, discord_user_id, username, channel_id, matched, action_taken) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [guildId, hitType, discordUserId, username || null, channelId || null, matched || null, actionTaken], + ) +} + +module.exports = { record } diff --git a/bot/src/model/memberEvents.js b/bot/src/model/memberEvents.js new file mode 100644 index 0000000..831c5fe --- /dev/null +++ b/bot/src/model/memberEvents.js @@ -0,0 +1,14 @@ +// Guild member join/leave events (Phase 6b). Bot-owned; the site reads these for +// the moderation dashboard's members feed + invite-usage view. Schema in +// server/db/schema.sql (member_events). +const db = require('../db') + +async function record({ guildId, eventType, discordUserId, username, inviteCode, inviterId, inviterTag }) { + await db.query( + `INSERT INTO member_events (guild_id, event_type, discord_user_id, username, invite_code, inviter_id, inviter_tag) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [guildId, eventType, discordUserId, username || null, inviteCode || null, inviterId || null, inviterTag || null], + ) +} + +module.exports = { record } diff --git a/bot/src/model/spamHits.js b/bot/src/model/spamHits.js new file mode 100644 index 0000000..e5fef14 --- /dev/null +++ b/bot/src/model/spamHits.js @@ -0,0 +1,14 @@ +// Automated spam-detection hits (Phase 6b). Bot-owned; recorded when the +// rate-limit / mass-mention / mass-emoji checks trip. mod_actions still logs the +// resulting warn separately. Schema: server/db/schema.sql (spam_hits). +const db = require('../db') + +async function record({ guildId, spamType, discordUserId, username, channelId }) { + await db.query( + `INSERT INTO spam_hits (guild_id, spam_type, discord_user_id, username, channel_id) + VALUES (?, ?, ?, ?, ?)`, + [guildId, spamType, discordUserId, username || null, channelId || null], + ) +} + +module.exports = { record } diff --git a/client/src/api/client.js b/client/src/api/client.js index be66a86..91c8223 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -131,6 +131,28 @@ export const api = { return req(`/admin/moderation/recent${s ? `?${s}` : ''}`) }, modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`), + modMembers: (params = {}) => { + const qs = new URLSearchParams() + if (params.type) qs.set('type', params.type) + if (params.limit) qs.set('limit', params.limit) + if (params.offset) qs.set('offset', params.offset) + const s = qs.toString() + return req(`/admin/moderation/members${s ? `?${s}` : ''}`) + }, + modFilterHits: (params = {}) => { + const qs = new URLSearchParams() + if (params.limit) qs.set('limit', params.limit) + if (params.offset) qs.set('offset', params.offset) + const s = qs.toString() + return req(`/admin/moderation/filter-hits${s ? `?${s}` : ''}`) + }, + modSpamHits: (params = {}) => { + const qs = new URLSearchParams() + if (params.limit) qs.set('limit', params.limit) + if (params.offset) qs.set('offset', params.offset) + const s = qs.toString() + return req(`/admin/moderation/spam-hits${s ? `?${s}` : ''}`) + }, modUser: (discordId) => req(`/admin/moderation/user/${discordId}`), modUserActions: (discordId, params = {}) => { const qs = new URLSearchParams() diff --git a/client/src/routes/admin/views/Moderation.jsx b/client/src/routes/admin/views/Moderation.jsx index e58fc07..f943db0 100644 --- a/client/src/routes/admin/views/Moderation.jsx +++ b/client/src/routes/admin/views/Moderation.jsx @@ -17,98 +17,106 @@ const TYPES = [ { key: 'mute', label: 'Mutes' }, { key: 'warn', label: 'Warnings' }, ] -const TILE_TYPES = [ +const MOD_TILES = [ { key: 'ban', label: 'Bans' }, { key: 'kick', label: 'Kicks' }, { key: 'mute', label: 'Mutes' }, { key: 'warn', label: 'Warnings' }, ] +// Second tile row → jumps the events panel to the matching stream. +const EVENT_TILES = [ + { key: 'joins', label: 'Joins', tab: 'members' }, + { key: 'leaves', label: 'Leaves', tab: 'members' }, + { key: 'filter_hits', label: 'Filter hits', tab: 'filter' }, + { key: 'spam_hits', label: 'Spam hits', tab: 'spam' }, +] +const EVENT_TABS = [ + { key: 'members', label: 'Members' }, + { key: 'filter', label: 'Filter hits' }, + { key: 'spam', label: 'Spam hits' }, +] export default function Moderation() { const navigate = useNavigate() const [win, setWin] = useState('24h') const [typeFilter, setTypeFilter] = useState(null) + const [eventTab, setEventTab] = useState('members') const { loading, error, data } = useAsync( - () => Promise.all([api.admin.modSummary(), api.admin.modRecent({ limit: 100 })]), + () => + Promise.all([ + api.admin.modSummary(), + api.admin.modRecent({ limit: 100 }), + api.admin.modMembers({ limit: 50 }), + api.admin.modFilterHits({ limit: 50 }), + api.admin.modSpamHits({ limit: 50 }), + ]), [], ) if (loading) return if (error) return - const [summary, recent] = data - const counts = summary.windows?.[win] || { ban: 0, kick: 0, mute: 0, warn: 0 } + const [summary, recent, members, filterHits, spamHits] = data + const counts = summary.windows?.[win] || {} const feed = typeFilter ? recent.filter((r) => r.action_type === typeFilter) : recent + const goUser = (id) => navigate(`/admin/moderation/user/${id}`) return (
- navigate(`/admin/moderation/user/${id}`)} /> + {/* Window selector */}
{WINDOWS.map((w) => ( - ))}
- {/* Stat tiles */} -
- {TILE_TYPES.map((t) => ( - + /> ))}
+ {/* Event tiles (click jumps the events panel to that stream) */} +
+ {EVENT_TILES.map((t) => ( + setEventTab(t.tab)} + /> + ))} +

- Joins / leaves, filter hits, spam hits, and invite usage aren’t tracked yet — they arrive - when bot event capture lands (Phase 6b). + Counts are for the selected window. Member, filter, and spam events are captured live by the bot.

- {/* Recent activity feed */} -
-

- Recent actions -

+ {/* Recent moderation actions */} +
+

Recent actions

{TYPES.map((t) => ( - ))}
- -
+
@@ -121,52 +129,154 @@ export default function Moderation() { {feed.length === 0 && ( - - - + )} {feed.map((a) => ( + - - - + + ))}
- No matching actions. -
No matching actions.
{a.action_type} - {a.action_type} + goUser(a.target_user_id)}>{a.target_tag || a.target_user_id} + {a.linked_account && site: {a.linked_account.username}} - navigate(`/admin/moderation/user/${a.target_user_id}`)} - > - {a.target_tag || a.target_user_id} - - {a.linked_account && ( - - site: {a.linked_account.username} - - )} - - {a.is_automated ? ( - Automated - ) : ( - {a.staff_tag || a.staff_user_id} - )} - - {a.reason || '—'} - - {ago(a.created_at)} + {a.is_automated ? Automated : {a.staff_tag || a.staff_user_id}} {a.reason || '—'}{ago(a.created_at)}
+ + {/* Event streams panel */} +
+

Events

+
+ {EVENT_TABS.map((t) => ( + + ))} +
+
+ {eventTab === 'members' && } + {eventTab === 'filter' && } + {eventTab === 'spam' && }
) } +function Tile({ value, label, sub, active, onClick }) { + return ( + + ) +} + +function MembersTable({ rows, onUser }) { + return ( +
+ + + + + + + + + + + {rows.length === 0 && } + {rows.map((m) => ( + + + + + + + ))} + +
EventUserInviteWhen
No member events yet.
{m.event_type} onUser(m.discord_user_id)}>{m.username || m.discord_user_id} + {m.invite_code ? ( + {m.invite_code}{m.inviter_tag ? ` · by ${m.inviter_tag}` : ''} + ) : '—'} + {ago(m.created_at)}
+
+ ) +} + +function FilterTable({ rows, onUser }) { + return ( +
+ + + + + + + + + + + + {rows.length === 0 && } + {rows.map((f) => ( + + + + + + + + ))} + +
TypeUserMatchedActionWhen
No filter hits yet.
{f.hit_type} onUser(f.discord_user_id)}>{f.username || f.discord_user_id}{f.matched || '—'}{f.action_taken}{ago(f.created_at)}
+
+ ) +} + +const SPAM_LABEL = { rate_limit: 'Rate limit', mass_mention: 'Mass mention', mass_emoji: 'Mass emoji' } + +function SpamTable({ rows, onUser }) { + return ( +
+ + + + + + + + + + {rows.length === 0 && } + {rows.map((s) => ( + + + + + + ))} + +
TypeUserWhen
No spam hits yet.
{SPAM_LABEL[s.spam_type] || s.spam_type} onUser(s.discord_user_id)}>{s.username || s.discord_user_id}{ago(s.created_at)}
+
+ ) +} + // User lookup: search by Discord id or a historical username snapshot. function UserSearch({ onPick }) { const [term, setTerm] = useState('') @@ -179,8 +289,7 @@ function UserSearch({ onPick }) { if (!q) return setBusy(true) try { - const rows = await api.admin.modSearch(q) - setResults(rows) + setResults(await api.admin.modSearch(q)) } finally { setBusy(false) } @@ -189,21 +298,11 @@ function UserSearch({ onPick }) { return (
- setTerm(e.target.value)} - style={{ maxWidth: 360 }} - /> - + setTerm(e.target.value)} style={{ maxWidth: 360 }} /> +
{results && results.length === 0 && ( -

- No moderated users match “{term}”. -

+

No moderated users match “{term}”.

)} {results && results.length > 0 && (
@@ -212,9 +311,7 @@ function UserSearch({ onPick }) { {results.map((r) => ( onPick(r.target_user_id)}> {r.target_tag || '(unknown tag)'} - - {r.target_user_id} - + {r.target_user_id} {r.action_count} action{Number(r.action_count) === 1 ? '' : 's'} last {ago(r.last_seen)} @@ -227,8 +324,7 @@ function UserSearch({ onPick }) { ) } -const activePill = { - background: 'var(--blue)', - color: 'var(--ink)', - borderColor: 'var(--accent)', -} +const activePill = { background: 'var(--blue)', color: 'var(--ink)', borderColor: 'var(--accent)' } +const rowHead = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 12 } +const h2 = { margin: 0, fontSize: '1.25rem', color: 'var(--head)' } +const muted = { color: 'var(--muted)' } diff --git a/server/db/schema.sql b/server/db/schema.sql index 6323521..ae9e9e1 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -368,6 +368,64 @@ CREATE TABLE IF NOT EXISTS invite_log ( INDEX idx_invite_log_guild (guild_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- Guild member join/leave events (Phase 6b). Powers the dashboard's joins/leaves +-- feeds and the invite-usage view. Bot-owned (written by bot/src/discord/ +-- guildMemberAdd.js + guildMemberRemove.js). For joins, invite_code/inviter_* +-- record which invite was used when the bot could attribute it (best-effort, see +-- bot/src/discord/inviteTracker.js) — NULL when undeterminable or for leaves. +-- These are member lifecycle events, not moderation actions, hence separate from +-- mod_actions. +CREATE TABLE IF NOT EXISTS member_events ( + id INT AUTO_INCREMENT PRIMARY KEY, + guild_id VARCHAR(32) NOT NULL, + event_type ENUM('join','leave') NOT NULL, + discord_user_id VARCHAR(32) NOT NULL, + username VARCHAR(120) NULL, + invite_code VARCHAR(20) NULL, + inviter_id VARCHAR(32) NULL, + inviter_tag VARCHAR(120) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_member_events_guild (guild_id, created_at), + INDEX idx_member_events_user (guild_id, discord_user_id, created_at), + INDEX idx_member_events_invite (guild_id, invite_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Automated content-filter hits (Phase 6b): one row per message the word filter +-- or the foreign-invite filter deleted. Separate from mod_actions (which still +-- records the resulting warn/mute) so the dashboard can show filter volume in +-- its own right. `matched` holds the offending word (word hits) or the blocked +-- invite code (invite hits); `action_taken` is what the pipeline did. Bot-owned +-- (bot/src/discord/messageFilter.js). +CREATE TABLE IF NOT EXISTS filter_hits ( + id INT AUTO_INCREMENT PRIMARY KEY, + guild_id VARCHAR(32) NOT NULL, + hit_type ENUM('word','invite') NOT NULL, + discord_user_id VARCHAR(32) NOT NULL, + username VARCHAR(120) NULL, + channel_id VARCHAR(32) NULL, + matched VARCHAR(200) NULL, + action_taken ENUM('delete','warn','mute') NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_filter_hits_guild (guild_id, created_at), + INDEX idx_filter_hits_user (guild_id, discord_user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Automated spam-detection hits (Phase 6b): rate-limit / mass-mention / +-- mass-emoji triggers. As with filter_hits, mod_actions still logs the resulting +-- warn; this records the detection itself for the dashboard's spam feed. +-- Bot-owned (bot/src/discord/messageFilter.js via bot/src/filter/spamFilter.js). +CREATE TABLE IF NOT EXISTS spam_hits ( + id INT AUTO_INCREMENT PRIMARY KEY, + guild_id VARCHAR(32) NOT NULL, + spam_type ENUM('rate_limit','mass_mention','mass_emoji') NOT NULL, + discord_user_id VARCHAR(32) NOT NULL, + username VARCHAR(120) NULL, + channel_id VARCHAR(32) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_spam_hits_guild (guild_id, created_at), + INDEX idx_spam_hits_user (guild_id, discord_user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- Staff notes on a Discord user, surfaced in the admin moderation dashboard -- (Phase 6). Unlike the tables above, this one is SERVER-owned — it is written -- and read only by the main site (moderation.controller), never by the bot. diff --git a/server/src/model/moderation/moderation.db.js b/server/src/model/moderation/moderation.db.js index 4f2b195..d6796de 100644 --- a/server/src/model/moderation/moderation.db.js +++ b/server/src/model/moderation/moderation.db.js @@ -89,6 +89,70 @@ async function linkedAccount(discordId) { return rows[0] || null } +// ── Phase 6b: member events + filter/spam hits (bot-owned, read-only) ── + +// Join/leave counts per window (grouped by event_type). +async function memberCountsByWindow({ cutoff24h, cutoff7d, cutoff30d }) { + return query( + `SELECT event_type, + SUM(created_at >= ?) AS d1, + SUM(created_at >= ?) AS d7, + SUM(created_at >= ?) AS d30 + FROM member_events + WHERE created_at >= ? + GROUP BY event_type`, + [cutoff24h, cutoff7d, cutoff30d, cutoff30d], + ) +} + +// Attributed-invite join counts per window (joins whose invite we identified). +async function inviteJoinCountsByWindow({ cutoff24h, cutoff7d, cutoff30d }) { + const rows = await query( + `SELECT SUM(created_at >= ?) AS d1, SUM(created_at >= ?) AS d7, SUM(created_at >= ?) AS d30 + FROM member_events + WHERE event_type = 'join' AND invite_code IS NOT NULL AND created_at >= ?`, + [cutoff24h, cutoff7d, cutoff30d, cutoff30d], + ) + return rows[0] +} + +// Row-count per window for a simple event table. `table` is an internal literal +// ('filter_hits' | 'spam_hits'), never user input — see the caller allowlist. +async function tableCountsByWindow(table, { cutoff24h, cutoff7d, cutoff30d }) { + const rows = await query( + `SELECT SUM(created_at >= ?) AS d1, SUM(created_at >= ?) AS d7, SUM(created_at >= ?) AS d30 + FROM ${table} WHERE created_at >= ?`, + [cutoff24h, cutoff7d, cutoff30d, cutoff30d], + ) + return rows[0] +} + +async function recentMemberEvents({ type = null, limit = 50, offset = 0 } = {}) { + const where = type ? 'WHERE event_type = ?' : '' + const params = type ? [type, limit, offset] : [limit, offset] + return query( + `SELECT id, guild_id, event_type, discord_user_id, username, invite_code, inviter_id, inviter_tag, created_at + FROM member_events ${where} ORDER BY id DESC LIMIT ? OFFSET ?`, + params, + ) +} + +async function recentFilterHits({ limit = 50, offset = 0 } = {}) { + return query( + `SELECT id, guild_id, hit_type, discord_user_id, username, channel_id, matched, action_taken, created_at + FROM filter_hits ORDER BY id DESC LIMIT ? OFFSET ?`, + [limit, offset], + ) +} + +async function recentSpamHits({ limit = 50, offset = 0 } = {}) { + return query( + `SELECT id, guild_id, spam_type, discord_user_id, username, channel_id, created_at + FROM spam_hits ORDER BY id DESC LIMIT ? OFFSET ?`, + [limit, offset], + ) +} + // User-lookup: match a Discord id exactly, or a username snapshot (target_tag) // by prefix, returning the most recently seen distinct targets. Powers the // dashboard search box (usernames drift, so we search historical snapshots too). @@ -114,4 +178,11 @@ module.exports = { latestTag, linkedAccount, searchTargets, + // Phase 6b + memberCountsByWindow, + inviteJoinCountsByWindow, + tableCountsByWindow, + recentMemberEvents, + recentFilterHits, + recentSpamHits, } diff --git a/server/src/model/moderation/moderation.model.js b/server/src/model/moderation/moderation.model.js index 5d775de..813c4ce 100644 --- a/server/src/model/moderation/moderation.model.js +++ b/server/src/model/moderation/moderation.model.js @@ -7,9 +7,10 @@ // without needing new columns on mod_actions. const moderationDb = require('./moderation.db') const botConfigDb = require('../botConfig/botConfig.db') -const { zeroCounts, annotate, reshapeWindows } = require('./moderation.pure') +const { zeroCounts, annotate, reshapeWindows, windowValue } = require('./moderation.pure') const DAY_MS = 24 * 60 * 60 * 1000 +const WINDOW_KEYS = ['24h', '7d', '30d'] async function botApplicationId() { try { @@ -20,15 +21,47 @@ async function botApplicationId() { } } -// Counts by type across 24h / 7d / 30d windows for the overview tiles. +// Counts by type across 24h / 7d / 30d windows for the overview tiles. Covers +// moderation actions (mod_actions) plus the Phase 6b event streams: member +// joins/leaves, attributed invite joins, and filter/spam hits. async function summary() { const now = Date.now() - const cutoff24h = new Date(now - DAY_MS) - const cutoff7d = new Date(now - 7 * DAY_MS) - const cutoff30d = new Date(now - 30 * DAY_MS) + const cutoffs = { + cutoff24h: new Date(now - DAY_MS), + cutoff7d: new Date(now - 7 * DAY_MS), + cutoff30d: new Date(now - 30 * DAY_MS), + } - const rows = await moderationDb.countsByWindow({ cutoff24h, cutoff7d, cutoff30d }) - return reshapeWindows(rows) + const [modRows, memberRows, inviteRow, filterRow, spamRow] = await Promise.all([ + moderationDb.countsByWindow(cutoffs), + moderationDb.memberCountsByWindow(cutoffs), + moderationDb.inviteJoinCountsByWindow(cutoffs), + moderationDb.tableCountsByWindow('filter_hits', cutoffs), + moderationDb.tableCountsByWindow('spam_hits', cutoffs), + ]) + + const windows = reshapeWindows(modRows).windows + const joinRow = memberRows.find((r) => r.event_type === 'join') + const leaveRow = memberRows.find((r) => r.event_type === 'leave') + for (const w of WINDOW_KEYS) { + windows[w].joins = windowValue(joinRow, w) + windows[w].leaves = windowValue(leaveRow, w) + windows[w].invite_joins = windowValue(inviteRow, w) + windows[w].filter_hits = windowValue(filterRow, w) + windows[w].spam_hits = windowValue(spamRow, w) + } + return { windows } +} + +// Recent event feeds for the overview's secondary panel (Phase 6b). +async function members(opts) { + return moderationDb.recentMemberEvents(opts) +} +async function filterHits(opts) { + return moderationDb.recentFilterHits(opts) +} +async function spamHits(opts) { + return moderationDb.recentSpamHits(opts) } async function recent(opts) { @@ -69,4 +102,4 @@ async function search(term, opts) { return moderationDb.searchTargets(term, opts) } -module.exports = { summary, recent, userActions, userSummary, search } +module.exports = { summary, recent, userActions, userSummary, search, members, filterHits, spamHits } diff --git a/server/src/model/moderation/moderation.pure.js b/server/src/model/moderation/moderation.pure.js index b94ee85..160ef1c 100644 --- a/server/src/model/moderation/moderation.pure.js +++ b/server/src/model/moderation/moderation.pure.js @@ -36,4 +36,12 @@ function reshapeWindows(rows) { return { windows } } -module.exports = { zeroCounts, annotate, reshapeWindows } +// Pull the count for one window key ('24h'|'7d'|'30d') out of a +// { d1, d7, d30 } sum row, coercing to a number and tolerating a null row. +function windowValue(row, key) { + if (!row) return 0 + const col = key === '24h' ? row.d1 : key === '7d' ? row.d7 : row.d30 + return Number(col) || 0 +} + +module.exports = { zeroCounts, annotate, reshapeWindows, windowValue } diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js index 51f81c7..d46025c 100644 --- a/server/src/router/v1/admin/admin.routes.js +++ b/server/src/router/v1/admin/admin.routes.js @@ -678,6 +678,27 @@ adminRouter.get( // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] moderation.search, ) +adminRouter.get( + '/moderation/members', + // #swagger.tags = ['Admin · Moderation'] + // #swagger.summary = 'Recent member join/leave events (optionally filtered by type)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + moderation.getMembers, +) +adminRouter.get( + '/moderation/filter-hits', + // #swagger.tags = ['Admin · Moderation'] + // #swagger.summary = 'Recent automated content-filter hits' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + moderation.getFilterHits, +) +adminRouter.get( + '/moderation/spam-hits', + // #swagger.tags = ['Admin · Moderation'] + // #swagger.summary = 'Recent automated spam-detection hits' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + moderation.getSpamHits, +) adminRouter.get( '/moderation/user/:discordId', // #swagger.tags = ['Admin · Moderation'] diff --git a/server/src/router/v1/admin/moderation.controller.js b/server/src/router/v1/admin/moderation.controller.js index 039a22f..bd3aefc 100644 --- a/server/src/router/v1/admin/moderation.controller.js +++ b/server/src/router/v1/admin/moderation.controller.js @@ -60,6 +60,40 @@ async function search(req, res) { } } +// ── Phase 6b event feeds ────────────────────────────────────────────── +const MEMBER_TYPES = new Set(['join', 'leave']) + +async function getMembers(req, res) { + try { + const { limit, offset } = pageParams(req) + const t = MEMBER_TYPES.has(req.query.type) ? req.query.type : null + return res.json(await moderation.members({ type: t, limit, offset })) + } catch (err) { + log.error('members failed', { error: err.message }) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +async function getFilterHits(req, res) { + try { + const { limit, offset } = pageParams(req) + return res.json(await moderation.filterHits({ limit, offset })) + } catch (err) { + log.error('filterHits failed', { error: err.message }) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +async function getSpamHits(req, res) { + try { + const { limit, offset } = pageParams(req) + return res.json(await moderation.spamHits({ limit, offset })) + } catch (err) { + log.error('spamHits failed', { error: err.message }) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + async function getUser(req, res) { try { const summary = await moderation.userSummary(req.params.discordId) @@ -126,6 +160,9 @@ module.exports = { getSummary, getRecent, search, + getMembers, + getFilterHits, + getSpamHits, getUser, getUserActions, getUserNotes, diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 327c685..7cac219 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -3940,6 +3940,90 @@ ] } }, + "/api/v1/admin/moderation/members": { + "get": { + "tags": [ + "Admin · Moderation" + ], + "summary": "Recent member join/leave events (optionally filtered by type)", + "description": "", + "parameters": [ + { + "name": "type", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/moderation/filter-hits": { + "get": { + "tags": [ + "Admin · Moderation" + ], + "summary": "Recent automated content-filter hits", + "description": "", + "responses": { + "200": { + "description": "OK" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/moderation/spam-hits": { + "get": { + "tags": [ + "Admin · Moderation" + ], + "summary": "Recent automated spam-detection hits", + "description": "", + "responses": { + "200": { + "description": "OK" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/admin/moderation/user/{discordId}": { "get": { "tags": [ diff --git a/server/test/moderation.test.js b/server/test/moderation.test.js index 627f7f6..9104a5b 100644 --- a/server/test/moderation.test.js +++ b/server/test/moderation.test.js @@ -71,3 +71,19 @@ test('annotate: no linked identity yields null linked_account', () => { const [row] = moderation.annotate([{ staff_user_id: '1', target_site_user_id: null }], null) assert.equal(row.linked_account, null) }) + +test('windowValue: picks the right column per window key and coerces to number', () => { + const row = { d1: '2', d7: 5, d30: '11' } + assert.strictEqual(moderation.windowValue(row, '24h'), 2) + assert.strictEqual(moderation.windowValue(row, '7d'), 5) + assert.strictEqual(moderation.windowValue(row, '30d'), 11) +}) + +test('windowValue: null row (no rows in window) yields 0', () => { + assert.strictEqual(moderation.windowValue(null, '24h'), 0) + assert.strictEqual(moderation.windowValue(undefined, '30d'), 0) +}) + +test('windowValue: null sum column yields 0', () => { + assert.strictEqual(moderation.windowValue({ d1: null, d7: null, d30: null }, '7d'), 0) +})