2026-05-29 19:37:08 +07:00
|
|
|
import { and, asc, desc, eq, gte, isNull, or, type SQL } from "drizzle-orm";
|
|
|
|
|
import { getDatabase } from "../database/drizzle.js";
|
|
|
|
|
import { messagesTable } from "../database/schema.js";
|
|
|
|
|
import { createChildLogger } from "../logger.js";
|
|
|
|
|
import type { MessageRecord } from "./types.js";
|
|
|
|
|
|
|
|
|
|
const logger = createChildLogger("analytics-store");
|
|
|
|
|
|
|
|
|
|
// ── DB helper ──────────────────────────────────────────────────────────
|
|
|
|
|
function db() {
|
|
|
|
|
return getDatabase() as {
|
|
|
|
|
select(fields?: Record<string, unknown>): {
|
|
|
|
|
from(table: unknown): {
|
|
|
|
|
where(cond: SQL | undefined): {
|
|
|
|
|
orderBy(...cols: unknown[]): {
|
|
|
|
|
limit(n: number): Promise<unknown[]>;
|
|
|
|
|
} & Promise<unknown[]>;
|
|
|
|
|
groupBy(...cols: unknown[]): Promise<unknown[]>;
|
|
|
|
|
} & Promise<unknown[]>;
|
|
|
|
|
limit(n: number): Promise<unknown[]>;
|
|
|
|
|
} & Promise<unknown[]>;
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Shared condition helper ────────────────────────────────────────────
|
|
|
|
|
function channelFilter(channelId: string): SQL {
|
|
|
|
|
return or(
|
|
|
|
|
eq(messagesTable.channel_id, channelId),
|
|
|
|
|
eq(messagesTable.thread_id, channelId),
|
|
|
|
|
) as SQL;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Hourly Message Stats ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function getHourlyStats(input: {
|
|
|
|
|
guildId: string;
|
|
|
|
|
channelId?: string;
|
|
|
|
|
hours?: number;
|
|
|
|
|
}): Promise<HourlyBucket[]> {
|
|
|
|
|
try {
|
|
|
|
|
const { guildId, channelId, hours = 24 } = input;
|
|
|
|
|
const since = Date.now() - hours * 3600_000;
|
|
|
|
|
const database = db();
|
|
|
|
|
|
|
|
|
|
const conditions: SQL[] = [
|
|
|
|
|
eq(messagesTable.guild_id, guildId),
|
|
|
|
|
gte(messagesTable.created_at, since),
|
|
|
|
|
isNull(messagesTable.deleted_at),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if (channelId) {
|
|
|
|
|
conditions.push(channelFilter(channelId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rows = (await database
|
|
|
|
|
.select()
|
|
|
|
|
.from(messagesTable)
|
|
|
|
|
.where(and(...conditions) as SQL)
|
|
|
|
|
.orderBy(asc(messagesTable.created_at))) as MessageRecord[];
|
|
|
|
|
|
|
|
|
|
// Initialize all hour buckets
|
|
|
|
|
const buckets = new Map<
|
|
|
|
|
string,
|
2026-05-30 15:55:01 +07:00
|
|
|
{
|
|
|
|
|
count: number;
|
|
|
|
|
clean: number;
|
|
|
|
|
warned: number;
|
|
|
|
|
flagged: number;
|
|
|
|
|
error: number;
|
|
|
|
|
}
|
2026-05-29 19:37:08 +07:00
|
|
|
>();
|
|
|
|
|
|
|
|
|
|
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.created_at);
|
|
|
|
|
d.setMinutes(0, 0, 0);
|
|
|
|
|
const key = d.toISOString().slice(0, 13) + ":00:00Z";
|
|
|
|
|
|
|
|
|
|
const bucket = buckets.get(key);
|
|
|
|
|
if (!bucket) continue;
|
|
|
|
|
|
|
|
|
|
bucket.count++;
|
|
|
|
|
const status = row.ai_status || "pending";
|
|
|
|
|
if (status === "clean") bucket.clean++;
|
|
|
|
|
else if (status === "warn") bucket.warned++;
|
|
|
|
|
else if (status === "flagged") bucket.flagged++;
|
|
|
|
|
else if (status === "error") bucket.error++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Array.from(buckets.entries())
|
|
|
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
|
|
|
.map(([hour, data]) => ({ hour, ...data }));
|
|
|
|
|
} 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([
|
2026-05-30 15:55:01 +07:00
|
|
|
"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",
|
2026-05-29 19:37:08 +07:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-05-30 15:55:01 +07:00
|
|
|
const key =
|
|
|
|
|
typeof topic === "string" ? topic : topic.name || topic.topic;
|
2026-05-29 19:37:08 +07:00
|
|
|
if (!key) continue;
|
|
|
|
|
const k = key.toLowerCase();
|
|
|
|
|
const score = msg.ai_moderation_score || 0;
|
|
|
|
|
const existing = topicScores.get(k);
|
2026-05-30 15:55:01 +07:00
|
|
|
if (existing) {
|
|
|
|
|
existing.count++;
|
|
|
|
|
existing.score += score;
|
|
|
|
|
} else {
|
|
|
|
|
topicScores.set(k, { count: 1, score });
|
|
|
|
|
}
|
2026-05-29 19:37:08 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (analysis.category) {
|
|
|
|
|
const cat = String(analysis.category).toLowerCase();
|
|
|
|
|
const existing = topicScores.get(cat);
|
2026-05-30 15:55:01 +07:00
|
|
|
if (existing) {
|
|
|
|
|
existing.count++;
|
|
|
|
|
existing.score += msg.ai_moderation_score || 0;
|
|
|
|
|
} else {
|
|
|
|
|
topicScores.set(cat, {
|
|
|
|
|
count: 1,
|
|
|
|
|
score: msg.ai_moderation_score || 0,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-05-29 19:37:08 +07:00
|
|
|
}
|
2026-05-30 15:55:01 +07:00
|
|
|
} catch {
|
|
|
|
|
/* not valid JSON */
|
|
|
|
|
}
|
2026-05-29 19:37:08 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)) {
|
2026-05-30 15:55:01 +07:00
|
|
|
results.push({
|
|
|
|
|
topic: word,
|
|
|
|
|
count,
|
|
|
|
|
score: flaggedWordFreq.get(word) || 0,
|
|
|
|
|
});
|
2026-05-29 19:37:08 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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[]> {
|
|
|
|
|
try {
|
|
|
|
|
const { guildId, channelId, hours = 24 } = input;
|
|
|
|
|
const since = Date.now() - hours * 3600_000;
|
|
|
|
|
const database = db();
|
|
|
|
|
|
|
|
|
|
const conditions: SQL[] = [
|
|
|
|
|
eq(messagesTable.guild_id, guildId),
|
|
|
|
|
gte(messagesTable.created_at, since),
|
|
|
|
|
isNull(messagesTable.deleted_at),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if (channelId) {
|
|
|
|
|
conditions.push(channelFilter(channelId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rows = (await database
|
|
|
|
|
.select()
|
|
|
|
|
.from(messagesTable)
|
|
|
|
|
.where(and(...conditions) as SQL)
|
|
|
|
|
.orderBy(desc(messagesTable.created_at))
|
|
|
|
|
.limit(1000)) as MessageRecord[];
|
|
|
|
|
|
|
|
|
|
return extractTopics(rows);
|
|
|
|
|
} 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[]> {
|
|
|
|
|
try {
|
|
|
|
|
const { guildId, channelId, hours = 24, limit = 20 } = input;
|
|
|
|
|
const since = Date.now() - hours * 3600_000;
|
|
|
|
|
const database = db();
|
|
|
|
|
|
|
|
|
|
const conditions: SQL[] = [
|
|
|
|
|
eq(messagesTable.guild_id, guildId),
|
|
|
|
|
gte(messagesTable.created_at, since),
|
|
|
|
|
isNull(messagesTable.deleted_at),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if (channelId) {
|
|
|
|
|
conditions.push(channelFilter(channelId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rows = (await database
|
|
|
|
|
.select()
|
|
|
|
|
.from(messagesTable)
|
|
|
|
|
.where(and(...conditions) as SQL)
|
|
|
|
|
.orderBy(asc(messagesTable.created_at))) as MessageRecord[];
|
|
|
|
|
|
|
|
|
|
const userMap = new Map<string, UserStat>();
|
|
|
|
|
|
|
|
|
|
for (const msg of rows) {
|
|
|
|
|
const existing = userMap.get(msg.user_id);
|
|
|
|
|
if (existing) {
|
|
|
|
|
existing.message_count++;
|
|
|
|
|
if (msg.type === "edited") existing.edited_count++;
|
|
|
|
|
if (msg.type === "deleted") existing.deleted_count++;
|
2026-05-30 15:55:01 +07:00
|
|
|
if (msg.ai_status === "flagged" || msg.ai_status === "warn")
|
|
|
|
|
existing.flagged_count++;
|
2026-05-29 19:37:08 +07:00
|
|
|
if (msg.created_at > existing.last_active) {
|
|
|
|
|
existing.last_active = msg.created_at;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
userMap.set(msg.user_id, {
|
|
|
|
|
user_id: msg.user_id,
|
|
|
|
|
username: msg.username,
|
|
|
|
|
avatar_url: msg.avatar_url,
|
|
|
|
|
message_count: 1,
|
|
|
|
|
edited_count: msg.type === "edited" ? 1 : 0,
|
|
|
|
|
deleted_count: msg.type === "deleted" ? 1 : 0,
|
2026-05-30 15:55:01 +07:00
|
|
|
flagged_count:
|
|
|
|
|
msg.ai_status === "flagged" || msg.ai_status === "warn" ? 1 : 0,
|
2026-05-29 19:37:08 +07:00
|
|
|
last_active: msg.created_at,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Array.from(userMap.values())
|
|
|
|
|
.sort((a, b) => b.message_count - a.message_count)
|
|
|
|
|
.slice(0, limit);
|
|
|
|
|
} 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> {
|
|
|
|
|
try {
|
|
|
|
|
const { guildId, channelId, hours = 24 } = input;
|
|
|
|
|
const since = Date.now() - hours * 3600_000;
|
|
|
|
|
const database = db();
|
|
|
|
|
|
|
|
|
|
const conditions: SQL[] = [
|
|
|
|
|
eq(messagesTable.guild_id, guildId),
|
|
|
|
|
gte(messagesTable.created_at, since),
|
|
|
|
|
isNull(messagesTable.deleted_at),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if (channelId) {
|
|
|
|
|
conditions.push(channelFilter(channelId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rows = (await database
|
|
|
|
|
.select()
|
|
|
|
|
.from(messagesTable)
|
|
|
|
|
.where(and(...conditions) as SQL)) as MessageRecord[];
|
|
|
|
|
|
|
|
|
|
const breakdown: ModerationBreakdown = {
|
|
|
|
|
total: rows.length,
|
2026-05-30 15:55:01 +07:00
|
|
|
clean: 0,
|
|
|
|
|
warned: 0,
|
|
|
|
|
flagged: 0,
|
|
|
|
|
error: 0,
|
|
|
|
|
pending: 0,
|
2026-05-29 19:37:08 +07:00
|
|
|
average_score: 0,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let scoreSum = 0;
|
|
|
|
|
let scoreCount = 0;
|
|
|
|
|
|
|
|
|
|
for (const msg of rows) {
|
|
|
|
|
const status = msg.ai_status || "pending";
|
|
|
|
|
if (status === "clean") breakdown.clean++;
|
|
|
|
|
else if (status === "warn") breakdown.warned++;
|
|
|
|
|
else if (status === "flagged") breakdown.flagged++;
|
|
|
|
|
else if (status === "error") breakdown.error++;
|
|
|
|
|
else breakdown.pending++;
|
|
|
|
|
|
|
|
|
|
if (msg.ai_moderation_score != null) {
|
|
|
|
|
scoreSum += msg.ai_moderation_score;
|
|
|
|
|
scoreCount++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
breakdown.average_score =
|
|
|
|
|
scoreCount > 0 ? Math.round((scoreSum / scoreCount) * 100) / 100 : 0;
|
|
|
|
|
|
|
|
|
|
return breakdown;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
{ error: error instanceof Error ? error.message : String(error) },
|
|
|
|
|
"Failed to get moderation stats",
|
|
|
|
|
);
|
|
|
|
|
return {
|
2026-05-30 15:55:01 +07:00
|
|
|
total: 0,
|
|
|
|
|
clean: 0,
|
|
|
|
|
warned: 0,
|
|
|
|
|
flagged: 0,
|
|
|
|
|
error: 0,
|
|
|
|
|
pending: 0,
|
|
|
|
|
average_score: 0,
|
2026-05-29 19:37:08 +07:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Active Channels Count ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function getActiveChannelCount(input: {
|
|
|
|
|
guildId: string;
|
|
|
|
|
hours?: number;
|
|
|
|
|
}): Promise<number> {
|
|
|
|
|
try {
|
|
|
|
|
const { guildId, hours = 24 } = input;
|
|
|
|
|
const since = Date.now() - hours * 3600_000;
|
|
|
|
|
const database = db();
|
|
|
|
|
|
|
|
|
|
const rows = (await database
|
|
|
|
|
.select({ channel_id: messagesTable.channel_id })
|
|
|
|
|
.from(messagesTable)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(messagesTable.guild_id, guildId),
|
|
|
|
|
gte(messagesTable.created_at, since),
|
|
|
|
|
isNull(messagesTable.deleted_at),
|
|
|
|
|
) as SQL,
|
|
|
|
|
)
|
|
|
|
|
.groupBy(messagesTable.channel_id)) as Array<{ channel_id: string }>;
|
|
|
|
|
|
|
|
|
|
return rows.length;
|
|
|
|
|
} 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; // weighted: flagged*3 + warned*1
|
2026-05-30 15:55:01 +07:00
|
|
|
worst_flags: string[]; // unique flag types
|
2026-05-29 19:37:08 +07:00
|
|
|
last_violation: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getTopViolators(input: {
|
|
|
|
|
guildId: string;
|
|
|
|
|
channelId?: string;
|
|
|
|
|
hours?: number;
|
|
|
|
|
limit?: number;
|
|
|
|
|
}): Promise<ViolatorStat[]> {
|
|
|
|
|
try {
|
|
|
|
|
const { guildId, channelId, hours = 24, limit = 20 } = input;
|
|
|
|
|
const since = Date.now() - hours * 3600_000;
|
|
|
|
|
const database = db();
|
|
|
|
|
|
|
|
|
|
const conditions: SQL[] = [
|
|
|
|
|
eq(messagesTable.guild_id, guildId),
|
|
|
|
|
gte(messagesTable.created_at, since),
|
|
|
|
|
isNull(messagesTable.deleted_at),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if (channelId) {
|
|
|
|
|
conditions.push(channelFilter(channelId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rows = (await database
|
|
|
|
|
.select()
|
|
|
|
|
.from(messagesTable)
|
|
|
|
|
.where(and(...conditions) as SQL)
|
|
|
|
|
.orderBy(asc(messagesTable.created_at))) as MessageRecord[];
|
|
|
|
|
|
2026-05-30 15:55:01 +07:00
|
|
|
const userMap = new Map<
|
|
|
|
|
string,
|
|
|
|
|
{
|
|
|
|
|
user_id: string;
|
|
|
|
|
username: string;
|
|
|
|
|
avatar_url: string | null;
|
|
|
|
|
total_messages: number;
|
|
|
|
|
flagged_count: number;
|
|
|
|
|
warned_count: number;
|
|
|
|
|
flags_set: Set<string>;
|
|
|
|
|
last_violation: number;
|
|
|
|
|
}
|
|
|
|
|
>();
|
2026-05-29 19:37:08 +07:00
|
|
|
|
|
|
|
|
for (const msg of rows) {
|
|
|
|
|
let entry = userMap.get(msg.user_id);
|
|
|
|
|
if (!entry) {
|
|
|
|
|
entry = {
|
|
|
|
|
user_id: msg.user_id,
|
|
|
|
|
username: msg.username,
|
|
|
|
|
avatar_url: msg.avatar_url,
|
|
|
|
|
total_messages: 0,
|
|
|
|
|
flagged_count: 0,
|
|
|
|
|
warned_count: 0,
|
|
|
|
|
flags_set: new Set(),
|
|
|
|
|
last_violation: 0,
|
|
|
|
|
};
|
|
|
|
|
userMap.set(msg.user_id, entry);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
entry.total_messages++;
|
|
|
|
|
|
2026-05-30 15:55:01 +07:00
|
|
|
const isViolation =
|
|
|
|
|
msg.ai_status === "flagged" || msg.ai_status === "warn";
|
2026-05-29 19:37:08 +07:00
|
|
|
|
|
|
|
|
if (msg.ai_status === "flagged") {
|
|
|
|
|
entry.flagged_count++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (msg.ai_status === "warn") {
|
|
|
|
|
entry.warned_count++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isViolation && msg.ai_moderation_flags) {
|
|
|
|
|
try {
|
|
|
|
|
const flags = JSON.parse(msg.ai_moderation_flags);
|
|
|
|
|
if (Array.isArray(flags)) {
|
|
|
|
|
for (const f of flags) entry.flags_set.add(String(f));
|
|
|
|
|
}
|
2026-05-30 15:55:01 +07:00
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
2026-05-29 19:37:08 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isViolation && msg.created_at > entry.last_violation) {
|
|
|
|
|
entry.last_violation = msg.created_at;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const violators: ViolatorStat[] = [];
|
|
|
|
|
|
|
|
|
|
for (const entry of userMap.values()) {
|
|
|
|
|
if (entry.flagged_count === 0 && entry.warned_count === 0) continue;
|
|
|
|
|
|
|
|
|
|
violators.push({
|
|
|
|
|
user_id: entry.user_id,
|
|
|
|
|
username: entry.username,
|
|
|
|
|
avatar_url: entry.avatar_url,
|
|
|
|
|
total_messages: entry.total_messages,
|
|
|
|
|
flagged_count: entry.flagged_count,
|
|
|
|
|
warned_count: entry.warned_count,
|
|
|
|
|
violation_score: entry.flagged_count * 3 + entry.warned_count * 1,
|
|
|
|
|
worst_flags: Array.from(entry.flags_set).slice(0, 5),
|
|
|
|
|
last_violation: entry.last_violation,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return violators
|
|
|
|
|
.sort((a, b) => b.violation_score - a.violation_score)
|
|
|
|
|
.slice(0, limit);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
{ error: error instanceof Error ? error.message : String(error) },
|
|
|
|
|
"Failed to get top violators",
|
|
|
|
|
);
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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;
|
|
|
|
|
|
2026-05-30 15:55:01 +07:00
|
|
|
const [messages, hourly, topics, topUsers, totalChannels] = await Promise.all(
|
|
|
|
|
[
|
|
|
|
|
getModerationStats(input),
|
|
|
|
|
getHourlyStats(input),
|
|
|
|
|
getTopicTrends(input),
|
|
|
|
|
getUserLeaderboard(input),
|
|
|
|
|
getActiveChannelCount({ guildId, hours }),
|
|
|
|
|
],
|
|
|
|
|
);
|
2026-05-29 19:37:08 +07:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
period: { start: since, end: now },
|
|
|
|
|
messages,
|
|
|
|
|
hourly,
|
|
|
|
|
topics,
|
|
|
|
|
top_users: topUsers,
|
|
|
|
|
active_users_count: topUsers.length,
|
|
|
|
|
total_channels: totalChannels,
|
|
|
|
|
};
|
|
|
|
|
}
|