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:
@@ -26,6 +26,7 @@ import {
|
|||||||
setEventBroadcaster as setMessageCaptureEventBroadcaster,
|
setEventBroadcaster as setMessageCaptureEventBroadcaster,
|
||||||
} from "../modules/message-capture/messageCapture.js";
|
} from "../modules/message-capture/messageCapture.js";
|
||||||
import { setModerationEventBroadcaster } from "../modules/message-capture/moderationActionsDb.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 { registerReactionCapture } from "../modules/reaction-tracking/index.js";
|
||||||
import { registerThreadCapture } from "../modules/thread-tracking/index.js";
|
import { registerThreadCapture } from "../modules/thread-tracking/index.js";
|
||||||
import { registerPresenceCapture } from "../modules/user-presence/index.js";
|
import { registerPresenceCapture } from "../modules/user-presence/index.js";
|
||||||
@@ -275,6 +276,8 @@ export async function initializeDiscordGateway() {
|
|||||||
|
|
||||||
// Start retention cleanup scheduler
|
// Start retention cleanup scheduler
|
||||||
startRetentionCleanup();
|
startRetentionCleanup();
|
||||||
|
// Start weekly moderation digest (public, automated)
|
||||||
|
startDigestScheduler();
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on("error", (err) => {
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user