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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user