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
106 lines
3.6 KiB
JavaScript
106 lines
3.6 KiB
JavaScript
// Business logic for the moderation dashboard: reshapes the raw mod_actions
|
|
// reads into the shapes the admin UI consumes, and annotates each action with
|
|
// whether it was an automated (bot) action. For a Discord bot the application_id
|
|
// IS the bot's user id, and the filter/spam pipeline records automated actions
|
|
// with staff_user_id = the bot user (see bot/src/discord/messageFilter.js), so
|
|
// staff_user_id === bot_config.application_id reliably flags automated actions
|
|
// without needing new columns on mod_actions.
|
|
const moderationDb = require('./moderation.db')
|
|
const botConfigDb = require('../botConfig/botConfig.db')
|
|
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 {
|
|
const cfg = await botConfigDb.get()
|
|
return cfg ? cfg.application_id : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
// 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 cutoffs = {
|
|
cutoff24h: new Date(now - DAY_MS),
|
|
cutoff7d: new Date(now - 7 * DAY_MS),
|
|
cutoff30d: new Date(now - 30 * DAY_MS),
|
|
}
|
|
|
|
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) {
|
|
const appId = await botApplicationId()
|
|
return annotate(await moderationDb.recentActions(opts), appId)
|
|
}
|
|
|
|
async function userActions(discordId, opts) {
|
|
const appId = await botApplicationId()
|
|
return annotate(await moderationDb.userActions(discordId, opts), appId)
|
|
}
|
|
|
|
// Header data for the per-user history page: latest known tag, linked site
|
|
// account (if any), and all-time counts per action type.
|
|
async function userSummary(discordId) {
|
|
const [countRows, tag, linked] = await Promise.all([
|
|
moderationDb.userCounts(discordId),
|
|
moderationDb.latestTag(discordId),
|
|
moderationDb.linkedAccount(discordId),
|
|
])
|
|
const counts = zeroCounts()
|
|
let total = 0
|
|
for (const row of countRows) {
|
|
const c = Number(row.c) || 0
|
|
if (counts[row.action_type] !== undefined) counts[row.action_type] = c
|
|
total += c
|
|
}
|
|
return {
|
|
discord_user_id: discordId,
|
|
tag,
|
|
linked_account: linked,
|
|
counts,
|
|
total_actions: total,
|
|
}
|
|
}
|
|
|
|
async function search(term, opts) {
|
|
return moderationDb.searchTargets(term, opts)
|
|
}
|
|
|
|
module.exports = { summary, recent, userActions, userSummary, search, members, filterHits, spamHits }
|