Capture member/filter/spam events for the dashboard (Phase 6b)
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
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
23
bot/src/discord/guildMemberRemove.js
Normal file
23
bot/src/discord/guildMemberRemove.js
Normal file
@@ -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 }
|
||||
74
bot/src/discord/inviteTracker.js
Normal file
74
bot/src/discord/inviteTracker.js
Normal file
@@ -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<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 }
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user