refactor: comprehensive codebase cleanup and architecture hardening
- Sprint 1 (Quick Wins): Remove dead analytics modules, fix 4 unresolved imports, replace 3 console.warn with logger, remove mock-crc import - Sprint 2 (Architecture): Create MascotChatRepository, AnalysisRepository, 3 Zod schemas (mascot-chat, analysis, voice), deduplicate error classes, move 3 SQL queries from routes to repository - Sprint 3 (Complexity): Replace 7 any types with proper interfaces, extract 6 helpers from prepareMediaMessage (CC 85 -> ~15) - Sprint 4 (Config): Remove 22 dead env vars from .env, add 30 missing vars to .env.example, standardize naming Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d0d9e1669e
commit
4becf0d6f1
@@ -1,3 +1,4 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import express, {
|
||||
type Express,
|
||||
type NextFunction,
|
||||
@@ -6,7 +7,6 @@ import express, {
|
||||
} from "express";
|
||||
import helmet from "helmet";
|
||||
import { createAnalysisRouter } from "../modules/analysis/analysis.routes.js";
|
||||
import { createAnalyticsRouter } from "../modules/analytics/analytics.routes.js";
|
||||
import { createAuthRouter } from "../modules/auth/auth.routes.js";
|
||||
import { createConfigRouter } from "../modules/config/config.routes.js";
|
||||
import { createHealthRouter } from "../modules/health/health.routes.js";
|
||||
@@ -15,9 +15,8 @@ import { createMediaRouter } from "../modules/media/media.routes.js";
|
||||
import { createMessagesRouter } from "../modules/messages/messages.routes.js";
|
||||
import { createRecordingsRouter } from "../modules/recordings/recordings.routes.js";
|
||||
import { createUiStateRouter } from "../modules/ui-state/ui-state.routes.js";
|
||||
import { createVoiceRouter } from "../modules/voice/voice.routes.js";
|
||||
import { createGuildsRouter } from "../modules/voice/guilds.routes.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { createVoiceRouter } from "../modules/voice/voice.routes.js";
|
||||
import { errorHandler } from "../shared/middlewares/index.js";
|
||||
|
||||
const logger = createChildLogger("http.app");
|
||||
@@ -66,7 +65,6 @@ export function createHttpApp(): Express {
|
||||
app.use("/api", createConfigRouter());
|
||||
app.use("/api", createMessagesRouter());
|
||||
app.use("/api", createAnalysisRouter());
|
||||
app.use("/api", createAnalyticsRouter());
|
||||
app.use("/api", createMascotChatRouter());
|
||||
app.use("/api", createMediaRouter());
|
||||
app.use("/api", createVoiceRouter());
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { config } from "../shared/config/index.js";
|
||||
import { initializeDatabase } from "../shared/database/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { createHttpApp } from "./app.js";
|
||||
import { createWebSocketServer } from "../ws/server.js";
|
||||
import { startRedisBridge } from "../ws/redis-bridge.js";
|
||||
import { createWebSocketServer } from "../ws/server.js";
|
||||
import { createHttpApp } from "./app.js";
|
||||
|
||||
const logger = createChildLogger("http.server");
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { startHttpServer } from "./http/server.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { Server } from "node:http";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { startHttpServer } from "./http/server.js";
|
||||
|
||||
const logger = createChildLogger("backend");
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { getPool } from "../../shared/database/index.js";
|
||||
|
||||
const logger = createChildLogger("analysis.repository");
|
||||
|
||||
export interface AnalysisSearchQuery {
|
||||
q?: string;
|
||||
channelId?: string;
|
||||
guildId?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AnalysisSearchResult {
|
||||
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: string;
|
||||
metadata: string | null;
|
||||
ai_status: string | null;
|
||||
ai_moderation_flags: string | null;
|
||||
ai_moderation_score: number | null;
|
||||
ai_analysis: string | null;
|
||||
ai_categories: string | null;
|
||||
ai_severity: string | null;
|
||||
ai_confidence: number | null;
|
||||
ai_recommended_action: string | null;
|
||||
ai_analyzed_at: number | null;
|
||||
ai_error: string | null;
|
||||
}
|
||||
|
||||
function mapSearchResult(row: Record<string, unknown>): AnalysisSearchResult {
|
||||
return {
|
||||
id: String(row.id ?? ""),
|
||||
guild_id: String(row.guild_id ?? ""),
|
||||
channel_id: String(row.channel_id ?? ""),
|
||||
thread_id: (row.thread_id as string | null) ?? null,
|
||||
user_id: String(row.user_id ?? ""),
|
||||
username: String(row.username ?? ""),
|
||||
avatar_url: (row.avatar_url as string | null) ?? null,
|
||||
content: String(row.content ?? ""),
|
||||
edited_content: (row.edited_content as string | null) ?? null,
|
||||
created_at: Number(row.created_at ?? 0),
|
||||
edited_at: (row.edited_at as number | null) ?? null,
|
||||
deleted_at: (row.deleted_at as number | null) ?? null,
|
||||
type: String(row.type ?? "text"),
|
||||
metadata: (row.metadata as string | null) ?? null,
|
||||
ai_status: (row.ai_status as string | null) ?? null,
|
||||
ai_moderation_flags: (row.ai_moderation_flags as string | null) ?? null,
|
||||
ai_moderation_score: (row.ai_moderation_score as number | null) ?? null,
|
||||
ai_analysis: (row.ai_analysis as string | null) ?? null,
|
||||
ai_categories: (row.ai_categories as string | null) ?? null,
|
||||
ai_severity: (row.ai_severity as string | null) ?? null,
|
||||
ai_confidence: (row.ai_confidence as number | null) ?? null,
|
||||
ai_recommended_action: (row.ai_recommended_action as string | null) ?? null,
|
||||
ai_analyzed_at: (row.ai_analyzed_at as number | null) ?? null,
|
||||
ai_error: (row.ai_error as string | null) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export class AnalysisRepository {
|
||||
async search(query: AnalysisSearchQuery): Promise<AnalysisSearchResult[]> {
|
||||
const pool = getPool();
|
||||
const { q = "", channelId, guildId, limit = 20 } = query;
|
||||
|
||||
logger.debug({ q, channelId, guildId, limit }, "Searching analysis");
|
||||
|
||||
const searchPattern = `%${q}%`;
|
||||
const clauses: string[] = ["content ILIKE $1"];
|
||||
const params: (string | number)[] = [searchPattern];
|
||||
let p = 2;
|
||||
|
||||
if (guildId) {
|
||||
clauses.push(`guild_id = $${p}`);
|
||||
params.push(guildId);
|
||||
p++;
|
||||
}
|
||||
|
||||
if (channelId) {
|
||||
clauses.push(`channel_id = $${p}`);
|
||||
params.push(channelId);
|
||||
p++;
|
||||
}
|
||||
|
||||
const where = clauses.join(" AND ");
|
||||
const { rows } = await pool.query(
|
||||
`SELECT
|
||||
id, guild_id, channel_id, thread_id,
|
||||
user_id, username, avatar_url,
|
||||
content, edited_content, created_at, edited_at, deleted_at,
|
||||
type, metadata,
|
||||
ai_status, ai_moderation_flags, ai_moderation_score,
|
||||
ai_analysis, ai_categories, ai_severity, ai_confidence,
|
||||
ai_recommended_action, ai_analyzed_at, ai_error
|
||||
FROM messages
|
||||
WHERE ${where}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${p}`,
|
||||
[...params, limit],
|
||||
);
|
||||
|
||||
return rows.map((r) => mapSearchResult(r as Record<string, unknown>));
|
||||
}
|
||||
}
|
||||
|
||||
export const analysisRepository = new AnalysisRepository();
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import { analysisService } from "./analysis.service.js";
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const searchQuerySchema = z.object({
|
||||
q: z.string().default(""),
|
||||
channelId: z.string().optional(),
|
||||
guildId: z.string().optional(),
|
||||
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||
});
|
||||
|
||||
export type SearchQuery = z.infer<typeof searchQuerySchema>;
|
||||
@@ -1,78 +1,26 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { config } from "../../shared/config/index.js";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { config } from "../../shared/config/index.js";
|
||||
import type { AnalysisSearchQuery } from "./analysis.repository.js";
|
||||
import { analysisRepository } from "./analysis.repository.js";
|
||||
|
||||
const logger = createChildLogger("analysis.service");
|
||||
|
||||
export interface AnalysisSearchQuery {
|
||||
q?: string;
|
||||
channelId?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** Full message columns for search results — matches MessageRecord from client.ts */
|
||||
const FULL_COLUMNS = sql.raw(`
|
||||
id, guild_id, channel_id, thread_id,
|
||||
user_id, username, avatar_url,
|
||||
content, edited_content, created_at, edited_at, deleted_at,
|
||||
type, metadata,
|
||||
ai_status, ai_moderation_flags, ai_moderation_score,
|
||||
ai_analysis, ai_categories, ai_severity, ai_confidence,
|
||||
ai_recommended_action, ai_analyzed_at, ai_error
|
||||
`);
|
||||
export type { AnalysisSearchQuery };
|
||||
|
||||
export class AnalysisService {
|
||||
async search(query: AnalysisSearchQuery) {
|
||||
const db = getDatabase();
|
||||
const { q = "", channelId, limit = 20 } = query;
|
||||
const guildId = config.MONITOR_GUILD_ID;
|
||||
|
||||
logger.debug({ q, channelId, limit, guildId }, "Searching analysis");
|
||||
|
||||
const searchPattern = `%${q}%`;
|
||||
const limitVal = limit;
|
||||
const rows = await analysisRepository.search({
|
||||
q,
|
||||
channelId,
|
||||
guildId,
|
||||
limit,
|
||||
});
|
||||
|
||||
let sqlQuery;
|
||||
if (channelId && guildId) {
|
||||
sqlQuery = sql`
|
||||
SELECT ${FULL_COLUMNS}
|
||||
FROM messages
|
||||
WHERE guild_id = ${guildId}
|
||||
AND channel_id = ${channelId}
|
||||
AND content ILIKE ${searchPattern}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${limitVal}
|
||||
`;
|
||||
} else if (channelId) {
|
||||
sqlQuery = sql`
|
||||
SELECT ${FULL_COLUMNS}
|
||||
FROM messages
|
||||
WHERE channel_id = ${channelId}
|
||||
AND content ILIKE ${searchPattern}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${limitVal}
|
||||
`;
|
||||
} else if (guildId) {
|
||||
sqlQuery = sql`
|
||||
SELECT ${FULL_COLUMNS}
|
||||
FROM messages
|
||||
WHERE guild_id = ${guildId}
|
||||
AND content ILIKE ${searchPattern}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${limitVal}
|
||||
`;
|
||||
} else {
|
||||
sqlQuery = sql`
|
||||
SELECT ${FULL_COLUMNS}
|
||||
FROM messages
|
||||
WHERE content ILIKE ${searchPattern}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${limitVal}
|
||||
`;
|
||||
}
|
||||
|
||||
const { rows } = await db.execute(sqlQuery);
|
||||
return { results: rows };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { asyncHandler, requireParam } from "../../shared/middlewares/index.js";
|
||||
import { analyticsQuerySchema } from "./analytics.schema.js";
|
||||
import { analyticsService } from "./analytics.service.js";
|
||||
|
||||
const logger = createChildLogger("analytics.controller");
|
||||
|
||||
export function handleGetOverview(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const query = analyticsQuerySchema.parse(req.query);
|
||||
logger.debug({ query }, "Handling get overview");
|
||||
const result = await analyticsService.getOverview(query);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
export function handleGetDailyTrend(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const guildId = requireParam(
|
||||
req.query.guildId,
|
||||
"query parameter",
|
||||
"guildId",
|
||||
);
|
||||
const hours = req.query.hours ? Number(req.query.hours) : 24;
|
||||
logger.debug({ guildId, hours }, "Handling get daily trend");
|
||||
const result = await analyticsService.getDailyTrend(guildId, hours);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
export function handleGetHourlyStats(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const query = analyticsQuerySchema.parse(req.query);
|
||||
logger.debug({ query }, "Handling get hourly stats");
|
||||
const result = await analyticsService.getHourlyStats(
|
||||
query.guildId,
|
||||
query.channelId,
|
||||
query.hours,
|
||||
);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
export function handleGetTopViolators(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const query = analyticsQuerySchema.parse(req.query);
|
||||
const limit = req.query.limit ? Number(req.query.limit) : 10;
|
||||
logger.debug({ query, limit }, "Handling get top violators");
|
||||
const result = await analyticsService.getTopViolators(
|
||||
query.guildId,
|
||||
query.channelId,
|
||||
query.hours,
|
||||
limit,
|
||||
);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
export function handleGetUserLeaderboard(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const query = analyticsQuerySchema.parse(req.query);
|
||||
const limit = req.query.limit ? Number(req.query.limit) : 10;
|
||||
logger.debug({ query, limit }, "Handling get user leaderboard");
|
||||
const result = await analyticsService.getUserLeaderboard(
|
||||
query.guildId,
|
||||
query.channelId,
|
||||
query.hours,
|
||||
limit,
|
||||
);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
export function handleGetModerationStats(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const query = analyticsQuerySchema.parse(req.query);
|
||||
logger.debug({ query }, "Handling get moderation stats");
|
||||
const result = await analyticsService.getModerationStats(
|
||||
query.guildId,
|
||||
query.channelId,
|
||||
query.hours,
|
||||
);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
export function handleGetHeatmap(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const query = analyticsQuerySchema.parse(req.query);
|
||||
logger.debug({ query }, "Handling get heatmap");
|
||||
const result = await analyticsService.getHeatmap(
|
||||
query.guildId,
|
||||
query.channelId,
|
||||
query.hours,
|
||||
);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
export function handleGetTopics(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const query = analyticsQuerySchema.parse(req.query);
|
||||
logger.debug({ query }, "Handling get topics");
|
||||
const result = await analyticsService.getTopics(
|
||||
query.guildId,
|
||||
query.channelId,
|
||||
query.hours,
|
||||
);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
export function handleGetModerationActions(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const query = analyticsQuerySchema.parse(req.query);
|
||||
const limit = req.query.limit ? Number(req.query.limit) : 20;
|
||||
logger.debug({ query, limit }, "Handling get moderation actions");
|
||||
const result = await analyticsService.getModerationActions(
|
||||
query.guildId,
|
||||
query.channelId,
|
||||
query.hours,
|
||||
limit,
|
||||
);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
export function handleGetAIStats(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const query = analyticsQuerySchema.parse(req.query);
|
||||
logger.debug({ query }, "Handling get AI stats");
|
||||
const result = await analyticsService.getAIStats(
|
||||
query.guildId,
|
||||
query.channelId,
|
||||
query.hours,
|
||||
);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
export function handleGetAttachmentStats(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const query = analyticsQuerySchema.parse(req.query);
|
||||
logger.debug({ query }, "Handling get attachment stats");
|
||||
const result = await analyticsService.getAttachmentStats(
|
||||
query.guildId,
|
||||
query.channelId,
|
||||
query.hours,
|
||||
);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
@@ -1,551 +0,0 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { getPool } from "../../shared/database/index.js";
|
||||
|
||||
const logger = createChildLogger("analytics.repository");
|
||||
|
||||
interface TimeFilter {
|
||||
where: string;
|
||||
params: Array<string | number>;
|
||||
paramOffset: number;
|
||||
}
|
||||
|
||||
function buildTimeFilter(
|
||||
guildId: string,
|
||||
channelId: string | undefined,
|
||||
hours: number,
|
||||
offset = 1,
|
||||
): TimeFilter {
|
||||
const clauses: string[] = ["guild_id = $" + offset];
|
||||
const params: Array<string | number> = [guildId];
|
||||
let p = offset + 1;
|
||||
|
||||
if (channelId) {
|
||||
clauses.push("channel_id = $" + p);
|
||||
params.push(channelId);
|
||||
p++;
|
||||
}
|
||||
|
||||
clauses.push("created_at > (EXTRACT(EPOCH FROM NOW()) * 1000 - $" + p + ")");
|
||||
params.push(hours * 3_600_000);
|
||||
|
||||
return { where: "WHERE " + clauses.join(" AND "), params, paramOffset: p };
|
||||
}
|
||||
|
||||
export class AnalyticsRepository {
|
||||
async getOverview(guildId: string, channelId?: string, hours = 24) {
|
||||
logger.debug({ guildId, channelId, hours }, "Getting analytics overview");
|
||||
const pool = getPool();
|
||||
const now = Date.now();
|
||||
const start = now - hours * 3_600_000;
|
||||
const filter = buildTimeFilter(guildId, channelId, hours);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
SELECT
|
||||
COUNT(*)::int AS total_messages,
|
||||
COUNT(DISTINCT user_id)::int AS active_users_count,
|
||||
COUNT(DISTINCT channel_id)::int AS total_channels,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warned,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'error')::int AS error,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'pending')::int AS pending,
|
||||
COALESCE(AVG(ai_moderation_score), 0)::real AS average_score
|
||||
FROM messages
|
||||
${filter.where}
|
||||
`,
|
||||
filter.params,
|
||||
);
|
||||
|
||||
const row = rows[0] as Record<string, unknown> | undefined;
|
||||
|
||||
// Fetch hourly stats, topics, and top violators to include in overview
|
||||
const hourly = await this.getHourlyStats(guildId, channelId, hours);
|
||||
const topics = await this.getTopics(guildId, channelId, hours);
|
||||
const topUsers = await this.getTopViolators(guildId, channelId, hours, 5);
|
||||
|
||||
return {
|
||||
period: { start, end: now },
|
||||
messages: {
|
||||
total: Number(row?.total_messages ?? 0),
|
||||
clean: Number(row?.clean ?? 0),
|
||||
warned: Number(row?.warned ?? 0),
|
||||
flagged: Number(row?.flagged ?? 0),
|
||||
error: Number(row?.error ?? 0),
|
||||
pending: Number(row?.pending ?? 0),
|
||||
average_score: Number(row?.average_score ?? 0),
|
||||
},
|
||||
hourly,
|
||||
topics,
|
||||
top_users: topUsers,
|
||||
active_users_count: Number(row?.active_users_count ?? 0),
|
||||
total_channels: Number(row?.total_channels ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async getDailyTrend(guildId: string, hours = 24) {
|
||||
logger.debug({ guildId, hours }, "Getting daily trend");
|
||||
const pool = getPool();
|
||||
const filter = buildTimeFilter(guildId, undefined, hours);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
SELECT
|
||||
TO_CHAR(to_timestamp(created_at / 1000), 'YYYY-MM-DD') AS date,
|
||||
COUNT(*)::int AS count,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warned,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'error')::int AS error
|
||||
FROM messages
|
||||
${filter.where}
|
||||
GROUP BY date
|
||||
ORDER BY date ASC
|
||||
`,
|
||||
filter.params,
|
||||
);
|
||||
|
||||
return rows.map((r) => ({
|
||||
date: r.date as string,
|
||||
count: Number(r.count ?? 0),
|
||||
clean: Number(r.clean ?? 0),
|
||||
warned: Number(r.warned ?? 0),
|
||||
flagged: Number(r.flagged ?? 0),
|
||||
error: Number(r.error ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
async getHourlyStats(guildId: string, channelId?: string, hours = 24) {
|
||||
logger.debug({ guildId, channelId, hours }, "Getting hourly stats");
|
||||
const pool = getPool();
|
||||
const filter = buildTimeFilter(guildId, channelId, hours);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
SELECT
|
||||
TO_CHAR(to_timestamp(created_at / 1000), 'HH24') AS hour,
|
||||
COUNT(*)::int AS count,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warned,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'error')::int AS error
|
||||
FROM messages
|
||||
${filter.where}
|
||||
GROUP BY hour
|
||||
ORDER BY hour ASC
|
||||
`,
|
||||
filter.params,
|
||||
);
|
||||
|
||||
return rows.map((r) => ({
|
||||
hour: r.hour as string,
|
||||
count: Number(r.count ?? 0),
|
||||
clean: Number(r.clean ?? 0),
|
||||
warned: Number(r.warned ?? 0),
|
||||
flagged: Number(r.flagged ?? 0),
|
||||
error: Number(r.error ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
async getTopViolators(
|
||||
guildId: string,
|
||||
channelId?: string,
|
||||
hours = 24,
|
||||
limit = 10,
|
||||
) {
|
||||
logger.debug({ guildId, channelId, hours, limit }, "Getting top violators");
|
||||
const pool = getPool();
|
||||
const filter = buildTimeFilter(guildId, channelId, hours);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
SELECT
|
||||
user_id,
|
||||
MAX(username) AS username,
|
||||
MAX(avatar_url) AS avatar_url,
|
||||
COUNT(*)::int AS total_messages,
|
||||
COUNT(*) FILTER (WHERE ai_status IN ('warn', 'flagged', 'error'))::int AS flagged_count,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warned_count,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS hard_flagged_count,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'error')::int AS error_count,
|
||||
COALESCE(AVG(ai_moderation_score), 0)::real AS violation_score,
|
||||
MAX(ai_moderation_flags) AS worst_flags,
|
||||
MAX(created_at) AS last_violation
|
||||
FROM messages
|
||||
${filter.where}
|
||||
AND ai_status IN ('warn', 'flagged', 'error')
|
||||
GROUP BY user_id
|
||||
ORDER BY flagged_count DESC
|
||||
LIMIT $${filter.params.length + 1}
|
||||
`,
|
||||
[...filter.params, limit],
|
||||
);
|
||||
|
||||
return rows.map((r) => ({
|
||||
user_id: r.user_id as string,
|
||||
username: (r.username as string) ?? "",
|
||||
avatar_url: (r.avatar_url as string | null) ?? null,
|
||||
total_messages: Number(r.total_messages ?? 0),
|
||||
flagged_count: Number(r.flagged_count ?? 0),
|
||||
warned_count: Number(r.warned_count ?? 0),
|
||||
violation_score: Number(r.violation_score ?? 0),
|
||||
worst_flags: (r.worst_flags as string | null)
|
||||
? (r.worst_flags as string)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
last_violation: Number(r.last_violation ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
async getUserLeaderboard(
|
||||
guildId: string,
|
||||
channelId?: string,
|
||||
hours = 24,
|
||||
limit = 10,
|
||||
) {
|
||||
logger.debug(
|
||||
{ guildId, channelId, hours, limit },
|
||||
"Getting user leaderboard",
|
||||
);
|
||||
const pool = getPool();
|
||||
const filter = buildTimeFilter(guildId, channelId, hours);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
SELECT
|
||||
user_id,
|
||||
MAX(username) AS username,
|
||||
MAX(avatar_url) AS avatar_url,
|
||||
COUNT(*)::int AS message_count,
|
||||
COUNT(*) FILTER (WHERE type = 'edited')::int AS edited_count,
|
||||
COUNT(*) FILTER (WHERE type = 'deleted')::int AS deleted_count,
|
||||
COUNT(*) FILTER (WHERE ai_status IN ('warn', 'flagged', 'error'))::int AS flagged_count,
|
||||
MAX(created_at) AS last_active
|
||||
FROM messages
|
||||
${filter.where}
|
||||
GROUP BY user_id
|
||||
ORDER BY message_count DESC
|
||||
LIMIT $${filter.params.length + 1}
|
||||
`,
|
||||
[...filter.params, limit],
|
||||
);
|
||||
|
||||
return rows.map((r) => ({
|
||||
user_id: r.user_id as string,
|
||||
username: (r.username as string) ?? "",
|
||||
avatar_url: (r.avatar_url as string | null) ?? null,
|
||||
message_count: Number(r.message_count ?? 0),
|
||||
edited_count: Number(r.edited_count ?? 0),
|
||||
deleted_count: Number(r.deleted_count ?? 0),
|
||||
flagged_count: Number(r.flagged_count ?? 0),
|
||||
last_active: Number(r.last_active ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
async getModerationStats(guildId: string, channelId?: string, hours = 24) {
|
||||
logger.debug({ guildId, channelId, hours }, "Getting moderation stats");
|
||||
const pool = getPool();
|
||||
const filter = buildTimeFilter(guildId, channelId, hours);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
SELECT
|
||||
COUNT(*)::int AS total,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warned,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'error')::int AS error,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'pending')::int AS pending,
|
||||
COALESCE(AVG(ai_moderation_score), 0)::real AS average_score
|
||||
FROM messages
|
||||
${filter.where}
|
||||
`,
|
||||
filter.params,
|
||||
);
|
||||
|
||||
const row = rows[0] as Record<string, unknown> | undefined;
|
||||
|
||||
return {
|
||||
total: Number(row?.total ?? 0),
|
||||
clean: Number(row?.clean ?? 0),
|
||||
warned: Number(row?.warned ?? 0),
|
||||
flagged: Number(row?.flagged ?? 0),
|
||||
error: Number(row?.error ?? 0),
|
||||
pending: Number(row?.pending ?? 0),
|
||||
average_score: Number(row?.average_score ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async getHeatmap(guildId: string, channelId?: string, hours = 24) {
|
||||
logger.debug({ guildId, channelId, hours }, "Getting heatmap data");
|
||||
const pool = getPool();
|
||||
const filter = buildTimeFilter(guildId, channelId, hours);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
SELECT
|
||||
EXTRACT(DOW FROM to_timestamp(created_at / 1000))::int AS day_of_week,
|
||||
EXTRACT(HOUR FROM to_timestamp(created_at / 1000))::int AS hour,
|
||||
COUNT(*)::int AS count,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warned,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged
|
||||
FROM messages
|
||||
${filter.where}
|
||||
GROUP BY day_of_week, hour
|
||||
ORDER BY day_of_week, hour
|
||||
`,
|
||||
filter.params,
|
||||
);
|
||||
|
||||
return rows.map((r) => ({
|
||||
dayOfWeek: Number(r.day_of_week ?? 0),
|
||||
hour: Number(r.hour ?? 0),
|
||||
count: Number(r.count ?? 0),
|
||||
clean: Number(r.clean ?? 0),
|
||||
warned: Number(r.warned ?? 0),
|
||||
flagged: Number(r.flagged ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
async getTopics(guildId: string, channelId?: string, hours = 24) {
|
||||
logger.debug({ guildId, channelId, hours }, "Getting topics");
|
||||
const pool = getPool();
|
||||
const filter = buildTimeFilter(guildId, channelId, hours);
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
WITH word_list AS (
|
||||
SELECT
|
||||
LOWER(TRIM(BOTH '.,!?;:\"()[]{}' FROM word)) AS word,
|
||||
ai_moderation_score
|
||||
FROM messages,
|
||||
LATERAL UNNEST(STRING_TO_ARRAY(content, ' ')) AS word
|
||||
${filter.where}
|
||||
AND content IS NOT NULL AND content != ''
|
||||
AND LENGTH(TRIM(BOTH '.,!?;:\"()[]{}' FROM word)) >= 4
|
||||
AND word !~ '^<.+:\d+>$'
|
||||
AND word !~ '^\['
|
||||
AND word !~ '^https?://'
|
||||
AND word !~ '^discord\.'
|
||||
AND word !~ '^cdn\.'
|
||||
)
|
||||
SELECT
|
||||
word AS topic,
|
||||
COUNT(*)::int AS count,
|
||||
COALESCE(AVG(ai_moderation_score), 0)::real AS score
|
||||
FROM word_list
|
||||
WHERE word NOT IN (
|
||||
'yang','dan','di','ke','dari','dengan','untuk','pada','ini','itu',
|
||||
'ada','akan','telah','sudah','bisa','dapat','tidak','nggak','enggak',
|
||||
'gak','gk','ga','aku','saya','kamu','dia','kami','kita','mereka',
|
||||
'iya','ya','yah','oh','ah','eh','lah','pun','juga','masih',
|
||||
'saja','hanya','sama','atau','tapi','namun','sedang','sangat',
|
||||
'begitu','karena','sebab','kalau','jika','maka','lalu','setelah',
|
||||
'seperti','antara','oleh','sebagai','secara','melalui','dalam',
|
||||
'the','and','for','are','but','not','you','all','can','has',
|
||||
'was','were','been','like','just','that','this','with','your',
|
||||
'from','they','have','what','when','where','which','their',
|
||||
'about','would','could','should','very','also','than','then',
|
||||
'mau','lagi','jadi','aja','nya','apa','orang',
|
||||
'lihat','kak','bro','bang','mas','pack','sih','dong','kok',
|
||||
'nih','deh','kali','loh','lho','doang',
|
||||
'gue','lo','lu','gua','elo','ane','wkwk','wkwkwk',
|
||||
'wkwkwkwk','wkwkwkwkwk','haha','hahaha','hehe','wk','wkwk',
|
||||
'kalo','buat','udah','jir','kan','tuh','pake','dulu',
|
||||
'banget','kayak','kya','kyk','klo','karna','soalnya',
|
||||
'bikin','bilang','makan','minum','tidur','main','pergi','pulang',
|
||||
'mana','cuma','kah','udh','gitu','gini','gtu','gni',
|
||||
'mending','wkakak','wakak'
|
||||
)
|
||||
AND word NOT LIKE '%.jpg'
|
||||
AND word NOT LIKE '%.jpeg'
|
||||
AND word NOT LIKE '%.png'
|
||||
AND word NOT LIKE '%.gif'
|
||||
AND word NOT LIKE '%.webp'
|
||||
AND word NOT LIKE '%.mp4'
|
||||
AND word NOT LIKE '%.mp3'
|
||||
AND word NOT LIKE '%.pdf'
|
||||
AND word NOT LIKE '%.zip'
|
||||
GROUP BY word
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT 10
|
||||
`,
|
||||
filter.params,
|
||||
);
|
||||
|
||||
return rows.map((r) => ({
|
||||
topic: (r.topic as string) ?? "",
|
||||
count: Number(r.count ?? 0),
|
||||
score: Number(r.score ?? 0),
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error), guildId },
|
||||
"getTopics query failed — returning empty",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── New endpoints ─────────────────────────────────────────────────────────
|
||||
|
||||
async getModerationActions(
|
||||
guildId: string,
|
||||
channelId?: string,
|
||||
hours = 24,
|
||||
limit = 20,
|
||||
) {
|
||||
logger.debug(
|
||||
{ guildId, channelId, hours, limit },
|
||||
"Getting moderation actions",
|
||||
);
|
||||
const pool = getPool();
|
||||
const filter = buildTimeFilter(guildId, channelId, hours);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
SELECT
|
||||
ma.id,
|
||||
ma.message_id,
|
||||
ma.user_id,
|
||||
ma.guild_id,
|
||||
ma.action_type,
|
||||
ma.reason,
|
||||
ma.executed_by,
|
||||
ma.status,
|
||||
ma.error,
|
||||
ma.created_at,
|
||||
ma.executed_at,
|
||||
m.username,
|
||||
m.content
|
||||
FROM moderation_actions ma
|
||||
LEFT JOIN messages m ON m.id = ma.message_id
|
||||
${filter.where.replace("guild_id", "ma.guild_id").replace("created_at", "ma.created_at")}
|
||||
ORDER BY ma.created_at DESC
|
||||
LIMIT $${filter.params.length + 1}
|
||||
`,
|
||||
[...filter.params, limit],
|
||||
);
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id as string,
|
||||
message_id: (r.message_id as string) ?? null,
|
||||
user_id: r.user_id as string,
|
||||
guild_id: r.guild_id as string,
|
||||
action_type: r.action_type as string,
|
||||
reason: (r.reason as string) ?? null,
|
||||
executed_by: (r.executed_by as string) ?? null,
|
||||
status: r.status as string,
|
||||
error: (r.error as string) ?? null,
|
||||
created_at: Number(r.created_at ?? 0),
|
||||
executed_at: r.executed_at ? Number(r.executed_at) : null,
|
||||
username: (r.username as string) ?? "Unknown",
|
||||
content: (r.content as string) ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async getAIStats(guildId: string, channelId?: string, hours = 24) {
|
||||
logger.debug({ guildId, channelId, hours }, "Getting AI stats");
|
||||
const pool = getPool();
|
||||
const filter = buildTimeFilter(guildId, channelId, hours);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
SELECT
|
||||
COUNT(*)::int AS total_analyzed,
|
||||
COUNT(*) FILTER (WHERE ai_severity = 'none')::int AS severity_none,
|
||||
COUNT(*) FILTER (WHERE ai_severity = 'low')::int AS severity_low,
|
||||
COUNT(*) FILTER (WHERE ai_severity = 'medium')::int AS severity_medium,
|
||||
COUNT(*) FILTER (WHERE ai_severity = 'high')::int AS severity_high,
|
||||
COUNT(*) FILTER (WHERE ai_severity = 'critical')::int AS severity_critical,
|
||||
COUNT(*) FILTER (WHERE ai_recommended_action = 'none')::int AS action_none,
|
||||
COUNT(*) FILTER (WHERE ai_recommended_action = 'monitor')::int AS action_monitor,
|
||||
COUNT(*) FILTER (WHERE ai_recommended_action = 'warn')::int AS action_warn,
|
||||
COUNT(*) FILTER (WHERE ai_recommended_action = 'review')::int AS action_review,
|
||||
COUNT(*) FILTER (WHERE ai_recommended_action = 'delete')::int AS action_delete,
|
||||
COUNT(*) FILTER (WHERE ai_recommended_action = 'escalate')::int AS action_escalate,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'error')::int AS analysis_errors,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'pending')::int AS analysis_pending,
|
||||
COALESCE(AVG(ai_confidence), 0)::real AS avg_confidence,
|
||||
COALESCE(AVG(ai_moderation_score), 0)::real AS avg_score
|
||||
FROM messages
|
||||
${filter.where}
|
||||
`,
|
||||
filter.params,
|
||||
);
|
||||
|
||||
const row = rows[0] as Record<string, unknown> | undefined;
|
||||
|
||||
return {
|
||||
total_analyzed: Number(row?.total_analyzed ?? 0),
|
||||
severity: {
|
||||
none: Number(row?.severity_none ?? 0),
|
||||
low: Number(row?.severity_low ?? 0),
|
||||
medium: Number(row?.severity_medium ?? 0),
|
||||
high: Number(row?.severity_high ?? 0),
|
||||
critical: Number(row?.severity_critical ?? 0),
|
||||
},
|
||||
recommended_actions: {
|
||||
none: Number(row?.action_none ?? 0),
|
||||
monitor: Number(row?.action_monitor ?? 0),
|
||||
warn: Number(row?.action_warn ?? 0),
|
||||
review: Number(row?.action_review ?? 0),
|
||||
delete: Number(row?.action_delete ?? 0),
|
||||
escalate: Number(row?.action_escalate ?? 0),
|
||||
},
|
||||
analysis_errors: Number(row?.analysis_errors ?? 0),
|
||||
analysis_pending: Number(row?.analysis_pending ?? 0),
|
||||
avg_confidence: Number(row?.avg_confidence ?? 0),
|
||||
avg_score: Number(row?.avg_score ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async getAttachmentStats(guildId: string, channelId?: string, hours = 24) {
|
||||
logger.debug({ guildId, channelId, hours }, "Getting attachment stats");
|
||||
const pool = getPool();
|
||||
const filter = buildTimeFilter(guildId, channelId, hours);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
SELECT
|
||||
COUNT(*)::int AS total_attachments,
|
||||
COUNT(*) FILTER (WHERE a.upload_status = 'uploaded')::int AS uploaded,
|
||||
COUNT(*) FILTER (WHERE a.upload_status = 'pending')::int AS pending,
|
||||
COUNT(*) FILTER (WHERE a.upload_status = 'failed')::int AS failed,
|
||||
COALESCE(SUM(a.size), 0)::bigint AS total_size_bytes,
|
||||
COUNT(DISTINCT a.user_id)::int AS unique_uploaders,
|
||||
(SELECT a2.type FROM attachments a2
|
||||
WHERE a2.guild_id = $1
|
||||
${channelId ? "AND a2.channel_id = $" + (filter.paramOffset - 1) : ""}
|
||||
AND a2.created_at > (EXTRACT(EPOCH FROM NOW()) * 1000 - $${filter.paramOffset})
|
||||
GROUP BY a2.type ORDER BY COUNT(*) DESC LIMIT 1
|
||||
) AS top_mime_type
|
||||
FROM attachments a
|
||||
WHERE a.guild_id = $1
|
||||
${channelId ? "AND a.channel_id = $" + (filter.paramOffset - 1) : ""}
|
||||
AND a.created_at > (EXTRACT(EPOCH FROM NOW()) * 1000 - $${filter.paramOffset})
|
||||
`,
|
||||
channelId
|
||||
? [guildId, channelId, hours * 3_600_000]
|
||||
: [guildId, hours * 3_600_000],
|
||||
);
|
||||
|
||||
const row = rows[0] as Record<string, unknown> | undefined;
|
||||
|
||||
return {
|
||||
total_attachments: Number(row?.total_attachments ?? 0),
|
||||
uploaded: Number(row?.uploaded ?? 0),
|
||||
pending: Number(row?.pending ?? 0),
|
||||
failed: Number(row?.failed ?? 0),
|
||||
total_size_bytes: Number(row?.total_size_bytes ?? 0),
|
||||
unique_uploaders: Number(row?.unique_uploaders ?? 0),
|
||||
top_mime_type: (row?.top_mime_type as string) ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const analyticsRepository = new AnalyticsRepository();
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { Router } from "express";
|
||||
import express from "express";
|
||||
import {
|
||||
handleGetAIStats,
|
||||
handleGetAttachmentStats,
|
||||
handleGetDailyTrend,
|
||||
handleGetHeatmap,
|
||||
handleGetHourlyStats,
|
||||
handleGetModerationActions,
|
||||
handleGetModerationStats,
|
||||
handleGetOverview,
|
||||
handleGetTopics,
|
||||
handleGetTopViolators,
|
||||
handleGetUserLeaderboard,
|
||||
} from "./analytics.controller.js";
|
||||
|
||||
export function createAnalyticsRouter(): Router {
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/analytics/overview", handleGetOverview);
|
||||
router.get("/analytics/trend", handleGetDailyTrend);
|
||||
router.get("/analytics/hourly", handleGetHourlyStats);
|
||||
router.get("/analytics/violators", handleGetTopViolators);
|
||||
router.get("/analytics/leaderboard", handleGetUserLeaderboard);
|
||||
router.get("/analytics/stats", handleGetModerationStats);
|
||||
router.get("/analytics/heatmap", handleGetHeatmap);
|
||||
router.get("/analytics/topics", handleGetTopics);
|
||||
router.get("/analytics/moderation-actions", handleGetModerationActions);
|
||||
router.get("/analytics/ai-stats", handleGetAIStats);
|
||||
router.get("/analytics/attachment-stats", handleGetAttachmentStats);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const analyticsQuerySchema = z.object({
|
||||
guildId: z.string(),
|
||||
channelId: z.string().optional(),
|
||||
hours: z.coerce.number().int().positive().default(24),
|
||||
});
|
||||
|
||||
export type AnalyticsQuery = z.infer<typeof analyticsQuerySchema>;
|
||||
@@ -1,127 +0,0 @@
|
||||
import { ForbiddenError, ValidationError } from "@bete/shared/errors";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { config } from "../../shared/config/index.js";
|
||||
import { analyticsRepository } from "./analytics.repository.js";
|
||||
import type { AnalyticsQuery } from "./analytics.schema.js";
|
||||
|
||||
const logger = createChildLogger("analytics.service");
|
||||
|
||||
export class AnalyticsService {
|
||||
private assertMonitorGuild(guildId: string) {
|
||||
if (!config.MONITOR_GUILD_ID) {
|
||||
throw new ValidationError("MONITOR_GUILD_ID is not configured");
|
||||
}
|
||||
|
||||
if (guildId !== config.MONITOR_GUILD_ID) {
|
||||
throw new ForbiddenError("Analytics are restricted to the monitor guild");
|
||||
}
|
||||
}
|
||||
|
||||
async getOverview(query: AnalyticsQuery) {
|
||||
this.assertMonitorGuild(query.guildId);
|
||||
logger.debug({ query }, "Getting analytics overview");
|
||||
return analyticsRepository.getOverview(
|
||||
query.guildId,
|
||||
query.channelId,
|
||||
query.hours,
|
||||
);
|
||||
}
|
||||
|
||||
async getDailyTrend(guildId: string, hours = 24) {
|
||||
this.assertMonitorGuild(guildId);
|
||||
logger.debug({ guildId, hours }, "Getting daily trend");
|
||||
return analyticsRepository.getDailyTrend(guildId, hours);
|
||||
}
|
||||
|
||||
async getHourlyStats(guildId: string, channelId?: string, hours = 24) {
|
||||
this.assertMonitorGuild(guildId);
|
||||
logger.debug({ guildId, channelId, hours }, "Getting hourly stats");
|
||||
return analyticsRepository.getHourlyStats(guildId, channelId, hours);
|
||||
}
|
||||
|
||||
async getTopViolators(
|
||||
guildId: string,
|
||||
channelId?: string,
|
||||
hours = 24,
|
||||
limit = 10,
|
||||
) {
|
||||
this.assertMonitorGuild(guildId);
|
||||
logger.debug({ guildId, channelId, hours, limit }, "Getting top violators");
|
||||
return analyticsRepository.getTopViolators(
|
||||
guildId,
|
||||
channelId,
|
||||
hours,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
async getUserLeaderboard(
|
||||
guildId: string,
|
||||
channelId?: string,
|
||||
hours = 24,
|
||||
limit = 10,
|
||||
) {
|
||||
this.assertMonitorGuild(guildId);
|
||||
logger.debug(
|
||||
{ guildId, channelId, hours, limit },
|
||||
"Getting user leaderboard",
|
||||
);
|
||||
return analyticsRepository.getUserLeaderboard(
|
||||
guildId,
|
||||
channelId,
|
||||
hours,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
async getModerationStats(guildId: string, channelId?: string, hours = 24) {
|
||||
this.assertMonitorGuild(guildId);
|
||||
logger.debug({ guildId, channelId, hours }, "Getting moderation stats");
|
||||
return analyticsRepository.getModerationStats(guildId, channelId, hours);
|
||||
}
|
||||
|
||||
async getHeatmap(guildId: string, channelId?: string, hours = 24) {
|
||||
this.assertMonitorGuild(guildId);
|
||||
logger.debug({ guildId, channelId, hours }, "Getting heatmap");
|
||||
return analyticsRepository.getHeatmap(guildId, channelId, hours);
|
||||
}
|
||||
|
||||
async getTopics(guildId: string, channelId?: string, hours = 24) {
|
||||
this.assertMonitorGuild(guildId);
|
||||
logger.debug({ guildId, channelId, hours }, "Getting topics");
|
||||
return analyticsRepository.getTopics(guildId, channelId, hours);
|
||||
}
|
||||
|
||||
async getModerationActions(
|
||||
guildId: string,
|
||||
channelId?: string,
|
||||
hours = 24,
|
||||
limit = 20,
|
||||
) {
|
||||
this.assertMonitorGuild(guildId);
|
||||
logger.debug(
|
||||
{ guildId, channelId, hours, limit },
|
||||
"Getting moderation actions",
|
||||
);
|
||||
return analyticsRepository.getModerationActions(
|
||||
guildId,
|
||||
channelId,
|
||||
hours,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
async getAIStats(guildId: string, channelId?: string, hours = 24) {
|
||||
this.assertMonitorGuild(guildId);
|
||||
logger.debug({ guildId, channelId, hours }, "Getting AI stats");
|
||||
return analyticsRepository.getAIStats(guildId, channelId, hours);
|
||||
}
|
||||
|
||||
async getAttachmentStats(guildId: string, channelId?: string, hours = 24) {
|
||||
this.assertMonitorGuild(guildId);
|
||||
logger.debug({ guildId, channelId, hours }, "Getting attachment stats");
|
||||
return analyticsRepository.getAttachmentStats(guildId, channelId, hours);
|
||||
}
|
||||
}
|
||||
|
||||
export const analyticsService = new AnalyticsService();
|
||||
@@ -1,8 +1,8 @@
|
||||
import { UnauthorizedError } from "@bete/shared/errors";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
import { config } from "../../shared/config/index.js";
|
||||
import { UnauthorizedError } from "@bete/shared/errors";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
|
||||
const logger = createChildLogger("auth.routes");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getPool } from "../../shared/database/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { getPool } from "../../shared/database/index.js";
|
||||
|
||||
const logger = createChildLogger("health.repository");
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { Request, Response } from "express";
|
||||
import { mascotChatService } from "./mascot-chat.service.js";
|
||||
|
||||
const logger = createChildLogger("mascot-chat.controller");
|
||||
@@ -20,11 +20,15 @@ export async function handleMascotChat(req: Request, res: Response) {
|
||||
|
||||
logger.debug(
|
||||
{ userId, messageLength: message.length, context },
|
||||
"Received mascot chat message"
|
||||
"Received mascot chat message",
|
||||
);
|
||||
|
||||
// Process message & generate response
|
||||
const response = await mascotChatService.processMessage(message, context, userId);
|
||||
const response = await mascotChatService.processMessage(
|
||||
message,
|
||||
context,
|
||||
userId,
|
||||
);
|
||||
|
||||
// Save conversation to database
|
||||
await mascotChatService.saveConversation({
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { getPool } from "../../shared/database/index.js";
|
||||
|
||||
const logger = createChildLogger("mascot-chat.repository");
|
||||
|
||||
export interface MascotChatContext {
|
||||
messageCount?: number;
|
||||
activeParticipants?: number;
|
||||
lastActivity?: string;
|
||||
topicsDiscussed?: string[];
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
}
|
||||
|
||||
export interface SaveConversationInput {
|
||||
userId: string;
|
||||
userMessage: string;
|
||||
mascotResponse: string;
|
||||
context?: MascotChatContext;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface MascotChatHistoryRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
user_message: string;
|
||||
mascot_response: string;
|
||||
context: MascotChatContext | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ServerInsights {
|
||||
total_messages: number;
|
||||
active_users: number;
|
||||
flagged: number;
|
||||
warned: number;
|
||||
}
|
||||
|
||||
export class MascotChatRepository {
|
||||
private initialized = false;
|
||||
|
||||
async ensureSchema(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
const pool = getPool();
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS mascot_chat_messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id TEXT NOT NULL,
|
||||
user_message TEXT NOT NULL,
|
||||
mascot_response TEXT NOT NULL,
|
||||
context JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_mascot_chat_messages_user_created
|
||||
ON mascot_chat_messages (user_id, created_at DESC)
|
||||
`);
|
||||
|
||||
this.initialized = true;
|
||||
logger.info("Mascot chat schema ready");
|
||||
}
|
||||
|
||||
async saveConversation(input: SaveConversationInput): Promise<void> {
|
||||
await this.ensureSchema();
|
||||
const pool = getPool();
|
||||
|
||||
await pool.query(
|
||||
`
|
||||
INSERT INTO mascot_chat_messages
|
||||
(user_id, user_message, mascot_response, context, created_at)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5)
|
||||
`,
|
||||
[
|
||||
input.userId,
|
||||
input.userMessage,
|
||||
input.mascotResponse,
|
||||
JSON.stringify(input.context ?? {}),
|
||||
input.timestamp.toISOString(),
|
||||
],
|
||||
);
|
||||
|
||||
logger.debug({ userId: input.userId }, "Conversation saved");
|
||||
}
|
||||
|
||||
async getChatHistory(
|
||||
userId: string,
|
||||
limit: number,
|
||||
): Promise<MascotChatHistoryRow[]> {
|
||||
await this.ensureSchema();
|
||||
const pool = getPool();
|
||||
|
||||
const { rows } = await pool.query<MascotChatHistoryRow>(
|
||||
`
|
||||
SELECT id, user_id, user_message, mascot_response, context, created_at
|
||||
FROM mascot_chat_messages
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`,
|
||||
[userId, limit],
|
||||
);
|
||||
|
||||
logger.debug({ userId, count: rows.length }, "Chat history fetched");
|
||||
return rows.reverse();
|
||||
}
|
||||
|
||||
async clearChatHistory(userId: string): Promise<void> {
|
||||
await this.ensureSchema();
|
||||
const pool = getPool();
|
||||
|
||||
const { rowCount } = await pool.query(
|
||||
`DELETE FROM mascot_chat_messages WHERE user_id = $1`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
logger.info({ userId, deletedRows: rowCount ?? 0 }, "Chat history cleared");
|
||||
}
|
||||
|
||||
async getServerInsights(
|
||||
guildId?: string,
|
||||
channelId?: string,
|
||||
): Promise<ServerInsights> {
|
||||
await this.ensureSchema();
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const params: string[] = [];
|
||||
const clauses: string[] = [];
|
||||
|
||||
if (guildId) {
|
||||
params.push(guildId);
|
||||
clauses.push(`guild_id = $${params.length}`);
|
||||
}
|
||||
if (channelId) {
|
||||
params.push(channelId);
|
||||
clauses.push(`channel_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
||||
|
||||
const { rows } = await pool.query<ServerInsights>(
|
||||
`
|
||||
SELECT
|
||||
COUNT(*)::int AS total_messages,
|
||||
COUNT(DISTINCT user_id)::int AS active_users,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warned
|
||||
FROM messages
|
||||
${where}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
const insights = rows[0] ?? {
|
||||
total_messages: 0,
|
||||
active_users: 0,
|
||||
flagged: 0,
|
||||
warned: 0,
|
||||
};
|
||||
|
||||
logger.debug({ guildId, channelId, insights }, "Server insights fetched");
|
||||
return insights;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ error, guildId, channelId },
|
||||
"Failed to load server insights",
|
||||
);
|
||||
return {
|
||||
total_messages: 0,
|
||||
active_users: 0,
|
||||
flagged: 0,
|
||||
warned: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mascotChatRepository = new MascotChatRepository();
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const contextSchema = z.object({
|
||||
messageCount: z.number().int().nonnegative().optional(),
|
||||
activeParticipants: z.number().int().nonnegative().optional(),
|
||||
lastActivity: z.string().datetime().optional(),
|
||||
topicsDiscussed: z.array(z.string()).optional(),
|
||||
guildId: z.string().optional(),
|
||||
channelId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const chatRequestSchema = z.object({
|
||||
message: z.string().min(1, "Message is required"),
|
||||
context: contextSchema.optional(),
|
||||
});
|
||||
|
||||
export const chatResponseSchema = z.object({
|
||||
response: z.string(),
|
||||
timestamp: z.string(),
|
||||
});
|
||||
|
||||
export const chatHistoryQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().positive().max(100).default(50),
|
||||
});
|
||||
|
||||
export type ChatRequest = z.infer<typeof chatRequestSchema>;
|
||||
export type ChatResponse = z.infer<typeof chatResponseSchema>;
|
||||
export type ChatContext = z.infer<typeof contextSchema>;
|
||||
export type ChatHistoryQuery = z.infer<typeof chatHistoryQuerySchema>;
|
||||
@@ -1,47 +1,25 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { config } from "../../shared/config/index.js";
|
||||
import { getPool } from "../../shared/database/index.js";
|
||||
import type {
|
||||
MascotChatContext,
|
||||
MascotChatHistoryRow,
|
||||
SaveConversationInput,
|
||||
} from "./mascot-chat.repository.js";
|
||||
import { mascotChatRepository } from "./mascot-chat.repository.js";
|
||||
|
||||
const logger = createChildLogger("mascot-chat.service");
|
||||
|
||||
export interface MascotChatContext {
|
||||
messageCount?: number;
|
||||
activeParticipants?: number;
|
||||
lastActivity?: string;
|
||||
topicsDiscussed?: string[];
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
}
|
||||
|
||||
export interface SaveConversationInput {
|
||||
userId: string;
|
||||
userMessage: string;
|
||||
mascotResponse: string;
|
||||
context?: MascotChatContext;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface MascotChatHistoryRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
user_message: string;
|
||||
mascot_response: string;
|
||||
context: MascotChatContext | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
class MascotChatService {
|
||||
private initialized = false;
|
||||
|
||||
async processMessage(
|
||||
message: string,
|
||||
context: MascotChatContext | undefined,
|
||||
userId: string,
|
||||
): Promise<string> {
|
||||
await this.ensureSchema();
|
||||
|
||||
const recentContext = await this.getRecentConversationContext(userId);
|
||||
const serverInsights = await this.getServerInsights(context);
|
||||
const serverInsights = await mascotChatRepository.getServerInsights(
|
||||
context?.guildId,
|
||||
context?.channelId,
|
||||
);
|
||||
|
||||
// Build LLM messages
|
||||
const systemPrompt = this.buildSystemPrompt(serverInsights);
|
||||
@@ -56,142 +34,30 @@ class MascotChatService {
|
||||
}
|
||||
|
||||
async saveConversation(input: SaveConversationInput): Promise<void> {
|
||||
await this.ensureSchema();
|
||||
const pool = getPool();
|
||||
|
||||
await pool.query(
|
||||
`
|
||||
INSERT INTO mascot_chat_messages
|
||||
(user_id, user_message, mascot_response, context, created_at)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5)
|
||||
`,
|
||||
[
|
||||
input.userId,
|
||||
input.userMessage,
|
||||
input.mascotResponse,
|
||||
JSON.stringify(input.context ?? {}),
|
||||
input.timestamp.toISOString(),
|
||||
],
|
||||
);
|
||||
await mascotChatRepository.saveConversation(input);
|
||||
}
|
||||
|
||||
async getChatHistory(
|
||||
userId: string,
|
||||
limit: number,
|
||||
): Promise<MascotChatHistoryRow[]> {
|
||||
await this.ensureSchema();
|
||||
const pool = getPool();
|
||||
|
||||
const { rows } = await pool.query<MascotChatHistoryRow>(
|
||||
`
|
||||
SELECT id, user_id, user_message, mascot_response, context, created_at
|
||||
FROM mascot_chat_messages
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`,
|
||||
[userId, limit],
|
||||
);
|
||||
|
||||
return rows.reverse();
|
||||
return mascotChatRepository.getChatHistory(userId, limit);
|
||||
}
|
||||
|
||||
async clearChatHistory(userId: string): Promise<void> {
|
||||
await this.ensureSchema();
|
||||
const pool = getPool();
|
||||
await pool.query(`DELETE FROM mascot_chat_messages WHERE user_id = $1`, [
|
||||
userId,
|
||||
]);
|
||||
}
|
||||
|
||||
private async ensureSchema(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
const pool = getPool();
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS mascot_chat_messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id TEXT NOT NULL,
|
||||
user_message TEXT NOT NULL,
|
||||
mascot_response TEXT NOT NULL,
|
||||
context JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_mascot_chat_messages_user_created
|
||||
ON mascot_chat_messages (user_id, created_at DESC)
|
||||
`);
|
||||
|
||||
this.initialized = true;
|
||||
logger.info("Mascot chat schema ready");
|
||||
await mascotChatRepository.clearChatHistory(userId);
|
||||
}
|
||||
|
||||
private async getRecentConversationContext(
|
||||
userId: string,
|
||||
): Promise<string[]> {
|
||||
const history = await this.getChatHistory(userId, 3);
|
||||
const history = await mascotChatRepository.getChatHistory(userId, 3);
|
||||
return history.flatMap((row) => [
|
||||
`User: ${row.user_message}`,
|
||||
`Mascot: ${row.mascot_response}`,
|
||||
]);
|
||||
}
|
||||
|
||||
private async getServerInsights(context?: MascotChatContext) {
|
||||
const pool = getPool();
|
||||
const guildId = context?.guildId;
|
||||
const channelId = context?.channelId;
|
||||
|
||||
try {
|
||||
const params: string[] = [];
|
||||
const clauses: string[] = [];
|
||||
if (guildId) {
|
||||
params.push(guildId);
|
||||
clauses.push(`guild_id = $${params.length}`);
|
||||
}
|
||||
if (channelId) {
|
||||
params.push(channelId);
|
||||
clauses.push(`channel_id = $${params.length}`);
|
||||
}
|
||||
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
||||
|
||||
const { rows } = await pool.query<{
|
||||
total_messages: number;
|
||||
active_users: number;
|
||||
flagged: number;
|
||||
warned: number;
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
COUNT(*)::int AS total_messages,
|
||||
COUNT(DISTINCT user_id)::int AS active_users,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warned
|
||||
FROM messages
|
||||
${where}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return (
|
||||
rows[0] ?? {
|
||||
total_messages: 0,
|
||||
active_users: 0,
|
||||
flagged: 0,
|
||||
warned: 0,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn({ error }, "Failed to load mascot server insights");
|
||||
return {
|
||||
total_messages: context?.messageCount ?? 0,
|
||||
active_users: context?.activeParticipants ?? 0,
|
||||
flagged: 0,
|
||||
warned: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private buildSystemPrompt(insights: {
|
||||
total_messages: number;
|
||||
active_users: number;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import { queue, skip, stop, setVolume, getStatus } from "./media.service.js";
|
||||
import { getStatus, queue, setVolume, skip, stop } from "./media.service.js";
|
||||
|
||||
const logger = createChildLogger("media.routes");
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import {
|
||||
asyncHandler,
|
||||
requireParam,
|
||||
} from "../../shared/middlewares/index.js";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { asyncHandler, requireParam } from "../../shared/middlewares/index.js";
|
||||
import { messageQuerySchema } from "./messages.schema.js";
|
||||
import { messagesService } from "./messages.service.js";
|
||||
|
||||
@@ -28,7 +25,11 @@ export function handleGetMessagesByChannel(
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const channelId = requireParam(req.params.channelId, "route parameter", "channelId");
|
||||
const channelId = requireParam(
|
||||
req.params.channelId,
|
||||
"route parameter",
|
||||
"channelId",
|
||||
);
|
||||
const query = messageQuerySchema.parse(req.query);
|
||||
logger.debug({ channelId, query }, "Handling get messages by channel");
|
||||
const result = await messagesService.getMessagesByChannel(channelId, query);
|
||||
@@ -55,7 +56,11 @@ export function handleGetAttachmentsByChannel(
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const channelId = requireParam(req.params.channelId, "route parameter", "channelId");
|
||||
const channelId = requireParam(
|
||||
req.params.channelId,
|
||||
"route parameter",
|
||||
"channelId",
|
||||
);
|
||||
const query = messageQuerySchema.parse(req.query);
|
||||
logger.debug({ channelId, query }, "Handling get attachments by channel");
|
||||
const result = await messagesService.getAttachmentsByChannel(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getPool } from "../../shared/database/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { getPool } from "../../shared/database/index.js";
|
||||
import type {
|
||||
MessageCreate,
|
||||
MessageQuery,
|
||||
@@ -61,7 +61,9 @@ function mapMessageRow(row: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
export class MessagesRepository {
|
||||
async findMany(query: MessageQuery): Promise<PageResult<ReturnType<typeof mapMessageRow>>> {
|
||||
async findMany(
|
||||
query: MessageQuery,
|
||||
): Promise<PageResult<ReturnType<typeof mapMessageRow>>> {
|
||||
const pool = getPool();
|
||||
const limit = query.limit ?? 50;
|
||||
const clauses: string[] = [];
|
||||
@@ -101,7 +103,8 @@ export class MessagesRepository {
|
||||
);
|
||||
|
||||
const data = rows.slice(0, limit).map(mapMessageRow);
|
||||
const nextCursor = rows.length > limit ? String(rows[limit].created_at) : null;
|
||||
const nextCursor =
|
||||
rows.length > limit ? String(rows[limit].created_at) : null;
|
||||
|
||||
logger.debug({ count: data.length, nextCursor }, "Found messages");
|
||||
return { data, nextCursor };
|
||||
@@ -109,10 +112,9 @@ export class MessagesRepository {
|
||||
|
||||
async findById(id: string) {
|
||||
const pool = getPool();
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM messages WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
const { rows } = await pool.query(`SELECT * FROM messages WHERE id = $1`, [
|
||||
id,
|
||||
]);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
return mapMessageRow(rows[0] as Record<string, unknown>);
|
||||
@@ -140,7 +142,8 @@ export class MessagesRepository {
|
||||
);
|
||||
|
||||
const data = rows.slice(0, limit).map(mapMessageRow);
|
||||
const nextCursor = rows.length > limit ? String(rows[limit].created_at) : null;
|
||||
const nextCursor =
|
||||
rows.length > limit ? String(rows[limit].created_at) : null;
|
||||
|
||||
return { data, nextCursor };
|
||||
}
|
||||
@@ -260,6 +263,58 @@ export class MessagesRepository {
|
||||
return rowCount ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a single message for re-analysis by resetting ai_status to 'pending'.
|
||||
* Skips messages already in 'pending' state to avoid write amplification.
|
||||
*/
|
||||
async markForReanalysis(id: string): Promise<void> {
|
||||
const pool = getPool();
|
||||
await pool.query(
|
||||
`UPDATE messages SET ai_status = 'pending'
|
||||
WHERE id = $1 AND ai_status != 'pending'`,
|
||||
[id],
|
||||
);
|
||||
logger.debug({ id }, "Message marked for re-analysis");
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve messages flagged for review (ai_status IN ('warn', 'flagged')).
|
||||
* Optionally filtered by channelId, with configurable limit.
|
||||
*/
|
||||
async getReviewMessages(
|
||||
channelId?: string,
|
||||
limit: number = 20,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const pool = getPool();
|
||||
|
||||
if (channelId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, guild_id, channel_id, user_id, username, avatar_url,
|
||||
content, type, created_at, ai_status, ai_severity,
|
||||
ai_confidence, ai_analysis
|
||||
FROM messages
|
||||
WHERE ai_status IN ('warn', 'flagged')
|
||||
AND channel_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`,
|
||||
[channelId, limit],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, guild_id, channel_id, user_id, username, avatar_url,
|
||||
content, type, created_at, ai_status, ai_severity,
|
||||
ai_confidence, ai_analysis
|
||||
FROM messages
|
||||
WHERE ai_status IN ('warn', 'flagged')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1`,
|
||||
[limit],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const pool = getPool();
|
||||
const { rowCount } = await pool.query(
|
||||
@@ -308,7 +363,8 @@ export class MessagesRepository {
|
||||
uploaded_at: (r.uploaded_at as number | null) ?? null,
|
||||
}));
|
||||
|
||||
const nextCursor = data.length > limit ? String(data[limit].created_at) : null;
|
||||
const nextCursor =
|
||||
data.length > limit ? String(data[limit].created_at) : null;
|
||||
const trimmed = data.slice(0, limit);
|
||||
|
||||
return { data: trimmed, nextCursor };
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
import { getPool } from "../../shared/database/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import {
|
||||
handleGetAttachmentsByChannel,
|
||||
@@ -85,7 +84,6 @@ export function createMessagesRouter(): Router {
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
// POST /api/messages/:id/reanalyze - Mark single message for re-analysis
|
||||
router.post(
|
||||
"/messages/:id/reanalyze",
|
||||
@@ -104,20 +102,11 @@ export function createMessagesRouter(): Router {
|
||||
|
||||
reanalyzeInFlight.add(id);
|
||||
try {
|
||||
const pool = getPool();
|
||||
await pool.query(
|
||||
// Only revert to pending if the message is not currently being
|
||||
// processed (pending) already — prevents write amplification when
|
||||
// the recovery worker already picked it up between UI clicks.
|
||||
`UPDATE messages SET ai_status = 'pending'
|
||||
WHERE id = $1 AND ai_status != 'pending'`,
|
||||
[id],
|
||||
);
|
||||
await messagesService.markForReanalysis(id);
|
||||
} finally {
|
||||
reanalyzeInFlight.delete(id);
|
||||
}
|
||||
|
||||
logger.debug({ id }, "Message marked for re-analysis");
|
||||
res.status(200).json({ ok: true });
|
||||
}),
|
||||
);
|
||||
@@ -129,36 +118,7 @@ export function createMessagesRouter(): Router {
|
||||
const limit = Number(req.query.limit) || 20;
|
||||
const channelId = (req.query.channelId as string) || undefined;
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
let sqlQuery: string;
|
||||
let params: (string | number)[];
|
||||
if (channelId) {
|
||||
sqlQuery = `
|
||||
SELECT id, guild_id, channel_id, user_id, username, avatar_url,
|
||||
content, type, created_at, ai_status, ai_severity,
|
||||
ai_confidence, ai_analysis
|
||||
FROM messages
|
||||
WHERE ai_status IN ('warn', 'flagged')
|
||||
AND channel_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`;
|
||||
params = [channelId, limit];
|
||||
} else {
|
||||
sqlQuery = `
|
||||
SELECT id, guild_id, channel_id, user_id, username, avatar_url,
|
||||
content, type, created_at, ai_status, ai_severity,
|
||||
ai_confidence, ai_analysis
|
||||
FROM messages
|
||||
WHERE ai_status IN ('warn', 'flagged')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1
|
||||
`;
|
||||
params = [limit];
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(sqlQuery, params);
|
||||
const rows = await messagesService.getReviewMessages(channelId, limit);
|
||||
logger.debug({ limit, channelId }, "Review query executed");
|
||||
res.json({ results: rows, limit, cursor: null });
|
||||
}),
|
||||
@@ -168,7 +128,7 @@ export function createMessagesRouter(): Router {
|
||||
router.post(
|
||||
"/messages/:id/moderate",
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const id = req.params.id;
|
||||
const id = String(req.params.id ?? "");
|
||||
if (!id) {
|
||||
res.status(400).json({ error: "MISSING_ID" });
|
||||
return;
|
||||
@@ -196,20 +156,12 @@ export function createMessagesRouter(): Router {
|
||||
}
|
||||
|
||||
// Fetch the message to get guild/user context
|
||||
const pool = getPool();
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, guild_id, channel_id, thread_id, user_id, content
|
||||
FROM messages WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
const msg = await messagesService.getMessageById(id).catch(() => null);
|
||||
if (!msg) {
|
||||
res.status(404).json({ error: "MESSAGE_NOT_FOUND" });
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = rows[0] as Record<string, unknown>;
|
||||
|
||||
// Publish command to DG via Redis
|
||||
const { publishCommand } = await import("../../ws/redis-bridge.js");
|
||||
await publishCommand({
|
||||
@@ -217,9 +169,9 @@ export function createMessagesRouter(): Router {
|
||||
type: "moderation:action",
|
||||
payload: {
|
||||
messageId: id,
|
||||
guildId: String(msg.guild_id ?? ""),
|
||||
channelId: (msg.thread_id as string) || String(msg.channel_id ?? ""),
|
||||
userId: String(msg.user_id ?? ""),
|
||||
guildId: msg.guild_id,
|
||||
channelId: msg.thread_id || msg.channel_id,
|
||||
userId: msg.user_id,
|
||||
actionType,
|
||||
reason: reason ?? "Manual moderation from dashboard",
|
||||
requestedAt: Date.now(),
|
||||
|
||||
@@ -46,12 +46,33 @@ export class MessagesService {
|
||||
return messagesRepository.getAttachmentsByChannel(channelId, query);
|
||||
}
|
||||
|
||||
async markForReanalysis(id: string): Promise<void> {
|
||||
if (!id) {
|
||||
throw new ValidationError("message ID is required");
|
||||
}
|
||||
|
||||
logger.debug({ id }, "Marking message for re-analysis");
|
||||
await messagesRepository.markForReanalysis(id);
|
||||
}
|
||||
|
||||
async getReviewMessages(
|
||||
channelId?: string,
|
||||
limit?: number,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
logger.debug({ channelId, limit }, "Getting review messages");
|
||||
return messagesRepository.getReviewMessages(channelId, limit);
|
||||
}
|
||||
|
||||
async reanalyzeErrorBatch(opts: {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
messageIds?: string[];
|
||||
}) {
|
||||
if (!opts.guildId && !opts.channelId && (!opts.messageIds || opts.messageIds.length === 0)) {
|
||||
if (
|
||||
!opts.guildId &&
|
||||
!opts.channelId &&
|
||||
(!opts.messageIds || opts.messageIds.length === 0)
|
||||
) {
|
||||
throw new ValidationError(
|
||||
"At least one of guildId, channelId, or messageIds[] is required",
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import { recordingsService } from "./recordings.service.js";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
|
||||
const logger = createChildLogger("recordings.service");
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import { uiStateService } from "./ui-state.service.js";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
|
||||
const logger = createChildLogger("ui-state.service");
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const voiceCommandSchema = z.object({
|
||||
command: z.string().min(1, "command is required"),
|
||||
});
|
||||
|
||||
export const connectVoiceSchema = z.object({
|
||||
guildId: z.string().min(1, "guildId is required"),
|
||||
channelId: z.string().min(1, "channelId is required"),
|
||||
});
|
||||
|
||||
export const guildIdParamSchema = z.object({
|
||||
guildId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const guildSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
icon: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const channelSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.enum(["voice", "text"]),
|
||||
});
|
||||
|
||||
export const voiceStatusSchema = z.object({
|
||||
connected: z.boolean(),
|
||||
activeGuildId: z.string().nullable(),
|
||||
activeChannelId: z.string().nullable(),
|
||||
activeChannelName: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type VoiceCommand = z.infer<typeof voiceCommandSchema>;
|
||||
export type ConnectVoice = z.infer<typeof connectVoiceSchema>;
|
||||
export type Guild = z.infer<typeof guildSchema>;
|
||||
export type Channel = z.infer<typeof channelSchema>;
|
||||
export type VoiceStatus = z.infer<typeof voiceStatusSchema>;
|
||||
@@ -1,9 +1,6 @@
|
||||
import { getPool } from "../../shared/database/index.js";
|
||||
import {
|
||||
publishCommand,
|
||||
readRedisStatus,
|
||||
} from "../../shared/redis/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { getPool } from "../../shared/database/index.js";
|
||||
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
|
||||
|
||||
const logger = createChildLogger("voice.service");
|
||||
|
||||
@@ -32,8 +29,7 @@ export interface VoiceStatus {
|
||||
*/
|
||||
export async function getGuilds(): Promise<Guild[]> {
|
||||
const reply = await publishCommand<Guild[]>("guilds:list", {});
|
||||
if (reply?.success && reply.data && reply.data.length > 0)
|
||||
return reply.data;
|
||||
if (reply?.success && reply.data && reply.data.length > 0) return reply.data;
|
||||
|
||||
// Fallback: Postgres with synthetic names
|
||||
logger.warn(
|
||||
@@ -59,8 +55,7 @@ export async function getTextChannels(guildId: string): Promise<Channel[]> {
|
||||
const reply = await publishCommand<Channel[]>("guilds:text-channels", {
|
||||
guildId,
|
||||
});
|
||||
if (reply?.success && reply.data && reply.data.length > 0)
|
||||
return reply.data;
|
||||
if (reply?.success && reply.data && reply.data.length > 0) return reply.data;
|
||||
|
||||
// Fallback: Postgres with synthetic names
|
||||
logger.warn(
|
||||
@@ -117,12 +112,14 @@ export async function connectVoice(
|
||||
|
||||
// Fallback: read from Redis status key
|
||||
const cached = await readRedisStatus("voice:status");
|
||||
return (cached as unknown as VoiceStatus) ?? {
|
||||
connected: false,
|
||||
activeGuildId: null,
|
||||
activeChannelId: null,
|
||||
activeChannelName: null,
|
||||
};
|
||||
return (
|
||||
(cached as unknown as VoiceStatus) ?? {
|
||||
connected: false,
|
||||
activeGuildId: null,
|
||||
activeChannelId: null,
|
||||
activeChannelName: null,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,10 +130,12 @@ export async function disconnectVoice(): Promise<VoiceStatus> {
|
||||
if (reply?.success && reply.data) return reply.data;
|
||||
|
||||
const cached = await readRedisStatus("voice:status");
|
||||
return (cached as unknown as VoiceStatus) ?? {
|
||||
connected: false,
|
||||
activeGuildId: null,
|
||||
activeChannelId: null,
|
||||
activeChannelName: null,
|
||||
};
|
||||
return (
|
||||
(cached as unknown as VoiceStatus) ?? {
|
||||
connected: false,
|
||||
activeGuildId: null,
|
||||
activeChannelId: null,
|
||||
activeChannelName: null,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { Pool } from "pg";
|
||||
import { config } from "../config/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
|
||||
const logger = createChildLogger("database");
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import {
|
||||
AppError,
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
} from "@bete/shared/errors";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
|
||||
const logger = createChildLogger("middleware");
|
||||
|
||||
@@ -54,7 +54,11 @@ export function asyncHandler(
|
||||
* Validate that a value is a non-empty string, or throw a descriptive error.
|
||||
* Use for both route params and query string values.
|
||||
*/
|
||||
export function requireParam(value: unknown, kind: string, name: string): string {
|
||||
export function requireParam(
|
||||
value: unknown,
|
||||
kind: string,
|
||||
name: string,
|
||||
): string {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`Missing ${kind}: ${name}`);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import Redis from "ioredis";
|
||||
import { config } from "../config/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
|
||||
const logger = createChildLogger("redis.command-channel");
|
||||
|
||||
@@ -73,13 +73,21 @@ export async function publishCommand<T = unknown>(
|
||||
timeoutMs = 5000,
|
||||
): Promise<CommandReply<T> | null> {
|
||||
if (!ensureRedisConfig()) {
|
||||
logger.warn({ commandType }, "Redis not configured, skipping command publish");
|
||||
logger.warn(
|
||||
{ commandType },
|
||||
"Redis not configured, skipping command publish",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const replyChannel = `backend:command:reply:${id}`;
|
||||
const command: CommandMessage = { id, type: commandType, payload, replyChannel };
|
||||
const command: CommandMessage = {
|
||||
id,
|
||||
type: commandType,
|
||||
payload,
|
||||
replyChannel,
|
||||
};
|
||||
|
||||
return new Promise<CommandReply<T> | null>((resolve) => {
|
||||
const pub = getPublisher();
|
||||
@@ -88,7 +96,9 @@ export async function publishCommand<T = unknown>(
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
sub.unsubscribe(replyChannel).catch(() => {/* ignore */});
|
||||
sub.unsubscribe(replyChannel).catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
logger.warn({ id, commandType }, "Command timed out waiting for reply");
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
@@ -99,11 +109,16 @@ export async function publishCommand<T = unknown>(
|
||||
if (channel !== replyChannel || settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
sub.unsubscribe(replyChannel).catch(() => {/* ignore */});
|
||||
sub.unsubscribe(replyChannel).catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
|
||||
try {
|
||||
const reply: CommandReply<T> = JSON.parse(message);
|
||||
logger.debug({ id, commandType, success: reply.success }, "Command reply received");
|
||||
logger.debug(
|
||||
{ id, commandType, success: reply.success },
|
||||
"Command reply received",
|
||||
);
|
||||
resolve(reply);
|
||||
} catch (err) {
|
||||
logger.error({ id, err }, "Failed to parse command reply");
|
||||
@@ -113,29 +128,34 @@ export async function publishCommand<T = unknown>(
|
||||
|
||||
sub.on("message", onMessage);
|
||||
|
||||
sub.subscribe(replyChannel).then(() => {
|
||||
pub
|
||||
.publish("backend:command", JSON.stringify(command))
|
||||
.then(() => {
|
||||
logger.debug({ id, commandType }, "Command published");
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
sub.unsubscribe(replyChannel).catch(() => {/* ignore */});
|
||||
logger.error({ err }, "Failed to publish command");
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}).catch((err: Error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
logger.error({ err }, "Failed to subscribe to reply channel");
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
sub
|
||||
.subscribe(replyChannel)
|
||||
.then(() => {
|
||||
pub
|
||||
.publish("backend:command", JSON.stringify(command))
|
||||
.then(() => {
|
||||
logger.debug({ id, commandType }, "Command published");
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
sub.unsubscribe(replyChannel).catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
logger.error({ err }, "Failed to publish command");
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
logger.error({ err }, "Failed to subscribe to reply channel");
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -147,7 +167,10 @@ export async function publishCommandNoReply(
|
||||
payload: Record<string, unknown> = {},
|
||||
): Promise<void> {
|
||||
if (!ensureRedisConfig()) {
|
||||
logger.warn({ commandType }, "Redis not configured, skipping command publish");
|
||||
logger.warn(
|
||||
{ commandType },
|
||||
"Redis not configured, skipping command publish",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -213,7 +236,9 @@ export function subscribe(
|
||||
// Status helpers — read keys set by discord-gateway
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function readRedisStatus(key: string): Promise<Record<string, unknown> | null> {
|
||||
export async function readRedisStatus(
|
||||
key: string,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
if (!ensureRedisConfig()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import Redis from "ioredis";
|
||||
import { config } from "../shared/config/index.js";
|
||||
import { getCommandPublisher } from "../shared/redis/index.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { broadcastRaw } from "./broadcast.js";
|
||||
|
||||
const logger = createChildLogger("ws.redis-bridge");
|
||||
|
||||
@@ -66,38 +66,61 @@ export function createWebSocketServer(server: Server): WebSocketServer {
|
||||
|
||||
ws.on("message", (data: Buffer) => {
|
||||
// Handle JSON messages from browser
|
||||
if (typeof data === 'string' || (Buffer.isBuffer(data) && data.length > 0 && data[0] === 0x7B)) {
|
||||
if (
|
||||
typeof data === "string" ||
|
||||
(Buffer.isBuffer(data) && data.length > 0 && data[0] === 0x7b)
|
||||
) {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
if (message.type === 'voice_transmit' && message.buffer) {
|
||||
if (message.type === "voice_transmit" && message.buffer) {
|
||||
// Forward PCM data to Redis for discord-gateway
|
||||
import('../shared/redis/index.js').then(({ getCommandPublisher }) => {
|
||||
const publisher = getCommandPublisher();
|
||||
publisher.publish('backend:voice:transmit', JSON.stringify({
|
||||
type: 'pcm',
|
||||
buffer: message.buffer
|
||||
})).catch((err: Error) => {
|
||||
logger.error({ err }, 'Failed to publish voice transmit to Redis');
|
||||
});
|
||||
});
|
||||
} else if (message.type === 'voice_command' && message.command) {
|
||||
import("../shared/redis/index.js").then(
|
||||
({ getCommandPublisher }) => {
|
||||
const publisher = getCommandPublisher();
|
||||
publisher
|
||||
.publish(
|
||||
"backend:voice:transmit",
|
||||
JSON.stringify({
|
||||
type: "pcm",
|
||||
buffer: message.buffer,
|
||||
}),
|
||||
)
|
||||
.catch((err: Error) => {
|
||||
logger.error(
|
||||
{ err },
|
||||
"Failed to publish voice transmit to Redis",
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
} else if (message.type === "voice_command" && message.command) {
|
||||
// Forward voice commands to discord-gateway
|
||||
import('../shared/redis/index.js').then(({ getCommandPublisher }) => {
|
||||
const publisher = getCommandPublisher();
|
||||
const commandId = `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
publisher.publish('backend:command', JSON.stringify({
|
||||
id: commandId,
|
||||
type: message.command,
|
||||
payload: {},
|
||||
replyChannel: `reply:${commandId}`
|
||||
})).catch((err: Error) => {
|
||||
logger.error({ err }, 'Failed to publish voice command to Redis');
|
||||
});
|
||||
});
|
||||
import("../shared/redis/index.js").then(
|
||||
({ getCommandPublisher }) => {
|
||||
const publisher = getCommandPublisher();
|
||||
const commandId = `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
publisher
|
||||
.publish(
|
||||
"backend:command",
|
||||
JSON.stringify({
|
||||
id: commandId,
|
||||
type: message.command,
|
||||
payload: {},
|
||||
replyChannel: `reply:${commandId}`,
|
||||
}),
|
||||
)
|
||||
.catch((err: Error) => {
|
||||
logger.error(
|
||||
{ err },
|
||||
"Failed to publish voice command to Redis",
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug({ err }, 'Failed to parse WebSocket message as JSON');
|
||||
logger.debug({ err }, "Failed to parse WebSocket message as JSON");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -260,7 +260,7 @@ On SIGINT/SIGTERM/uncaughtException/unhandledRejection:
|
||||
### Shared Infrastructure (9 files)
|
||||
- `src/shared/config/config.ts`
|
||||
- `src/shared/database/` (5 files)
|
||||
- `src/shared/errors/errors.ts`
|
||||
- `@bete/shared/errors` (shared package)
|
||||
- `src/shared/logger/logger.ts`
|
||||
- `src/shared/logger/serialization.ts`
|
||||
- `src/shared/utils/retry.ts`
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ConfigError, DatabaseError } from "@bete/shared/errors";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { Client } from "discord.js-selfbot-v13";
|
||||
import { startPendingAIAnalysisWorker } from "../modules/ai-moderation/aiAnalyzer.js";
|
||||
@@ -19,7 +20,6 @@ import {
|
||||
} from "../shared/database/drizzle.js";
|
||||
import { runMigrations } from "../shared/database/migrate.js";
|
||||
import { createDiscordClientOptions } from "../shared/discord/clientOptions.js";
|
||||
import { ConfigError, DatabaseError } from "../shared/errors/errors.js";
|
||||
import { createGracefulShutdown } from "./shutdown.js";
|
||||
|
||||
const logger = createChildLogger("discord-gateway");
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import "./mock-crc.js";
|
||||
import "libsodium-wrappers";
|
||||
import "@snazzah/davey";
|
||||
import "dotenv/config";
|
||||
|
||||
@@ -7,7 +7,6 @@ import { LRUCache } from "lru-cache";
|
||||
import { Piscina } from "piscina";
|
||||
import { config } from "../../shared/config/config.js";
|
||||
import type { EventBroadcaster } from "../event-broadcaster/index.js";
|
||||
import { invalidateAnalyticsCache } from "../message-capture/analyticsStore.js";
|
||||
import { isAgeRestrictedMetadata } from "../message-capture/messageMetadata.js";
|
||||
import {
|
||||
getConversationKeysWithIncompleteAnalysis,
|
||||
@@ -500,7 +499,6 @@ async function processIndividualFallback(
|
||||
const rows = await updateMessagesAIAnalysisBulk(updates);
|
||||
for (const row of rows) {
|
||||
broadcastAnalysisCompleted(row);
|
||||
invalidateAnalyticsCache(row.guild_id);
|
||||
scheduleAutoDelete(row);
|
||||
|
||||
// Update reputation autonomously (Belajar & Kebijaksanaan)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getDatabase } from "../../shared/database/drizzle.js";
|
||||
import {
|
||||
channelCulturesTable,
|
||||
ChannelCulture,
|
||||
channelCulturesTable,
|
||||
} from "../../shared/database/schema.js";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { eq, desc, sql, and } from "drizzle-orm";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import { config } from "../../shared/config/config.js";
|
||||
import { getDatabase } from "../../shared/database/drizzle.js";
|
||||
import {
|
||||
messagesTable,
|
||||
channelCulturesTable,
|
||||
messagesTable,
|
||||
} from "../../shared/database/schema.js";
|
||||
import { config } from "../../shared/config/config.js";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { llmChat } from "./llmClient.js";
|
||||
import { updateChannelCulture } from "./channelCultureStore.js";
|
||||
import { llmChat } from "./llmClient.js";
|
||||
|
||||
const CULTURE_LEARNING_INTERVAL = 1000 * 60 * 60 * 12; // 12 hours
|
||||
const log = createChildLogger("cultureLearner");
|
||||
|
||||
@@ -84,14 +84,14 @@ export async function llmChat(
|
||||
signal,
|
||||
} = opts;
|
||||
|
||||
const params: any = {
|
||||
const params = {
|
||||
model,
|
||||
messages,
|
||||
};
|
||||
...(stream !== undefined ? { stream } : {}),
|
||||
} as OpenAI.Chat.Completions.ChatCompletionCreateParams;
|
||||
|
||||
// Attach optional parameters only if explicitly provided to maintain
|
||||
// maximum compatibility with various LLM providers and local APIs.
|
||||
if (stream !== undefined) params.stream = stream;
|
||||
if (temperature !== undefined) params.temperature = temperature;
|
||||
if (top_p !== undefined) params.top_p = top_p;
|
||||
if (max_tokens !== undefined) params.max_tokens = max_tokens;
|
||||
@@ -103,7 +103,9 @@ export async function llmChat(
|
||||
return retryWithBackoff(
|
||||
async () => {
|
||||
return withLlmConcurrency(async () => {
|
||||
const execute = async (currentParams: any) => {
|
||||
const execute = async (
|
||||
currentParams: OpenAI.Chat.Completions.ChatCompletionCreateParams,
|
||||
) => {
|
||||
const response = await client.chat.completions.create(currentParams, {
|
||||
signal,
|
||||
});
|
||||
@@ -161,7 +163,9 @@ export async function llmChat(
|
||||
{ model },
|
||||
"Provider rejected non-streaming request. Fallback to stream: true initiated.",
|
||||
);
|
||||
params.stream = true;
|
||||
(
|
||||
params as unknown as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming
|
||||
).stream = true;
|
||||
return await execute(params);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,11 +11,9 @@ import type {
|
||||
AttachmentRecord,
|
||||
MessageRecord,
|
||||
} from "../message-capture/types.js";
|
||||
|
||||
import { getChannelCulture } from "./channelCultureStore.js";
|
||||
import { llmChat, llmVision } from "./llmClient.js";
|
||||
import { buildSystemPrompt as buildSystemPromptModular } from "./moderationPrompt.js";
|
||||
import { initializeUserReputation } from "./userReputationStore.js";
|
||||
import { getChannelCulture } from "./channelCultureStore.js";
|
||||
import { logModerationAnalysis, logModerationError } from "./responseLogger.js";
|
||||
import {
|
||||
getStickerFromCache,
|
||||
@@ -46,6 +44,7 @@ import {
|
||||
upsertCachedMediaByPhash,
|
||||
} from "./textCacheStore.js";
|
||||
import { extractUrlsFromText, fetchUrlSafely } from "./urlFetcher.js";
|
||||
import { initializeUserReputation } from "./userReputationStore.js";
|
||||
|
||||
const SeveritySchema = z.enum(["none", "low", "medium", "high", "critical"]);
|
||||
const RecommendedActionSchema = z.enum([
|
||||
@@ -170,7 +169,7 @@ function deriveRecommendedAction(
|
||||
/**
|
||||
* Helper to extract JSON from a potentially conversational or markdown-wrapped string.
|
||||
*/
|
||||
export function extractJson(content: string): any {
|
||||
export function extractJson(content: string): unknown {
|
||||
const codeBlockRegex = /```(?:json)?\s*([\s\S]*?)\s*```/g;
|
||||
const matches = content.matchAll(codeBlockRegex);
|
||||
for (const match of matches) {
|
||||
@@ -1046,7 +1045,7 @@ async function runTextOnlyBatch(
|
||||
if (rawContent.length > 0 && rawContent.length < 20) {
|
||||
const groupKey = rawContent.toLowerCase();
|
||||
if (shortContentGroups.has(groupKey)) {
|
||||
shortContentGroups.get(groupKey)!.push(msg);
|
||||
shortContentGroups.get(groupKey)?.push(msg);
|
||||
} else {
|
||||
shortContentGroups.set(groupKey, [msg]);
|
||||
deduplicatedTargets.push(msg); // first occurrence = representative
|
||||
@@ -1278,9 +1277,6 @@ async function prepareMediaMessage(
|
||||
const webTextMap = new Map<string, string[]>();
|
||||
const mediaAnalysisMap = new Map<string, string[]>();
|
||||
|
||||
const getAttachmentImageUrl = (att: AttachmentRecord): string | null =>
|
||||
att.uploaded_url ?? att.discord_url ?? null;
|
||||
|
||||
const maxDimension = config.AI_LLM_IMAGE_MAX_DIMENSION ?? 1024;
|
||||
const content = getAnalysisContent(target);
|
||||
|
||||
@@ -1292,93 +1288,14 @@ async function prepareMediaMessage(
|
||||
.filter(
|
||||
(att) =>
|
||||
att.message_id === targetId &&
|
||||
getAttachmentImageUrl(att) &&
|
||||
(att.uploaded_url ?? att.discord_url ?? null) &&
|
||||
att.type.startsWith("image/"),
|
||||
)
|
||||
.slice(0, 8);
|
||||
|
||||
for (const att of msgAttachments) {
|
||||
downloadPromises.push(
|
||||
(async () => {
|
||||
const urlToUse = getAttachmentImageUrl(att);
|
||||
if (!urlToUse) {
|
||||
log.warn(
|
||||
{ attachmentId: att.id, messageId: att.message_id },
|
||||
"Skipping attachment: no uploaded URL available",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 15000);
|
||||
|
||||
try {
|
||||
const res = await fetch(urlToUse, { signal: controller.signal });
|
||||
if (!res.ok || !res.body) {
|
||||
log.warn(
|
||||
{ attachmentId: att.id, url: urlToUse, status: res.status },
|
||||
"Failed to download attachment: HTTP error or no body",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
const chunks: Uint8Array[] = [];
|
||||
const reader = res.body.getReader();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
totalBytes += value.length;
|
||||
if (totalBytes > 10 * 1024 * 1024) {
|
||||
log.warn(
|
||||
{ attachmentId: att.id, totalBytes },
|
||||
"Attachment too large (>10MB) — skipping",
|
||||
);
|
||||
reader.cancel();
|
||||
return;
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
const imageBytes = Buffer.concat(chunks);
|
||||
const sniffedMime = sniffImageMimeType(imageBytes);
|
||||
if (!sniffedMime) {
|
||||
log.warn(
|
||||
{ attachmentId: att.id },
|
||||
"Skipping attachment: not a recognised image format",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: resizedBuffer, mimeType: resizedMime } =
|
||||
await resizeImageForVision(imageBytes, maxDimension);
|
||||
|
||||
const dataUrl = `data:${resizedMime};base64,${resizedBuffer.toString("base64")}`;
|
||||
const part: MessageImagePart = {
|
||||
type: "image_url",
|
||||
image_url: { url: dataUrl },
|
||||
sourceLabel: `[gambar di atas adalah attachment ${att.filename} dari pesan id=${att.message_id}]`,
|
||||
};
|
||||
const existing = imageMap.get(targetId) ?? [];
|
||||
if (existing.length < 8) {
|
||||
existing.push(part);
|
||||
imageMap.set(targetId, existing);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{
|
||||
attachmentId: att.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"Error downloading attachment",
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
})(),
|
||||
downloadSingleAttachment(att, targetId, maxDimension, imageMap),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1388,186 +1305,23 @@ async function prepareMediaMessage(
|
||||
|
||||
for (const url of urls) {
|
||||
downloadPromises.push(
|
||||
(async () => {
|
||||
const result = await fetchUrlSafely(url);
|
||||
if (result.type === "image" && result.data && result.mimeType) {
|
||||
const { data: resizedBuffer, mimeType: resizedMime } =
|
||||
await resizeImageForVision(result.data, maxDimension);
|
||||
|
||||
const dataUrl = `data:${resizedMime};base64,${resizedBuffer.toString("base64")}`;
|
||||
const part: MessageImagePart = {
|
||||
type: "image_url",
|
||||
image_url: { url: dataUrl },
|
||||
sourceLabel: `[gambar di atas berasal dari link ${url} pada pesan id=${targetId}]`,
|
||||
};
|
||||
const existing = imageMap.get(targetId) ?? [];
|
||||
if (existing.length < 8) {
|
||||
existing.push(part);
|
||||
imageMap.set(targetId, existing);
|
||||
}
|
||||
} else if (result.type === "text" && result.textContent) {
|
||||
urlWebTexts.push(`[Isi Web dari ${url}]: ${result.textContent}`);
|
||||
}
|
||||
})(),
|
||||
fetchUrlInline(url, targetId, maxDimension, imageMap, urlWebTexts),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sticker / embed / custom emoji download promises ──
|
||||
const mediaEvidence = extractMessageMediaEvidence(target.metadata);
|
||||
const mediaCandidates: Array<{
|
||||
messageId: string;
|
||||
url: string;
|
||||
label: string;
|
||||
stickerName?: string;
|
||||
customEmojiId?: string;
|
||||
customEmojiName?: string;
|
||||
}> = [
|
||||
...mediaEvidence.stickers
|
||||
.filter((s) => s.url)
|
||||
.map((s) => ({
|
||||
messageId: targetId,
|
||||
url: s.url,
|
||||
label: `[gambar di atas adalah sticker "${s.name}" dari pesan id=${targetId}]`,
|
||||
stickerName: s.name,
|
||||
})),
|
||||
...mediaEvidence.embeds.flatMap((embed) =>
|
||||
[
|
||||
embed.image
|
||||
? {
|
||||
messageId: targetId,
|
||||
url: embed.image,
|
||||
label: `[gambar di atas berasal dari embed image pada pesan id=${targetId}]`,
|
||||
}
|
||||
: null,
|
||||
embed.thumbnail
|
||||
? {
|
||||
messageId: targetId,
|
||||
url: embed.thumbnail,
|
||||
label: `[gambar di atas berasal dari embed thumbnail pada pesan id=${targetId}]`,
|
||||
}
|
||||
: null,
|
||||
].filter(
|
||||
(
|
||||
c,
|
||||
): c is {
|
||||
messageId: string;
|
||||
url: string;
|
||||
label: string;
|
||||
stickerName?: string;
|
||||
customEmojiId?: string;
|
||||
customEmojiName?: string;
|
||||
} => c !== null,
|
||||
),
|
||||
),
|
||||
...mediaEvidence.customEmojis.map((emoji) => ({
|
||||
messageId: targetId,
|
||||
url: emoji.url,
|
||||
label: `[gambar di atas adalah custom emoji "${emoji.name}" dari pesan id=${targetId}]`,
|
||||
customEmojiId: emoji.id,
|
||||
customEmojiName: emoji.name,
|
||||
})),
|
||||
];
|
||||
const mediaCandidates = buildMediaCandidates(targetId, mediaEvidence);
|
||||
|
||||
for (const candidate of mediaCandidates) {
|
||||
downloadPromises.push(
|
||||
(async () => {
|
||||
if ((imageMap.get(targetId)?.length ?? 0) >= 8) return;
|
||||
|
||||
if (candidate.customEmojiId || candidate.stickerName) {
|
||||
const visionCacheKey = candidate.customEmojiId
|
||||
? makeCustomEmojiCacheKey(candidate.customEmojiId)
|
||||
: makeStickerCacheKey(candidate.stickerName!);
|
||||
const cachedVision = await getCachedMediaAnalysis(visionCacheKey);
|
||||
if (cachedVision) {
|
||||
log.debug(
|
||||
{ cacheKey: visionCacheKey },
|
||||
"Vision cache HIT for media candidate — skipped download",
|
||||
);
|
||||
const analysisText = `[Media analysis for message ${candidate.messageId}] ${candidate.label}: ${cachedVision}`;
|
||||
const existing = mediaAnalysisMap.get(targetId) ?? [];
|
||||
existing.push(analysisText);
|
||||
mediaAnalysisMap.set(targetId, existing);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate.stickerName && isStickerCacheReady()) {
|
||||
try {
|
||||
const cached = await getStickerFromCache(candidate.stickerName);
|
||||
if (cached && cached.imageUrl) {
|
||||
const part: MessageImagePart = {
|
||||
type: "image_url",
|
||||
image_url: { url: cached.imageUrl },
|
||||
sourceLabel: candidate.label,
|
||||
stickerName: candidate.stickerName,
|
||||
};
|
||||
const existing = imageMap.get(targetId) ?? [];
|
||||
if (existing.length < 8) {
|
||||
existing.push(part);
|
||||
imageMap.set(targetId, existing);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (stickerErr) {
|
||||
log.warn(
|
||||
{
|
||||
stickerName: candidate.stickerName,
|
||||
error:
|
||||
stickerErr instanceof Error
|
||||
? stickerErr.message
|
||||
: String(stickerErr),
|
||||
},
|
||||
"Sticker cache lookup failed — falling through to network fetch",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await fetchUrlSafely(candidate.url);
|
||||
if (result.type !== "image" || !result.data || !result.mimeType) {
|
||||
log.warn(
|
||||
{
|
||||
url: candidate.url,
|
||||
resultType: result.type,
|
||||
resultHasData: !!result.data,
|
||||
messageId: candidate.messageId,
|
||||
label: candidate.stickerName
|
||||
? `sticker:${candidate.stickerName}`
|
||||
: candidate.customEmojiName
|
||||
? `emoji:${candidate.customEmojiName}`
|
||||
: "embed/other",
|
||||
},
|
||||
"Media candidate fetch did not return a usable image — skipping",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: resizedBuffer, mimeType: resizedMime } =
|
||||
await resizeImageForVision(result.data, maxDimension);
|
||||
|
||||
const base64 = resizedBuffer.toString("base64");
|
||||
|
||||
if (candidate.stickerName) {
|
||||
uploadAndCacheSticker(
|
||||
candidate.stickerName,
|
||||
resizedBuffer,
|
||||
resizedMime,
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
const part: MessageImagePart = {
|
||||
type: "image_url",
|
||||
image_url: { url: `data:${resizedMime};base64,${base64}` },
|
||||
sourceLabel: candidate.label,
|
||||
stickerName: candidate.stickerName,
|
||||
customEmojiId: candidate.customEmojiId,
|
||||
customEmojiName: candidate.customEmojiName,
|
||||
};
|
||||
const existing = imageMap.get(targetId) ?? [];
|
||||
if (existing.length < 8) {
|
||||
existing.push(part);
|
||||
imageMap.set(targetId, existing);
|
||||
}
|
||||
})(),
|
||||
downloadMediaCandidate(
|
||||
candidate,
|
||||
targetId,
|
||||
maxDimension,
|
||||
imageMap,
|
||||
mediaAnalysisMap,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2132,3 +1886,275 @@ Kategori: spam`;
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Refactored helpers for prepareMediaMessage (extracted to reduce CC)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function downloadSingleAttachment(
|
||||
att: AttachmentRecord,
|
||||
targetId: string,
|
||||
maxDimension: number,
|
||||
imageMap: Map<string, MessageImagePart[]>,
|
||||
): Promise<void> {
|
||||
const urlToUse = att.uploaded_url ?? att.discord_url ?? null;
|
||||
if (!urlToUse) {
|
||||
log.warn(
|
||||
{ attachmentId: att.id, messageId: att.message_id },
|
||||
"Skipping attachment: no uploaded URL available",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 15000);
|
||||
|
||||
try {
|
||||
const res = await fetch(urlToUse, { signal: controller.signal });
|
||||
if (!res.ok || !res.body) {
|
||||
log.warn(
|
||||
{ attachmentId: att.id, url: urlToUse, status: res.status },
|
||||
"Failed to download attachment: HTTP error or no body",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
const chunks: Uint8Array[] = [];
|
||||
const reader = res.body.getReader();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
totalBytes += value.length;
|
||||
if (totalBytes > 10 * 1024 * 1024) {
|
||||
log.warn(
|
||||
{ attachmentId: att.id, totalBytes },
|
||||
"Attachment too large (>10MB) — skipping",
|
||||
);
|
||||
reader.cancel();
|
||||
return;
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
const imageBytes = Buffer.concat(chunks);
|
||||
const sniffedMime = sniffImageMimeType(imageBytes);
|
||||
if (!sniffedMime) {
|
||||
log.warn(
|
||||
{ attachmentId: att.id },
|
||||
"Skipping attachment: not a recognised image format",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: resizedBuffer, mimeType: resizedMime } =
|
||||
await resizeImageForVision(imageBytes, maxDimension);
|
||||
|
||||
const dataUrl = `data:${resizedMime};base64,${resizedBuffer.toString("base64")}`;
|
||||
const part: MessageImagePart = {
|
||||
type: "image_url",
|
||||
image_url: { url: dataUrl },
|
||||
sourceLabel: `[gambar di atas adalah attachment ${att.filename} dari pesan id=${att.message_id}]`,
|
||||
};
|
||||
addImageToMap(imageMap, targetId, part);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{
|
||||
attachmentId: att.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"Error downloading attachment",
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadMediaCandidate(
|
||||
candidate: MediaCandidate,
|
||||
targetId: string,
|
||||
maxDimension: number,
|
||||
imageMap: Map<string, MessageImagePart[]>,
|
||||
mediaAnalysisMap: Map<string, string[]>,
|
||||
): Promise<void> {
|
||||
if ((imageMap.get(targetId)?.length ?? 0) >= 8) return;
|
||||
|
||||
if (candidate.customEmojiId || candidate.stickerName) {
|
||||
const visionCacheKey = candidate.customEmojiId
|
||||
? makeCustomEmojiCacheKey(candidate.customEmojiId)
|
||||
: makeStickerCacheKey(candidate.stickerName!);
|
||||
const cachedVision = await getCachedMediaAnalysis(visionCacheKey);
|
||||
if (cachedVision) {
|
||||
log.debug(
|
||||
{ cacheKey: visionCacheKey },
|
||||
"Vision cache HIT for media candidate — skipped download",
|
||||
);
|
||||
const analysisText = `[Media analysis for message ${candidate.messageId}] ${candidate.label}: ${cachedVision}`;
|
||||
const existing = mediaAnalysisMap.get(targetId) ?? [];
|
||||
existing.push(analysisText);
|
||||
mediaAnalysisMap.set(targetId, existing);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate.stickerName && isStickerCacheReady()) {
|
||||
try {
|
||||
const cached = await getStickerFromCache(candidate.stickerName);
|
||||
if (cached && cached.imageUrl) {
|
||||
const part: MessageImagePart = {
|
||||
type: "image_url",
|
||||
image_url: { url: cached.imageUrl },
|
||||
sourceLabel: candidate.label,
|
||||
stickerName: candidate.stickerName,
|
||||
};
|
||||
addImageToMap(imageMap, targetId, part);
|
||||
return;
|
||||
}
|
||||
} catch (stickerErr) {
|
||||
log.warn(
|
||||
{
|
||||
stickerName: candidate.stickerName,
|
||||
error:
|
||||
stickerErr instanceof Error
|
||||
? stickerErr.message
|
||||
: String(stickerErr),
|
||||
},
|
||||
"Sticker cache lookup failed — falling through to network fetch",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await fetchUrlSafely(candidate.url);
|
||||
if (result.type !== "image" || !result.data || !result.mimeType) {
|
||||
log.warn(
|
||||
{
|
||||
url: candidate.url,
|
||||
resultType: result.type,
|
||||
resultHasData: !!result.data,
|
||||
messageId: candidate.messageId,
|
||||
label: candidate.stickerName
|
||||
? `sticker:${candidate.stickerName}`
|
||||
: candidate.customEmojiName
|
||||
? `emoji:${candidate.customEmojiName}`
|
||||
: "embed/other",
|
||||
},
|
||||
"Media candidate fetch did not return a usable image — skipping",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: resizedBuffer, mimeType: resizedMime } =
|
||||
await resizeImageForVision(result.data, maxDimension);
|
||||
const base64 = resizedBuffer.toString("base64");
|
||||
|
||||
if (candidate.stickerName) {
|
||||
uploadAndCacheSticker(
|
||||
candidate.stickerName,
|
||||
resizedBuffer,
|
||||
resizedMime,
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
const part: MessageImagePart = {
|
||||
type: "image_url",
|
||||
image_url: { url: `data:${resizedMime};base64,${base64}` },
|
||||
sourceLabel: candidate.label,
|
||||
stickerName: candidate.stickerName,
|
||||
customEmojiId: candidate.customEmojiId,
|
||||
customEmojiName: candidate.customEmojiName,
|
||||
};
|
||||
addImageToMap(imageMap, targetId, part);
|
||||
}
|
||||
|
||||
async function fetchUrlInline(
|
||||
url: string,
|
||||
targetId: string,
|
||||
maxDimension: number,
|
||||
imageMap: Map<string, MessageImagePart[]>,
|
||||
urlWebTexts: string[],
|
||||
): Promise<void> {
|
||||
const result = await fetchUrlSafely(url);
|
||||
if (result.type === "image" && result.data && result.mimeType) {
|
||||
const { data: resizedBuffer, mimeType: resizedMime } =
|
||||
await resizeImageForVision(result.data, maxDimension);
|
||||
|
||||
const dataUrl = `data:${resizedMime};base64,${resizedBuffer.toString("base64")}`;
|
||||
const part: MessageImagePart = {
|
||||
type: "image_url",
|
||||
image_url: { url: dataUrl },
|
||||
sourceLabel: `[gambar di atas berasal dari link ${url} pada pesan id=${targetId}]`,
|
||||
};
|
||||
addImageToMap(imageMap, targetId, part);
|
||||
} else if (result.type === "text" && result.textContent) {
|
||||
urlWebTexts.push(`[Isi Web dari ${url}]: ${result.textContent}`);
|
||||
}
|
||||
}
|
||||
|
||||
function addImageToMap(
|
||||
imageMap: Map<string, MessageImagePart[]>,
|
||||
targetId: string,
|
||||
part: MessageImagePart,
|
||||
): void {
|
||||
const existing = imageMap.get(targetId) ?? [];
|
||||
if (existing.length < 8) {
|
||||
existing.push(part);
|
||||
imageMap.set(targetId, existing);
|
||||
}
|
||||
}
|
||||
|
||||
interface MediaCandidate {
|
||||
messageId: string;
|
||||
url: string;
|
||||
label: string;
|
||||
stickerName?: string;
|
||||
customEmojiId?: string;
|
||||
customEmojiName?: string;
|
||||
}
|
||||
|
||||
function buildMediaCandidates(
|
||||
targetId: string,
|
||||
mediaEvidence: ReturnType<typeof extractMessageMediaEvidence>,
|
||||
): MediaCandidate[] {
|
||||
return [
|
||||
...mediaEvidence.stickers
|
||||
.filter((s) => s.url)
|
||||
.map(
|
||||
(s): MediaCandidate => ({
|
||||
messageId: targetId,
|
||||
url: s.url,
|
||||
label: `[gambar di atas adalah sticker "${s.name}" dari pesan id=${targetId}]`,
|
||||
stickerName: s.name,
|
||||
}),
|
||||
),
|
||||
...mediaEvidence.embeds.flatMap((embed): MediaCandidate[] =>
|
||||
[
|
||||
embed.image
|
||||
? ({
|
||||
messageId: targetId,
|
||||
url: embed.image,
|
||||
label: `[gambar di atas berasal dari embed image pada pesan id=${targetId}]`,
|
||||
} as MediaCandidate)
|
||||
: null,
|
||||
embed.thumbnail
|
||||
? ({
|
||||
messageId: targetId,
|
||||
url: embed.thumbnail,
|
||||
label: `[gambar di atas berasal dari embed thumbnail pada pesan id=${targetId}]`,
|
||||
} as MediaCandidate)
|
||||
: null,
|
||||
].filter((c): c is MediaCandidate => c !== null),
|
||||
),
|
||||
...mediaEvidence.customEmojis.map(
|
||||
(emoji): MediaCandidate => ({
|
||||
messageId: targetId,
|
||||
url: emoji.url,
|
||||
label: `[gambar di atas adalah custom emoji "${emoji.name}" dari pesan id=${targetId}]`,
|
||||
customEmojiId: emoji.id,
|
||||
customEmojiName: emoji.name,
|
||||
}),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { config } from "../../shared/config/config.js";
|
||||
import { uploadToTele } from "../attachment-upload/teleUpload.js";
|
||||
import { executeAll, executeGet } from "../../shared/database/drizzle.js";
|
||||
import { uploadToTele } from "../attachment-upload/teleUpload.js";
|
||||
|
||||
const logger = createChildLogger("sticker-cache");
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { getDatabase } from "../../shared/database/drizzle.js";
|
||||
import {
|
||||
userReputationsTable,
|
||||
messagesTable,
|
||||
UserReputation,
|
||||
userReputationsTable,
|
||||
} from "../../shared/database/schema.js";
|
||||
|
||||
/**
|
||||
|
||||
@@ -377,7 +377,9 @@ export class CommandHandler {
|
||||
};
|
||||
}
|
||||
|
||||
private async handleVoiceTransmitStart(cmd: BackendCommand): Promise<CommandReply> {
|
||||
private async handleVoiceTransmitStart(
|
||||
cmd: BackendCommand,
|
||||
): Promise<CommandReply> {
|
||||
if (!discordPlayer.isConnected()) {
|
||||
return {
|
||||
id: cmd.id,
|
||||
@@ -412,7 +414,9 @@ export class CommandHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleVoiceTransmitStop(cmd: BackendCommand): Promise<CommandReply> {
|
||||
private async handleVoiceTransmitStop(
|
||||
cmd: BackendCommand,
|
||||
): Promise<CommandReply> {
|
||||
try {
|
||||
await voiceTransmitter.stop();
|
||||
logger.info("Voice transmit stopped");
|
||||
@@ -464,11 +468,9 @@ export class CommandHandler {
|
||||
* Fire-and-forget SET using the persistent Redis publisher connection.
|
||||
*/
|
||||
private setKey(key: string, value: string): void {
|
||||
this.redisPub
|
||||
.set(key, value)
|
||||
.catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger.warn({ key, error: msg }, "Failed to update Redis status key");
|
||||
});
|
||||
this.redisPub.set(key, value).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger.warn({ key, error: msg }, "Failed to update Redis status key");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ export class EventBroadcaster {
|
||||
async voicePcmData(
|
||||
pcmBuffer: Buffer,
|
||||
userId: string,
|
||||
metadata?: any,
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await this.publisher.publish("discord:voice:pcm", {
|
||||
type: "voice_pcm_data",
|
||||
|
||||
@@ -1,929 +0,0 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { executeAll, executeGet } from "../../shared/database/drizzle.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,
|
||||
};
|
||||
}
|
||||
@@ -672,7 +672,7 @@ export async function getPendingMessagesByConversation(
|
||||
.limit(limit)
|
||||
.for("update", { skipLocked: true });
|
||||
|
||||
const pendingIds = await pendingIdsQuery;
|
||||
const pendingIds = (await pendingIdsQuery) as Array<{ id: string }>;
|
||||
|
||||
if (pendingIds.length === 0) return [];
|
||||
|
||||
@@ -682,7 +682,7 @@ export async function getPendingMessagesByConversation(
|
||||
.where(
|
||||
inArray(
|
||||
messagesTable.id,
|
||||
(pendingIds as any[]).map((r) => r.id as string),
|
||||
pendingIds.map((r) => r.id),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
@@ -897,7 +897,7 @@ export async function getIncompleteMessagesByConversation(
|
||||
.limit(limit)
|
||||
.for("update", { skipLocked: true });
|
||||
|
||||
const pendingIds = await pendingIdsQuery;
|
||||
const pendingIds = (await pendingIdsQuery) as Array<{ id: string }>;
|
||||
|
||||
if (pendingIds.length === 0) return [];
|
||||
|
||||
@@ -907,7 +907,7 @@ export async function getIncompleteMessagesByConversation(
|
||||
.where(
|
||||
inArray(
|
||||
messagesTable.id,
|
||||
(pendingIds as any[]).map((r) => r.id as string),
|
||||
pendingIds.map((r) => r.id),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
@@ -177,6 +177,22 @@ export interface AnalysisResult {
|
||||
evidence?: string[];
|
||||
}
|
||||
|
||||
export interface VoiceRecordingUploadData {
|
||||
id: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
guild_id: string | null;
|
||||
channel_id: string | null;
|
||||
channel_name: string | null;
|
||||
filename: string;
|
||||
size_bytes: number;
|
||||
download_url: string;
|
||||
upload_status: string;
|
||||
created_at: number;
|
||||
uploaded_at: number;
|
||||
}
|
||||
|
||||
export type ModerationWsEvent =
|
||||
| { type: "ui_state"; state: unknown }
|
||||
| { type: "user_state"; users: unknown[] }
|
||||
@@ -187,7 +203,7 @@ export type ModerationWsEvent =
|
||||
| { type: "attachment_created"; data: AttachmentRecord }
|
||||
| { type: "analysis_queue_status"; data: AnalysisQueueStatus }
|
||||
| { type: "media_state"; state: unknown }
|
||||
| { type: "voice_recording_uploaded"; data: any };
|
||||
| { type: "voice_recording_uploaded"; data: VoiceRecordingUploadData };
|
||||
|
||||
export interface AnalysisQueueStatus {
|
||||
queuedConversations: number;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn } from "child_process";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export interface MuxFfmpegArgsOptions {
|
||||
inputs: string[];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { OpusDecoder } from "./recorder/decoder.js";
|
||||
export { SegmentManager } from "./recorder/segment.js";
|
||||
export { startRecording, stopRecording } from "./recorder.js";
|
||||
export { VoiceController } from "./voiceController.js";
|
||||
export { voiceTransmitter } from "./transmitter.js";
|
||||
export { VoiceController } from "./voiceController.js";
|
||||
|
||||
@@ -91,18 +91,22 @@ export async function uploadRecordingSegment(input: {
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
broadcaster.getClients().forEach((client: any) => {
|
||||
if (client.readyState === 1) {
|
||||
try {
|
||||
client.send(payload);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
"Failed to send recording upload event to client",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
broadcaster
|
||||
.getClients()
|
||||
.forEach(
|
||||
(client: { readyState: number; send: (data: string) => void }) => {
|
||||
if (client.readyState === 1) {
|
||||
try {
|
||||
client.send(payload);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
"Failed to send recording upload event to client",
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PassThrough } from "node:stream";
|
||||
import { spawn } from "node:child_process";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { StreamType } from "@discordjs/voice";
|
||||
import type Redis from "ioredis";
|
||||
@@ -39,23 +39,39 @@ export class VoiceTransmitter {
|
||||
// Spawn FFmpeg to encode 24kHz mono PCM → OggOpus
|
||||
// Input: 24kHz mono s16le (raw PCM)
|
||||
// Output: OGG container with Opus audio
|
||||
this.ffmpegProcess = spawn("ffmpeg", [
|
||||
"-f", "s16le", // Input format: signed 16-bit little-endian
|
||||
"-ar", "24000", // Input sample rate: 24kHz
|
||||
"-ac", "1", // Input channels: mono
|
||||
"-i", "pipe:0", // Read from stdin
|
||||
"-f", "ogg", // Output format: OGG
|
||||
"-c:a", "libopus", // Codec: Opus
|
||||
"-b:a", "96k", // Bitrate: 96kbps
|
||||
"-ar", "48000", // Output sample rate: 48kHz
|
||||
"-ac", "2", // Output channels: stereo
|
||||
"-application", "lowdelay", // Low delay mode for real-time
|
||||
"-frame_duration", "20", // 20ms frames
|
||||
"-packet_loss", "0", // No packet loss expected
|
||||
"pipe:1", // Write to stdout
|
||||
], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
this.ffmpegProcess = spawn(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-f",
|
||||
"s16le", // Input format: signed 16-bit little-endian
|
||||
"-ar",
|
||||
"24000", // Input sample rate: 24kHz
|
||||
"-ac",
|
||||
"1", // Input channels: mono
|
||||
"-i",
|
||||
"pipe:0", // Read from stdin
|
||||
"-f",
|
||||
"ogg", // Output format: OGG
|
||||
"-c:a",
|
||||
"libopus", // Codec: Opus
|
||||
"-b:a",
|
||||
"96k", // Bitrate: 96kbps
|
||||
"-ar",
|
||||
"48000", // Output sample rate: 48kHz
|
||||
"-ac",
|
||||
"2", // Output channels: stereo
|
||||
"-application",
|
||||
"lowdelay", // Low delay mode for real-time
|
||||
"-frame_duration",
|
||||
"20", // 20ms frames
|
||||
"-packet_loss",
|
||||
"0", // No packet loss expected
|
||||
"pipe:1", // Write to stdout
|
||||
],
|
||||
{
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
|
||||
// Pipe PCM data to FFmpeg stdin
|
||||
if (this.ffmpegProcess.stdin) {
|
||||
@@ -69,16 +85,20 @@ export class VoiceTransmitter {
|
||||
});
|
||||
|
||||
this.ffmpegProcess.on("error", (err) => {
|
||||
const msg = err.message === "spawn ffmpeg ENOENT"
|
||||
? "FFmpeg/avconv not found! Install ffmpeg in the container."
|
||||
: err.message;
|
||||
const msg =
|
||||
err.message === "spawn ffmpeg ENOENT"
|
||||
? "FFmpeg/avconv not found! Install ffmpeg in the container."
|
||||
: err.message;
|
||||
logger.error({ error: msg }, "FFmpeg process error");
|
||||
});
|
||||
|
||||
this.ffmpegProcess.on("exit", (code) => {
|
||||
if (code !== 0) {
|
||||
const stderr = Buffer.concat(stderrChunks).toString();
|
||||
logger.error({ code, stderr: stderr.slice(-500) }, "FFmpeg exited with error");
|
||||
logger.error(
|
||||
{ code, stderr: stderr.slice(-500) },
|
||||
"FFmpeg exited with error",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -90,11 +110,16 @@ export class VoiceTransmitter {
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("Voice transmitter pipeline ready (PCM → FFmpeg → OggOpus → Discord)");
|
||||
logger.info(
|
||||
"Voice transmitter pipeline ready (PCM → FFmpeg → OggOpus → Discord)",
|
||||
);
|
||||
|
||||
// Subscribe to Redis channel for PCM data
|
||||
await this.redisSub.subscribe(this.TRANSMIT_CHANNEL);
|
||||
logger.info({ channel: this.TRANSMIT_CHANNEL }, "Subscribed to transmit channel");
|
||||
logger.info(
|
||||
{ channel: this.TRANSMIT_CHANNEL },
|
||||
"Subscribed to transmit channel",
|
||||
);
|
||||
|
||||
this.redisSub.on("message", (channel, message) => {
|
||||
if (channel !== this.TRANSMIT_CHANNEL || !this.pcmStream) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "dotenv/config";
|
||||
import { ConfigError } from "@bete/shared/errors";
|
||||
import { z } from "zod";
|
||||
import { ConfigError } from "../errors/errors.js";
|
||||
|
||||
const configSchema = z
|
||||
.object({
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
export class AppError extends Error {
|
||||
public code: string;
|
||||
public statusCode: number;
|
||||
|
||||
constructor(message: string, code: string, statusCode: number = 500) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.statusCode = statusCode;
|
||||
this.name = "AppError";
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "CONFIG_ERROR", 500);
|
||||
this.name = "ConfigError";
|
||||
}
|
||||
}
|
||||
|
||||
export class AudioError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "AUDIO_ERROR", 500);
|
||||
this.name = "AudioError";
|
||||
}
|
||||
}
|
||||
|
||||
export class DatabaseError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "DATABASE_ERROR", 500);
|
||||
this.name = "DatabaseError";
|
||||
}
|
||||
}
|
||||
|
||||
export class VoiceConnectionError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "VOICE_CONNECTION_ERROR", 500);
|
||||
this.name = "VoiceConnectionError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
public details?: Record<string, string[]>;
|
||||
|
||||
constructor(message: string, details?: Record<string, string[]>) {
|
||||
super(message, "VALIDATION_ERROR", 400);
|
||||
this.details = details;
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AuthOverlay } from "./features/auth";
|
||||
import { LivePanel } from "./features/live";
|
||||
import { useMediaControl } from "./features/live/hooks/useMediaControl";
|
||||
@@ -18,38 +18,10 @@ import {
|
||||
import { useAudioPlayback } from "./shared/hooks/useAudioPlayback";
|
||||
import { useAudioTransmit } from "./shared/hooks/useAudioTransmit";
|
||||
import { useUIState } from "./shared/hooks/useUIState";
|
||||
import { Skeleton } from "./shared/ui";
|
||||
import { MobileTabBar } from "./shared/ui/MobileTabBar";
|
||||
import { useDashboardSocket } from "./shared/ws/socket";
|
||||
import { DashboardLayout } from "./widgets/DashboardLayout";
|
||||
|
||||
const AnalyticsPanel = lazy(() =>
|
||||
import("./features/analytics").then((module) => ({
|
||||
default: module.AnalyticsPanel,
|
||||
})),
|
||||
);
|
||||
|
||||
class AnalyticsErrorBoundary extends Component<
|
||||
{ children: React.ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
state = { hasError: false };
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 p-6 text-sm text-destructive">
|
||||
Analytics failed to load. The rest of the dashboard is still
|
||||
available.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { uiState, patchUIState } = useUIState();
|
||||
const voice = useVoiceControl();
|
||||
@@ -76,7 +48,8 @@ export default function App() {
|
||||
);
|
||||
|
||||
const socket = useDashboardSocket({
|
||||
onVoicePcmData: (d) => audio.handleIncomingPcm(d as { userId: string; pcm: string }),
|
||||
onVoicePcmData: (d) =>
|
||||
audio.handleIncomingPcm(d as { userId: string; pcm: string }),
|
||||
onUserState: (users) => setActiveSpeakers(users as ActiveSpeaker[]),
|
||||
onMessageCreated: (m) =>
|
||||
messages.setMessages((prev) => mergeMessages(prev, [m as MessageRecord])),
|
||||
@@ -144,9 +117,7 @@ export default function App() {
|
||||
// Auto-fetch messages for the monitor guild
|
||||
useEffect(() => {
|
||||
if (monitorGuildId)
|
||||
messages
|
||||
.fetchMessages(monitorGuildId)
|
||||
.catch(() => undefined);
|
||||
messages.fetchMessages(monitorGuildId).catch(() => undefined);
|
||||
}, [monitorGuildId, messages.fetchMessages]);
|
||||
|
||||
// Periodic refetch — keeps dashboard in sync even if WS events missed
|
||||
@@ -166,7 +137,9 @@ export default function App() {
|
||||
onTabChange={(tab) => patchUIState({ activeTab: tab })}
|
||||
recentMessages={messages.messages}
|
||||
guildId={monitorGuildId}
|
||||
channelId={uiState.selectedTextChannel || uiState.selectedVoiceChannel || undefined}
|
||||
channelId={
|
||||
uiState.selectedTextChannel || uiState.selectedVoiceChannel || undefined
|
||||
}
|
||||
>
|
||||
{activeTab === "live" ? (
|
||||
!isAuthenticated ? (
|
||||
@@ -205,7 +178,7 @@ export default function App() {
|
||||
onVolumeChange={media.setVolume}
|
||||
/>
|
||||
)
|
||||
) : activeTab === "messages" ? (
|
||||
) : (
|
||||
<MessagesPanel
|
||||
guildName={monitorGuildName}
|
||||
messages={messages.messages}
|
||||
@@ -215,24 +188,6 @@ export default function App() {
|
||||
hasMore={messages.hasMore}
|
||||
loadingMore={messages.loadingMore}
|
||||
/>
|
||||
) : (
|
||||
<AnalyticsErrorBoundary>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex flex-col gap-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full rounded-xl" />
|
||||
))}
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AnalyticsPanel
|
||||
guildId={monitorGuildId}
|
||||
guildName={monitorGuildName}
|
||||
/>
|
||||
</Suspense>
|
||||
</AnalyticsErrorBoundary>
|
||||
)}
|
||||
<MobileTabBar
|
||||
activeTab={activeTab}
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { AIStats } from "../../../shared/api/client";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../shared/ui";
|
||||
|
||||
interface AIDistributionPanelProps {
|
||||
stats: AIStats | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const SEVERITY_META: Record<
|
||||
string,
|
||||
{ label: string; color: string; darkColor: string }
|
||||
> = {
|
||||
critical: {
|
||||
label: "Critical",
|
||||
color: "#e11d48",
|
||||
darkColor: "#be123c",
|
||||
},
|
||||
high: {
|
||||
label: "High",
|
||||
color: "#f43f5e",
|
||||
darkColor: "#e11d48",
|
||||
},
|
||||
medium: {
|
||||
label: "Medium",
|
||||
color: "#fb923c",
|
||||
darkColor: "#f97316",
|
||||
},
|
||||
low: {
|
||||
label: "Low",
|
||||
color: "#facc15",
|
||||
darkColor: "#eab308",
|
||||
},
|
||||
none: {
|
||||
label: "None",
|
||||
color: "#94a3b8",
|
||||
darkColor: "#64748b",
|
||||
},
|
||||
};
|
||||
|
||||
const ACTION_META: Record<
|
||||
string,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
escalate: { label: "Escalate", color: "#e11d48" },
|
||||
delete: { label: "Delete", color: "#f43f5e" },
|
||||
review: { label: "Review", color: "#fb923c" },
|
||||
warn: { label: "Warn", color: "#facc15" },
|
||||
monitor: { label: "Monitor", color: "#38bdf8" },
|
||||
none: { label: "None", color: "#94a3b8" },
|
||||
};
|
||||
|
||||
function DonutChart({
|
||||
entries,
|
||||
size = 140,
|
||||
strokeWidth = 22,
|
||||
}: {
|
||||
entries: Array<{ key: string; value: number; color: string; label: string }>;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
}) {
|
||||
const total = entries.reduce((sum, e) => sum + e.value, 0);
|
||||
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center text-[11px] text-muted-foreground">
|
||||
No data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const center = size / 2;
|
||||
|
||||
let cumulative = 0;
|
||||
const segments = entries
|
||||
.filter((e) => e.value > 0)
|
||||
.map((e) => {
|
||||
const offset = cumulative;
|
||||
const length = (e.value / total) * circumference;
|
||||
cumulative += length;
|
||||
return { ...e, length, offset };
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
{/* Background ring */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="hsl(var(--muted))"
|
||||
strokeWidth={strokeWidth}
|
||||
opacity={0.2}
|
||||
/>
|
||||
{/* Segments */}
|
||||
{segments.map((seg) => {
|
||||
const isHovered = hoveredKey === seg.key;
|
||||
return (
|
||||
<circle
|
||||
key={seg.key}
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={seg.color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={`${seg.length} ${circumference - seg.length}`}
|
||||
strokeDashoffset={-seg.offset}
|
||||
strokeLinecap="round"
|
||||
className={cn(
|
||||
"transition-all duration-200",
|
||||
hoveredKey && !isHovered ? "opacity-30" : "opacity-100",
|
||||
)}
|
||||
onMouseEnter={() => setHoveredKey(seg.key)}
|
||||
onMouseLeave={() => setHoveredKey(null)}
|
||||
style={{
|
||||
filter: isHovered ? `drop-shadow(0 0 4px ${seg.color}80)` : undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Center label */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<span className="text-lg font-bold tabular-nums leading-none">
|
||||
{total}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground mt-0.5">Total</span>
|
||||
</div>
|
||||
|
||||
{/* Hover tooltip */}
|
||||
{hoveredKey && (() => {
|
||||
const entry = entries.find((e) => e.key === hoveredKey);
|
||||
if (!entry) return null;
|
||||
const pct = ((entry.value / total) * 100).toFixed(0);
|
||||
return (
|
||||
<div
|
||||
className="absolute -bottom-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-md border border-muted bg-white px-2.5 py-1 text-xs shadow-lg z-10"
|
||||
>
|
||||
<span className="font-medium">{entry.label}</span>:{" "}
|
||||
<span className="tabular-nums">{entry.value}</span> ({pct}%)
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HorizontalBarChart({
|
||||
entries,
|
||||
maxValue,
|
||||
}: {
|
||||
entries: Array<{ key: string; value: number; color: string; label: string }>;
|
||||
maxValue: number;
|
||||
}) {
|
||||
const effectiveMax = Math.max(maxValue, 1);
|
||||
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{entries.map((e) => {
|
||||
const isHovered = hoveredKey === e.key;
|
||||
const widthPct = (e.value / effectiveMax) * 100;
|
||||
return (
|
||||
<div
|
||||
key={e.key}
|
||||
className="flex items-center gap-2"
|
||||
onMouseEnter={() => setHoveredKey(e.key)}
|
||||
onMouseLeave={() => setHoveredKey(null)}
|
||||
>
|
||||
<span className="w-16 text-[10px] font-medium text-right truncate text-muted-foreground">
|
||||
{e.label}
|
||||
</span>
|
||||
<div className="flex-1 h-3 overflow-hidden rounded-md bg-muted/30">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-md transition-all duration-300",
|
||||
isHovered ? "opacity-100" : "opacity-80",
|
||||
)}
|
||||
style={{
|
||||
width: `${widthPct}%`,
|
||||
backgroundColor: e.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-right font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
{e.value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AIDistributionPanel({
|
||||
stats,
|
||||
loading,
|
||||
}: AIDistributionPanelProps) {
|
||||
if (loading && !stats) return <LoadingBox />;
|
||||
|
||||
if (!stats || stats.total_analyzed === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
Belum ada data analisis AI.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const severityEntries = Object.entries(stats.severity)
|
||||
.map(([key, value]) => {
|
||||
const m = SEVERITY_META[key] ?? {
|
||||
label: key,
|
||||
color: "#94a3b8",
|
||||
darkColor: "#64748b",
|
||||
};
|
||||
return { key, value, color: m.color, label: m.label };
|
||||
})
|
||||
.filter((e) => e.value > 0);
|
||||
|
||||
const actionEntries = Object.entries(stats.recommended_actions)
|
||||
.map(([key, value]) => {
|
||||
const m = ACTION_META[key] ?? {
|
||||
label: key,
|
||||
color: "#94a3b8",
|
||||
};
|
||||
return { key, value, color: m.color, label: m.label };
|
||||
})
|
||||
.filter((e) => e.value > 0);
|
||||
|
||||
const maxAction = Math.max(
|
||||
...actionEntries.map((e) => e.value),
|
||||
1,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<span className="text-lg">🤖</span>
|
||||
Distribusi Analisis AI
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Sebaran tingkat keparahan dan rekomendasi dari{" "}
|
||||
{stats.total_analyzed} pesan yang dianalisis.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{/* Severity Donut */}
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground self-start">
|
||||
Severity
|
||||
</h4>
|
||||
<DonutChart entries={severityEntries} />
|
||||
{/* Severity legend */}
|
||||
<div className="flex flex-wrap justify-center gap-x-3 gap-y-1">
|
||||
{severityEntries.map((e) => (
|
||||
<span
|
||||
key={e.key}
|
||||
className="flex items-center gap-1 text-[10px] text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-sm"
|
||||
style={{ backgroundColor: e.color }}
|
||||
/>
|
||||
{e.label}:{" "}
|
||||
<span className="font-medium tabular-nums text-foreground">
|
||||
{e.value}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommended Actions Bar Chart */}
|
||||
<div>
|
||||
<h4 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Rekomendasi Tindakan
|
||||
</h4>
|
||||
<HorizontalBarChart
|
||||
entries={actionEntries}
|
||||
maxValue={maxAction}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer metrics */}
|
||||
<div className="mt-4 flex flex-wrap gap-3 border-t border-muted pt-3 text-[10px] text-muted-foreground">
|
||||
<span>
|
||||
Rerata confidence:{" "}
|
||||
<strong>{(stats.avg_confidence * 100).toFixed(0)}%</strong>
|
||||
</span>
|
||||
<span>
|
||||
Rerata score:{" "}
|
||||
<strong>{(stats.avg_score * 100).toFixed(0)}%</strong>
|
||||
</span>
|
||||
<span>
|
||||
Error:{" "}
|
||||
<strong className="text-destructive">
|
||||
{stats.analysis_errors}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
Pending:{" "}
|
||||
<strong className="text-orange-500">
|
||||
{stats.analysis_pending}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingBox() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
<span className="h-4 w-4 animate-spin rounded-sm border-2 border-current border-t-transparent" />
|
||||
<span className="ml-2">Memuat data...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { HourlyBucket } from "../../../shared/api/client";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../shared/ui";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
|
||||
interface ActivityChartProps {
|
||||
hourly: HourlyBucket[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
clean: { fill: "#38bdf8", label: "Clean" },
|
||||
warned: { fill: "#facc15", label: "Warned" },
|
||||
flagged: { fill: "#f472b6", label: "Flagged" },
|
||||
error: { fill: "#fb923c", label: "Error" },
|
||||
} as const;
|
||||
|
||||
type BarKey = keyof typeof COLORS;
|
||||
|
||||
export function ActivityChart({ hourly, loading }: ActivityChartProps) {
|
||||
const [tooltip, setTooltip] = useState<{
|
||||
hour: string;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
const [hoveredBar, setHoveredBar] = useState<string | null>(null);
|
||||
|
||||
if (loading && !hourly?.length) return <LoadingBox />;
|
||||
if (!hourly?.length) return <EmptyBox text="Belum ada data untuk periode ini." />;
|
||||
|
||||
const data = hourly.map((b) => {
|
||||
const utcHour = parseInt(b.hour.slice(11, 13), 10);
|
||||
const jakartaHour = (utcHour + 7) % 24;
|
||||
return {
|
||||
hour: `${String(jakartaHour).padStart(2, "0")}:00`,
|
||||
clean: b.clean,
|
||||
warned: b.warned,
|
||||
flagged: b.flagged,
|
||||
error: b.error,
|
||||
total: b.count,
|
||||
};
|
||||
});
|
||||
|
||||
const maxTotal = Math.max(...data.map((d) => d.total), 1);
|
||||
// Only show every Nth label to avoid crowding
|
||||
const labelInterval = data.length > 16 ? 2 : 1;
|
||||
|
||||
const bars: Array<{ key: BarKey; color: string; label: string }> = [
|
||||
{ key: "clean", color: COLORS.clean.fill, label: COLORS.clean.label },
|
||||
{ key: "warned", color: COLORS.warned.fill, label: COLORS.warned.label },
|
||||
{ key: "flagged", color: COLORS.flagged.fill, label: COLORS.flagged.label },
|
||||
{ key: "error", color: COLORS.error.fill, label: COLORS.error.label },
|
||||
];
|
||||
|
||||
const CHART_HEIGHT = 200;
|
||||
const BAR_GROUP_WIDTH = 28;
|
||||
const BAR_WIDTH = 5;
|
||||
const GAP = 2;
|
||||
|
||||
return (
|
||||
<Card className="col-span-1 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-sm font-semibold">
|
||||
Aktivitas per Jam
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Distribusi pesan per jam — arahkan kursor ke bar untuk detail.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Legend */}
|
||||
<div className="mb-3 flex flex-wrap gap-4 text-[11px]">
|
||||
{bars.map((b) => (
|
||||
<span key={b.key} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-sm"
|
||||
style={{ backgroundColor: b.color }}
|
||||
/>
|
||||
{b.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chart area */}
|
||||
<div className="relative overflow-x-auto">
|
||||
<div className="min-w-[560px]">
|
||||
<svg
|
||||
viewBox={`0 0 ${Math.max(data.length * BAR_GROUP_WIDTH + 40, 200)} ${CHART_HEIGHT + 40}`}
|
||||
className="w-full"
|
||||
style={{ height: CHART_HEIGHT + 40 }}
|
||||
>
|
||||
{/* Grid lines */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||
const y = CHART_HEIGHT - ratio * (CHART_HEIGHT - 20) - 20;
|
||||
return (
|
||||
<g key={ratio}>
|
||||
<line
|
||||
x1={30}
|
||||
y1={y}
|
||||
x2={data.length * BAR_GROUP_WIDTH + 10}
|
||||
y2={y}
|
||||
stroke="hsl(var(--muted))"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<text
|
||||
x={28}
|
||||
y={y + 3}
|
||||
textAnchor="end"
|
||||
className="fill-muted-foreground"
|
||||
fontSize={9}
|
||||
>
|
||||
{Math.round(ratio * maxTotal)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Bars */}
|
||||
{data.map((d, i) => {
|
||||
const x = i * BAR_GROUP_WIDTH + 32;
|
||||
let accumulated = 0;
|
||||
|
||||
return (
|
||||
<g key={d.hour}>
|
||||
{/* Hover target (invisible wider rect) */}
|
||||
<rect
|
||||
x={x - 4}
|
||||
y={0}
|
||||
width={BAR_GROUP_WIDTH}
|
||||
height={CHART_HEIGHT}
|
||||
fill="transparent"
|
||||
className="cursor-crosshair"
|
||||
onMouseEnter={() => {
|
||||
setTooltip({ hour: d.hour, total: d.total });
|
||||
setHoveredBar(d.hour);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setTooltip(null);
|
||||
setHoveredBar(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Stacked bars */}
|
||||
{bars.map((bar) => {
|
||||
const val = d[bar.key];
|
||||
const barH = (val / maxTotal) * (CHART_HEIGHT - 20);
|
||||
const y = CHART_HEIGHT - accumulated - barH - 20;
|
||||
accumulated += barH;
|
||||
return val > 0 ? (
|
||||
<rect
|
||||
key={bar.key}
|
||||
x={x + GAP}
|
||||
y={y}
|
||||
width={BAR_WIDTH}
|
||||
height={Math.max(barH, 1)}
|
||||
fill={bar.color}
|
||||
rx={1.5}
|
||||
className={cn(
|
||||
"transition-opacity",
|
||||
hoveredBar === d.hour
|
||||
? "opacity-100"
|
||||
: hoveredBar
|
||||
? "opacity-40"
|
||||
: "opacity-90",
|
||||
)}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
|
||||
{/* X-axis label */}
|
||||
{i % labelInterval === 0 && (
|
||||
<text
|
||||
x={x + BAR_WIDTH / 2 + GAP}
|
||||
y={CHART_HEIGHT - 2}
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground"
|
||||
fontSize={9}
|
||||
>
|
||||
{d.hour}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Tooltip */}
|
||||
{tooltip && (
|
||||
<div
|
||||
className="pointer-events-none absolute top-0 z-10 rounded-lg border border-muted bg-white px-3 py-2 text-xs shadow-lg"
|
||||
style={{
|
||||
left: `${data.findIndex((d) => d.hour === tooltip.hour) * BAR_GROUP_WIDTH + 36}px`,
|
||||
}}
|
||||
>
|
||||
<div className="mb-1 font-semibold text-foreground">
|
||||
{tooltip.hour}
|
||||
</div>
|
||||
{bars.map((b) => {
|
||||
const d = data.find((d) => d.hour === tooltip.hour);
|
||||
const val = d?.[b.key] ?? 0;
|
||||
return val > 0 ? (
|
||||
<div key={b.key} className="flex items-center gap-2 text-muted-foreground">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-sm"
|
||||
style={{ backgroundColor: b.color }}
|
||||
/>
|
||||
<span>{b.label}</span>
|
||||
<span className="ml-auto font-medium tabular-nums text-foreground">
|
||||
{val}
|
||||
</span>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
<div className="mt-1 flex items-center gap-2 border-t border-muted pt-1 text-muted-foreground">
|
||||
<span>Total</span>
|
||||
<span className="ml-auto font-bold tabular-nums text-foreground">
|
||||
{tooltip.total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingBox() {
|
||||
return (
|
||||
<Card className="col-span-1 flex h-65 items-center justify-center text-sm text-muted-foreground lg:col-span-2">
|
||||
<span className="h-4 w-4 animate-spin rounded-sm border-2 border-primary border-t-transparent" />
|
||||
<span className="ml-2">Memuat data...</span>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyBox({ text }: { text: string }) {
|
||||
return (
|
||||
<Card className="col-span-1 flex h-65 items-center justify-center text-sm text-muted-foreground lg:col-span-2">
|
||||
{text}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { AttachmentStats } from "../../../shared/api/client";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../shared/ui";
|
||||
|
||||
interface AttachmentStatsPanelProps {
|
||||
stats: AttachmentStats | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const UPLOAD_COLORS = {
|
||||
uploaded: { color: "#38bdf8", label: "Uploaded" },
|
||||
pending: { color: "#facc15", label: "Pending" },
|
||||
failed: { color: "#f472b6", label: "Failed" },
|
||||
} as const;
|
||||
|
||||
function UploadDonut({
|
||||
uploaded,
|
||||
pending,
|
||||
failed,
|
||||
total,
|
||||
}: { uploaded: number; pending: number; failed: number; total: number }) {
|
||||
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
|
||||
const size = 120;
|
||||
const strokeWidth = 20;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const center = size / 2;
|
||||
|
||||
const entries = [
|
||||
{ key: "uploaded" as const, ...UPLOAD_COLORS.uploaded, value: uploaded },
|
||||
{ key: "pending" as const, ...UPLOAD_COLORS.pending, value: pending },
|
||||
{ key: "failed" as const, ...UPLOAD_COLORS.failed, value: failed },
|
||||
].filter((e) => e.value > 0);
|
||||
|
||||
let cumulative = 0;
|
||||
const segments = entries.map((e) => {
|
||||
const offset = cumulative;
|
||||
const length = (e.value / total) * circumference;
|
||||
cumulative += length;
|
||||
return { ...e, length, offset };
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="hsl(var(--muted))"
|
||||
strokeWidth={strokeWidth}
|
||||
opacity={0.15}
|
||||
/>
|
||||
{segments.map((seg) => {
|
||||
const isHovered = hoveredKey === seg.key;
|
||||
return (
|
||||
<circle
|
||||
key={seg.key}
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={seg.color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={`${seg.length} ${circumference - seg.length}`}
|
||||
strokeDashoffset={-seg.offset}
|
||||
strokeLinecap="round"
|
||||
className={cn(
|
||||
"transition-all duration-200",
|
||||
hoveredKey && !isHovered ? "opacity-30" : "opacity-100",
|
||||
)}
|
||||
onMouseEnter={() => setHoveredKey(seg.key)}
|
||||
onMouseLeave={() => setHoveredKey(null)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<span className="text-lg font-bold tabular-nums leading-none">
|
||||
{total}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground mt-0.5">Total</span>
|
||||
</div>
|
||||
{hoveredKey && (
|
||||
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-md border border-muted bg-white px-2.5 py-1 text-xs shadow-lg z-10 pointer-events-none">
|
||||
{(() => {
|
||||
const e = entries.find((en) => en.key === hoveredKey);
|
||||
if (!e) return null;
|
||||
return `${e.label}: ${e.value}`;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AttachmentStatsPanel({
|
||||
stats,
|
||||
loading,
|
||||
}: AttachmentStatsPanelProps) {
|
||||
if (loading && !stats) return <LoadingBox />;
|
||||
if (!stats || stats.total_attachments === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
Belum ada lampiran/media.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const uploadPct =
|
||||
stats.total_attachments > 0
|
||||
? Math.round((stats.uploaded / stats.total_attachments) * 100)
|
||||
: 0;
|
||||
const failedPct =
|
||||
stats.total_attachments > 0
|
||||
? Math.round((stats.failed / stats.total_attachments) * 100)
|
||||
: 0;
|
||||
const totalSizeMB = stats.total_size_bytes / (1024 * 1024);
|
||||
|
||||
const metricCards = [
|
||||
{ label: "Total Media", value: formatNum(stats.total_attachments), accent: "text-foreground" },
|
||||
{ label: "Upload Success", value: `${uploadPct}%`, accent: "text-primary" },
|
||||
{ label: "Gagal Upload", value: `${failedPct}%`, accent: "text-accent" },
|
||||
{ label: "Total Ukuran", value: `${totalSizeMB.toFixed(1)} MB`, accent: "text-muted-foreground" },
|
||||
{ label: "Pengupload", value: formatNum(stats.unique_uploaders), accent: "text-primary" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<span className="text-lg">🖼️</span>
|
||||
Statistik Media
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{stats.top_mime_type ? (
|
||||
<>
|
||||
Upload status media — tipe dominan:{" "}
|
||||
<span className="font-medium text-primary">
|
||||
{stats.top_mime_type}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
"Upload status media di semua channel."
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 mb-4">
|
||||
{metricCards.map((c) => (
|
||||
<div
|
||||
key={c.label}
|
||||
className="rounded-lg border border-muted/50 bg-white p-3"
|
||||
>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{c.label}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 font-mono text-lg font-bold tabular-nums",
|
||||
c.accent,
|
||||
)}
|
||||
>
|
||||
{c.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Donut + status bars */}
|
||||
<div className="flex flex-col sm:flex-row items-center gap-6">
|
||||
<UploadDonut
|
||||
uploaded={stats.uploaded}
|
||||
pending={stats.pending}
|
||||
failed={stats.failed}
|
||||
total={stats.total_attachments}
|
||||
/>
|
||||
|
||||
{/* Status legend with inline bars */}
|
||||
<div className="flex-1 w-full space-y-2">
|
||||
{[
|
||||
{
|
||||
key: "uploaded",
|
||||
...UPLOAD_COLORS.uploaded,
|
||||
value: stats.uploaded,
|
||||
},
|
||||
{
|
||||
key: "pending",
|
||||
...UPLOAD_COLORS.pending,
|
||||
value: stats.pending,
|
||||
},
|
||||
{
|
||||
key: "failed",
|
||||
...UPLOAD_COLORS.failed,
|
||||
value: stats.failed,
|
||||
},
|
||||
].map((s) => {
|
||||
const pct = (s.value / stats.total_attachments) * 100;
|
||||
return (
|
||||
<div key={s.key} className="flex items-center gap-2">
|
||||
<span className="w-20 text-[10px] font-medium text-muted-foreground truncate">
|
||||
{s.label}
|
||||
</span>
|
||||
<div className="flex-1 h-2 overflow-hidden rounded-md bg-muted/30">
|
||||
<div
|
||||
className="h-full rounded-md transition-all duration-300"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
backgroundColor: s.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-right font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
{s.value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function formatNum(v: number | undefined | null): string {
|
||||
if (v == null || v === 0) return "0";
|
||||
return v.toLocaleString("id-ID");
|
||||
}
|
||||
|
||||
function LoadingBox() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
<span className="h-4 w-4 animate-spin rounded-sm border-2 border-current border-t-transparent" />
|
||||
<span className="ml-2">Memuat data...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Activity, BarChart3 } from "lucide-react";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../shared/ui";
|
||||
|
||||
const TIME_RANGES = [
|
||||
{ label: "1j", value: 1 },
|
||||
{ label: "3j", value: 3 },
|
||||
{ label: "6j", value: 6 },
|
||||
{ label: "12j", value: 12 },
|
||||
{ label: "24j", value: 24 },
|
||||
{ label: "48j", value: 48 },
|
||||
{ label: "7h", value: 168 },
|
||||
];
|
||||
|
||||
interface ControlBarProps {
|
||||
guildName: string | null;
|
||||
hours: number;
|
||||
isFetching: boolean;
|
||||
onHoursChange: (hours: number) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function ControlBar({
|
||||
guildName,
|
||||
hours,
|
||||
isFetching,
|
||||
onHoursChange,
|
||||
onRefresh,
|
||||
}: ControlBarProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<BarChart3 className="h-5 w-5 text-primary" />
|
||||
Analisis Moderasi
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{guildName ? (
|
||||
<>
|
||||
Pantau statistik, tren topik, dan aktivitas user di seluruh
|
||||
channel{" "}
|
||||
<span className="font-medium text-primary">{guildName}</span>.
|
||||
</>
|
||||
) : (
|
||||
"Pantau statistik, tren topik, dan aktivitas user."
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-1 rounded-lg bg-muted/30 p-0.5 ring-1 ring-muted/50">
|
||||
{TIME_RANGES.map((tr) => (
|
||||
<button
|
||||
key={tr.value}
|
||||
type="button"
|
||||
onClick={() => onHoursChange(tr.value)}
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 text-xs font-medium transition-all",
|
||||
hours === tr.value
|
||||
? "bg-primary text-white shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tr.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
disabled={isFetching}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto shrink-0 rounded-lg border-primary/40 text-primary hover:bg-primary/10 hover:text-primary"
|
||||
>
|
||||
{isFetching ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-3 w-3 animate-spin rounded-sm border-2 border-primary border-t-transparent" />
|
||||
Memuat...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
Refresh
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { HeatmapCell } from "../../../shared/api/client";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../shared/ui";
|
||||
|
||||
const DAYS = ["Sen", "Sel", "Rab", "Kam", "Jum", "Sab", "Min"];
|
||||
|
||||
interface HeatmapProps {
|
||||
cells: HeatmapCell[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function Heatmap({ cells, loading }: HeatmapProps) {
|
||||
const [tooltip, setTooltip] = useState<{
|
||||
day: string;
|
||||
hour: string;
|
||||
total: number;
|
||||
clean: number;
|
||||
warned: number;
|
||||
flagged: number;
|
||||
} | null>(null);
|
||||
|
||||
const maxCount = useMemo(
|
||||
() => Math.max(1, ...cells.map((c) => c.count)),
|
||||
[cells],
|
||||
);
|
||||
|
||||
if (loading && !cells?.length) return <LoadingBox />;
|
||||
if (!cells?.length) return <EmptyBox />;
|
||||
|
||||
const cellMap = new Map<string, HeatmapCell>();
|
||||
for (const c of cells) cellMap.set(`${c.dayOfWeek}-${c.hour}`, c);
|
||||
|
||||
function getIntensity(day: number, hour: number): number {
|
||||
return (cellMap.get(`${day}-${hour}`)?.count ?? 0) / maxCount;
|
||||
}
|
||||
|
||||
function getHeatClass(intensity: number): string {
|
||||
if (intensity === 0) return "bg-muted/20";
|
||||
if (intensity < 0.1) return "bg-primary/15";
|
||||
if (intensity < 0.2) return "bg-primary/25";
|
||||
if (intensity < 0.35) return "bg-primary/40";
|
||||
if (intensity < 0.5) return "bg-primary/55";
|
||||
if (intensity < 0.7) return "bg-primary/70";
|
||||
return "bg-primary/85";
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">
|
||||
Heatmap Aktivitas
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Hari × jam — arahkan kursor ke sel untuk detail.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="relative">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[560px]">
|
||||
{/* Header row */}
|
||||
<div className="mb-1 flex gap-[3px] pl-8">
|
||||
{Array.from({ length: 24 }, (_, h) => (
|
||||
<div
|
||||
key={h}
|
||||
className="flex-1 text-center text-[9px] text-muted-foreground tabular-nums"
|
||||
>
|
||||
{h % 3 === 0 ? `${h}` : ""}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{DAYS.map((dayLabel, d) => (
|
||||
<div key={d} className="mb-[3px] flex items-center gap-[3px]">
|
||||
<div className="w-8 shrink-0 text-right pr-1 text-[10px] text-muted-foreground">
|
||||
{dayLabel}
|
||||
</div>
|
||||
{Array.from({ length: 24 }, (_, h) => {
|
||||
const intensity = getIntensity(d, h);
|
||||
const cell = cellMap.get(`${d}-${h}`);
|
||||
const count = cell?.count ?? 0;
|
||||
return (
|
||||
<div
|
||||
key={h}
|
||||
className={cn(
|
||||
"flex-1 rounded-md aspect-square border border-muted/30 transition-all duration-150",
|
||||
getHeatClass(intensity),
|
||||
count > 0
|
||||
? "cursor-pointer hover:ring-2 hover:ring-primary/50 hover:scale-110"
|
||||
: "",
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (count > 0) {
|
||||
setTooltip({
|
||||
day: dayLabel,
|
||||
hour: `${h}:00`,
|
||||
total: count,
|
||||
clean: cell?.clean ?? 0,
|
||||
warned: cell?.warned ?? 0,
|
||||
flagged: cell?.flagged ?? 0,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tooltip */}
|
||||
{tooltip && (
|
||||
<div
|
||||
className="pointer-events-none absolute z-10 rounded-lg border border-muted bg-white px-3 py-2 text-xs shadow-lg"
|
||||
style={{
|
||||
left: "50%",
|
||||
top: "100%",
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
>
|
||||
<div className="mb-1 font-semibold text-foreground">
|
||||
{tooltip.day} {tooltip.hour}
|
||||
</div>
|
||||
<div className="space-y-0.5 text-muted-foreground">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span>Total</span>
|
||||
<span className="font-medium tabular-nums text-foreground">
|
||||
{tooltip.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-primary">Clean</span>
|
||||
<span className="tabular-nums">{tooltip.clean}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-yellow-600">Warned</span>
|
||||
<span className="tabular-nums">{tooltip.warned}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-accent">Flagged</span>
|
||||
<span className="tabular-nums">{tooltip.flagged}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-3 flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<span>Sepi</span>
|
||||
<span className="inline-block h-3 w-3 rounded-sm bg-muted/20" />
|
||||
<span className="inline-block h-3 w-3 rounded-sm bg-primary/15" />
|
||||
<span className="inline-block h-3 w-3 rounded-sm bg-primary/40" />
|
||||
<span className="inline-block h-3 w-3 rounded-sm bg-primary/70" />
|
||||
<span className="inline-block h-3 w-3 rounded-sm bg-primary/85" />
|
||||
<span>Ramai</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingBox() {
|
||||
return (
|
||||
<Card className="col-span-2">
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
<span className="h-4 w-4 animate-spin rounded-sm border-2 border-current border-t-transparent" />
|
||||
<span className="ml-2">Memuat data...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyBox() {
|
||||
return (
|
||||
<Card className="col-span-2">
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
Belum ada data heatmap.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import type { ModerationActionRecord } from "../../../shared/api/client";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
ScrollArea,
|
||||
} from "../../../shared/ui";
|
||||
|
||||
interface ModerationActionsPanelProps {
|
||||
actions: ModerationActionRecord[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, { label: string; color: string }> = {
|
||||
delete_message: {
|
||||
label: "Hapus Pesan",
|
||||
color: "bg-red-100 text-red-700 border-red-200",
|
||||
},
|
||||
warn_user: {
|
||||
label: "Peringatan",
|
||||
color: "bg-yellow-100 text-yellow-700 border-yellow-200",
|
||||
},
|
||||
mute_user: {
|
||||
label: "Mute",
|
||||
color: "bg-orange-100 text-orange-700 border-orange-200",
|
||||
},
|
||||
kick_user: {
|
||||
label: "Kick",
|
||||
color: "bg-pink-100 text-pink-700 border-pink-200",
|
||||
},
|
||||
ban_user: {
|
||||
label: "Ban",
|
||||
color: "bg-accent/20 text-accent border-accent/30",
|
||||
},
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: "Pending", color: "bg-gray-100 text-gray-600" },
|
||||
completed: { label: "Selesai", color: "bg-green-100 text-green-700" },
|
||||
executed: { label: "Tereksekusi", color: "bg-green-100 text-green-700" },
|
||||
failed: { label: "Gagal", color: "bg-red-100 text-red-700" },
|
||||
};
|
||||
|
||||
export function ModerationActionsPanel({
|
||||
actions,
|
||||
loading,
|
||||
}: ModerationActionsPanelProps) {
|
||||
if (loading && !actions?.length) return <LoadingBox />;
|
||||
if (!actions?.length) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
Belum ada aksi moderasi.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<span className="text-lg">🛡️</span>
|
||||
Aksi Moderasi
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Riwayat tindakan moderasi yang telah diambil.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary">{actions.length} aksi</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ScrollArea className="max-h-[320px]">
|
||||
<div className="divide-y divide-muted/30">
|
||||
{actions.map((action) => {
|
||||
const actionStyle = ACTION_LABELS[action.action_type] ?? {
|
||||
label: action.action_type,
|
||||
color: "bg-gray-100 text-gray-600",
|
||||
};
|
||||
const statusStyle = STATUS_LABELS[action.status] ?? {
|
||||
label: action.status,
|
||||
color: "bg-gray-100 text-gray-600",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
className="px-5 py-3 text-sm hover:bg-muted/10 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px] px-1.5 py-0 font-semibold whitespace-nowrap border",
|
||||
actionStyle.color,
|
||||
)}
|
||||
>
|
||||
{actionStyle.label}
|
||||
</Badge>
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
{action.username}
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[9px] px-1.5 py-0 shrink-0",
|
||||
statusStyle.color,
|
||||
)}
|
||||
>
|
||||
{statusStyle.label}
|
||||
</Badge>
|
||||
</div>
|
||||
{action.reason && (
|
||||
<p className="mt-1 text-[11px] text-muted-foreground line-clamp-1 pl-1">
|
||||
{action.reason}
|
||||
</p>
|
||||
)}
|
||||
{action.error && (
|
||||
<p className="mt-0.5 text-[10px] text-destructive pl-1">
|
||||
Error: {action.error}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-1 text-[10px] text-muted-foreground pl-1">
|
||||
{new Date(action.created_at).toLocaleString("id-ID", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingBox() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
<span className="h-4 w-4 animate-spin rounded-sm border-2 border-current border-t-transparent" />
|
||||
<span className="ml-2">Memuat data...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import type { ModerationBreakdown } from "../../../shared/api/client";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
import { Card, CardContent, Skeleton } from "../../../shared/ui";
|
||||
|
||||
interface SummaryCardsProps {
|
||||
messages: ModerationBreakdown | null;
|
||||
activeUsersCount: number;
|
||||
totalChannels: number;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
interface CardDef {
|
||||
label: string;
|
||||
value: string;
|
||||
accent: string;
|
||||
barColor: string;
|
||||
barPct?: number;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export function SummaryCards({
|
||||
messages,
|
||||
activeUsersCount,
|
||||
totalChannels,
|
||||
loading,
|
||||
}: SummaryCardsProps) {
|
||||
const avgPerHour = messages
|
||||
? Math.round(messages.total / Math.max(1, 24))
|
||||
: 0;
|
||||
const cleanPct =
|
||||
messages && messages.total > 0
|
||||
? Math.round((messages.clean / messages.total) * 100)
|
||||
: 0;
|
||||
const flaggedPct =
|
||||
messages && messages.total > 0
|
||||
? Math.round((messages.flagged / messages.total) * 100)
|
||||
: 0;
|
||||
const warnedPct =
|
||||
messages && messages.total > 0
|
||||
? Math.round((messages.warned / messages.total) * 100)
|
||||
: 0;
|
||||
|
||||
const cards: CardDef[] = [
|
||||
{
|
||||
label: "Total Pesan",
|
||||
value: formatNum(messages?.total),
|
||||
accent: "text-foreground",
|
||||
barColor: "bg-primary",
|
||||
barPct: 100,
|
||||
icon: "💬",
|
||||
},
|
||||
{
|
||||
label: "Rata-rata/jam",
|
||||
value: formatNum(avgPerHour),
|
||||
accent: "text-muted-foreground",
|
||||
barColor: "bg-primary/60",
|
||||
barPct: avgPerHour > 0 ? Math.min((avgPerHour / 50) * 100, 100) : 0,
|
||||
icon: "📊",
|
||||
},
|
||||
{
|
||||
label: "Clean",
|
||||
value: cleanPct > 0 ? `${cleanPct}%` : "—",
|
||||
accent: "text-primary",
|
||||
barColor: "bg-primary",
|
||||
barPct: cleanPct,
|
||||
icon: "✅",
|
||||
},
|
||||
{
|
||||
label: "Warned",
|
||||
value: warnedPct > 0 ? `${warnedPct}%` : "—",
|
||||
accent: "text-yellow-600",
|
||||
barColor: "bg-yellow-400",
|
||||
barPct: warnedPct,
|
||||
icon: "⚠️",
|
||||
},
|
||||
{
|
||||
label: "Flagged",
|
||||
value: flaggedPct > 0 ? `${flaggedPct}%` : "—",
|
||||
accent: "text-accent",
|
||||
barColor: "bg-accent",
|
||||
barPct: flaggedPct,
|
||||
icon: "🚩",
|
||||
},
|
||||
{
|
||||
label: "Pending",
|
||||
value: formatNum(messages?.pending),
|
||||
accent: "text-muted-foreground",
|
||||
barColor: "bg-muted-foreground/40",
|
||||
barPct:
|
||||
messages && messages.total > 0
|
||||
? Math.round((messages.pending / messages.total) * 100)
|
||||
: 0,
|
||||
icon: "⏳",
|
||||
},
|
||||
{
|
||||
label: "User Aktif",
|
||||
value: formatNum(activeUsersCount),
|
||||
accent: "text-primary",
|
||||
barColor: "bg-primary",
|
||||
barPct: activeUsersCount > 0 ? Math.min((activeUsersCount / 20) * 100, 100) : 0,
|
||||
icon: "👤",
|
||||
},
|
||||
{
|
||||
label: "Channel",
|
||||
value: formatNum(totalChannels),
|
||||
accent: "text-primary",
|
||||
barColor: "bg-primary",
|
||||
barPct: totalChannels > 0 ? Math.min((totalChannels / 20) * 100, 100) : 0,
|
||||
icon: "📡",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-8">
|
||||
{cards.map((card) => (
|
||||
<Card key={card.label} className="overflow-hidden">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<span className="flex h-5 w-5 items-center justify-center text-[11px]">
|
||||
{card.icon}
|
||||
</span>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{card.label}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"font-mono text-lg font-bold tabular-nums",
|
||||
loading ? "opacity-30" : card.accent,
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<Skeleton className="h-7 w-12 mt-1" />
|
||||
) : (
|
||||
card.value
|
||||
)}
|
||||
</div>
|
||||
{/* Mini bar indicator */}
|
||||
{card.barPct != null && !loading && (
|
||||
<div className="mt-1.5 h-1 overflow-hidden rounded-sm bg-muted/30">
|
||||
<div
|
||||
className={cn("h-full rounded-sm transition-all duration-500", card.barColor)}
|
||||
style={{ width: `${card.barPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatNum(v: number | undefined | null): string {
|
||||
if (v == null || v === 0) return "—";
|
||||
return v.toLocaleString("id-ID");
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { Flame } from "lucide-react";
|
||||
import type { TopicTrend } from "../../../shared/api/client";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
ScrollArea,
|
||||
} from "../../../shared/ui";
|
||||
|
||||
interface TopicListProps {
|
||||
topics: TopicTrend[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const TOPIC_COLORS = [
|
||||
"from-primary to-sky-300",
|
||||
"from-accent to-pink-300",
|
||||
"from-orange-400 to-yellow-300",
|
||||
"from-emerald-400 to-teal-300",
|
||||
"from-violet-400 to-purple-300",
|
||||
];
|
||||
|
||||
export function TopicList({ topics, loading }: TopicListProps) {
|
||||
if (loading && !topics?.length) return <LoadingBox />;
|
||||
if (!topics?.length) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
Topik akan muncul setelah AI selesai menganalisis.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const maxCount = Math.max(...topics.map((t) => t.count), 1);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Flame className="h-4 w-4 text-primary" />
|
||||
Topik Trending
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Yang paling ramai dibicarakan.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ScrollArea className="max-h-[260px]">
|
||||
<div className="divide-y divide-muted/30">
|
||||
{topics.map((topic, i) => {
|
||||
const colorClass =
|
||||
TOPIC_COLORS[i % TOPIC_COLORS.length];
|
||||
return (
|
||||
<div
|
||||
key={topic.topic}
|
||||
className="flex items-center gap-3 px-5 py-2.5 text-sm border-l-2"
|
||||
style={{
|
||||
borderLeftColor: `hsl(${199 + i * 35}, 80%, 50%)`,
|
||||
}}
|
||||
>
|
||||
<span className="w-5 shrink-0 text-right font-mono text-[10px] text-muted-foreground">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="flex-1 truncate font-medium text-xs">
|
||||
{topic.topic}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-16 overflow-hidden rounded-md bg-muted/30">
|
||||
<div
|
||||
className={`h-full rounded-md bg-gradient-to-r ${colorClass}`}
|
||||
style={{
|
||||
width: `${(topic.count / maxCount) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-right font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{topic.count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingBox() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
<span className="h-4 w-4 animate-spin rounded-sm border-2 border-current border-t-transparent" />
|
||||
<span className="ml-2">Memuat data...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { TrendBucket } from "../../../shared/api/client";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../shared/ui";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
|
||||
interface TrendChartProps {
|
||||
trend: TrendBucket[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const LINE_COLORS: Array<{
|
||||
key: keyof Omit<TrendBucket, "date">;
|
||||
color: string;
|
||||
label: string;
|
||||
dash?: string;
|
||||
}> = [
|
||||
{ key: "count", color: "#38bdf8", label: "Total" },
|
||||
{ key: "clean", color: "#34d399", label: "Clean" },
|
||||
{ key: "flagged", color: "#f472b6", label: "Flagged" },
|
||||
{ key: "warned", color: "#facc15", label: "Warned" },
|
||||
{ key: "error", color: "#fb923c", label: "Error", dash: "4 3" },
|
||||
];
|
||||
|
||||
export function TrendChart({ trend, loading }: TrendChartProps) {
|
||||
const [tooltip, setTooltip] = useState<{
|
||||
date: string;
|
||||
values: Array<{ key: string; label: string; value: number; color: string }>;
|
||||
} | null>(null);
|
||||
|
||||
if (loading && !trend?.length) return <LoadingBox />;
|
||||
if (!trend?.length) return null;
|
||||
|
||||
const CHART_HEIGHT = 200;
|
||||
const CHART_PADDING = { top: 10, right: 16, bottom: 30, left: 40 };
|
||||
const chartW = Math.max((trend.length - 1) * 64, 200);
|
||||
const plotW = chartW - CHART_PADDING.left - CHART_PADDING.right;
|
||||
const plotH = CHART_HEIGHT - CHART_PADDING.top - CHART_PADDING.bottom;
|
||||
|
||||
const allValues = trend.flatMap((d) =>
|
||||
LINE_COLORS.map((l) => Number(d[l.key] ?? 0)),
|
||||
);
|
||||
const maxValue = Math.max(...allValues, 1);
|
||||
|
||||
function getX(index: number): number {
|
||||
if (trend.length <= 1) return CHART_PADDING.left;
|
||||
return (
|
||||
CHART_PADDING.left +
|
||||
(index / (trend.length - 1)) * plotW
|
||||
);
|
||||
}
|
||||
|
||||
function getY(value: number): number {
|
||||
return CHART_PADDING.top + plotH - (value / maxValue) * plotH;
|
||||
}
|
||||
|
||||
function buildLinePath(
|
||||
data: TrendBucket[],
|
||||
key: keyof Omit<TrendBucket, "date">,
|
||||
): string {
|
||||
const points = data.map((d, i) => ({
|
||||
x: getX(i),
|
||||
y: getY(Number(d[key] ?? 0)),
|
||||
}));
|
||||
if (points.length === 0) return "";
|
||||
|
||||
const segments: string[] = [`M ${points[0].x} ${points[0].y}`];
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const prev = points[i - 1];
|
||||
const curr = points[i];
|
||||
const cx = (prev.x + curr.x) / 2;
|
||||
segments.push(`Q ${cx} ${prev.y} ${curr.x} ${curr.y}`);
|
||||
}
|
||||
return segments.join(" ");
|
||||
}
|
||||
|
||||
function buildAreaPath(
|
||||
data: TrendBucket[],
|
||||
key: keyof Omit<TrendBucket, "date">,
|
||||
): string {
|
||||
const line = buildLinePath(data, key);
|
||||
if (!line) return "";
|
||||
const first = getX(0);
|
||||
const last = getX(data.length - 1);
|
||||
const bottom = CHART_PADDING.top + plotH;
|
||||
return `${line} L ${last} ${bottom} L ${first} ${bottom} Z`;
|
||||
}
|
||||
|
||||
const totalMessages = trend.reduce((sum, d) => sum + d.count, 0);
|
||||
|
||||
return (
|
||||
<Card className="col-span-3">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">Tren Harian</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Volume pesan per hari — arahkan kursor ke titik untuk detail.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Legend */}
|
||||
<div className="mb-3 flex flex-wrap gap-4 text-[11px]">
|
||||
{LINE_COLORS.map((l) => (
|
||||
<span key={l.key} className="flex items-center gap-1.5">
|
||||
<svg width="14" height="3" className="overflow-visible">
|
||||
<line
|
||||
x1="0"
|
||||
y1="1.5"
|
||||
x2="14"
|
||||
y2="1.5"
|
||||
stroke={l.color}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={l.dash ?? "none"}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{l.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-muted/50 bg-white/60 p-4">
|
||||
<div className="mb-3 flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
Rangkuman{" "}
|
||||
{trend.length > 1
|
||||
? `${trend.length} hari terakhir`
|
||||
: "hari ini"}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{totalMessages} total pesan
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${chartW} ${CHART_HEIGHT}`}
|
||||
className="w-full"
|
||||
style={{ height: CHART_HEIGHT }}
|
||||
>
|
||||
{/* Grid lines */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||
const y = getY(ratio * maxValue);
|
||||
return (
|
||||
<g key={ratio}>
|
||||
<line
|
||||
x1={CHART_PADDING.left}
|
||||
y1={y}
|
||||
x2={chartW - CHART_PADDING.right}
|
||||
y2={y}
|
||||
stroke="hsl(var(--muted))"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<text
|
||||
x={CHART_PADDING.left - 6}
|
||||
y={y + 3}
|
||||
textAnchor="end"
|
||||
className="fill-muted-foreground"
|
||||
fontSize={9}
|
||||
>
|
||||
{Math.round(ratio * maxValue)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Area fills */}
|
||||
{LINE_COLORS.filter((l) => l.key === "count" || l.key === "flagged").map((l) => (
|
||||
<path
|
||||
key={`area-${l.key}`}
|
||||
d={buildAreaPath(trend, l.key)}
|
||||
fill={l.color}
|
||||
opacity={0.07}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Lines */}
|
||||
{LINE_COLORS.map((l) => (
|
||||
<path
|
||||
key={`line-${l.key}`}
|
||||
d={buildLinePath(trend, l.key)}
|
||||
fill="none"
|
||||
stroke={l.color}
|
||||
strokeWidth={l.key === "count" ? 2.5 : 1.5}
|
||||
strokeDasharray={l.dash ?? "none"}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
className="transition-opacity"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Interactive dots */}
|
||||
{trend.map((d, i) => {
|
||||
const x = getX(i);
|
||||
const y = getY(d.count);
|
||||
const isActive = tooltip?.date === d.date;
|
||||
return (
|
||||
<g key={d.date}>
|
||||
{/* Invisible hit area */}
|
||||
<rect
|
||||
x={x - 28}
|
||||
y={CHART_PADDING.top}
|
||||
width={56}
|
||||
height={plotH}
|
||||
fill="transparent"
|
||||
className="cursor-crosshair"
|
||||
onMouseEnter={() => {
|
||||
setTooltip({
|
||||
date: d.date,
|
||||
values: LINE_COLORS.map((l) => ({
|
||||
key: l.key,
|
||||
label: l.label,
|
||||
value: Number(d[l.key] ?? 0),
|
||||
color: l.color,
|
||||
})),
|
||||
});
|
||||
}}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
/>
|
||||
{/* Dot */}
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={isActive ? 5 : 2.5}
|
||||
fill={isActive ? "#38bdf8" : "#38bdf8"}
|
||||
stroke="white"
|
||||
strokeWidth={isActive ? 2 : 0}
|
||||
className={cn(
|
||||
"transition-all",
|
||||
isActive ? "opacity-100" : "opacity-70",
|
||||
)}
|
||||
/>
|
||||
{/* Date label */}
|
||||
<text
|
||||
x={x}
|
||||
y={CHART_PADDING.top + plotH + 16}
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground"
|
||||
fontSize={9}
|
||||
>
|
||||
{d.date.slice(5)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Tooltip */}
|
||||
{tooltip && (
|
||||
<div
|
||||
className="pointer-events-none absolute z-10 rounded-lg border border-muted bg-white px-3 py-2 text-xs shadow-lg"
|
||||
style={{
|
||||
top: `${CHART_PADDING.top + 4}px`,
|
||||
left: `${getX(trend.findIndex((d) => d.date === tooltip.date)) + 12}px`,
|
||||
}}
|
||||
>
|
||||
<div className="mb-1 font-semibold text-foreground">
|
||||
{tooltip.date}
|
||||
</div>
|
||||
{tooltip.values
|
||||
.filter((v) => v.value > 0)
|
||||
.map((v) => (
|
||||
<div
|
||||
key={v.key}
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<svg width="8" height="8">
|
||||
<circle
|
||||
cx="4"
|
||||
cy="4"
|
||||
r="3"
|
||||
fill={v.color}
|
||||
/>
|
||||
</svg>
|
||||
<span>{v.label}</span>
|
||||
<span className="ml-6 font-medium tabular-nums text-foreground">
|
||||
{v.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingBox() {
|
||||
return (
|
||||
<Card className="col-span-3">
|
||||
<CardContent className="flex h-65 items-center justify-center text-sm text-muted-foreground">
|
||||
<span className="h-4 w-4 animate-spin rounded-sm border-2 border-current border-t-transparent" />
|
||||
<span className="ml-2">Memuat data...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { Users } from "lucide-react";
|
||||
import type { UserStat } from "../../../shared/api/client";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
ScrollArea,
|
||||
} from "../../../shared/ui";
|
||||
|
||||
interface UserTableProps {
|
||||
users: UserStat[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function UserTable({ users, loading }: UserTableProps) {
|
||||
if (loading && !users?.length) return <LoadingBox />;
|
||||
if (!users?.length) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
Belum ada aktivitas user.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const maxMsgs = Math.max(...users.map((u) => u.message_count), 1);
|
||||
const medals = ["🥇", "🥈", "🥉"];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Users className="h-4 w-4 text-primary" />
|
||||
User Paling Aktif
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Leaderboard berdasarkan jumlah pesan.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ScrollArea className="max-h-[260px]">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="sticky top-0 z-10 bg-white border-b border-muted/50 text-left text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<th className="py-2 pl-4 pr-2 font-semibold">#</th>
|
||||
<th className="py-2 pr-2 font-semibold">User</th>
|
||||
<th className="py-2 pr-2 font-semibold text-right">Pesan</th>
|
||||
<th className="py-2 pr-2 font-semibold text-right">Edit</th>
|
||||
<th className="py-2 pr-4 font-semibold text-right">Flag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-muted/20">
|
||||
{users.map((user, i) => (
|
||||
<tr
|
||||
key={user.user_id}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
i % 2 === 0 ? "bg-white" : "bg-muted/10",
|
||||
)}
|
||||
>
|
||||
<td className="py-1.5 pl-4 pr-2 font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||
{medals[i] ?? i + 1}
|
||||
</td>
|
||||
<td className="py-1.5 pr-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{user.avatar_url ? (
|
||||
<img
|
||||
src={user.avatar_url}
|
||||
alt=""
|
||||
className="h-6 w-6 rounded-md ring-1 ring-muted"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/10 text-[10px] font-bold text-primary">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="max-w-[100px] truncate text-xs font-medium">
|
||||
{user.username}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 pr-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<div className="h-1.5 w-14 overflow-hidden rounded-sm bg-muted/30">
|
||||
<div
|
||||
className="h-full rounded-sm bg-gradient-to-r from-primary to-sky-300"
|
||||
style={{
|
||||
width: `${(user.message_count / maxMsgs) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono text-xs tabular-nums text-foreground">
|
||||
{user.message_count}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 pr-2 text-right font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||
{user.edited_count > 0 ? user.edited_count : "—"}
|
||||
</td>
|
||||
<td className="py-1.5 pr-4 text-right">
|
||||
{user.flagged_count > 0 ? (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="text-[9px] px-1 py-0"
|
||||
>
|
||||
{user.flagged_count}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingBox() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
<span className="h-4 w-4 animate-spin rounded-sm border-2 border-current border-t-transparent" />
|
||||
<span className="ml-2">Memuat data...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import { Siren } from "lucide-react";
|
||||
import type { ViolatorStat } from "../../../shared/api/client";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
ScrollArea,
|
||||
} from "../../../shared/ui";
|
||||
|
||||
interface ViolatorTableProps {
|
||||
users: ViolatorStat[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function ViolatorTable({ users, loading }: ViolatorTableProps) {
|
||||
if (loading && !users?.length) return <LoadingBox />;
|
||||
if (!users?.length) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
Tidak ada pelanggaran terdeteksi.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const maxScore = Math.max(...users.map((u) => u.violation_score), 1);
|
||||
|
||||
function dangerLabel(score: number) {
|
||||
if (score >= 10) return { variant: "destructive" as const, text: "HIGH" };
|
||||
if (score >= 5) return { variant: "warning" as const, text: "MED" };
|
||||
return { variant: "secondary" as const, text: "LOW" };
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Siren className="h-4 w-4 text-accent" />
|
||||
Pelanggar Terbanyak
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Skor: flagged × 3 + warned. Flag terbanyak terakhir ditampilkan.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="destructive">{users.length} pelanggar</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ScrollArea className="max-h-[320px]">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="sticky top-0 z-10 bg-white border-b border-muted/50 text-left text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<th className="py-2 pl-4 pr-2 font-semibold">#</th>
|
||||
<th className="py-2 pr-2 font-semibold">User</th>
|
||||
<th className="py-2 pr-2 font-semibold text-right">Flagged</th>
|
||||
<th className="py-2 pr-2 font-semibold text-right">Warned</th>
|
||||
<th className="py-2 pr-2 font-semibold text-right">Skor</th>
|
||||
<th className="py-2 pr-4 font-semibold">Flag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-muted/20">
|
||||
{users.map((user, i) => {
|
||||
const danger = dangerLabel(user.violation_score);
|
||||
return (
|
||||
<tr
|
||||
key={user.user_id}
|
||||
className={cn(
|
||||
"transition-colors border-l-2",
|
||||
i % 2 === 0 ? "bg-white" : "bg-muted/10",
|
||||
danger.variant === "destructive"
|
||||
? "border-l-accent/60"
|
||||
: danger.variant === "warning"
|
||||
? "border-l-pink-300/60"
|
||||
: "border-l-pink-200/40",
|
||||
)}
|
||||
>
|
||||
<td className="py-1.5 pl-4 pr-2 font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||
{i + 1}
|
||||
</td>
|
||||
<td className="py-1.5 pr-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{user.avatar_url ? (
|
||||
<img
|
||||
src={user.avatar_url}
|
||||
alt=""
|
||||
className="h-6 w-6 rounded-md ring-1 ring-muted"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-accent/10 text-[10px] font-bold text-accent">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="max-w-[100px] truncate text-xs font-medium">
|
||||
{user.username}
|
||||
</span>
|
||||
<Badge
|
||||
variant={danger.variant}
|
||||
className="text-[9px] px-1 py-0"
|
||||
>
|
||||
{danger.text}
|
||||
</Badge>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 pr-2 text-right font-mono text-xs text-accent tabular-nums">
|
||||
{user.flagged_count}
|
||||
</td>
|
||||
<td className="py-1.5 pr-2 text-right font-mono text-xs text-yellow-600 tabular-nums">
|
||||
{user.warned_count > 0 ? user.warned_count : "—"}
|
||||
</td>
|
||||
<td className="py-1.5 pr-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<div className="h-1.5 w-14 overflow-hidden rounded-sm bg-muted/30">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-sm",
|
||||
user.violation_score >= 10
|
||||
? "bg-gradient-to-r from-accent to-pink-400"
|
||||
: user.violation_score >= 5
|
||||
? "bg-gradient-to-r from-pink-400 to-pink-300"
|
||||
: "bg-gradient-to-r from-pink-300 to-pink-200",
|
||||
)}
|
||||
style={{
|
||||
width: `${(user.violation_score / maxScore) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono text-xs font-bold tabular-nums text-foreground">
|
||||
{user.violation_score}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 pr-4">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.worst_flags?.length > 0 ? (
|
||||
user.worst_flags.slice(0, 3).map((flag) => (
|
||||
<Badge
|
||||
key={flag}
|
||||
variant="outline"
|
||||
className="text-[8px] px-1 py-0 border-accent/30 text-accent"
|
||||
>
|
||||
{flag}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingBox() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
<span className="h-4 w-4 animate-spin rounded-sm border-2 border-current border-t-transparent" />
|
||||
<span className="ml-2">Memuat data...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import type {
|
||||
AIStats,
|
||||
AnalyticsOverview,
|
||||
AttachmentStats,
|
||||
HeatmapCell,
|
||||
HourlyBucket,
|
||||
ModerationActionRecord,
|
||||
TopicTrend,
|
||||
TrendBucket,
|
||||
UserStat,
|
||||
ViolatorStat,
|
||||
} from "../../../shared/api/client";
|
||||
import {
|
||||
fetchAIStats,
|
||||
fetchAnalyticsOverview,
|
||||
fetchAttachmentStats,
|
||||
fetchHeatmap,
|
||||
fetchModerationActions,
|
||||
fetchTrend,
|
||||
fetchViolators,
|
||||
} from "../../../shared/api/client";
|
||||
|
||||
function analyticsKeys(
|
||||
guildId: string,
|
||||
channelId: string | undefined,
|
||||
hours: number,
|
||||
) {
|
||||
const base = [guildId, channelId ?? "", hours] as const;
|
||||
return {
|
||||
overview: ["analytics", "overview", ...base] as const,
|
||||
violators: ["analytics", "violators", ...base] as const,
|
||||
trend: ["analytics", "trend", ...base] as const,
|
||||
heatmap: ["analytics", "heatmap", ...base] as const,
|
||||
aiStats: ["analytics", "ai-stats", ...base] as const,
|
||||
attachmentStats: ["analytics", "attachment-stats", ...base] as const,
|
||||
moderationActions: ["analytics", "moderation-actions", ...base] as const,
|
||||
};
|
||||
}
|
||||
|
||||
interface UseAnalyticsOptions {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}
|
||||
|
||||
export function useAnalytics({
|
||||
guildId,
|
||||
channelId,
|
||||
hours = 24,
|
||||
}: UseAnalyticsOptions) {
|
||||
const keys = analyticsKeys(guildId, channelId, hours);
|
||||
|
||||
const overviewQuery = useQuery({
|
||||
queryKey: keys.overview,
|
||||
queryFn: () => fetchAnalyticsOverview({ guildId, channelId, hours }),
|
||||
enabled: !!guildId,
|
||||
staleTime: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const violatorsQuery = useQuery({
|
||||
queryKey: keys.violators,
|
||||
queryFn: () => fetchViolators({ guildId, channelId, hours, limit: 20 }),
|
||||
enabled: !!guildId,
|
||||
staleTime: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const trendQuery = useQuery({
|
||||
queryKey: keys.trend,
|
||||
queryFn: () => fetchTrend({ guildId, channelId, hours }),
|
||||
enabled: !!guildId,
|
||||
staleTime: 60_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const heatmapQuery = useQuery({
|
||||
queryKey: keys.heatmap,
|
||||
queryFn: () => fetchHeatmap({ guildId, channelId, hours }),
|
||||
enabled: !!guildId,
|
||||
staleTime: 60_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const aiStatsQuery = useQuery({
|
||||
queryKey: keys.aiStats,
|
||||
queryFn: () => fetchAIStats({ guildId, channelId, hours }),
|
||||
enabled: !!guildId,
|
||||
staleTime: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const attachmentStatsQuery = useQuery({
|
||||
queryKey: keys.attachmentStats,
|
||||
queryFn: () => fetchAttachmentStats({ guildId, channelId, hours }),
|
||||
enabled: !!guildId,
|
||||
staleTime: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const moderationActionsQuery = useQuery({
|
||||
queryKey: keys.moderationActions,
|
||||
queryFn: () => fetchModerationActions({ guildId, channelId, hours, limit: 50 }),
|
||||
enabled: !!guildId,
|
||||
staleTime: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!guildId) return;
|
||||
window.dispatchEvent(new CustomEvent("analytics_refresh"));
|
||||
}, [guildId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
if (!guildId) return;
|
||||
window.dispatchEvent(new CustomEvent("analytics_force_refresh"));
|
||||
};
|
||||
window.addEventListener("analytics_refresh", handler);
|
||||
return () => window.removeEventListener("analytics_refresh", handler);
|
||||
}, [refresh]);
|
||||
|
||||
const overview = overviewQuery.data ?? null;
|
||||
const isFetching = overviewQuery.isFetching && !overviewQuery.isLoading;
|
||||
const isLoading = overviewQuery.isLoading && !overviewQuery.data;
|
||||
|
||||
return {
|
||||
overview,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error:
|
||||
overviewQuery.error instanceof Error ? overviewQuery.error.message : null,
|
||||
refresh,
|
||||
|
||||
violators: violatorsQuery.data ?? [],
|
||||
violatorsLoading: violatorsQuery.isLoading && !violatorsQuery.data,
|
||||
violatorsFetching: violatorsQuery.isFetching && !violatorsQuery.isLoading,
|
||||
refreshViolators: () => {
|
||||
if (guildId) window.dispatchEvent(new CustomEvent("analytics_refresh"));
|
||||
},
|
||||
|
||||
trend: trendQuery.data ?? [],
|
||||
trendLoading: trendQuery.isLoading && !trendQuery.data,
|
||||
trendFetching: trendQuery.isFetching && !trendQuery.isLoading,
|
||||
|
||||
heatmap: heatmapQuery.data ?? [],
|
||||
heatmapLoading: heatmapQuery.isLoading && !heatmapQuery.data,
|
||||
heatmapFetching: heatmapQuery.isFetching && !heatmapQuery.isLoading,
|
||||
|
||||
aiStats: aiStatsQuery.data ?? null,
|
||||
aiStatsLoading: aiStatsQuery.isLoading && !aiStatsQuery.data,
|
||||
|
||||
attachmentStats: attachmentStatsQuery.data ?? null,
|
||||
attachmentStatsLoading:
|
||||
attachmentStatsQuery.isLoading && !attachmentStatsQuery.data,
|
||||
|
||||
moderationActions: moderationActionsQuery.data ?? [],
|
||||
moderationActionsLoading:
|
||||
moderationActionsQuery.isLoading && !moderationActionsQuery.data,
|
||||
|
||||
hourly: overview?.hourly ?? ([] as HourlyBucket[]),
|
||||
topics: overview?.topics ?? ([] as TopicTrend[]),
|
||||
topUsers: overview?.top_users ?? ([] as UserStat[]),
|
||||
messages: overview?.messages ?? null,
|
||||
period: overview?.period ?? null,
|
||||
activeUsersCount: overview?.active_users_count ?? 0,
|
||||
totalChannels: overview?.total_channels ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export type {
|
||||
AIStats,
|
||||
AnalyticsOverview,
|
||||
AttachmentStats,
|
||||
HeatmapCell,
|
||||
HourlyBucket,
|
||||
ModerationActionRecord,
|
||||
TopicTrend,
|
||||
TrendBucket,
|
||||
UserStat,
|
||||
ViolatorStat,
|
||||
};
|
||||
@@ -1,130 +0,0 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
|
||||
import { EmptyStateMascot } from "../../shared/ui";
|
||||
import { ActivityChart } from "./components/ActivityChart";
|
||||
import { AIDistributionPanel } from "./components/AIDistributionPanel";
|
||||
import { AttachmentStatsPanel } from "./components/AttachmentStatsPanel";
|
||||
import { ControlBar } from "./components/ControlBar";
|
||||
import { Heatmap } from "./components/Heatmap";
|
||||
import { ModerationActionsPanel } from "./components/ModerationActionsPanel";
|
||||
import { SummaryCards } from "./components/SummaryCards";
|
||||
import { TopicList } from "./components/TopicList";
|
||||
import { TrendChart } from "./components/TrendChart";
|
||||
import { UserTable } from "./components/UserTable";
|
||||
import { ViolatorTable } from "./components/ViolatorTable";
|
||||
import { useAnalytics } from "./hooks/useAnalytics";
|
||||
|
||||
interface AnalyticsPanelProps {
|
||||
guildId: string;
|
||||
guildName: string | null;
|
||||
}
|
||||
|
||||
export function AnalyticsPanel({ guildId, guildName }: AnalyticsPanelProps) {
|
||||
const [hours, setHours] = useState(24);
|
||||
const analytics = useAnalytics({
|
||||
guildId,
|
||||
// No channelId — analytics for all channels in the guild
|
||||
channelId: undefined,
|
||||
hours,
|
||||
});
|
||||
|
||||
const {
|
||||
hourly,
|
||||
topics,
|
||||
topUsers,
|
||||
activeUsersCount,
|
||||
totalChannels,
|
||||
violators,
|
||||
trend,
|
||||
heatmap,
|
||||
aiStats,
|
||||
attachmentStats,
|
||||
moderationActions,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refresh,
|
||||
refreshViolators,
|
||||
messages: analyticsMessages,
|
||||
} = analytics;
|
||||
const loading = isLoading && !isFetching;
|
||||
|
||||
if (error && !analyticsMessages) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-red-300/40 bg-red-50/60 p-6 text-sm text-red-600 shadow-sm">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!guildId) {
|
||||
return <EmptyStateMascot />;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex flex-col gap-4"
|
||||
variants={cardStagger}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
<motion.div variants={cardItem}>
|
||||
<ControlBar
|
||||
guildName={guildName}
|
||||
hours={hours}
|
||||
isFetching={isFetching}
|
||||
onHoursChange={setHours}
|
||||
onRefresh={() => {
|
||||
refresh();
|
||||
refreshViolators();
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.div variants={cardItem}>
|
||||
<SummaryCards
|
||||
messages={analyticsMessages}
|
||||
activeUsersCount={activeUsersCount}
|
||||
totalChannels={totalChannels}
|
||||
loading={loading}
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.div variants={cardItem}>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<ActivityChart hourly={hourly} loading={loading} />
|
||||
<div className="col-span-1">
|
||||
<TopicList topics={topics} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
{hours >= 48 && (
|
||||
<motion.div variants={cardItem}>
|
||||
<TrendChart trend={trend} loading={loading} />
|
||||
</motion.div>
|
||||
)}
|
||||
<motion.div variants={cardItem}>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Heatmap cells={heatmap} loading={loading} />
|
||||
<div className="col-span-1">
|
||||
<UserTable users={topUsers} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div variants={cardItem}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<AIDistributionPanel stats={aiStats} loading={loading} />
|
||||
<AttachmentStatsPanel stats={attachmentStats} loading={loading} />
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div variants={cardItem}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<ViolatorTable users={violators} loading={loading} />
|
||||
<ModerationActionsPanel
|
||||
actions={moderationActions}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -225,7 +225,9 @@ function MessageRow({
|
||||
{shouldShowContent ? (
|
||||
<p
|
||||
className={`whitespace-pre-wrap break-words text-sm leading-6 ${
|
||||
message.deleted_at ? "text-muted-foreground/60" : "text-foreground/90"
|
||||
message.deleted_at
|
||||
? "text-muted-foreground/60"
|
||||
: "text-foreground/90"
|
||||
}`}
|
||||
>
|
||||
{renderContentWithCustomEmojis(displayContent)}
|
||||
@@ -412,7 +414,9 @@ export function MessageCard({ messages, onReanalyze }: MessageCardProps) {
|
||||
|
||||
{/* Message rows — divided by separator when multiple */}
|
||||
<div
|
||||
className={hasMultiple ? "divide-y divide-border/30 space-y-2.5" : ""}
|
||||
className={
|
||||
hasMultiple ? "divide-y divide-border/30 space-y-2.5" : ""
|
||||
}
|
||||
>
|
||||
{messages.map((msg, idx) => (
|
||||
<div
|
||||
|
||||
@@ -2,7 +2,7 @@ import { motion } from "framer-motion";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { MessageRecord } from "../../../shared/api/client";
|
||||
import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger";
|
||||
import { ScrollArea, EmptyStateMascot } from "../../../shared/ui";
|
||||
import { EmptyStateMascot, ScrollArea } from "../../../shared/ui";
|
||||
import { MessageCard, MessageCardSkeleton } from "./MessageCard";
|
||||
|
||||
export interface MessageFeedProps {
|
||||
@@ -96,10 +96,7 @@ export function MessageFeed({
|
||||
>
|
||||
{groupedMessages.map((group) => (
|
||||
<motion.div key={group.messages[0].id} variants={cardItem}>
|
||||
<MessageCard
|
||||
messages={group.messages}
|
||||
onReanalyze={onReanalyze}
|
||||
/>
|
||||
<MessageCard messages={group.messages} onReanalyze={onReanalyze} />
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
|
||||
@@ -110,7 +110,6 @@ export function useMessages() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
const reanalyzeAllErrors = useCallback(async (): Promise<number> => {
|
||||
// Optimistically mark all error messages as pending
|
||||
setMessages((prev) =>
|
||||
|
||||
@@ -138,7 +138,7 @@ export interface UIState {
|
||||
selectedTextChannel?: string;
|
||||
selectedAnalyticsGuild?: string;
|
||||
selectedAnalyticsChannel?: string;
|
||||
activeTab?: "live" | "messages" | "analytics";
|
||||
activeTab?: "live" | "messages";
|
||||
isListening?: boolean;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
@@ -147,7 +147,7 @@ export interface AppConfig {
|
||||
monitorGuildId: string | null;
|
||||
}
|
||||
|
||||
export type DashboardTab = "live" | "messages" | "analytics";
|
||||
export type DashboardTab = "live" | "messages";
|
||||
|
||||
// ─── Messages ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -277,287 +277,3 @@ export function updateUIState(patch: Partial<UIState>): Promise<UIState> {
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Analytics ───────────────────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 interface TrendBucket {
|
||||
date: string;
|
||||
count: number;
|
||||
clean: number;
|
||||
warned: number;
|
||||
flagged: number;
|
||||
error: number;
|
||||
}
|
||||
|
||||
export interface HeatmapCell {
|
||||
dayOfWeek: number;
|
||||
hour: number;
|
||||
count: number;
|
||||
clean: number;
|
||||
warned: number;
|
||||
flagged: number;
|
||||
}
|
||||
|
||||
export function fetchAnalyticsOverview(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<AnalyticsOverview> {
|
||||
const sp = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
});
|
||||
return request<AnalyticsOverview>(`/api/analytics/overview?${sp}`);
|
||||
}
|
||||
|
||||
export function fetchHourlyStats(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<HourlyBucket[]> {
|
||||
const sp = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
});
|
||||
return request<HourlyBucket[]>(`/api/analytics/hourly?${sp}`);
|
||||
}
|
||||
|
||||
export function fetchTopicTrends(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<TopicTrend[]> {
|
||||
const sp = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
});
|
||||
return request<TopicTrend[]>(`/api/analytics/topics?${sp}`);
|
||||
}
|
||||
|
||||
export function fetchLeaderboard(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
limit?: number;
|
||||
}): Promise<UserStat[]> {
|
||||
const sp = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
...(params.limit && { limit: String(params.limit) }),
|
||||
});
|
||||
return request<UserStat[]>(`/api/analytics/leaderboard?${sp}`);
|
||||
}
|
||||
|
||||
export function fetchModerationStats(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<ModerationBreakdown> {
|
||||
const sp = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
});
|
||||
return request<ModerationBreakdown>(`/api/analytics/stats?${sp}`);
|
||||
}
|
||||
|
||||
export function fetchViolators(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
limit?: number;
|
||||
}): Promise<ViolatorStat[]> {
|
||||
const sp = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
...(params.limit && { limit: String(params.limit) }),
|
||||
});
|
||||
return request<ViolatorStat[]>(`/api/analytics/violators?${sp}`);
|
||||
}
|
||||
|
||||
export function fetchTrend(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<TrendBucket[]> {
|
||||
const sp = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
});
|
||||
return request<TrendBucket[]>(`/api/analytics/trend?${sp}`);
|
||||
}
|
||||
|
||||
export function fetchHeatmap(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<HeatmapCell[]> {
|
||||
const sp = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
});
|
||||
return request<HeatmapCell[]>(`/api/analytics/heatmap?${sp}`);
|
||||
}
|
||||
|
||||
// ── New analytics types & endpoints ────────────────────────────────────────
|
||||
|
||||
export interface ModerationActionRecord {
|
||||
id: string;
|
||||
message_id: string | null;
|
||||
user_id: string;
|
||||
guild_id: string;
|
||||
action_type: string;
|
||||
reason: string | null;
|
||||
executed_by: string | null;
|
||||
status: string;
|
||||
error: string | null;
|
||||
created_at: number;
|
||||
executed_at: number | null;
|
||||
username: string;
|
||||
content: string | null;
|
||||
}
|
||||
|
||||
export interface AISeverityBreakdown {
|
||||
none: number;
|
||||
low: number;
|
||||
medium: number;
|
||||
high: number;
|
||||
critical: number;
|
||||
}
|
||||
|
||||
export interface AIRecommendedActions {
|
||||
none: number;
|
||||
monitor: number;
|
||||
warn: number;
|
||||
review: number;
|
||||
delete: number;
|
||||
escalate: number;
|
||||
}
|
||||
|
||||
export interface AIStats {
|
||||
total_analyzed: number;
|
||||
severity: AISeverityBreakdown;
|
||||
recommended_actions: AIRecommendedActions;
|
||||
analysis_errors: number;
|
||||
analysis_pending: number;
|
||||
avg_confidence: number;
|
||||
avg_score: number;
|
||||
}
|
||||
|
||||
export interface AttachmentStats {
|
||||
total_attachments: number;
|
||||
uploaded: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
total_size_bytes: number;
|
||||
unique_uploaders: number;
|
||||
top_mime_type: string | null;
|
||||
}
|
||||
|
||||
export function fetchModerationActions(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
limit?: number;
|
||||
}): Promise<ModerationActionRecord[]> {
|
||||
const sp = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
...(params.limit && { limit: String(params.limit) }),
|
||||
});
|
||||
return request<ModerationActionRecord[]>(
|
||||
`/api/analytics/moderation-actions?${sp}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchAIStats(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<AIStats> {
|
||||
const sp = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
});
|
||||
return request<AIStats>(`/api/analytics/ai-stats?${sp}`);
|
||||
}
|
||||
|
||||
export function fetchAttachmentStats(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<AttachmentStats> {
|
||||
const sp = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
});
|
||||
return request<AttachmentStats>(`/api/analytics/attachment-stats?${sp}`);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// ─── Audio transmit hook — captures mic, encodes to PCM, sends via WebSocket ──
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { getAPIURL } from "../api/client";
|
||||
import { createChildLogger } from "../logger";
|
||||
|
||||
const SAMPLE_RATE = 24000;
|
||||
const logger = createChildLogger("useAudioTransmit");
|
||||
|
||||
async function sendTransmitCommand(command: string): Promise<void> {
|
||||
// Send via HTTP API
|
||||
@@ -12,9 +14,12 @@ async function sendTransmitCommand(command: string): Promise<void> {
|
||||
body: JSON.stringify({ command }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn("HTTP command response:", resp.status, resp.statusText);
|
||||
logger.warn("HTTP command response", {
|
||||
status: resp.status,
|
||||
statusText: resp.statusText,
|
||||
});
|
||||
const text = await resp.text().catch(() => resp.statusText);
|
||||
console.warn("HTTP command failed:", text);
|
||||
logger.warn("HTTP command failed", { error: text });
|
||||
throw new Error(`HTTP ${resp.status}: ${text}`);
|
||||
}
|
||||
}
|
||||
@@ -72,16 +77,18 @@ export function useAudioTransmit(socketRef: {
|
||||
|
||||
// Base64 encode
|
||||
const bytes = new Uint8Array(pcmData.buffer);
|
||||
let binary = '';
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
const base64 = btoa(binary);
|
||||
|
||||
socketRef.current.send(JSON.stringify({
|
||||
type: 'voice_transmit',
|
||||
buffer: base64
|
||||
}));
|
||||
socketRef.current.send(
|
||||
JSON.stringify({
|
||||
type: "voice_transmit",
|
||||
buffer: base64,
|
||||
}),
|
||||
);
|
||||
};
|
||||
}, [socketRef]);
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { createChildLogger } from "../logger";
|
||||
|
||||
const logger = createChildLogger("useMascotChat");
|
||||
|
||||
export interface ChatContext {
|
||||
messageCount: number;
|
||||
@@ -28,7 +31,7 @@ export function useMascotChat(context?: ChatContext) {
|
||||
const data = (await response.json()) as { response?: string };
|
||||
return data.response || fallbackResponse(message, context);
|
||||
} catch (error) {
|
||||
console.warn("Mascot backend unavailable, using fallback", error);
|
||||
logger.warn("Mascot backend unavailable, using fallback", { error });
|
||||
return fallbackResponse(message, context);
|
||||
}
|
||||
},
|
||||
@@ -53,7 +56,10 @@ function fallbackResponse(input: string, context?: ChatContext): string {
|
||||
return `Ada ${context?.messageCount || 0} pesan di konteks dashboard saat ini 📊`;
|
||||
}
|
||||
|
||||
if (lower.includes("berapa") && (lower.includes("orang") || lower.includes("user"))) {
|
||||
if (
|
||||
lower.includes("berapa") &&
|
||||
(lower.includes("orang") || lower.includes("user"))
|
||||
) {
|
||||
return `Ada ${context?.activeParticipants || 0} user aktif yang terdeteksi 👥`;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ function generateInsight(messages: MessageRecord[]): string {
|
||||
// Hitung average panjang pesan
|
||||
const avgLength = Math.round(
|
||||
recentMessages.reduce((sum, m) => sum + (m.content?.length || 0), 0) /
|
||||
recentMessages.length
|
||||
recentMessages.length,
|
||||
);
|
||||
|
||||
// Tentukan tipe percakapan
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Simple logger for frontend - structured logging wrapper
|
||||
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
interface LogContext {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
class Logger {
|
||||
constructor(private context: string) {}
|
||||
|
||||
private log(level: LogLevel, message: string, context?: LogContext) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const logData = {
|
||||
level,
|
||||
context: this.context,
|
||||
message,
|
||||
timestamp,
|
||||
...context,
|
||||
};
|
||||
|
||||
// Use appropriate console method
|
||||
const consoleMethod = console[level] || console.log;
|
||||
consoleMethod(
|
||||
`[${level.toUpperCase()}] [${this.context}]`,
|
||||
message,
|
||||
context || "",
|
||||
);
|
||||
}
|
||||
|
||||
debug(message: string, context?: LogContext) {
|
||||
this.log("debug", message, context);
|
||||
}
|
||||
|
||||
info(message: string, context?: LogContext) {
|
||||
this.log("info", message, context);
|
||||
}
|
||||
|
||||
warn(message: string, context?: LogContext) {
|
||||
this.log("warn", message, context);
|
||||
}
|
||||
|
||||
error(message: string, context?: LogContext) {
|
||||
this.log("error", message, context);
|
||||
}
|
||||
}
|
||||
|
||||
export function createChildLogger(context: string): Logger {
|
||||
return new Logger(context);
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { BarChart3, MessageSquare, Radio } from "lucide-react";
|
||||
import { MessageSquare, Radio } from "lucide-react";
|
||||
import type { DashboardTab } from "../../entities/ui/types";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const tabs: Array<{ id: DashboardTab; label: string; Icon: typeof Radio }> = [
|
||||
{ id: "live", label: "Live", Icon: Radio },
|
||||
{ id: "messages", label: "Messages", Icon: MessageSquare },
|
||||
{ id: "analytics", label: "Analytics", Icon: BarChart3 },
|
||||
];
|
||||
|
||||
interface MobileTabBarProps {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// ─── Shared UI barrel export ────────────────────────────────────────────────
|
||||
|
||||
export { EmptyStateMascot, MascotImage } from "../../widgets/mascot/MascotImage";
|
||||
export {
|
||||
EmptyStateMascot,
|
||||
MascotImage,
|
||||
} from "../../widgets/mascot/MascotImage";
|
||||
export { Badge } from "./badge";
|
||||
export { Button } from "./button";
|
||||
export {
|
||||
|
||||
@@ -157,10 +157,8 @@ export function useDashboardSocket(handlers: WsHandlers) {
|
||||
handlersRef.current.onVoiceRecordingStarted?.(d),
|
||||
onVoiceRecordingStopped: (d) =>
|
||||
handlersRef.current.onVoiceRecordingStopped?.(d),
|
||||
onVoicePcmData: (d) =>
|
||||
handlersRef.current.onVoicePcmData?.(d),
|
||||
onVoiceActiveUser: (d) =>
|
||||
handlersRef.current.onVoiceActiveUser?.(d),
|
||||
onVoicePcmData: (d) => handlersRef.current.onVoicePcmData?.(d),
|
||||
onVoiceActiveUser: (d) => handlersRef.current.onVoiceActiveUser?.(d),
|
||||
};
|
||||
|
||||
_listeners.add(wrapper);
|
||||
|
||||
@@ -10,13 +10,11 @@ import type { WsStatus } from "../shared/ws/socket";
|
||||
const titles: Record<DashboardTab, string> = {
|
||||
live: "Voice, Media & Recordings",
|
||||
messages: "Messages & Moderation",
|
||||
analytics: "Analytics & Insights",
|
||||
};
|
||||
|
||||
const subtitles: Record<DashboardTab, string> = {
|
||||
live: "Join voice channels, play media, stream audio, and browse recordings.",
|
||||
messages: "Capture, analyse, and moderate Discord messages.",
|
||||
analytics: "Server moderation statistics and trends.",
|
||||
};
|
||||
|
||||
interface HeaderProps {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { BarChart3, MessageSquare, Radio } from "lucide-react";
|
||||
import { MessageSquare, Radio } from "lucide-react";
|
||||
import type { DashboardTab } from "../entities/ui/types";
|
||||
import type { MessageRecord } from "../shared/api/client";
|
||||
import { useMascotChat } from "../shared/hooks/useMascotChat";
|
||||
@@ -11,7 +11,6 @@ const navItems: Array<{ id: DashboardTab; label: string; icon: typeof Radio }> =
|
||||
[
|
||||
{ id: "live", label: "Live", icon: Radio },
|
||||
{ id: "messages", label: "Messages", icon: MessageSquare },
|
||||
{ id: "analytics", label: "Analytics", icon: BarChart3 },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -37,7 +36,7 @@ export function Sidebar({
|
||||
recentMessages.map((message) => message.user_id),
|
||||
).size,
|
||||
lastActivity: recentMessages.length > 0 ? "Active" : "Idle",
|
||||
topicsDiscussed: ["Messages", "Moderation", "Analytics"],
|
||||
topicsDiscussed: ["Messages", "Moderation"],
|
||||
guildId,
|
||||
channelId,
|
||||
});
|
||||
|
||||
@@ -32,7 +32,8 @@ export function MascotChatbot({
|
||||
{
|
||||
id: "init-1",
|
||||
role: "mascot",
|
||||
content: "Halo! 👋 Saya mascot mu. Ada yang bisa aku bantu tentang conversation atau analytics?",
|
||||
content:
|
||||
"Halo! 👋 Saya mascot mu. Ada yang bisa aku bantu tentang conversation atau analytics?",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
@@ -162,7 +163,7 @@ export function MascotChatbot({
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={cn(
|
||||
"flex gap-2",
|
||||
message.role === "user" ? "justify-end" : "justify-start"
|
||||
message.role === "user" ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
{message.role === "mascot" && (
|
||||
@@ -177,7 +178,7 @@ export function MascotChatbot({
|
||||
"max-w-xs px-3 py-2 rounded-xl text-sm break-words",
|
||||
message.role === "user"
|
||||
? "bg-primary text-white rounded-br-none"
|
||||
: "bg-muted text-foreground rounded-bl-none"
|
||||
: "bg-muted text-foreground rounded-bl-none",
|
||||
)}
|
||||
>
|
||||
{message.content}
|
||||
@@ -204,12 +205,20 @@ export function MascotChatbot({
|
||||
/>
|
||||
<motion.div
|
||||
animate={{ y: [0, -4, 0] }}
|
||||
transition={{ duration: 0.6, repeat: Infinity, delay: 0.1 }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
repeat: Infinity,
|
||||
delay: 0.1,
|
||||
}}
|
||||
className="w-2 h-2 bg-muted-foreground rounded-full"
|
||||
/>
|
||||
<motion.div
|
||||
animate={{ y: [0, -4, 0] }}
|
||||
transition={{ duration: 0.6, repeat: Infinity, delay: 0.2 }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
repeat: Infinity,
|
||||
delay: 0.2,
|
||||
}}
|
||||
className="w-2 h-2 bg-muted-foreground rounded-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -252,15 +261,20 @@ export function MascotChatbot({
|
||||
}
|
||||
|
||||
// Default mascot responses based on keywords
|
||||
function generateMascotResponse(input: string, messages: ChatMessage[]): string {
|
||||
function generateMascotResponse(
|
||||
input: string,
|
||||
messages: ChatMessage[],
|
||||
): string {
|
||||
const lowerInput = input.toLowerCase();
|
||||
const responseMap: Record<string, string> = {
|
||||
halo: "Halo juga! 👋 Senang ketemu kamu. Ada yang bisa aku bantu?",
|
||||
terima: "Sama-sama! 😊",
|
||||
apa: "Aku adalah mascot virtual yang membantu kamu memahami conversation dan analytics. Tanya aku apa saja!",
|
||||
siapa: "Aku mascot mu yang baik hati! Siap membantu dengan insights tentang chat dan analytics.",
|
||||
siapa:
|
||||
"Aku mascot mu yang baik hati! Siap membantu dengan insights tentang chat dan analytics.",
|
||||
chat: "Setiap chat yang terjadi di sini aku analisis untuk memberikan insights yang berguna. Keren kan? 😎",
|
||||
pesan: "Aku bisa memberikan ringkasan tentang pesan-pesan yang dikirim, siapa yang paling aktif, dan topik populer!",
|
||||
pesan:
|
||||
"Aku bisa memberikan ringkasan tentang pesan-pesan yang dikirim, siapa yang paling aktif, dan topik populer!",
|
||||
analitik:
|
||||
"Analytics menunjukkan pola conversation, waktu aktif, partisipan utama, dan banyak hal menarik lainnya! 📊",
|
||||
berapa:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { MessageCircle } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* MascotImage — Anime mascot PNG from GitHub CDN
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -16,6 +16,10 @@ export default defineConfig({
|
||||
preview: {
|
||||
port: 3000,
|
||||
host: true,
|
||||
allowedHosts: ["imphnen.asepharyana.my.id", "imphnen.asepharyana.tech", "imphnen.asepharyana.web.id"],
|
||||
allowedHosts: [
|
||||
"imphnen.asepharyana.my.id",
|
||||
"imphnen.asepharyana.tech",
|
||||
"imphnen.asepharyana.web.id",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user