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) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
15
bot/src/model/filterHits.js
Normal file
15
bot/src/model/filterHits.js
Normal file
@@ -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 }
|
||||
14
bot/src/model/memberEvents.js
Normal file
14
bot/src/model/memberEvents.js
Normal file
@@ -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 }
|
||||
14
bot/src/model/spamHits.js
Normal file
14
bot/src/model/spamHits.js
Normal file
@@ -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 }
|
||||
@@ -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()
|
||||
|
||||
@@ -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 <Loading />
|
||||
if (error) return <ErrorState message="Could not load moderation data." />
|
||||
|
||||
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 (
|
||||
<section>
|
||||
<UserSearch onPick={(id) => navigate(`/admin/moderation/user/${id}`)} />
|
||||
<UserSearch onPick={goUser} />
|
||||
|
||||
{/* Window selector */}
|
||||
<div style={{ display: 'flex', gap: 8, margin: '4px 0 14px' }}>
|
||||
{WINDOWS.map((w) => (
|
||||
<button
|
||||
key={w.key}
|
||||
onClick={() => setWin(w.key)}
|
||||
className="pill"
|
||||
style={win === w.key ? activePill : undefined}
|
||||
>
|
||||
<button key={w.key} onClick={() => setWin(w.key)} className="pill" style={win === w.key ? activePill : undefined}>
|
||||
{w.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stat tiles */}
|
||||
<div className="grid-4" style={{ gap: 14, marginBottom: 12 }}>
|
||||
{TILE_TYPES.map((t) => (
|
||||
<button
|
||||
{/* Moderation-action tiles (click filters the recent-actions feed) */}
|
||||
<div className="grid-4" style={{ gap: 14, marginBottom: 14 }}>
|
||||
{MOD_TILES.map((t) => (
|
||||
<Tile
|
||||
key={t.key}
|
||||
value={counts[t.key] ?? 0}
|
||||
label={t.label}
|
||||
active={typeFilter === t.key}
|
||||
onClick={() => setTypeFilter(typeFilter === t.key ? null : t.key)}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: 20,
|
||||
border: `1px solid ${typeFilter === t.key ? 'var(--accent)' : 'var(--line)'}`,
|
||||
borderRadius: 12,
|
||||
background: 'var(--panel-grad)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div className="display" style={{ fontSize: '2rem', color: 'var(--head)', lineHeight: 1 }}>
|
||||
{counts[t.key] ?? 0}
|
||||
</div>
|
||||
<div className="card-kicker" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
{t.label}
|
||||
</div>
|
||||
</button>
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Event tiles (click jumps the events panel to that stream) */}
|
||||
<div className="grid-4" style={{ gap: 14, marginBottom: 8 }}>
|
||||
{EVENT_TILES.map((t) => (
|
||||
<Tile
|
||||
key={t.key}
|
||||
value={counts[t.key] ?? 0}
|
||||
label={t.label}
|
||||
sub={t.key === 'joins' && counts.invite_joins ? `${counts.invite_joins} via invite` : null}
|
||||
active={eventTab === t.tab}
|
||||
onClick={() => setEventTab(t.tab)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 24px' }}>
|
||||
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.
|
||||
</p>
|
||||
|
||||
{/* Recent activity feed */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.25rem', color: 'var(--head)' }}>
|
||||
Recent actions
|
||||
</h2>
|
||||
{/* Recent moderation actions */}
|
||||
<div style={rowHead}>
|
||||
<h2 className="display" style={h2}>Recent actions</h2>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{TYPES.map((t) => (
|
||||
<button
|
||||
key={t.label}
|
||||
onClick={() => setTypeFilter(t.key)}
|
||||
className="pill"
|
||||
style={typeFilter === t.key ? activePill : undefined}
|
||||
>
|
||||
<button key={t.label} onClick={() => setTypeFilter(t.key)} className="pill" style={typeFilter === t.key ? activePill : undefined}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-flat">
|
||||
<div className="panel-flat" style={{ marginBottom: 30 }}>
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -121,52 +129,154 @@ export default function Moderation() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{feed.length === 0 && (
|
||||
<tr>
|
||||
<td className="adm-td" colSpan={5} style={{ color: 'var(--muted)' }}>
|
||||
No matching actions.
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td className="adm-td" colSpan={5} style={muted}>No matching actions.</td></tr>
|
||||
)}
|
||||
{feed.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td className="adm-td"><span className={`badge badge-${a.action_type}`}>{a.action_type}</span></td>
|
||||
<td className="adm-td">
|
||||
<span className={`badge badge-${a.action_type}`}>{a.action_type}</span>
|
||||
<span className="link-accent" onClick={() => goUser(a.target_user_id)}>{a.target_tag || a.target_user_id}</span>
|
||||
{a.linked_account && <span className="badge badge-editor" style={{ marginLeft: 8 }}>site: {a.linked_account.username}</span>}
|
||||
</td>
|
||||
<td className="adm-td">
|
||||
<span
|
||||
className="link-accent"
|
||||
onClick={() => navigate(`/admin/moderation/user/${a.target_user_id}`)}
|
||||
>
|
||||
{a.target_tag || a.target_user_id}
|
||||
</span>
|
||||
{a.linked_account && (
|
||||
<span className="badge badge-editor" style={{ marginLeft: 8 }}>
|
||||
site: {a.linked_account.username}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td">
|
||||
{a.is_automated ? (
|
||||
<span className="badge badge-auto">Automated</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text)' }}>{a.staff_tag || a.staff_user_id}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td" style={{ color: 'var(--muted)', maxWidth: 280 }}>
|
||||
{a.reason || '—'}
|
||||
</td>
|
||||
<td className="adm-td dim" title={dateTime(a.created_at)}>
|
||||
{ago(a.created_at)}
|
||||
{a.is_automated ? <span className="badge badge-auto">Automated</span> : <span style={{ color: 'var(--text)' }}>{a.staff_tag || a.staff_user_id}</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ color: 'var(--muted)', maxWidth: 280 }}>{a.reason || '—'}</td>
|
||||
<td className="adm-td dim" title={dateTime(a.created_at)}>{ago(a.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Event streams panel */}
|
||||
<div style={rowHead}>
|
||||
<h2 className="display" style={h2}>Events</h2>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{EVENT_TABS.map((t) => (
|
||||
<button key={t.key} onClick={() => setEventTab(t.key)} className="pill" style={eventTab === t.key ? activePill : undefined}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{eventTab === 'members' && <MembersTable rows={members} onUser={goUser} />}
|
||||
{eventTab === 'filter' && <FilterTable rows={filterHits} onUser={goUser} />}
|
||||
{eventTab === 'spam' && <SpamTable rows={spamHits} onUser={goUser} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Tile({ value, label, sub, active, onClick }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: 20,
|
||||
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
|
||||
borderRadius: 12,
|
||||
background: 'var(--panel-grad)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div className="display" style={{ fontSize: '2rem', color: 'var(--head)', lineHeight: 1 }}>{value}</div>
|
||||
<div className="card-kicker" style={{ marginTop: 8, marginBottom: 0 }}>{label}</div>
|
||||
{sub && <div className="sans dim" style={{ fontSize: '0.68rem', marginTop: 4 }}>{sub}</div>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function MembersTable({ rows, onUser }) {
|
||||
return (
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Event</th>
|
||||
<th className="adm-th">User</th>
|
||||
<th className="adm-th">Invite</th>
|
||||
<th className="adm-th">When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && <tr><td className="adm-td" colSpan={4} style={muted}>No member events yet.</td></tr>}
|
||||
{rows.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td className="adm-td"><span className={`badge ${m.event_type === 'join' ? 'badge-pub' : 'badge-ban'}`}>{m.event_type}</span></td>
|
||||
<td className="adm-td"><span className="link-accent" onClick={() => onUser(m.discord_user_id)}>{m.username || m.discord_user_id}</span></td>
|
||||
<td className="adm-td dim">
|
||||
{m.invite_code ? (
|
||||
<span>{m.invite_code}{m.inviter_tag ? ` · by ${m.inviter_tag}` : ''}</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td className="adm-td dim" title={dateTime(m.created_at)}>{ago(m.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterTable({ rows, onUser }) {
|
||||
return (
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Type</th>
|
||||
<th className="adm-th">User</th>
|
||||
<th className="adm-th">Matched</th>
|
||||
<th className="adm-th">Action</th>
|
||||
<th className="adm-th">When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && <tr><td className="adm-td" colSpan={5} style={muted}>No filter hits yet.</td></tr>}
|
||||
{rows.map((f) => (
|
||||
<tr key={f.id}>
|
||||
<td className="adm-td"><span className={`badge ${f.hit_type === 'invite' ? 'badge-ban' : 'badge-warn'}`}>{f.hit_type}</span></td>
|
||||
<td className="adm-td"><span className="link-accent" onClick={() => onUser(f.discord_user_id)}>{f.username || f.discord_user_id}</span></td>
|
||||
<td className="adm-td" style={{ color: 'var(--text)', maxWidth: 240 }}>{f.matched || '—'}</td>
|
||||
<td className="adm-td"><span className={`badge badge-${f.action_taken === 'delete' ? 'auto' : f.action_taken}`}>{f.action_taken}</span></td>
|
||||
<td className="adm-td dim" title={dateTime(f.created_at)}>{ago(f.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SPAM_LABEL = { rate_limit: 'Rate limit', mass_mention: 'Mass mention', mass_emoji: 'Mass emoji' }
|
||||
|
||||
function SpamTable({ rows, onUser }) {
|
||||
return (
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Type</th>
|
||||
<th className="adm-th">User</th>
|
||||
<th className="adm-th">When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && <tr><td className="adm-td" colSpan={3} style={muted}>No spam hits yet.</td></tr>}
|
||||
{rows.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td className="adm-td"><span className="badge badge-warn">{SPAM_LABEL[s.spam_type] || s.spam_type}</span></td>
|
||||
<td className="adm-td"><span className="link-accent" onClick={() => onUser(s.discord_user_id)}>{s.username || s.discord_user_id}</span></td>
|
||||
<td className="adm-td dim" title={dateTime(s.created_at)}>{ago(s.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div style={{ marginBottom: 22 }}>
|
||||
<form onSubmit={run} style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Search by Discord ID or username…"
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
style={{ maxWidth: 360 }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>
|
||||
{busy ? 'Searching…' : 'Look up'}
|
||||
</button>
|
||||
<input className="input" placeholder="Search by Discord ID or username…" value={term} onChange={(e) => setTerm(e.target.value)} style={{ maxWidth: 360 }} />
|
||||
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>{busy ? 'Searching…' : 'Look up'}</button>
|
||||
</form>
|
||||
{results && results.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 10 }}>
|
||||
No moderated users match “{term}”.
|
||||
</p>
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 10 }}>No moderated users match “{term}”.</p>
|
||||
)}
|
||||
{results && results.length > 0 && (
|
||||
<div className="panel-flat" style={{ marginTop: 10 }}>
|
||||
@@ -212,9 +311,7 @@ function UserSearch({ onPick }) {
|
||||
{results.map((r) => (
|
||||
<tr key={r.target_user_id} style={{ cursor: 'pointer' }} onClick={() => onPick(r.target_user_id)}>
|
||||
<td className="adm-td" style={{ color: 'var(--head)' }}>{r.target_tag || '(unknown tag)'}</td>
|
||||
<td className="adm-td dim" style={{ fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.8rem' }}>
|
||||
{r.target_user_id}
|
||||
</td>
|
||||
<td className="adm-td dim" style={{ fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.8rem' }}>{r.target_user_id}</td>
|
||||
<td className="adm-td dim">{r.action_count} action{Number(r.action_count) === 1 ? '' : 's'}</td>
|
||||
<td className="adm-td dim">last {ago(r.last_seen)}</td>
|
||||
</tr>
|
||||
@@ -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)' }
|
||||
|
||||
@@ -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