Light up the moderation dashboard's previously-empty widgets by persisting the
event streams the bot only reacted to in-memory before.
Schema (bot-owned)
- member_events: join/leave, with invite_code/inviter_* for best-effort invite
attribution on joins
- filter_hits: word / foreign-invite filter deletions (matched + action_taken)
- spam_hits: rate_limit / mass_mention / mass_emoji detections
Bot
- new models memberEvents/filterHits/spamHits
- guildMemberAdd records the join with invite attribution; new inviteTracker.js
keeps an invite-use cache (GuildInvites intent + inviteCreate/inviteDelete) and
diffs it on join to find which invite was used — best-effort, never blocks
auto-role
- new guildMemberRemove records leaves
- messageFilter records filter/spam hits alongside the existing warn/mute;
inviteFilter now returns the offending code; detectSpam identifies which spam
rule tripped (preserving the rate-limit-first side-effect order)
- mod_actions still logs the resulting warn/mute — the new tables are additive
Server
- summary extended with joins/leaves/invite_joins/filter_hits/spam_hits per window
- new feeds: /api/v1/admin/moderation/{members,filter-hits,spam-hits}
Client
- overview now shows 8 tiles (mod actions + joins/leaves/filter/spam, joins tile
notes "N via invite") plus an Events panel with Members/Filter/Spam tabs;
removed the coming-soon note
Verified: 119 server unit tests, client build, 14-check DB-backed smoke, and a
browser click-through of every tile and events tab (incl. invite attribution).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
75 lines
2.7 KiB
JavaScript
75 lines
2.7 KiB
JavaScript
// 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<inviteCode, uses>
|
|
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 }
|