diff --git a/services/discord-gateway/src/app/bootstrap.ts b/services/discord-gateway/src/app/bootstrap.ts index 11be6aa..48581d7 100644 --- a/services/discord-gateway/src/app/bootstrap.ts +++ b/services/discord-gateway/src/app/bootstrap.ts @@ -26,6 +26,7 @@ import { setEventBroadcaster as setMessageCaptureEventBroadcaster, } from "../modules/message-capture/messageCapture.js"; import { setModerationEventBroadcaster } from "../modules/message-capture/moderationActionsDb.js"; +import { startDigestScheduler } from "../modules/monitor/digestScheduler.js"; import { registerReactionCapture } from "../modules/reaction-tracking/index.js"; import { registerThreadCapture } from "../modules/thread-tracking/index.js"; import { registerPresenceCapture } from "../modules/user-presence/index.js"; @@ -275,6 +276,8 @@ export async function initializeDiscordGateway() { // Start retention cleanup scheduler startRetentionCleanup(); + // Start weekly moderation digest (public, automated) + startDigestScheduler(); }); client.on("error", (err) => { diff --git a/services/discord-gateway/src/modules/monitor/digestScheduler.ts b/services/discord-gateway/src/modules/monitor/digestScheduler.ts new file mode 100644 index 0000000..edb3f1d --- /dev/null +++ b/services/discord-gateway/src/modules/monitor/digestScheduler.ts @@ -0,0 +1,150 @@ +import { sql } from "drizzle-orm"; +import { config } from "@/shared/config/index.js"; +import { getDatabase } from "@/shared/database/drizzle.js"; +import { createChildLogger } from "@/shared/logger/index"; + +const logger = createChildLogger("digest-scheduler"); + +// 7 days +const WEEK_MS = 7 * 24 * 60 * 60 * 1000; + +let lastDigestTs: number = 0; + +/** + * Weekly moderation digest for the public monitor channel (or webhook). + * Fully automatic — no UI, no shadow mode. Queries the DB directly and posts + * a compact embed to WEBHOOK_URLS. Read-only: never mutates moderation state. + */ +export async function runWeeklyDigest(now = Date.now()): Promise { + // Run at most once per week (guard against double-scheduling on restart). + if (now - lastDigestTs < WEEK_MS) return; + lastDigestTs = now; + + if (!config.WEBHOOK_URLS.length) { + logger.warn("No WEBHOOK_URLS configured — skipping weekly digest"); + return; + } + + const db = getDatabase(); + const since = now - WEEK_MS; + + try { + const [trends, domains, channels, coverage] = await Promise.all([ + // Top categories + db.execute(sql` + SELECT jsonb_array_elements_text(categories)::text AS name, + COUNT(*)::int AS c + FROM moderation_actions + WHERE created_at >= ${since} AND categories IS NOT NULL + GROUP BY name ORDER BY c DESC LIMIT 5 + `), + // Top flagged domains + db.execute(sql` + SELECT host, COUNT(*)::int AS c + FROM ( + SELECT DISTINCT id, + (regexp_matches(COALESCE(content,'') || ' ' || COALESCE(reason,'') || ' ' || COALESCE(evidence,''), 'https?://([^/\\s?#]+)', 'g'))[1] AS host + FROM moderation_actions + WHERE created_at >= ${since} + AND (content IS NOT NULL OR reason IS NOT NULL OR evidence IS NOT NULL) + ) sub + WHERE host IS NOT NULL + GROUP BY host ORDER BY c DESC LIMIT 5 + `), + // Top flagged channels + db.execute(sql` + SELECT COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name, + COUNT(*)::int AS c + FROM moderation_actions a + LEFT JOIN messages m ON m.id = a.message_id + WHERE a.created_at >= ${since} AND m.channel_id IS NOT NULL + GROUP BY channel_name ORDER BY c DESC LIMIT 5 + `), + // Coverage + db.execute(sql` + SELECT status, COUNT(*)::int AS c + FROM ai_analysis_runs + WHERE created_at >= ${since} + GROUP BY status + `), + ]); + + const topCats = (trends.rows as Record[]).map( + (r) => `${r.name} (${r.c})`, + ); + const topDomains = (domains.rows as Record[]).map( + (r) => `${r.host} (${r.c})`, + ); + const topChannels = (channels.rows as Record[]).map( + (r) => `${r.channel_name} (${r.c})`, + ); + const cov = (coverage.rows as Record[]) || []; + const total = cov.reduce((s, r) => s + Number(r.c), 0); + const completed = Number(cov.find((r) => r.status === "completed")?.c ?? 0); + const covRate = total > 0 ? ((completed / total) * 100).toFixed(1) : "0"; + + const lines: string[] = []; + lines.push(`**GMW Weekly Moderation Digest** (last 7 days)`); + lines.push(""); + lines.push( + `Auto-mod coverage: ${covRate}% (${completed}/${total} runs completed)`, + ); + lines.push( + `**Top flagged categories:** ${topCats.length ? topCats.join(", ") : "—"}`, + ); + lines.push( + `**Top flagged domains:** ${topDomains.length ? topDomains.join(", ") : "—"}`, + ); + lines.push( + `**Top flagged channels:** ${topChannels.length ? topChannels.join(", ") : "—"}`, + ); + lines.push(""); + lines.push( + "_View full breakdowns at the moderation dashboard (public, read-only)._", + ); + + const body = JSON.stringify({ + username: "GMW Digest", + avatar_url: + "https://upload.wikimedia.org/wikipedia/commons/6/6a/Orange_tabby_cat_sitting_on_fallen_leaves-Hisashi-01A.jpg", + content: null, + embeds: [ + { + title: "GMW Weekly Moderation Digest", + description: lines.join("\n"), + color: 0x38bdf8, + timestamp: new Date().toISOString(), + }, + ], + }); + + for (const url of config.WEBHOOK_URLS) { + try { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + if (!res.ok) { + logger.warn({ url, status: res.status }, "Digest webhook failed"); + } + } catch (err) { + logger.warn({ err }, "Digest webhook threw"); + } + } + logger.info("Weekly digest posted"); + } catch (err) { + logger.error({ err }, "Weekly digest failed"); + } +} + +/** Schedule the weekly digest. Runs on an interval; the guard inside + * `runWeeklyDigest` ensures it only fires once per WEEK_MS. + */ +export function startDigestScheduler(intervalMs = 60 * 60 * 1000): void { + // Fire an immediate (guarded) digest on start, then tick hourly. + void runWeeklyDigest(); + setInterval(() => { + void runWeeklyDigest(); + }, intervalMs); +}