feat(gateway): weekly moderation digest via WEBHOOK_URLS (#15)

Automated public weekly summary: top categories/domains/channels + coverage rate, posted to configured webhook. Uses getDatabase() direct query (no oRPC HTTP dependency), guards one-fire-per-week on restart.
This commit is contained in:
asepharyana
2026-08-18 20:51:12 +07:00
parent 00e8d68ce5
commit b784d6d796
2 changed files with 153 additions and 0 deletions
@@ -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) => {
@@ -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<void> {
// 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<string, unknown>[]).map(
(r) => `${r.name} (${r.c})`,
);
const topDomains = (domains.rows as Record<string, unknown>[]).map(
(r) => `${r.host} (${r.c})`,
);
const topChannels = (channels.rows as Record<string, unknown>[]).map(
(r) => `${r.channel_name} (${r.c})`,
);
const cov = (coverage.rows as Record<string, unknown>[]) || [];
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);
}