refactor: split monolith into 3 microservices (frontend, backend, discord-gateway)

- Extract services into services/{frontend,backend,discord-gateway}
- Create packages/shared/ for shared logger, errors, utils, types
- Setup Modular MVC pattern in backend (controller→service→repository)
- Setup event-driven architecture in discord-gateway with Redis pub/sub
- Move Docker files to infra/docker/ with per-service Dockerfiles
- Update docker-compose.yml to use Traefik-only routing (no port exposes)
- Update GitHub Actions deploy workflow for multi-service matrix build
- Fix all import paths and resolve type errors across all services
- All 3 services pass tsc --noEmit clean

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-01 21:44:29 +07:00
co-authored by Claude Opus 4.8
parent bda8304bb9
commit c48a0c5e3b
193 changed files with 16879 additions and 1158 deletions
@@ -0,0 +1,929 @@
import { executeAll, executeGet } from "../../shared/database/drizzle.js";
import { createChildLogger } from "../../shared/logger/logger.js";
import type { MessageRecord } from "./types.js";
const logger = createChildLogger("analytics-store");
// ── Types ──────────────────────────────────────────────────────────────
export interface HourlyBucket {
hour: string;
count: number;
clean: number;
warned: number;
flagged: number;
error: number;
}
export interface TopicTrend {
topic: string;
count: number;
score: number;
}
export interface UserStat {
user_id: string;
username: string;
avatar_url: string | null;
message_count: number;
edited_count: number;
deleted_count: number;
flagged_count: number;
last_active: number;
}
export interface ModerationBreakdown {
total: number;
clean: number;
warned: number;
flagged: number;
error: number;
pending: number;
average_score: number;
}
export interface AnalyticsOverview {
period: { start: number; end: number };
messages: ModerationBreakdown;
hourly: HourlyBucket[];
topics: TopicTrend[];
top_users: UserStat[];
active_users_count: number;
total_channels: number;
}
// ══════════════════════════════════════════════════════════════════════════
// GENERIC QUERY CACHE (reduces duplicate DB calls from 5s auto-refresh)
// ══════════════════════════════════════════════════════════════════════════
interface CacheEntry<T> {
data: T;
expiresAt: number;
}
const queryCache = new Map<string, CacheEntry<any>>();
/** Default TTL for aggregate queries — 10s is long enough to prevent redundant
* calls from the 5s auto-refresh but short enough to feel real-time. */
const AGGREGATE_CACHE_TTL_MS = 10_000;
/** Topic extraction is expensive (JSON parsing). Cache longer. */
const TOPIC_CACHE_TTL_MS = 120_000;
function makeCacheKey(prefix: string, params: Record<string, any>): string {
return `${prefix}:${JSON.stringify(params)}`;
}
function getCached<T>(key: string): T | undefined {
const entry = queryCache.get(key);
if (entry && entry.expiresAt > Date.now()) return entry.data;
if (entry) queryCache.delete(key); // expired
return undefined;
}
function setCache<T>(key: string, data: T, ttl: number): void {
queryCache.set(key, { data, expiresAt: Date.now() + ttl });
// Prune old entries if cache grows too large (>200 entries)
if (queryCache.size > 200) {
const now = Date.now();
for (const [k, v] of queryCache) {
if (v.expiresAt <= now) queryCache.delete(k);
}
}
}
// ── Hourly Message Stats ───────────────────────────────────────────────
export async function getHourlyStats(input: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<HourlyBucket[]> {
const { guildId, channelId, hours = 24 } = input;
const cacheKey = makeCacheKey("hourly", { guildId, channelId, hours });
const cached = getCached<HourlyBucket[]>(cacheKey);
if (cached) return cached;
try {
const since = Date.now() - hours * 3600_000;
const hourExpr = `to_char(to_timestamp((created_at / 3600000) * 3600), 'YYYY-MM-DD HH24:MI:SS') as hour`;
const rows = await executeAll(
`
SELECT
${hourExpr},
count(*) as count,
count(case when ai_status = 'clean' then 1 end) as clean,
count(case when ai_status = 'warn' then 1 end) as warned,
count(case when ai_status = 'flagged' then 1 end) as flagged,
count(case when ai_status = 'error' then 1 end) as error
FROM messages
WHERE guild_id = ?
AND created_at >= ?
AND deleted_at IS NULL
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
GROUP BY (created_at / 3600000)
ORDER BY hour ASC
`,
channelId ? [guildId, since, channelId, channelId] : [guildId, since],
);
// Initialize all hour buckets (fill gaps with zeros)
const buckets = new Map<
string,
{
count: number;
clean: number;
warned: number;
flagged: number;
error: number;
}
>();
for (let h = 0; h < hours; h++) {
const ts = new Date(since + h * 3600_000);
ts.setMinutes(0, 0, 0);
const key = ts.toISOString().slice(0, 13) + ":00:00Z";
buckets.set(key, { count: 0, clean: 0, warned: 0, flagged: 0, error: 0 });
}
for (const row of rows) {
const d = new Date(row.hour.replace(" ", "T") + "Z");
const key = d.toISOString().slice(0, 13) + ":00:00Z";
const bucket = buckets.get(key);
if (!bucket) continue;
bucket.count = row.count;
bucket.clean = row.clean;
bucket.warned = row.warned;
bucket.flagged = row.flagged;
bucket.error = row.error;
}
const result = Array.from(buckets.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([hour, data]) => ({ hour, ...data }));
setCache(cacheKey, result, AGGREGATE_CACHE_TTL_MS);
return result;
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to get hourly stats",
);
return [];
}
}
// ── Topic Trends ───────────────────────────────────────────────────────
const STOP_WORDS = new Set([
"yang",
"dan",
"itu",
"ini",
"dengan",
"akan",
"pada",
"dari",
"di",
"ke",
"untuk",
"tidak",
"ada",
"juga",
"sudah",
"saya",
"kamu",
"dia",
"mereka",
"kami",
"aku",
"lo",
"lu",
"gua",
"gue",
"org",
"orang",
"aja",
"sama",
"kalo",
"kalau",
"bisa",
"karena",
"gak",
"nggak",
"ga",
"tak",
"belum",
"udah",
"dah",
"lah",
"kah",
"pun",
"nih",
"tuh",
"deh",
"dong",
"si",
"nya",
"kan",
"ya",
"yah",
"yuk",
"kok",
"loh",
"nah",
"wow",
"eh",
"the",
"a",
"an",
"is",
"are",
"was",
"were",
"be",
"been",
"being",
"have",
"has",
"had",
"having",
"do",
"does",
"did",
"doing",
"will",
"would",
"could",
"should",
"may",
"might",
"must",
"shall",
"i",
"you",
"he",
"she",
"it",
"we",
"they",
"me",
"him",
"her",
"us",
"them",
"my",
"your",
"his",
"its",
"our",
"their",
"and",
"but",
"or",
"nor",
"not",
"so",
"yet",
"for",
"if",
"to",
"of",
"in",
"on",
"at",
"by",
"as",
"with",
"about",
"just",
"then",
"now",
"here",
"there",
"when",
"where",
"why",
"how",
"all",
"both",
"each",
"few",
"more",
"most",
"other",
"some",
"such",
"only",
"own",
"same",
"too",
"very",
"can",
"go",
"ok",
"okay",
"yeah",
"yes",
"no",
]);
function extractTopics(messages: MessageRecord[], topN = 15): TopicTrend[] {
const topicScores = new Map<string, { count: number; score: number }>();
const wordFreq = new Map<string, number>();
const flaggedWordFreq = new Map<string, number>();
for (const msg of messages) {
if (msg.ai_analysis) {
try {
const analysis = JSON.parse(msg.ai_analysis);
const topics = analysis.topics;
if (topics && Array.isArray(topics)) {
for (const topic of topics) {
const key =
typeof topic === "string" ? topic : topic.name || topic.topic;
if (!key) continue;
const k = key.toLowerCase();
const score = msg.ai_moderation_score || 0;
const existing = topicScores.get(k);
if (existing) {
existing.count++;
existing.score += score;
} else {
topicScores.set(k, { count: 1, score });
}
}
}
if (analysis.category) {
const cat = String(analysis.category).toLowerCase();
const existing = topicScores.get(cat);
if (existing) {
existing.count++;
existing.score += msg.ai_moderation_score || 0;
} else {
topicScores.set(cat, {
count: 1,
score: msg.ai_moderation_score || 0,
});
}
}
} catch {
/* not valid JSON */
}
}
if (msg.content) {
const words = msg.content
.toLowerCase()
.replace(/[^\w\s]/g, " ")
.split(/\s+/)
.filter((w) => w.length > 2 && !STOP_WORDS.has(w));
for (const word of words) {
wordFreq.set(word, (wordFreq.get(word) || 0) + 1);
if (msg.ai_status === "flagged" || msg.ai_status === "warn") {
flaggedWordFreq.set(word, (flaggedWordFreq.get(word) || 0) + 1);
}
}
}
}
const results: TopicTrend[] = [];
for (const [topic, data] of topicScores) {
results.push({ topic, count: data.count, score: data.score });
}
const sortedWords = Array.from(wordFreq.entries())
.sort(([, a], [, b]) => b - a)
.slice(0, topN);
for (const [word, count] of sortedWords) {
if (!topicScores.has(word)) {
results.push({
topic: word,
count,
score: flaggedWordFreq.get(word) || 0,
});
}
}
return results.sort((a, b) => b.count - a.count).slice(0, topN);
}
export async function getTopicTrends(input: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<TopicTrend[]> {
const { guildId, channelId, hours = 24 } = input;
const cacheKey = makeCacheKey("topics", { guildId, channelId, hours });
const cached = getCached<TopicTrend[]>(cacheKey);
if (cached) return cached;
try {
const since = Date.now() - hours * 3600_000;
// Fetch all analyzed messages within the time window (no hard row cap).
// Messages without ai_analysis are excluded which naturally limits rows.
const rows = (await executeAll(
`
SELECT
id, content, ai_status, ai_analysis, ai_moderation_score,
ai_moderation_flags, created_at
FROM messages
WHERE guild_id = ?
AND created_at >= ?
AND deleted_at IS NULL
AND ai_analysis IS NOT NULL
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
ORDER BY created_at DESC
`,
channelId ? [guildId, since, channelId, channelId] : [guildId, since],
)) as MessageRecord[];
const result = extractTopics(rows);
setCache(cacheKey, result, TOPIC_CACHE_TTL_MS);
return result;
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to get topic trends",
);
return [];
}
}
// ── User Leaderboard ────────────────────────────────────────────────────
export async function getUserLeaderboard(input: {
guildId: string;
channelId?: string;
hours?: number;
limit?: number;
}): Promise<UserStat[]> {
const { guildId, channelId, hours = 24, limit = 20 } = input;
const cacheKey = makeCacheKey("leaderboard", {
guildId,
channelId,
hours,
limit,
});
const cached = getCached<UserStat[]>(cacheKey);
if (cached) return cached;
try {
const since = Date.now() - hours * 3600_000;
const rows = await executeAll(
`
SELECT
user_id,
username,
avatar_url,
count(*) as message_count,
count(case when type = 'edited' then 1 end) as edited_count,
count(case when type = 'deleted' then 1 end) as deleted_count,
count(case when ai_status = 'flagged' then 1 end) as flagged_count,
max(created_at) as last_active
FROM messages
WHERE guild_id = ?
AND created_at >= ?
AND deleted_at IS NULL
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
GROUP BY user_id, username, avatar_url
ORDER BY message_count DESC
LIMIT ?
`,
channelId
? [guildId, since, channelId, channelId, limit]
: [guildId, since, limit],
);
const result = rows as UserStat[];
setCache(cacheKey, result, AGGREGATE_CACHE_TTL_MS);
return result;
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to get user leaderboard",
);
return [];
}
}
// ── Moderation Stats ───────────────────────────────────────────────────
export async function getModerationStats(input: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<ModerationBreakdown> {
const { guildId, channelId, hours = 24 } = input;
const cacheKey = makeCacheKey("modstats", { guildId, channelId, hours });
const cached = getCached<ModerationBreakdown>(cacheKey);
if (cached) return cached;
try {
const since = Date.now() - hours * 3600_000;
const avgScoreExpr = `round(avg(ai_moderation_score)::numeric, 2)`;
const row = await executeGet(
`
SELECT
count(*) as total,
count(case when ai_status = 'clean' then 1 end) as clean,
count(case when ai_status = 'warn' then 1 end) as warned,
count(case when ai_status = 'flagged' then 1 end) as flagged,
count(case when ai_status = 'error' then 1 end) as error,
count(case when ai_status = 'pending' or ai_status IS NULL then 1 end) as pending,
${avgScoreExpr} as average_score
FROM messages
WHERE guild_id = ?
AND created_at >= ?
AND deleted_at IS NULL
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
`,
channelId ? [guildId, since, channelId, channelId] : [guildId, since],
);
const result: ModerationBreakdown = row
? {
total: row.total ?? 0,
clean: row.clean ?? 0,
warned: row.warned ?? 0,
flagged: row.flagged ?? 0,
error: row.error ?? 0,
pending: row.pending ?? 0,
average_score: row.average_score ?? 0,
}
: {
total: 0,
clean: 0,
warned: 0,
flagged: 0,
error: 0,
pending: 0,
average_score: 0,
};
setCache(cacheKey, result, AGGREGATE_CACHE_TTL_MS);
return result;
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to get moderation stats",
);
return {
total: 0,
clean: 0,
warned: 0,
flagged: 0,
error: 0,
pending: 0,
average_score: 0,
};
}
}
// ── Active Channels Count ──────────────────────────────────────────────
export async function getActiveChannelCount(input: {
guildId: string;
hours?: number;
}): Promise<number> {
const { guildId, hours = 24 } = input;
const cacheKey = makeCacheKey("channels", { guildId, hours });
const cached = getCached<number>(cacheKey);
if (cached !== undefined) return cached;
try {
const since = Date.now() - hours * 3600_000;
const row = await executeGet(
`
SELECT count(DISTINCT channel_id) as cnt
FROM messages
WHERE guild_id = ?
AND created_at >= ?
AND deleted_at IS NULL
`,
[guildId, since],
);
const result = row?.cnt ?? 0;
setCache(cacheKey, result, AGGREGATE_CACHE_TTL_MS);
return result;
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to get active channel count",
);
return 0;
}
}
// ── Top Violators ─────────────────────────────────────────────────────
export interface ViolatorStat {
user_id: string;
username: string;
avatar_url: string | null;
total_messages: number;
flagged_count: number;
warned_count: number;
violation_score: number;
worst_flags: string[];
last_violation: number;
}
export async function getTopViolators(input: {
guildId: string;
channelId?: string;
hours?: number;
limit?: number;
}): Promise<ViolatorStat[]> {
const { guildId, channelId, hours = 24, limit = 20 } = input;
const cacheKey = makeCacheKey("violators", {
guildId,
channelId,
hours,
limit,
});
const cached = getCached<ViolatorStat[]>(cacheKey);
if (cached) return cached;
try {
const since = Date.now() - hours * 3600_000;
const rows = await executeAll(
`
SELECT
user_id,
username,
avatar_url,
count(*) as total_messages,
count(case when ai_status = 'flagged' then 1 end) as flagged_count,
count(case when ai_status = 'warn' then 1 end) as warned_count,
max(case when ai_status in ('flagged', 'warn') then created_at else 0 end) as last_violation
FROM messages
WHERE guild_id = ?
AND created_at >= ?
AND deleted_at IS NULL
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
GROUP BY user_id, username, avatar_url
HAVING count(case when ai_status = 'flagged' then 1 end) > 0
OR count(case when ai_status = 'warn' then 1 end) > 0
ORDER BY (
count(case when ai_status = 'flagged' then 1 end) * 3
+ count(case when ai_status = 'warn' then 1 end)
) DESC
LIMIT ?
`,
channelId
? [guildId, since, channelId, channelId, limit]
: [guildId, since, limit],
);
const violators: ViolatorStat[] = rows.map((row: any) => {
const flaggedCount = Number(row.flagged_count ?? 0);
const warnedCount = Number(row.warned_count ?? 0);
return {
user_id: row.user_id,
username: row.username,
avatar_url: row.avatar_url,
total_messages: Number(row.total_messages ?? 0),
flagged_count: flaggedCount,
warned_count: warnedCount,
violation_score: flaggedCount * 3 + warnedCount,
worst_flags: [],
last_violation: Number(row.last_violation ?? 0),
};
});
setCache(cacheKey, violators, AGGREGATE_CACHE_TTL_MS);
return violators;
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to get top violators",
);
return [];
}
}
// ── Daily Trend (for multi-day line chart) ────────────────────────────
export interface TrendBucket {
date: string;
count: number;
clean: number;
warned: number;
flagged: number;
error: number;
}
export async function getDailyTrend(input: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<TrendBucket[]> {
const { guildId, channelId, hours = 168 } = input;
const cacheKey = makeCacheKey("daily_trend", { guildId, channelId, hours });
const cached = getCached<TrendBucket[]>(cacheKey);
if (cached) return cached;
try {
const since = Date.now() - hours * 3600_000;
const dateExpr = `to_char(date_trunc('day', to_timestamp(created_at / 1000)), 'YYYY-MM-DD') as date`;
const rows = await executeAll(
`
SELECT
${dateExpr},
count(*) as count,
count(case when ai_status = 'clean' then 1 end) as clean,
count(case when ai_status = 'warn' then 1 end) as warned,
count(case when ai_status = 'flagged' then 1 end) as flagged,
count(case when ai_status = 'error' then 1 end) as error
FROM messages
WHERE guild_id = ?
AND created_at >= ?
AND deleted_at IS NULL
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
GROUP BY 1
ORDER BY 1 ASC
`,
channelId ? [guildId, since, channelId, channelId] : [guildId, since],
);
// Initialize all day buckets (fill gaps with zeros)
const buckets = new Map<
string,
{
count: number;
clean: number;
warned: number;
flagged: number;
error: number;
}
>();
const msPerDay = 86400_000;
const startDay = Math.floor(since / msPerDay) * msPerDay;
const endDay = Math.floor(Date.now() / msPerDay) * msPerDay;
for (let d = startDay; d <= endDay; d += msPerDay) {
const key = new Date(d).toISOString().slice(0, 10);
buckets.set(key, { count: 0, clean: 0, warned: 0, flagged: 0, error: 0 });
}
for (const row of rows) {
const bucket = buckets.get(row.date);
if (!bucket) continue;
bucket.count = row.count;
bucket.clean = row.clean;
bucket.warned = row.warned;
bucket.flagged = row.flagged;
bucket.error = row.error;
}
const result = Array.from(buckets.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, data]) => ({ date, ...data }));
setCache(cacheKey, result, AGGREGATE_CACHE_TTL_MS);
return result;
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to get daily trend",
);
return [];
}
}
// ── Activity Heatmap (day-of-week × hour-of-day) ──────────────────────
export interface HeatmapCell {
dayOfWeek: number; // 0=Senin, 6=Minggu
hour: number; // 0-23
count: number;
clean: number;
warned: number;
flagged: number;
}
export async function getActivityHeatmap(input: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<HeatmapCell[]> {
const { guildId, channelId, hours = 168 } = input;
const cacheKey = makeCacheKey("heatmap", { guildId, channelId, hours });
const cached = getCached<HeatmapCell[]>(cacheKey);
if (cached) return cached;
try {
const since = Date.now() - hours * 3600_000;
const dayExpr = `(extract(isodow from to_timestamp(created_at / 1000)) % 7)::int as day_of_week`;
const hourExpr = `extract(hour from to_timestamp(created_at / 1000))::int as hour`;
const rows = await executeAll(
`
SELECT
${dayExpr},
${hourExpr},
count(*) as count,
count(case when ai_status = 'clean' then 1 end) as clean,
count(case when ai_status = 'warn' then 1 end) as warned,
count(case when ai_status = 'flagged' then 1 end) as flagged
FROM messages
WHERE guild_id = ?
AND created_at >= ?
AND deleted_at IS NULL
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
GROUP BY day_of_week, hour
ORDER BY day_of_week, hour
`,
channelId ? [guildId, since, channelId, channelId] : [guildId, since],
);
// Initialize all 7×24 cells with zeros
const cells = new Map<
string,
{ count: number; clean: number; warned: number; flagged: number }
>();
for (let d = 0; d < 7; d++) {
for (let h = 0; h < 24; h++) {
cells.set(`${d}-${h}`, { count: 0, clean: 0, warned: 0, flagged: 0 });
}
}
for (const row of rows) {
const key = `${row.day_of_week}-${row.hour}`;
const cell = cells.get(key);
if (!cell) continue;
cell.count = row.count;
cell.clean = row.clean;
cell.warned = row.warned;
cell.flagged = row.flagged;
}
const result = Array.from(cells.entries())
.map(([key, data]) => {
const [dayOfWeek, hour] = key.split("-").map(Number);
return { dayOfWeek, hour, ...data };
})
.sort((a, b) => a.dayOfWeek - b.dayOfWeek || a.hour - b.hour);
setCache(cacheKey, result, AGGREGATE_CACHE_TTL_MS);
return result;
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to get activity heatmap",
);
return [];
}
}
// ── Cache Invalidation (called when new messages arrive) ───────────────
export function invalidateAnalyticsCache(guildId: string): void {
const now = Date.now();
const needle = `"${guildId}"`;
for (const [key, entry] of queryCache) {
if (key.includes(needle) && entry.expiresAt > now) {
entry.expiresAt = 0; // expire immediately
}
}
}
// ── Combined Overview ──────────────────────────────────────────────────
export async function getAnalyticsOverview(input: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<AnalyticsOverview> {
const { guildId, hours = 24 } = input;
const now = Date.now();
const since = now - hours * 3600_000;
const [messages, hourly, topics, topUsers, totalChannels] = await Promise.all(
[
getModerationStats(input),
getHourlyStats(input),
getTopicTrends(input),
getUserLeaderboard(input),
getActiveChannelCount({ guildId, hours }),
],
);
return {
period: { start: since, end: now },
messages,
hourly,
topics,
top_users: topUsers,
active_users_count: topUsers.length,
total_channels: totalChannels,
};
}
@@ -0,0 +1,82 @@
import type { WebSocket } from "ws";
import { createChildLogger } from "../../shared/logger/logger.js";
import type {
AnalysisQueueStatus,
AttachmentRecord,
MediaState,
MessageRecord,
ModerationWsEvent,
} from "../message-capture/types.js";
export type BroadcasterClient = Pick<WebSocket, "readyState" | "send">;
const log = createChildLogger("broadcaster");
function sendJson(
clients: Set<BroadcasterClient>,
event: ModerationWsEvent,
): void {
const payload = JSON.stringify({ ...event, timestamp: Date.now() });
for (const client of clients) {
if (client.readyState === 1) {
try {
client.send(payload);
} catch (error) {
log.warn(
{ error, eventType: event.type },
"Failed to send event to client",
);
}
}
}
}
export function createBroadcaster() {
const clients = new Set<BroadcasterClient>();
return {
addClient(client: BroadcasterClient) {
clients.add(client);
log.debug({ clientCount: clients.size }, "Client added");
},
removeClient(client: BroadcasterClient) {
clients.delete(client);
log.debug({ clientCount: clients.size }, "Client removed");
},
clientCount() {
return clients.size;
},
getClients() {
return Array.from(clients);
},
uiState(state: unknown) {
sendJson(clients, { type: "ui_state", state });
},
userState(users: unknown[]) {
sendJson(clients, { type: "user_state", users });
},
messageCreated(data: MessageRecord) {
sendJson(clients, { type: "message_created", data });
},
messageUpdated(data: Partial<MessageRecord> & { id: string }) {
sendJson(clients, { type: "message_updated", data });
},
messageDeleted(data: { id: string; deleted_at: number }) {
sendJson(clients, { type: "message_deleted", data });
},
messageAnalyzed(data: MessageRecord) {
sendJson(clients, { type: "message_analyzed", data });
},
attachmentCreated(data: AttachmentRecord) {
sendJson(clients, { type: "attachment_created", data });
},
analysisQueueStatus(data: AnalysisQueueStatus) {
sendJson(clients, { type: "analysis_queue_status", data });
},
mediaState(state: MediaState) {
sendJson(clients, { type: "media_state", state });
},
};
}
export type ModerationBroadcaster = ReturnType<typeof createBroadcaster>;
@@ -0,0 +1,21 @@
export { registerMessageCapture } from "./messageCapture.js";
export {
getDisplayContent,
getMessageLocation,
getMessageMetadata,
} from "../message-capture/messageMetadata.js";
export {
getMessageById,
insertAttachment,
updateMessageAsDeleted,
updateMessageAsEdited,
upsertMessageForCapture,
} from "../message-capture/messageStore.js";
export type {
AIRecommendedAction,
AISeverity,
AIStatus,
AttachmentRecord,
MessageRecord,
VoiceSegmentRecord,
} from "../message-capture/types.js";
@@ -0,0 +1,302 @@
import type { Client, Message } from "discord.js-selfbot-v13";
import { config } from "../../shared/config/config.js";
import { createChildLogger } from "../../shared/logger/logger.js";
import { queueMessageAnalysis } from "../ai-moderation/aiAnalyzer.js";
import { processAttachmentUpload } from "../attachment-upload/attachmentUploader.js";
import type { EventBroadcaster } from "../event-broadcaster/eventBroadcaster.js";
import {
getDisplayContent,
getMessageLocation,
getMessageMetadata,
} from "../message-capture/messageMetadata.js";
import {
getMessageById,
insertAttachment,
updateMessageAsDeleted,
updateMessageAsEdited,
upsertMessageForCapture,
} from "../message-capture/messageStore.js";
import type { AttachmentRecord, MessageRecord } from "../message-capture/types.js";
const logger = createChildLogger("message-capture");
let _eventBroadcaster: EventBroadcaster | undefined;
export function setEventBroadcaster(broadcaster: EventBroadcaster | undefined) {
_eventBroadcaster = broadcaster;
}
export interface TextCaptureTarget {
guildId?: string;
channelId?: string;
}
export interface MessageLocationInput {
guildId?: string | null;
channelId?: string | null;
}
export function shouldCaptureMessageLocation(
message: MessageLocationInput,
target: TextCaptureTarget,
): boolean {
if (
message.channelId === "1310988070996414494" ||
message.channelId === "1265679542144467035" ||
message.channelId === "1310867899745046558"
)
return false;
if (!message.guildId || message.guildId !== target.guildId) return false;
if (target.channelId && message.channelId !== target.channelId) return false;
return true;
}
function getTextCaptureTarget(): TextCaptureTarget {
return {
guildId: config.EFFECTIVE_TEXT_GUILD_ID,
channelId: config.TEXT_CHANNEL_ID,
};
}
function requireMessageGuildId(message: Message): string {
if (!message.guildId) {
throw new Error(`Message ${message.id} is missing guildId`);
}
return message.guildId;
}
function buildMessageRecord(
message: Message,
type: "text" | "edited" | "deleted",
): MessageRecord {
const location = getMessageLocation(message);
const metadata = getMessageMetadata(message);
const guildId = requireMessageGuildId(message);
return {
id: message.id,
guild_id: guildId,
channel_id: location.channelId,
thread_id: location.threadId,
user_id: message.author?.id,
username: message.author?.username,
avatar_url: message.author?.avatarURL() || null,
content: getDisplayContent(message),
edited_content: null,
created_at: message.createdTimestamp,
edited_at: null,
deleted_at: null,
type,
metadata: JSON.stringify(metadata),
};
}
function buildAttachmentRecord(
message: Message,
location: ReturnType<typeof getMessageLocation>,
attachment: {
id: string;
name: string | null;
size: number;
contentType: string | null;
url: string;
},
): AttachmentRecord {
const guildId = requireMessageGuildId(message);
return {
id: attachment.id,
message_id: message.id,
guild_id: guildId,
channel_id: location.channelId,
thread_id: location.threadId,
user_id: message.author?.id,
filename: attachment.name || "unknown",
size: attachment.size,
type: attachment.contentType || "application/octet-stream",
discord_url: attachment.url,
uploaded_url: null,
upload_status: "pending",
upload_error: null,
created_at: Date.now(),
uploaded_at: null,
};
}
export async function captureMessage(
message: Message,
type: "text" | "edited" | "deleted",
options: { source?: "live" | "backlog" } = {},
): Promise<void> {
const location = getMessageLocation(message);
const messageRecord = buildMessageRecord(message, type);
const inserted = await upsertMessageForCapture(messageRecord);
if (!inserted) {
return;
}
const isBacklog = options.source === "backlog";
if (_eventBroadcaster && !isBacklog) {
_eventBroadcaster.messageCreated(messageRecord);
}
const attachmentUploadTasks: Promise<void>[] = [];
if (message.attachments.size > 0) {
for (const [, attachment] of message.attachments) {
const attachmentRecord = buildAttachmentRecord(message, location, {
id: attachment.id,
name: attachment.name,
size: attachment.size,
contentType: attachment.contentType,
url: attachment.url,
});
await insertAttachment(attachmentRecord);
if (!isBacklog) {
attachmentUploadTasks.push(
processAttachmentUpload(
attachment.id,
attachment.url,
attachment.name || "unknown",
{
contentType: attachment.contentType ?? undefined,
refreshDiscordUrl: async () => {
const freshMessage = await message.channel.messages.fetch(
message.id,
);
const freshAttachment = freshMessage.attachments.get(
attachment.id,
);
return freshAttachment?.url ?? null;
},
},
).catch((err: unknown) => {
logger.error(
{ attachmentId: attachment.id, error: err },
"Failed to initiate attachment upload",
);
}),
);
}
if (_eventBroadcaster) {
_eventBroadcaster.attachmentCreated(attachmentRecord);
}
}
}
if (!isBacklog) {
if (attachmentUploadTasks.length > 0) {
let analysisQueued = false;
let fallbackTimer: NodeJS.Timeout | null = null;
const queueAnalysisOnce = () => {
if (analysisQueued) return;
analysisQueued = true;
if (fallbackTimer) {
clearTimeout(fallbackTimer);
fallbackTimer = null;
}
queueMessageAnalysis(message.id);
};
fallbackTimer = setTimeout(queueAnalysisOnce, 30000);
Promise.allSettled(attachmentUploadTasks)
.then(queueAnalysisOnce)
.catch((err: unknown) => {
logger.error(
{ messageId: message.id, error: err },
"Failed to queue message analysis after attachment upload",
);
queueAnalysisOnce();
});
} else {
queueMessageAnalysis(message.id);
}
}
}
export function registerMessageCapture(client: Client): void {
client.on("messageCreate", async (message) => {
if (!shouldCaptureMessageLocation(message, getTextCaptureTarget())) return;
if (message.author?.bot) return;
try {
await captureMessage(message, "text");
} catch (error) {
logger.error(
{
messageId: message.id,
error: error instanceof Error ? error.message : String(error),
},
"Failed to capture message",
);
}
});
client.on("messageUpdate", async (_oldMessage, newMessage) => {
if (!shouldCaptureMessageLocation(newMessage, getTextCaptureTarget()))
return;
if (newMessage.author?.bot) return;
try {
const existing = await getMessageById(newMessage.id);
if (existing) {
const editedAt = Date.now();
await updateMessageAsEdited(
newMessage.id,
getDisplayContent(newMessage as Message),
editedAt,
);
queueMessageAnalysis(newMessage.id);
if (_eventBroadcaster) {
_eventBroadcaster.messageUpdated({
id: newMessage.id,
edited_content: getDisplayContent(newMessage as Message),
edited_at: editedAt,
});
}
} else if (newMessage.author) {
await captureMessage(newMessage as Message, "text");
}
} catch (error) {
logger.error(
{
messageId: newMessage.id,
error: error instanceof Error ? error.message : String(error),
},
"Failed to capture message update",
);
}
});
client.on("messageDelete", async (message) => {
if (!shouldCaptureMessageLocation(message, getTextCaptureTarget())) return;
if (!message.author) return;
try {
const deletedAt = Date.now();
await updateMessageAsDeleted(message.id, deletedAt);
if (_eventBroadcaster) {
_eventBroadcaster.messageDeleted({
id: message.id,
deleted_at: deletedAt,
});
}
} catch (error) {
logger.error(
{
messageId: message.id,
error: error instanceof Error ? error.message : String(error),
},
"Failed to capture message deletion",
);
}
});
}
@@ -0,0 +1,375 @@
import type {
Message,
TextChannel,
ThreadChannel,
} from "discord.js-selfbot-v13";
export interface MessageLocation {
channelId: string;
threadId: string | null;
threadName: string | null;
channelName: string | null;
nsfw?: boolean;
nsfwLevel?: string | null;
ageRestricted?: boolean;
}
export interface StickerEvidence {
id: string;
name: string;
url: string;
format: string | null;
}
export interface CustomEmojiEvidence {
id: string;
name: string;
animated: boolean;
url: string;
}
export interface EmbedEvidence {
title: string | null;
description: string | null;
url: string | null;
color: number | null;
image: string | null;
thumbnail: string | null;
author: {
name: string | null;
url: string | null;
iconURL: string | null;
} | null;
footer: { text: string | null; iconURL: string | null } | null;
fields: Array<{ name: string; value: string; inline: boolean }>;
}
export interface AttachmentEvidence {
id: string;
name: string;
url: string;
contentType: string | null;
size: number;
}
export interface MessageMediaEvidence {
stickers: StickerEvidence[];
embeds: EmbedEvidence[];
attachments: AttachmentEvidence[];
customEmojis: CustomEmojiEvidence[];
}
export interface RichMessageMetadata {
stickers: Array<StickerEvidence>;
embeds: Array<EmbedEvidence>;
attachments: Array<AttachmentEvidence>;
customEmojis: Array<CustomEmojiEvidence>;
author: {
id: string;
username: string;
tag: string | null;
avatarURL: string | null;
bot: boolean;
};
member: {
displayName: string | null;
roles: Array<{ id: string; name: string }>;
joinedTimestamp: number | null;
} | null;
channel: MessageLocation;
reference: {
messageId: string | null;
channelId: string | null;
guildId: string | null;
} | null;
}
export function getMessageLocation(message: Message): MessageLocation {
const channel = message.channel as TextChannel | ThreadChannel;
const safetyChannel = channel as TextChannel & {
nsfw?: boolean;
nsfwLevel?: string | null;
};
if (!channel.isThread?.()) {
return {
channelId: message.channelId,
threadId: null,
threadName: null,
channelName: "name" in channel ? channel.name : null,
nsfw:
typeof safetyChannel.nsfw === "boolean"
? safetyChannel.nsfw
: undefined,
nsfwLevel:
typeof safetyChannel.nsfwLevel === "string"
? safetyChannel.nsfwLevel
: null,
ageRestricted:
typeof safetyChannel.nsfw === "boolean"
? safetyChannel.nsfw
: undefined,
};
}
return {
channelId: channel.parentId ?? message.channelId,
threadId: channel.id,
threadName: channel.name,
channelName: channel.parent?.name ?? null,
nsfw:
typeof safetyChannel.nsfw === "boolean" ? safetyChannel.nsfw : undefined,
nsfwLevel:
typeof safetyChannel.nsfwLevel === "string"
? safetyChannel.nsfwLevel
: null,
ageRestricted:
typeof safetyChannel.nsfw === "boolean" ? safetyChannel.nsfw : undefined,
};
}
export function getStickerMetadata(
message: Message,
): RichMessageMetadata["stickers"] {
return Array.from(message.stickers.values()).map((sticker) => ({
id: sticker.id,
name: sticker.name,
url: sticker.url,
format: sticker.format ?? null,
}));
}
/**
* Extract custom emoji references from message content.
* Builds Discord CDN URLs for each emoji so they can be downloaded
* and sent to the vision model for analysis.
*/
export function getCustomEmojiMetadata(
message: Message,
): RichMessageMetadata["customEmojis"] {
const CUSTOM_EMOJI_PATTERN = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g;
const emojis: CustomEmojiEvidence[] = [];
let match;
while ((match = CUSTOM_EMOJI_PATTERN.exec(message.content)) !== null) {
const [, animated, name, id] = match;
const ext = animated ? "gif" : "png";
emojis.push({
id,
name,
animated: animated === "a",
url: `https://cdn.discordapp.com/emojis/${id}.${ext}?size=128`,
});
}
return emojis;
}
export function getAttachmentMetadata(
message: Message,
): RichMessageMetadata["attachments"] {
return Array.from(message.attachments.values()).map((attachment) => ({
id: attachment.id,
name: attachment.name || "unknown",
url: attachment.url,
contentType: attachment.contentType ?? null,
size: attachment.size,
}));
}
export function getEmbedMetadata(
message: Message,
): RichMessageMetadata["embeds"] {
return message.embeds.map((embed) => ({
title: embed.title ?? null,
description: embed.description ?? null,
url: embed.url ?? null,
color: embed.color ?? null,
image: embed.image?.url ?? null,
thumbnail: embed.thumbnail?.url ?? null,
author: embed.author
? {
name: embed.author.name ?? null,
url: embed.author.url ?? null,
iconURL: embed.author.iconURL ?? null,
}
: null,
footer: embed.footer
? {
text: embed.footer.text ?? null,
iconURL: embed.footer.iconURL ?? null,
}
: null,
fields: embed.fields.map((field) => ({
name: field.name,
value: field.value,
inline: Boolean(field.inline),
})),
}));
}
export function getMessageMetadata(message: Message): RichMessageMetadata {
const member = message.member;
return {
stickers: getStickerMetadata(message),
embeds: getEmbedMetadata(message),
attachments: getAttachmentMetadata(message),
customEmojis: getCustomEmojiMetadata(message),
author: {
id: message.author.id,
username: message.author.username,
tag: "tag" in message.author ? message.author.tag : null,
avatarURL: message.author.avatarURL() ?? null,
bot: Boolean(message.author.bot),
},
member: member
? {
displayName: member.displayName ?? null,
roles: member.roles.cache.map((role) => ({
id: role.id,
name: role.name,
})),
joinedTimestamp: member.joinedTimestamp ?? null,
}
: null,
channel: getMessageLocation(message),
reference: message.reference
? {
messageId: message.reference.messageId ?? null,
channelId: message.reference.channelId ?? null,
guildId: message.reference.guildId ?? null,
}
: null,
};
}
export function parseRichMessageMetadata(
metadata: string | null | undefined,
): RichMessageMetadata | null {
if (!metadata) return null;
try {
const parsed = JSON.parse(metadata) as Partial<RichMessageMetadata>;
return {
stickers: Array.isArray(parsed.stickers) ? parsed.stickers : [],
embeds: Array.isArray(parsed.embeds) ? parsed.embeds : [],
attachments: Array.isArray(parsed.attachments) ? parsed.attachments : [],
customEmojis: Array.isArray(parsed.customEmojis)
? parsed.customEmojis
: [],
author: parsed.author as RichMessageMetadata["author"],
member: (parsed.member ?? null) as RichMessageMetadata["member"],
channel: parsed.channel as RichMessageMetadata["channel"],
reference: (parsed.reference ?? null) as RichMessageMetadata["reference"],
};
} catch {
return null;
}
}
export function isAgeRestrictedMetadata(
metadata: string | null | undefined,
): boolean {
const parsed = parseRichMessageMetadata(metadata);
if (!parsed) return false;
const nsfwLevel = parsed.channel.nsfwLevel?.toUpperCase();
return Boolean(
parsed.channel.nsfw ||
parsed.channel.ageRestricted ||
nsfwLevel === "AGE_RESTRICTED",
);
}
export function extractMessageMediaEvidence(
metadata: string | null | undefined,
): MessageMediaEvidence {
const parsed = parseRichMessageMetadata(metadata);
return {
stickers: parsed?.stickers ?? [],
embeds: parsed?.embeds ?? [],
attachments: parsed?.attachments ?? [],
customEmojis: parsed?.customEmojis ?? [],
};
}
export function formatMediaEvidenceForPrompt(
metadata: string | null | undefined,
): string {
const evidence = extractMessageMediaEvidence(metadata);
const parts: string[] = [];
if (evidence.stickers.length > 0) {
parts.push(
`[stickers: ${evidence.stickers
.map((sticker) =>
[`name=${sticker.name}`, sticker.url ? `url=${sticker.url}` : null]
.filter(Boolean)
.join(", "),
)
.join(" | ")}]`,
);
}
if (evidence.embeds.length > 0) {
parts.push(
`[embeds: ${evidence.embeds
.map((embed) =>
[
embed.title ? `title=${embed.title}` : null,
embed.description ? `description=${embed.description}` : null,
embed.url ? `url=${embed.url}` : null,
embed.image ? `image=${embed.image}` : null,
embed.thumbnail ? `thumbnail=${embed.thumbnail}` : null,
embed.fields.length > 0
? `fields=${embed.fields.map((field) => `${field.name}: ${field.value}`).join("; ")}`
: null,
]
.filter(Boolean)
.join(", "),
)
.join(" | ")}]`,
);
}
if (evidence.attachments.length > 0) {
parts.push(
`[attachments: ${evidence.attachments
.map((attachment) =>
[
`name=${attachment.name}`,
attachment.contentType ? `type=${attachment.contentType}` : null,
`size=${attachment.size}`,
attachment.url ? `url=${attachment.url}` : null,
]
.filter(Boolean)
.join(", "),
)
.join(" | ")}]`,
);
}
return parts.join(" ");
}
export function getDisplayContent(message: Message): string {
if (message.content.trim().length > 0) return message.content;
const stickers = getStickerMetadata(message);
if (stickers.length > 0) {
return stickers.map((sticker) => `[Sticker: ${sticker.name}]`).join(" ");
}
const attachments = getAttachmentMetadata(message);
if (attachments.length > 0) {
return attachments
.map((attachment) => `[Attachment: ${attachment.name}]`)
.join(" ");
}
const embeds = getEmbedMetadata(message);
if (embeds.length > 0) {
return embeds
.map((embed) => embed.title || embed.description || "[Embed]")
.join(" ");
}
return "";
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
export interface CursorData {
created_at: number;
id: string;
}
export function encodeCursor(data: CursorData): string {
return Buffer.from(JSON.stringify(data)).toString("base64");
}
export function decodeCursor(cursor?: string): CursorData | null {
if (!cursor) return null;
try {
const data = JSON.parse(Buffer.from(cursor, "base64").toString("utf-8"));
if (typeof data.created_at === "number" && typeof data.id === "string") {
return data;
}
return null;
} catch {
return null;
}
}
@@ -0,0 +1,267 @@
import type fs from "node:fs";
import type prism from "prism-media";
export type AIStatus = "pending" | "clean" | "warn" | "flagged" | "error";
export type AISeverity = "none" | "low" | "medium" | "high" | "critical";
export type AIRecommendedAction =
| "none"
| "monitor"
| "warn"
| "review"
| "delete"
| "escalate";
export interface BroadcasterClient {
messageCreated: (data: unknown) => void;
messageUpdated: (data: unknown) => void;
messageDeleted: (data: unknown) => void;
messageAnalyzed: (data: unknown) => void;
attachmentCreated: (data: unknown) => void;
attachmentUploaded: (data: unknown) => void;
voiceRecordingStarted: (data: unknown) => void;
voiceRecordingStopped: (data: unknown) => void;
voiceRecordingUploaded: (data: unknown) => void;
analysisQueueStatus: (data: unknown) => void;
}
export type ModerationBroadcaster = BroadcasterClient;
export interface RoleMetadata {
id: string;
name: string;
position: number;
}
export interface UserMetadata {
userId: string;
username: string;
tag: string;
displayName: string;
avatarUrl: string;
bot: boolean;
roles: RoleMetadata[];
highestRole: RoleMetadata | null;
joinedTimestamp: number | null;
}
export interface SegmentState {
index: number;
startTime: number;
endTime: number | null;
filename: string;
jsonFilename: string;
oggStream: prism.opus.OggLogicalBitstream;
out: fs.WriteStream;
}
export interface SegmentMetadata extends UserMetadata {
recordingSessionId: string;
sessionId: string;
sessionStartTime: number;
segmentIndex: number;
segmentMs: number;
startTime: number;
endTime: number;
durationMs: number;
filename: string;
}
export interface PcmBroadcaster {
broadcastPcmToWeb?: (chunk: Buffer, userId: string) => void;
updateActiveUser?: (
userId: string,
data: { username: string; avatar: string; speaking: boolean },
) => void;
}
export interface MessageRecord {
id: string;
guild_id: string;
channel_id: string;
thread_id: string | null;
user_id: string;
username: string;
avatar_url: string | null;
content: string;
edited_content: string | null;
created_at: number;
edited_at: number | null;
deleted_at: number | null;
type: "text" | "edited" | "deleted";
metadata: string | null;
ai_status?: AIStatus | null;
ai_moderation_flags?: string | null;
ai_moderation_score?: number | null;
ai_analysis?: string | null;
ai_categories?: string | null;
ai_severity?: AISeverity | null;
ai_confidence?: number | null;
ai_recommended_action?: AIRecommendedAction | null;
ai_analyzed_at?: number | null;
ai_error?: string | null;
}
export interface AttachmentRecord {
id: string;
message_id: string;
guild_id: string;
channel_id: string;
thread_id: string | null;
user_id: string;
filename: string;
size: number;
type: string;
discord_url: string;
uploaded_url: string | null;
upload_status: "pending" | "uploaded" | "failed";
upload_error: string | null;
created_at: number;
uploaded_at: number | null;
}
export interface VoiceSegmentRecord {
id: string;
user_id: string;
session_id: string;
guild_id: string;
channel_id: string;
filename: string;
duration_ms: number;
created_at: number;
}
export interface DashboardMessage {
id: string;
channel_id: string;
user_id: string;
username: string;
avatar_url: string | null;
content: string;
created_at: number;
type: "text" | "image" | "voice";
}
export interface MessageQuery {
guildId?: string;
channelId?: string;
threadId?: string;
status?: AIStatus[];
userId?: string;
q?: string;
cursor?: string;
limit: number;
}
export interface PageResult<T> {
data: T[];
nextCursor: string | null;
}
export interface AnalysisResult {
messageId: string;
status: Exclude<AIStatus, "pending">;
flags: string[];
score: number;
analysis: string;
categories?: string[];
severity?: AISeverity;
confidence?: number;
recommendedAction?: AIRecommendedAction;
policyVersion?: string;
evidence?: string[];
}
export type MediaMode = "music" | "screen";
export type MediaSourceKind =
| "url"
| "local"
| "youtube"
| "spotify"
| "search";
export type MediaQueueItemStatus = "queued" | "playing" | "failed";
export interface MediaQueueItem {
id: string;
mode: MediaMode;
source: string;
title: string;
kind: MediaSourceKind;
requestedBy: string;
addedAt: number;
status: MediaQueueItemStatus;
}
export interface MediaState {
playing: boolean;
musicVolume: number;
current: MediaQueueItem | null;
queue: MediaQueueItem[];
}
export type ModerationWsEvent =
| { type: "ui_state"; state: unknown }
| { type: "user_state"; users: unknown[] }
| { type: "message_created"; data: MessageRecord }
| { type: "message_updated"; data: Partial<MessageRecord> & { id: string } }
| { type: "message_deleted"; data: { id: string; deleted_at: number } }
| { type: "message_analyzed"; data: MessageRecord }
| { type: "attachment_created"; data: AttachmentRecord }
| { type: "analysis_queue_status"; data: AnalysisQueueStatus }
| { type: "media_state"; state: MediaState }
| { type: "voice_recording_uploaded"; data: any };
export interface AnalysisQueueStatus {
queuedConversations: number;
activeRequests: number;
activeIndividualRequests: number;
individualInFlightCount: number;
individualCircuitBreakerActive: boolean;
lastError: string | null;
}
export type ReviewStatus = "pending" | "approved" | "rejected" | "escalated";
export interface MessageReview {
id: string;
message_id: string;
guild_id: string;
channel_id: string;
reviewer_id: string | null;
status: ReviewStatus;
notes: string | null;
created_at: number;
reviewed_at: number | null;
}
export type ModerationActionType =
| "delete_message"
| "mute_user"
| "warn_user"
| "kick_user"
| "ban_user";
export interface ModerationAction {
id: string;
message_id: string | null;
user_id: string | null;
guild_id: string;
action_type: ModerationActionType;
reason: string | null;
executed_by: string | null;
status: "pending" | "executed" | "failed";
error: string | null;
created_at: number;
executed_at: number | null;
}
export interface RetentionPolicy {
id: string;
guild_id: string;
channel_id: string | null;
retention_days: number;
apply_to_media: boolean;
apply_to_voice: boolean;
enabled: boolean;
created_at: number;
updated_at: number;
}