diff --git a/.env.example b/.env.example index 7257e88..3fdd43a 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,8 @@ # Discord Bot Configuration DISCORD_TOKEN=your_bot_token_here +MONITOR_GUILD_ID=your_guild_id_here +TEXT_GUILD_ID=optional_text_guild_id +TEXT_CHANNEL_ID=optional_text_channel_id # Recording Configuration RECORDINGS_DIR=./recordings @@ -50,6 +53,39 @@ AI_LLM_BASE_URL=https://9router.asepharyana.tech/v1 AI_LLM_MODEL=free # Vision model for image/video moderation (falls back to AI_LLM_MODEL if unset) AI_LLM_VISION_MODEL=multimodal +# Max concurrent LLM API calls (default: 5) +AI_LLM_MAX_CONCURRENT=5 +# Maximum image dimension in pixels before resize for vision API (default: 1024) +AI_LLM_IMAGE_MAX_DIMENSION=1024 +# Maximum messages per text-only moderation batch (default: 20) +AI_LLM_TEXT_BATCH_SIZE=20 +# Timeout in ms for individual media analysis calls (default: 60000) +AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS=60000 + +# AI Moderation Analysis Tuning (advanced) +AI_ANALYSIS_DEBOUNCE_MS=500 +AI_ANALYSIS_RECOVERY_INTERVAL_MS=15000 +AI_ANALYSIS_ERROR_COOLDOWN_MS=30000 +# Max messages fetched per conversation batch (default: 200) +AI_ANALYSIS_MAX_BATCH_SIZE=200 +AI_ANALYSIS_MAX_CONTEXT_TOKENS=8000 +# Token budget for target messages specifically (default: 4000) +AI_ANALYSIS_MAX_TARGET_TOKENS=4000 +AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT=20 +# How long a conversation is locked while being processed (default: 120000ms) +AI_ANALYSIS_PROCESSING_TIMEOUT_MS=120000 +# Max concurrent individual-fallback jobs (default: 50) +AI_ANALYSIS_INDIVIDUAL_MAX_CONCURRENT=50 +# Consecutive errors before individual circuit breaker trips (default: 50) +AI_ANALYSIS_INDIVIDUAL_CB_THRESHOLD=50 + +# OpenAI Moderation (optional separate provider) +# OPENAI_MODERATION_API_KEY=your_key_here +# OPENAI_MODERATION_BASE_URL=https://api.openai.com/v1 +# OPENAI_MODERATION_MODEL=omni-moderation-latest + +# Admin +ADMIN_PASSWORD=admin123 # Database Configuration (PostgreSQL) # Option 1: Use DATABASE_URL for connection string @@ -72,22 +108,36 @@ AI_LLM_VISION_MODEL=multimodal # Auto-Delete Configuration AUTO_DELETE_FLAGGED_ENABLED=true AUTO_DELETE_FLAGGED_DRY_RUN=true +AUTO_DELETE_FLAGGED_DELAY_MS=0 AUTO_DELETE_MIN_CONFIDENCE=0.50 AUTO_DELETE_ALLOWED_SEVERITIES=critical,high,medium +AUTO_DELETE_NOTIFY_USER=false # Optional: comma-separated channel/user IDs to exclude # AUTO_DELETE_EXCLUDED_CHANNEL_IDS= # AUTO_DELETE_EXCLUDED_USER_IDS= # Optional: comma-separated category filter (empty = all categories) # AUTO_DELETE_ALLOWED_CATEGORIES= +# Optional: log channel ID for auto-delete actions +# AUTO_DELETE_LOG_CHANNEL_ID= + +# Retention Configuration (0 = disabled) +RETENTION_MESSAGES_DAYS=0 +RETENTION_ATTACHMENTS_DAYS=0 +RETENTION_VOICE_DAYS=0 +# Cleanup interval in ms (default: 24h) +RETENTION_CLEANUP_INTERVAL_MS=86400000 +RETENTION_DRY_RUN=true # Database Migration Configuration # Safe default: run migrations on startup before the app accepts traffic. AUTO_MIGRATE_ON_STARTUP=true +# Worker Pool Configuration +# PISCINA_MAX_THREADS=4 + # Cache Model Versioning # Bump this version when the vision/LLM model prompt changes significantly. # Old cache entries with mismatched versions are automatically ignored, forcing fresh analysis. # Format: "v" or "v--" # Example progression: v1 → v2-2026-06-02-terminal-fix → v3-2026-06-15-new-model # CACHE_MODEL_VERSION=v2-2026-06-02 - diff --git a/packages/shared/src/errors/index.ts b/packages/shared/src/errors/index.ts index 9737724..bb01bfb 100644 --- a/packages/shared/src/errors/index.ts +++ b/packages/shared/src/errors/index.ts @@ -21,11 +21,7 @@ export class ValidationError extends AppError { export class NotFoundError extends AppError { constructor(resource: string, id?: string) { - super( - `${resource} not found${id ? `: ${id}` : ""}`, - "NOT_FOUND", - 404, - ); + super(`${resource} not found${id ? `: ${id}` : ""}`, "NOT_FOUND", 404); this.name = "NotFoundError"; } } diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 47d2bd8..c5d233a 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -96,7 +96,7 @@ export async function retryWithBackoff( err.name = "AbortError"; throw err; } - + try { return await fn(); } catch (err) { @@ -109,7 +109,7 @@ export async function retryWithBackoff( minTimeout * factor ** attempt + Math.random() * 100, maxTimeout, ); - + await new Promise((resolve, reject) => { let timeoutId: NodeJS.Timeout; const onAbort = () => { @@ -119,12 +119,12 @@ export async function retryWithBackoff( reject(abortErr); }; if (signal?.aborted) return onAbort(); - + timeoutId = setTimeout(() => { if (signal) signal.removeEventListener("abort", onAbort); resolve(); }, backoff); - + if (signal) signal.addEventListener("abort", onAbort, { once: true }); }); } diff --git a/scripts/migrate-data.ts b/scripts/migrate-data.ts index 854372c..f05e8b6 100644 --- a/scripts/migrate-data.ts +++ b/scripts/migrate-data.ts @@ -1,7 +1,7 @@ import path from "node:path"; import Database from "better-sqlite3"; -import { createChildLogger } from "../src/logger"; -import * as postgres from "../src/database/postgres"; +import { createChildLogger } from "@bete/shared/logger"; +import { getPool, closeDatabase, initializeDatabase } from "../services/backend/src/shared/database/index.js"; const logger = createChildLogger("migrate-data"); @@ -76,7 +76,8 @@ async function migrateData(): Promise { logger.info({ dbPath }, "SQLite database opened"); // Initialize PostgreSQL pool - const pool = postgres.getPool(); + await initializeDatabase(); + const pool = getPool(); logger.info("PostgreSQL connection pool initialized"); // Migrate muxer_jobs table @@ -85,7 +86,7 @@ async function migrateData(): Promise { const muxerJobs = muxerJobsStmt.all() as MuxerJob[]; for (const job of muxerJobs) { - await postgres.query( + await pool.query( `INSERT INTO muxer_jobs (id, data, status, attempts, maxAttempts, createdAt, updatedAt, error) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (id) DO NOTHING`, @@ -109,7 +110,7 @@ async function migrateData(): Promise { const messages = messagesStmt.all() as Message[]; for (const msg of messages) { - await postgres.query( + await pool.query( `INSERT INTO messages ( id, guild_id, channel_id, thread_id, user_id, username, avatar_url, content, edited_content, created_at, edited_at, deleted_at, type, @@ -153,7 +154,7 @@ async function migrateData(): Promise { const attachments = attachmentsStmt.all() as Attachment[]; for (const att of attachments) { - await postgres.query( + await pool.query( `INSERT INTO attachments ( id, message_id, guild_id, channel_id, thread_id, user_id, filename, size, type, discord_url, uploaded_url, upload_status, upload_error, @@ -189,7 +190,7 @@ async function migrateData(): Promise { const uiStates = uiStateStmt.all() as UiState[]; for (const state of uiStates) { - await postgres.query( + await pool.query( `INSERT INTO ui_state (key, value, updated_at) VALUES ($1, $2, $3) ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, @@ -224,7 +225,7 @@ async function migrateData(): Promise { } // Close PostgreSQL pool - await postgres.closePool(); + await closeDatabase(); logger.info("PostgreSQL connection pool closed"); } } diff --git a/services/backend/src/http/app.ts b/services/backend/src/http/app.ts index bff9c24..8ab7a71 100644 --- a/services/backend/src/http/app.ts +++ b/services/backend/src/http/app.ts @@ -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()); diff --git a/services/backend/src/http/server.ts b/services/backend/src/http/server.ts index 3e6eb2b..732e4ae 100644 --- a/services/backend/src/http/server.ts +++ b/services/backend/src/http/server.ts @@ -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"); diff --git a/services/backend/src/index.ts b/services/backend/src/index.ts index dca988c..903a0cf 100644 --- a/services/backend/src/index.ts +++ b/services/backend/src/index.ts @@ -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"); diff --git a/services/backend/src/modules/analysis/analysis.repository.ts b/services/backend/src/modules/analysis/analysis.repository.ts new file mode 100644 index 0000000..86e0d41 --- /dev/null +++ b/services/backend/src/modules/analysis/analysis.repository.ts @@ -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): 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 { + 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)); + } +} + +export const analysisRepository = new AnalysisRepository(); diff --git a/services/backend/src/modules/analysis/analysis.routes.ts b/services/backend/src/modules/analysis/analysis.routes.ts index eb2d528..92ac45e 100644 --- a/services/backend/src/modules/analysis/analysis.routes.ts +++ b/services/backend/src/modules/analysis/analysis.routes.ts @@ -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"; diff --git a/services/backend/src/modules/analysis/analysis.schema.ts b/services/backend/src/modules/analysis/analysis.schema.ts new file mode 100644 index 0000000..339e8ef --- /dev/null +++ b/services/backend/src/modules/analysis/analysis.schema.ts @@ -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; diff --git a/services/backend/src/modules/analysis/analysis.service.ts b/services/backend/src/modules/analysis/analysis.service.ts index 92a6b61..97fca20 100644 --- a/services/backend/src/modules/analysis/analysis.service.ts +++ b/services/backend/src/modules/analysis/analysis.service.ts @@ -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 }; } } diff --git a/services/backend/src/modules/analytics/analytics.controller.ts b/services/backend/src/modules/analytics/analytics.controller.ts deleted file mode 100644 index 99017d9..0000000 --- a/services/backend/src/modules/analytics/analytics.controller.ts +++ /dev/null @@ -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); -} diff --git a/services/backend/src/modules/analytics/analytics.repository.ts b/services/backend/src/modules/analytics/analytics.repository.ts deleted file mode 100644 index 93b02bb..0000000 --- a/services/backend/src/modules/analytics/analytics.repository.ts +++ /dev/null @@ -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; - paramOffset: number; -} - -function buildTimeFilter( - guildId: string, - channelId: string | undefined, - hours: number, - offset = 1, -): TimeFilter { - const clauses: string[] = ["guild_id = $" + offset]; - const params: Array = [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 | 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 | 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 | 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 | 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(); diff --git a/services/backend/src/modules/analytics/analytics.routes.ts b/services/backend/src/modules/analytics/analytics.routes.ts deleted file mode 100644 index ce36e54..0000000 --- a/services/backend/src/modules/analytics/analytics.routes.ts +++ /dev/null @@ -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; -} diff --git a/services/backend/src/modules/analytics/analytics.schema.ts b/services/backend/src/modules/analytics/analytics.schema.ts deleted file mode 100644 index 33b835a..0000000 --- a/services/backend/src/modules/analytics/analytics.schema.ts +++ /dev/null @@ -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; diff --git a/services/backend/src/modules/analytics/analytics.service.ts b/services/backend/src/modules/analytics/analytics.service.ts deleted file mode 100644 index 4ade019..0000000 --- a/services/backend/src/modules/analytics/analytics.service.ts +++ /dev/null @@ -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(); diff --git a/services/backend/src/modules/auth/auth.routes.ts b/services/backend/src/modules/auth/auth.routes.ts index 54842f1..10ec58f 100644 --- a/services/backend/src/modules/auth/auth.routes.ts +++ b/services/backend/src/modules/auth/auth.routes.ts @@ -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"); diff --git a/services/backend/src/modules/health/health.repository.ts b/services/backend/src/modules/health/health.repository.ts index efe19e8..6054c04 100644 --- a/services/backend/src/modules/health/health.repository.ts +++ b/services/backend/src/modules/health/health.repository.ts @@ -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"); diff --git a/services/backend/src/modules/mascot-chat/mascot-chat.controller.ts b/services/backend/src/modules/mascot-chat/mascot-chat.controller.ts index a343196..3355ab8 100644 --- a/services/backend/src/modules/mascot-chat/mascot-chat.controller.ts +++ b/services/backend/src/modules/mascot-chat/mascot-chat.controller.ts @@ -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({ diff --git a/services/backend/src/modules/mascot-chat/mascot-chat.repository.ts b/services/backend/src/modules/mascot-chat/mascot-chat.repository.ts new file mode 100644 index 0000000..1e516aa --- /dev/null +++ b/services/backend/src/modules/mascot-chat/mascot-chat.repository.ts @@ -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 { + 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 { + 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 { + await this.ensureSchema(); + const pool = getPool(); + + const { rows } = await pool.query( + ` + 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 { + 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 { + 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( + ` + 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(); diff --git a/services/backend/src/modules/mascot-chat/mascot-chat.schema.ts b/services/backend/src/modules/mascot-chat/mascot-chat.schema.ts new file mode 100644 index 0000000..d0d50fe --- /dev/null +++ b/services/backend/src/modules/mascot-chat/mascot-chat.schema.ts @@ -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; +export type ChatResponse = z.infer; +export type ChatContext = z.infer; +export type ChatHistoryQuery = z.infer; diff --git a/services/backend/src/modules/mascot-chat/mascot-chat.service.ts b/services/backend/src/modules/mascot-chat/mascot-chat.service.ts index 32cea58..e55f5e1 100644 --- a/services/backend/src/modules/mascot-chat/mascot-chat.service.ts +++ b/services/backend/src/modules/mascot-chat/mascot-chat.service.ts @@ -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 { - 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 { - 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 { - await this.ensureSchema(); - const pool = getPool(); - - const { rows } = await pool.query( - ` - 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 { - await this.ensureSchema(); - const pool = getPool(); - await pool.query(`DELETE FROM mascot_chat_messages WHERE user_id = $1`, [ - userId, - ]); - } - - private async ensureSchema(): Promise { - 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 { - 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; diff --git a/services/backend/src/modules/media/media.routes.ts b/services/backend/src/modules/media/media.routes.ts index 6b85bb0..1ac4568 100644 --- a/services/backend/src/modules/media/media.routes.ts +++ b/services/backend/src/modules/media/media.routes.ts @@ -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"); diff --git a/services/backend/src/modules/messages/messages.controller.ts b/services/backend/src/modules/messages/messages.controller.ts index 7e57ab3..f7f4938 100644 --- a/services/backend/src/modules/messages/messages.controller.ts +++ b/services/backend/src/modules/messages/messages.controller.ts @@ -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( diff --git a/services/backend/src/modules/messages/messages.repository.ts b/services/backend/src/modules/messages/messages.repository.ts index 639810c..0a3bbc7 100644 --- a/services/backend/src/modules/messages/messages.repository.ts +++ b/services/backend/src/modules/messages/messages.repository.ts @@ -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) { } export class MessagesRepository { - async findMany(query: MessageQuery): Promise>> { + async findMany( + query: MessageQuery, + ): Promise>> { 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); @@ -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 { + 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[]> { + 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 { 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 }; diff --git a/services/backend/src/modules/messages/messages.routes.ts b/services/backend/src/modules/messages/messages.routes.ts index 4d66dd4..60033a8 100644 --- a/services/backend/src/modules/messages/messages.routes.ts +++ b/services/backend/src/modules/messages/messages.routes.ts @@ -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; - // 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(), diff --git a/services/backend/src/modules/messages/messages.service.ts b/services/backend/src/modules/messages/messages.service.ts index dc82fae..210cee9 100644 --- a/services/backend/src/modules/messages/messages.service.ts +++ b/services/backend/src/modules/messages/messages.service.ts @@ -46,12 +46,33 @@ export class MessagesService { return messagesRepository.getAttachmentsByChannel(channelId, query); } + async markForReanalysis(id: string): Promise { + 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[]> { + 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", ); diff --git a/services/backend/src/modules/recordings/recordings.routes.ts b/services/backend/src/modules/recordings/recordings.routes.ts index bb6871d..382d79f 100644 --- a/services/backend/src/modules/recordings/recordings.routes.ts +++ b/services/backend/src/modules/recordings/recordings.routes.ts @@ -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"; diff --git a/services/backend/src/modules/recordings/recordings.service.ts b/services/backend/src/modules/recordings/recordings.service.ts index 84c2e83..1c0dbb3 100644 --- a/services/backend/src/modules/recordings/recordings.service.ts +++ b/services/backend/src/modules/recordings/recordings.service.ts @@ -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"); diff --git a/services/backend/src/modules/ui-state/ui-state.routes.ts b/services/backend/src/modules/ui-state/ui-state.routes.ts index 661e038..389e80d 100644 --- a/services/backend/src/modules/ui-state/ui-state.routes.ts +++ b/services/backend/src/modules/ui-state/ui-state.routes.ts @@ -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"; diff --git a/services/backend/src/modules/ui-state/ui-state.service.ts b/services/backend/src/modules/ui-state/ui-state.service.ts index 3252e1b..bb75508 100644 --- a/services/backend/src/modules/ui-state/ui-state.service.ts +++ b/services/backend/src/modules/ui-state/ui-state.service.ts @@ -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"); diff --git a/services/backend/src/modules/voice/voice.schema.ts b/services/backend/src/modules/voice/voice.schema.ts new file mode 100644 index 0000000..e368cd7 --- /dev/null +++ b/services/backend/src/modules/voice/voice.schema.ts @@ -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; +export type ConnectVoice = z.infer; +export type Guild = z.infer; +export type Channel = z.infer; +export type VoiceStatus = z.infer; diff --git a/services/backend/src/modules/voice/voice.service.ts b/services/backend/src/modules/voice/voice.service.ts index 7920d29..1b0c8ab 100644 --- a/services/backend/src/modules/voice/voice.service.ts +++ b/services/backend/src/modules/voice/voice.service.ts @@ -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 { const reply = await publishCommand("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 { const reply = await publishCommand("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 { 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, + } + ); } diff --git a/services/backend/src/shared/database/index.ts b/services/backend/src/shared/database/index.ts index 78b0fa9..30bc08e 100644 --- a/services/backend/src/shared/database/index.ts +++ b/services/backend/src/shared/database/index.ts @@ -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"); diff --git a/services/backend/src/shared/middlewares/index.ts b/services/backend/src/shared/middlewares/index.ts index 38b0350..77a710c 100644 --- a/services/backend/src/shared/middlewares/index.ts +++ b/services/backend/src/shared/middlewares/index.ts @@ -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}`); } diff --git a/services/backend/src/shared/redis/index.ts b/services/backend/src/shared/redis/index.ts index 94dc3e4..251c0fe 100644 --- a/services/backend/src/shared/redis/index.ts +++ b/services/backend/src/shared/redis/index.ts @@ -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( timeoutMs = 5000, ): Promise | 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 | null>((resolve) => { const pub = getPublisher(); @@ -88,7 +96,9 @@ export async function publishCommand( 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( if (channel !== replyChannel || settled) return; settled = true; clearTimeout(timer); - sub.unsubscribe(replyChannel).catch(() => {/* ignore */}); + sub.unsubscribe(replyChannel).catch(() => { + /* ignore */ + }); try { const reply: CommandReply = 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( 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 = {}, ): Promise { 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 | null> { +export async function readRedisStatus( + key: string, +): Promise | null> { if (!ensureRedisConfig()) { return null; } diff --git a/services/backend/src/ws/redis-bridge.ts b/services/backend/src/ws/redis-bridge.ts index 651642d..4cce247 100644 --- a/services/backend/src/ws/redis-bridge.ts +++ b/services/backend/src/ws/redis-bridge.ts @@ -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"); diff --git a/services/backend/src/ws/server.ts b/services/backend/src/ws/server.ts index 34b73e8..56cf81d 100644 --- a/services/backend/src/ws/server.ts +++ b/services/backend/src/ws/server.ts @@ -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"); } } }); diff --git a/services/discord-gateway/README.md b/services/discord-gateway/README.md index 89a00e6..3a2a75d 100644 --- a/services/discord-gateway/README.md +++ b/services/discord-gateway/README.md @@ -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` diff --git a/services/discord-gateway/src/app/bootstrap.ts b/services/discord-gateway/src/app/bootstrap.ts index 1777927..9603fa0 100644 --- a/services/discord-gateway/src/app/bootstrap.ts +++ b/services/discord-gateway/src/app/bootstrap.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"); diff --git a/services/discord-gateway/src/index.ts b/services/discord-gateway/src/index.ts index a4ece8b..b34bd06 100644 --- a/services/discord-gateway/src/index.ts +++ b/services/discord-gateway/src/index.ts @@ -1,4 +1,3 @@ -import "./mock-crc.js"; import "libsodium-wrappers"; import "@snazzah/davey"; import "dotenv/config"; diff --git a/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts b/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts index 0913d56..fa69b34 100644 --- a/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts +++ b/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts @@ -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) diff --git a/services/discord-gateway/src/modules/ai-moderation/channelCultureStore.ts b/services/discord-gateway/src/modules/ai-moderation/channelCultureStore.ts index eb026cf..9f91b9a 100644 --- a/services/discord-gateway/src/modules/ai-moderation/channelCultureStore.ts +++ b/services/discord-gateway/src/modules/ai-moderation/channelCultureStore.ts @@ -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"; /** diff --git a/services/discord-gateway/src/modules/ai-moderation/cultureLearner.ts b/services/discord-gateway/src/modules/ai-moderation/cultureLearner.ts index 127195d..a3e5863 100644 --- a/services/discord-gateway/src/modules/ai-moderation/cultureLearner.ts +++ b/services/discord-gateway/src/modules/ai-moderation/cultureLearner.ts @@ -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"); diff --git a/services/discord-gateway/src/modules/ai-moderation/llmClient.ts b/services/discord-gateway/src/modules/ai-moderation/llmClient.ts index e264951..e21732b 100644 --- a/services/discord-gateway/src/modules/ai-moderation/llmClient.ts +++ b/services/discord-gateway/src/modules/ai-moderation/llmClient.ts @@ -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); } diff --git a/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts b/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts index 9e75841..e113786 100644 --- a/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts +++ b/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts @@ -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(); const mediaAnalysisMap = new Map(); - 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, +): Promise { + 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, + mediaAnalysisMap: Map, +): Promise { + 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, + urlWebTexts: string[], +): Promise { + 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, + 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, +): 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, + }), + ), + ]; +} diff --git a/services/discord-gateway/src/modules/ai-moderation/stickerCache.ts b/services/discord-gateway/src/modules/ai-moderation/stickerCache.ts index f1f5b9e..e15c5c5 100644 --- a/services/discord-gateway/src/modules/ai-moderation/stickerCache.ts +++ b/services/discord-gateway/src/modules/ai-moderation/stickerCache.ts @@ -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"); diff --git a/services/discord-gateway/src/modules/ai-moderation/userReputationStore.ts b/services/discord-gateway/src/modules/ai-moderation/userReputationStore.ts index 6e301d9..c54d86a 100644 --- a/services/discord-gateway/src/modules/ai-moderation/userReputationStore.ts +++ b/services/discord-gateway/src/modules/ai-moderation/userReputationStore.ts @@ -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"; /** diff --git a/services/discord-gateway/src/modules/command-handler/commandHandler.ts b/services/discord-gateway/src/modules/command-handler/commandHandler.ts index b7208c1..df2fc7f 100644 --- a/services/discord-gateway/src/modules/command-handler/commandHandler.ts +++ b/services/discord-gateway/src/modules/command-handler/commandHandler.ts @@ -377,7 +377,9 @@ export class CommandHandler { }; } - private async handleVoiceTransmitStart(cmd: BackendCommand): Promise { + private async handleVoiceTransmitStart( + cmd: BackendCommand, + ): Promise { if (!discordPlayer.isConnected()) { return { id: cmd.id, @@ -412,7 +414,9 @@ export class CommandHandler { } } - private async handleVoiceTransmitStop(cmd: BackendCommand): Promise { + private async handleVoiceTransmitStop( + cmd: BackendCommand, + ): Promise { 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"); + }); } } diff --git a/services/discord-gateway/src/modules/event-broadcaster/eventBroadcaster.ts b/services/discord-gateway/src/modules/event-broadcaster/eventBroadcaster.ts index 7546cf8..c84733e 100644 --- a/services/discord-gateway/src/modules/event-broadcaster/eventBroadcaster.ts +++ b/services/discord-gateway/src/modules/event-broadcaster/eventBroadcaster.ts @@ -138,7 +138,7 @@ export class EventBroadcaster { async voicePcmData( pcmBuffer: Buffer, userId: string, - metadata?: any, + metadata?: Record, ): Promise { await this.publisher.publish("discord:voice:pcm", { type: "voice_pcm_data", diff --git a/services/discord-gateway/src/modules/message-capture/analyticsStore.ts b/services/discord-gateway/src/modules/message-capture/analyticsStore.ts deleted file mode 100644 index 8cd1f83..0000000 --- a/services/discord-gateway/src/modules/message-capture/analyticsStore.ts +++ /dev/null @@ -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 { - data: T; - expiresAt: number; -} - -const queryCache = new Map>(); - -/** 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 { - return `${prefix}:${JSON.stringify(params)}`; -} - -function getCached(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(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 { - const { guildId, channelId, hours = 24 } = input; - const cacheKey = makeCacheKey("hourly", { guildId, channelId, hours }); - const cached = getCached(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(); - const wordFreq = new Map(); - const flaggedWordFreq = new Map(); - - 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 { - const { guildId, channelId, hours = 24 } = input; - const cacheKey = makeCacheKey("topics", { guildId, channelId, hours }); - const cached = getCached(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 { - const { guildId, channelId, hours = 24, limit = 20 } = input; - const cacheKey = makeCacheKey("leaderboard", { - guildId, - channelId, - hours, - limit, - }); - const cached = getCached(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 { - const { guildId, channelId, hours = 24 } = input; - const cacheKey = makeCacheKey("modstats", { guildId, channelId, hours }); - const cached = getCached(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 { - const { guildId, hours = 24 } = input; - const cacheKey = makeCacheKey("channels", { guildId, hours }); - const cached = getCached(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 { - const { guildId, channelId, hours = 24, limit = 20 } = input; - const cacheKey = makeCacheKey("violators", { - guildId, - channelId, - hours, - limit, - }); - const cached = getCached(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 { - const { guildId, channelId, hours = 168 } = input; - const cacheKey = makeCacheKey("daily_trend", { guildId, channelId, hours }); - const cached = getCached(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 { - const { guildId, channelId, hours = 168 } = input; - const cacheKey = makeCacheKey("heatmap", { guildId, channelId, hours }); - const cached = getCached(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 { - 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, - }; -} diff --git a/services/discord-gateway/src/modules/message-capture/messageStore.ts b/services/discord-gateway/src/modules/message-capture/messageStore.ts index 3851913..b843e7a 100644 --- a/services/discord-gateway/src/modules/message-capture/messageStore.ts +++ b/services/discord-gateway/src/modules/message-capture/messageStore.ts @@ -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(); diff --git a/services/discord-gateway/src/modules/message-capture/types.ts b/services/discord-gateway/src/modules/message-capture/types.ts index 234a7c1..8922911 100644 --- a/services/discord-gateway/src/modules/message-capture/types.ts +++ b/services/discord-gateway/src/modules/message-capture/types.ts @@ -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; diff --git a/services/discord-gateway/src/modules/voice-recording/ffmpegProcess.ts b/services/discord-gateway/src/modules/voice-recording/ffmpegProcess.ts index 9fb5675..fc5f1ed 100644 --- a/services/discord-gateway/src/modules/voice-recording/ffmpegProcess.ts +++ b/services/discord-gateway/src/modules/voice-recording/ffmpegProcess.ts @@ -1,4 +1,4 @@ -import { spawn } from "child_process"; +import { spawn } from "node:child_process"; export interface MuxFfmpegArgsOptions { inputs: string[]; diff --git a/services/discord-gateway/src/modules/voice-recording/index.ts b/services/discord-gateway/src/modules/voice-recording/index.ts index 39bb99d..3b53a75 100644 --- a/services/discord-gateway/src/modules/voice-recording/index.ts +++ b/services/discord-gateway/src/modules/voice-recording/index.ts @@ -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"; diff --git a/services/discord-gateway/src/modules/voice-recording/recorder/uploader.ts b/services/discord-gateway/src/modules/voice-recording/recorder/uploader.ts index 50b5b53..84c1578 100644 --- a/services/discord-gateway/src/modules/voice-recording/recorder/uploader.ts +++ b/services/discord-gateway/src/modules/voice-recording/recorder/uploader.ts @@ -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); diff --git a/services/discord-gateway/src/modules/voice-recording/transmitter.ts b/services/discord-gateway/src/modules/voice-recording/transmitter.ts index ca4f9cf..01de5e9 100644 --- a/services/discord-gateway/src/modules/voice-recording/transmitter.ts +++ b/services/discord-gateway/src/modules/voice-recording/transmitter.ts @@ -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; diff --git a/services/discord-gateway/src/shared/config/config.ts b/services/discord-gateway/src/shared/config/config.ts index 5030d4f..5542f79 100644 --- a/services/discord-gateway/src/shared/config/config.ts +++ b/services/discord-gateway/src/shared/config/config.ts @@ -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({ diff --git a/services/discord-gateway/src/shared/errors/errors.ts b/services/discord-gateway/src/shared/errors/errors.ts deleted file mode 100644 index ff52145..0000000 --- a/services/discord-gateway/src/shared/errors/errors.ts +++ /dev/null @@ -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; - - constructor(message: string, details?: Record) { - super(message, "VALIDATION_ERROR", 400); - this.details = details; - this.name = "ValidationError"; - } -} diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx index e396705..71e0969 100644 --- a/services/frontend/src/App.tsx +++ b/services/frontend/src/App.tsx @@ -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 ( -
- Analytics failed to load. The rest of the dashboard is still - available. -
- ); - } - 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" ? ( + ) : ( - ) : ( - - - {Array.from({ length: 8 }).map((_, i) => ( - - ))} - - - } - > - - - )} = { - 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(null); - - if (total === 0) { - return ( -
- No data -
- ); - } - - 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 ( -
- - {/* Background ring */} - - {/* Segments */} - {segments.map((seg) => { - const isHovered = hoveredKey === seg.key; - return ( - setHoveredKey(seg.key)} - onMouseLeave={() => setHoveredKey(null)} - style={{ - filter: isHovered ? `drop-shadow(0 0 4px ${seg.color}80)` : undefined, - }} - /> - ); - })} - - - {/* Center label */} -
- - {total} - - Total -
- - {/* Hover tooltip */} - {hoveredKey && (() => { - const entry = entries.find((e) => e.key === hoveredKey); - if (!entry) return null; - const pct = ((entry.value / total) * 100).toFixed(0); - return ( -
- {entry.label}:{" "} - {entry.value} ({pct}%) -
- ); - })()} -
- ); -} - -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(null); - - return ( -
- {entries.map((e) => { - const isHovered = hoveredKey === e.key; - const widthPct = (e.value / effectiveMax) * 100; - return ( -
setHoveredKey(e.key)} - onMouseLeave={() => setHoveredKey(null)} - > - - {e.label} - -
-
-
- - {e.value} - -
- ); - })} -
- ); -} - -export function AIDistributionPanel({ - stats, - loading, -}: AIDistributionPanelProps) { - if (loading && !stats) return ; - - if (!stats || stats.total_analyzed === 0) { - return ( - - - Belum ada data analisis AI. - - - ); - } - - 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 ( - - - - 🤖 - Distribusi Analisis AI - - - Sebaran tingkat keparahan dan rekomendasi dari{" "} - {stats.total_analyzed} pesan yang dianalisis. - - - -
- {/* Severity Donut */} -
-

- Severity -

- - {/* Severity legend */} -
- {severityEntries.map((e) => ( - - - {e.label}:{" "} - - {e.value} - - - ))} -
-
- - {/* Recommended Actions Bar Chart */} -
-

- Rekomendasi Tindakan -

- -
-
- - {/* Footer metrics */} -
- - Rerata confidence:{" "} - {(stats.avg_confidence * 100).toFixed(0)}% - - - Rerata score:{" "} - {(stats.avg_score * 100).toFixed(0)}% - - - Error:{" "} - - {stats.analysis_errors} - - - - Pending:{" "} - - {stats.analysis_pending} - - -
-
-
- ); -} - -function LoadingBox() { - return ( - - - - Memuat data... - - - ); -} diff --git a/services/frontend/src/features/analytics/components/ActivityChart.tsx b/services/frontend/src/features/analytics/components/ActivityChart.tsx deleted file mode 100644 index 39ed19d..0000000 --- a/services/frontend/src/features/analytics/components/ActivityChart.tsx +++ /dev/null @@ -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(null); - - if (loading && !hourly?.length) return ; - if (!hourly?.length) return ; - - 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 ( - - -
-
- - Aktivitas per Jam - - - Distribusi pesan per jam — arahkan kursor ke bar untuk detail. - -
-
-
- - {/* Legend */} -
- {bars.map((b) => ( - - - {b.label} - - ))} -
- - {/* Chart area */} -
-
- - {/* Grid lines */} - {[0, 0.25, 0.5, 0.75, 1].map((ratio) => { - const y = CHART_HEIGHT - ratio * (CHART_HEIGHT - 20) - 20; - return ( - - - - {Math.round(ratio * maxTotal)} - - - ); - })} - - {/* Bars */} - {data.map((d, i) => { - const x = i * BAR_GROUP_WIDTH + 32; - let accumulated = 0; - - return ( - - {/* Hover target (invisible wider rect) */} - { - 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 ? ( - - ) : null; - })} - - {/* X-axis label */} - {i % labelInterval === 0 && ( - - {d.hour} - - )} - - ); - })} - - - {/* Tooltip */} - {tooltip && ( -
d.hour === tooltip.hour) * BAR_GROUP_WIDTH + 36}px`, - }} - > -
- {tooltip.hour} -
- {bars.map((b) => { - const d = data.find((d) => d.hour === tooltip.hour); - const val = d?.[b.key] ?? 0; - return val > 0 ? ( -
- - {b.label} - - {val} - -
- ) : null; - })} -
- Total - - {tooltip.total} - -
-
- )} -
-
-
-
- ); -} - -function LoadingBox() { - return ( - - - Memuat data... - - ); -} - -function EmptyBox({ text }: { text: string }) { - return ( - - {text} - - ); -} diff --git a/services/frontend/src/features/analytics/components/AttachmentStatsPanel.tsx b/services/frontend/src/features/analytics/components/AttachmentStatsPanel.tsx deleted file mode 100644 index 87a5954..0000000 --- a/services/frontend/src/features/analytics/components/AttachmentStatsPanel.tsx +++ /dev/null @@ -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(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 ( -
- - - {segments.map((seg) => { - const isHovered = hoveredKey === seg.key; - return ( - setHoveredKey(seg.key)} - onMouseLeave={() => setHoveredKey(null)} - /> - ); - })} - -
- - {total} - - Total -
- {hoveredKey && ( -
- {(() => { - const e = entries.find((en) => en.key === hoveredKey); - if (!e) return null; - return `${e.label}: ${e.value}`; - })()} -
- )} -
- ); -} - -export function AttachmentStatsPanel({ - stats, - loading, -}: AttachmentStatsPanelProps) { - if (loading && !stats) return ; - if (!stats || stats.total_attachments === 0) { - return ( - - - Belum ada lampiran/media. - - - ); - } - - 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 ( - - - - 🖼️ - Statistik Media - - - {stats.top_mime_type ? ( - <> - Upload status media — tipe dominan:{" "} - - {stats.top_mime_type} - - - ) : ( - "Upload status media di semua channel." - )} - - - -
- {metricCards.map((c) => ( -
-
- {c.label} -
-
- {c.value} -
-
- ))} -
- - {/* Donut + status bars */} -
- - - {/* Status legend with inline bars */} -
- {[ - { - 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 ( -
- - {s.label} - -
-
-
- - {s.value} - -
- ); - })} -
-
- - - ); -} - -function formatNum(v: number | undefined | null): string { - if (v == null || v === 0) return "0"; - return v.toLocaleString("id-ID"); -} - -function LoadingBox() { - return ( - - - - Memuat data... - - - ); -} diff --git a/services/frontend/src/features/analytics/components/ControlBar.tsx b/services/frontend/src/features/analytics/components/ControlBar.tsx deleted file mode 100644 index dde02a7..0000000 --- a/services/frontend/src/features/analytics/components/ControlBar.tsx +++ /dev/null @@ -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 ( - - - - - Analisis Moderasi - - - {guildName ? ( - <> - Pantau statistik, tren topik, dan aktivitas user di seluruh - channel{" "} - {guildName}. - - ) : ( - "Pantau statistik, tren topik, dan aktivitas user." - )} - - - -
-
- {TIME_RANGES.map((tr) => ( - - ))} -
- -
-
-
- ); -} diff --git a/services/frontend/src/features/analytics/components/Heatmap.tsx b/services/frontend/src/features/analytics/components/Heatmap.tsx deleted file mode 100644 index 1d40d47..0000000 --- a/services/frontend/src/features/analytics/components/Heatmap.tsx +++ /dev/null @@ -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 ; - if (!cells?.length) return ; - - const cellMap = new Map(); - 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 ( - - - - Heatmap Aktivitas - - - Hari × jam — arahkan kursor ke sel untuk detail. - - - -
-
- {/* Header row */} -
- {Array.from({ length: 24 }, (_, h) => ( -
- {h % 3 === 0 ? `${h}` : ""} -
- ))} -
- {/* Rows */} - {DAYS.map((dayLabel, d) => ( -
-
- {dayLabel} -
- {Array.from({ length: 24 }, (_, h) => { - const intensity = getIntensity(d, h); - const cell = cellMap.get(`${d}-${h}`); - const count = cell?.count ?? 0; - return ( -
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)} - /> - ); - })} -
- ))} -
-
- - {/* Tooltip */} - {tooltip && ( -
-
- {tooltip.day} {tooltip.hour} -
-
-
- Total - - {tooltip.total} - -
-
- Clean - {tooltip.clean} -
-
- Warned - {tooltip.warned} -
-
- Flagged - {tooltip.flagged} -
-
-
- )} - - {/* Legend */} -
- Sepi - - - - - - Ramai -
- - - ); -} - -function LoadingBox() { - return ( - - - - Memuat data... - - - ); -} - -function EmptyBox() { - return ( - - - Belum ada data heatmap. - - - ); -} diff --git a/services/frontend/src/features/analytics/components/ModerationActionsPanel.tsx b/services/frontend/src/features/analytics/components/ModerationActionsPanel.tsx deleted file mode 100644 index 9c6d464..0000000 --- a/services/frontend/src/features/analytics/components/ModerationActionsPanel.tsx +++ /dev/null @@ -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 = { - 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 = { - 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 ; - if (!actions?.length) { - return ( - - - Belum ada aksi moderasi. - - - ); - } - - return ( - - -
-
- - 🛡️ - Aksi Moderasi - - - Riwayat tindakan moderasi yang telah diambil. - -
- {actions.length} aksi -
-
- - -
- {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 ( -
-
-
- - {actionStyle.label} - - - {action.username} - -
- - {statusStyle.label} - -
- {action.reason && ( -

- {action.reason} -

- )} - {action.error && ( -

- Error: {action.error} -

- )} -
- {new Date(action.created_at).toLocaleString("id-ID", { - day: "numeric", - month: "short", - hour: "2-digit", - minute: "2-digit", - })} -
-
- ); - })} -
-
-
-
- ); -} - -function LoadingBox() { - return ( - - - - Memuat data... - - - ); -} diff --git a/services/frontend/src/features/analytics/components/SummaryCards.tsx b/services/frontend/src/features/analytics/components/SummaryCards.tsx deleted file mode 100644 index 63df4a5..0000000 --- a/services/frontend/src/features/analytics/components/SummaryCards.tsx +++ /dev/null @@ -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 ( -
- {cards.map((card) => ( - - -
- - {card.icon} - -
- {card.label} -
-
-
- {loading ? ( - - ) : ( - card.value - )} -
- {/* Mini bar indicator */} - {card.barPct != null && !loading && ( -
-
-
- )} - - - ))} -
- ); -} - -function formatNum(v: number | undefined | null): string { - if (v == null || v === 0) return "—"; - return v.toLocaleString("id-ID"); -} diff --git a/services/frontend/src/features/analytics/components/TopicList.tsx b/services/frontend/src/features/analytics/components/TopicList.tsx deleted file mode 100644 index 9073b69..0000000 --- a/services/frontend/src/features/analytics/components/TopicList.tsx +++ /dev/null @@ -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 ; - if (!topics?.length) { - return ( - - - Topik akan muncul setelah AI selesai menganalisis. - - - ); - } - - const maxCount = Math.max(...topics.map((t) => t.count), 1); - - return ( - - - - - Topik Trending - - - Yang paling ramai dibicarakan. - - - - -
- {topics.map((topic, i) => { - const colorClass = - TOPIC_COLORS[i % TOPIC_COLORS.length]; - return ( -
- - {i + 1} - - - {topic.topic} - -
-
-
-
- - {topic.count} - -
-
- ); - })} -
- - - - ); -} - -function LoadingBox() { - return ( - - - - Memuat data... - - - ); -} diff --git a/services/frontend/src/features/analytics/components/TrendChart.tsx b/services/frontend/src/features/analytics/components/TrendChart.tsx deleted file mode 100644 index 80c03b4..0000000 --- a/services/frontend/src/features/analytics/components/TrendChart.tsx +++ /dev/null @@ -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; - 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 ; - 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, - ): 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, - ): 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 ( - - - Tren Harian - - Volume pesan per hari — arahkan kursor ke titik untuk detail. - - - - {/* Legend */} -
- {LINE_COLORS.map((l) => ( - - - - - {l.label} - - ))} -
- -
-
- - Rangkuman{" "} - {trend.length > 1 - ? `${trend.length} hari terakhir` - : "hari ini"} - - - {totalMessages} total pesan - -
- -
- - {/* Grid lines */} - {[0, 0.25, 0.5, 0.75, 1].map((ratio) => { - const y = getY(ratio * maxValue); - return ( - - - - {Math.round(ratio * maxValue)} - - - ); - })} - - {/* Area fills */} - {LINE_COLORS.filter((l) => l.key === "count" || l.key === "flagged").map((l) => ( - - ))} - - {/* Lines */} - {LINE_COLORS.map((l) => ( - - ))} - - {/* Interactive dots */} - {trend.map((d, i) => { - const x = getX(i); - const y = getY(d.count); - const isActive = tooltip?.date === d.date; - return ( - - {/* Invisible hit area */} - { - 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 */} - - {/* Date label */} - - {d.date.slice(5)} - - - ); - })} - - - {/* Tooltip */} - {tooltip && ( -
d.date === tooltip.date)) + 12}px`, - }} - > -
- {tooltip.date} -
- {tooltip.values - .filter((v) => v.value > 0) - .map((v) => ( -
- - - - {v.label} - - {v.value} - -
- ))} -
- )} -
-
-
-
- ); -} - -function LoadingBox() { - return ( - - - - Memuat data... - - - ); -} diff --git a/services/frontend/src/features/analytics/components/UserTable.tsx b/services/frontend/src/features/analytics/components/UserTable.tsx deleted file mode 100644 index d3506f8..0000000 --- a/services/frontend/src/features/analytics/components/UserTable.tsx +++ /dev/null @@ -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 ; - if (!users?.length) { - return ( - - - Belum ada aktivitas user. - - - ); - } - - const maxMsgs = Math.max(...users.map((u) => u.message_count), 1); - const medals = ["🥇", "🥈", "🥉"]; - - return ( - - - - - User Paling Aktif - - - Leaderboard berdasarkan jumlah pesan. - - - - - - - - - - - - - - - - {users.map((user, i) => ( - - - - - - - - ))} - -
#UserPesanEditFlag
- {medals[i] ?? i + 1} - -
- {user.avatar_url ? ( - - ) : ( -
- {user.username.charAt(0).toUpperCase()} -
- )} - - {user.username} - -
-
-
-
-
-
- - {user.message_count} - -
-
- {user.edited_count > 0 ? user.edited_count : "—"} - - {user.flagged_count > 0 ? ( - - {user.flagged_count} - - ) : ( - - — - - )} -
-
-
-
- ); -} - -function LoadingBox() { - return ( - - - - Memuat data... - - - ); -} diff --git a/services/frontend/src/features/analytics/components/ViolatorTable.tsx b/services/frontend/src/features/analytics/components/ViolatorTable.tsx deleted file mode 100644 index d769779..0000000 --- a/services/frontend/src/features/analytics/components/ViolatorTable.tsx +++ /dev/null @@ -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 ; - if (!users?.length) { - return ( - - - Tidak ada pelanggaran terdeteksi. - - - ); - } - - 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 ( - - -
-
- - - Pelanggar Terbanyak - - - Skor: flagged × 3 + warned. Flag terbanyak terakhir ditampilkan. - -
- {users.length} pelanggar -
-
- - - - - - - - - - - - - - - {users.map((user, i) => { - const danger = dangerLabel(user.violation_score); - return ( - - - - - - - - - ); - })} - -
#UserFlaggedWarnedSkorFlag
- {i + 1} - -
- {user.avatar_url ? ( - - ) : ( -
- {user.username.charAt(0).toUpperCase()} -
- )} - - {user.username} - - - {danger.text} - -
-
- {user.flagged_count} - - {user.warned_count > 0 ? user.warned_count : "—"} - -
-
-
= 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}%`, - }} - /> -
- - {user.violation_score} - -
-
-
- {user.worst_flags?.length > 0 ? ( - user.worst_flags.slice(0, 3).map((flag) => ( - - {flag} - - )) - ) : ( - - — - - )} -
-
-
-
-
- ); -} - -function LoadingBox() { - return ( - - - - Memuat data... - - - ); -} diff --git a/services/frontend/src/features/analytics/hooks/useAnalytics.ts b/services/frontend/src/features/analytics/hooks/useAnalytics.ts deleted file mode 100644 index 167a8ea..0000000 --- a/services/frontend/src/features/analytics/hooks/useAnalytics.ts +++ /dev/null @@ -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, -}; diff --git a/services/frontend/src/features/analytics/index.tsx b/services/frontend/src/features/analytics/index.tsx deleted file mode 100644 index c0b5cff..0000000 --- a/services/frontend/src/features/analytics/index.tsx +++ /dev/null @@ -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 ( -
- {error} -
- ); - } - - if (!guildId) { - return ; - } - - return ( - - - { - refresh(); - refreshViolators(); - }} - /> - - - - - -
- -
- -
-
-
- {hours >= 48 && ( - - - - )} - -
- -
- -
-
-
- -
- - -
-
- -
- - -
-
-
- ); -} diff --git a/services/frontend/src/features/messages/components/MessageCard.tsx b/services/frontend/src/features/messages/components/MessageCard.tsx index 1715c9f..4951502 100644 --- a/services/frontend/src/features/messages/components/MessageCard.tsx +++ b/services/frontend/src/features/messages/components/MessageCard.tsx @@ -225,7 +225,9 @@ function MessageRow({ {shouldShowContent ? (

{renderContentWithCustomEmojis(displayContent)} @@ -412,7 +414,9 @@ export function MessageCard({ messages, onReanalyze }: MessageCardProps) { {/* Message rows — divided by separator when multiple */}

{messages.map((msg, idx) => (
{groupedMessages.map((group) => ( - + ))} diff --git a/services/frontend/src/features/messages/hooks/useMessages.ts b/services/frontend/src/features/messages/hooks/useMessages.ts index a40ee54..442ee9b 100644 --- a/services/frontend/src/features/messages/hooks/useMessages.ts +++ b/services/frontend/src/features/messages/hooks/useMessages.ts @@ -110,7 +110,6 @@ export function useMessages() { } }, []); - const reanalyzeAllErrors = useCallback(async (): Promise => { // Optimistically mark all error messages as pending setMessages((prev) => diff --git a/services/frontend/src/shared/api/client.ts b/services/frontend/src/shared/api/client.ts index ce80e8b..c011f85 100644 --- a/services/frontend/src/shared/api/client.ts +++ b/services/frontend/src/shared/api/client.ts @@ -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): Promise { 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 { - const sp = new URLSearchParams({ - guildId: params.guildId, - ...(params.channelId && { channelId: params.channelId }), - ...(params.hours && { hours: String(params.hours) }), - }); - return request(`/api/analytics/overview?${sp}`); -} - -export function fetchHourlyStats(params: { - guildId: string; - channelId?: string; - hours?: number; -}): Promise { - const sp = new URLSearchParams({ - guildId: params.guildId, - ...(params.channelId && { channelId: params.channelId }), - ...(params.hours && { hours: String(params.hours) }), - }); - return request(`/api/analytics/hourly?${sp}`); -} - -export function fetchTopicTrends(params: { - guildId: string; - channelId?: string; - hours?: number; -}): Promise { - const sp = new URLSearchParams({ - guildId: params.guildId, - ...(params.channelId && { channelId: params.channelId }), - ...(params.hours && { hours: String(params.hours) }), - }); - return request(`/api/analytics/topics?${sp}`); -} - -export function fetchLeaderboard(params: { - guildId: string; - channelId?: string; - hours?: number; - limit?: number; -}): Promise { - 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(`/api/analytics/leaderboard?${sp}`); -} - -export function fetchModerationStats(params: { - guildId: string; - channelId?: string; - hours?: number; -}): Promise { - const sp = new URLSearchParams({ - guildId: params.guildId, - ...(params.channelId && { channelId: params.channelId }), - ...(params.hours && { hours: String(params.hours) }), - }); - return request(`/api/analytics/stats?${sp}`); -} - -export function fetchViolators(params: { - guildId: string; - channelId?: string; - hours?: number; - limit?: number; -}): Promise { - 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(`/api/analytics/violators?${sp}`); -} - -export function fetchTrend(params: { - guildId: string; - channelId?: string; - hours?: number; -}): Promise { - const sp = new URLSearchParams({ - guildId: params.guildId, - ...(params.channelId && { channelId: params.channelId }), - ...(params.hours && { hours: String(params.hours) }), - }); - return request(`/api/analytics/trend?${sp}`); -} - -export function fetchHeatmap(params: { - guildId: string; - channelId?: string; - hours?: number; -}): Promise { - const sp = new URLSearchParams({ - guildId: params.guildId, - ...(params.channelId && { channelId: params.channelId }), - ...(params.hours && { hours: String(params.hours) }), - }); - return request(`/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 { - 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( - `/api/analytics/moderation-actions?${sp}`, - ); -} - -export function fetchAIStats(params: { - guildId: string; - channelId?: string; - hours?: number; -}): Promise { - const sp = new URLSearchParams({ - guildId: params.guildId, - ...(params.channelId && { channelId: params.channelId }), - ...(params.hours && { hours: String(params.hours) }), - }); - return request(`/api/analytics/ai-stats?${sp}`); -} - -export function fetchAttachmentStats(params: { - guildId: string; - channelId?: string; - hours?: number; -}): Promise { - const sp = new URLSearchParams({ - guildId: params.guildId, - ...(params.channelId && { channelId: params.channelId }), - ...(params.hours && { hours: String(params.hours) }), - }); - return request(`/api/analytics/attachment-stats?${sp}`); -} diff --git a/services/frontend/src/shared/hooks/useAudioTransmit.ts b/services/frontend/src/shared/hooks/useAudioTransmit.ts index 5496484..9208cab 100644 --- a/services/frontend/src/shared/hooks/useAudioTransmit.ts +++ b/services/frontend/src/shared/hooks/useAudioTransmit.ts @@ -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 { // Send via HTTP API @@ -12,9 +14,12 @@ async function sendTransmitCommand(command: string): Promise { 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]); diff --git a/services/frontend/src/shared/hooks/useMascotChat.ts b/services/frontend/src/shared/hooks/useMascotChat.ts index faf67b8..00ae0a7 100644 --- a/services/frontend/src/shared/hooks/useMascotChat.ts +++ b/services/frontend/src/shared/hooks/useMascotChat.ts @@ -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 👥`; } diff --git a/services/frontend/src/shared/hooks/useMascotSummary.ts b/services/frontend/src/shared/hooks/useMascotSummary.ts index 3c6174c..96ec339 100644 --- a/services/frontend/src/shared/hooks/useMascotSummary.ts +++ b/services/frontend/src/shared/hooks/useMascotSummary.ts @@ -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 diff --git a/services/frontend/src/shared/logger.ts b/services/frontend/src/shared/logger.ts new file mode 100644 index 0000000..d1fac64 --- /dev/null +++ b/services/frontend/src/shared/logger.ts @@ -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); +} diff --git a/services/frontend/src/shared/ui/MobileTabBar.tsx b/services/frontend/src/shared/ui/MobileTabBar.tsx index 8fcc948..23f6ede 100644 --- a/services/frontend/src/shared/ui/MobileTabBar.tsx +++ b/services/frontend/src/shared/ui/MobileTabBar.tsx @@ -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 { diff --git a/services/frontend/src/shared/ui/index.ts b/services/frontend/src/shared/ui/index.ts index 8f72bf6..18facfc 100644 --- a/services/frontend/src/shared/ui/index.ts +++ b/services/frontend/src/shared/ui/index.ts @@ -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 { diff --git a/services/frontend/src/shared/ws/socket.ts b/services/frontend/src/shared/ws/socket.ts index dd17f33..c06488c 100644 --- a/services/frontend/src/shared/ws/socket.ts +++ b/services/frontend/src/shared/ws/socket.ts @@ -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); diff --git a/services/frontend/src/widgets/Header.tsx b/services/frontend/src/widgets/Header.tsx index ce3c33d..20f3e8f 100644 --- a/services/frontend/src/widgets/Header.tsx +++ b/services/frontend/src/widgets/Header.tsx @@ -10,13 +10,11 @@ import type { WsStatus } from "../shared/ws/socket"; const titles: Record = { live: "Voice, Media & Recordings", messages: "Messages & Moderation", - analytics: "Analytics & Insights", }; const subtitles: Record = { 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 { diff --git a/services/frontend/src/widgets/Sidebar.tsx b/services/frontend/src/widgets/Sidebar.tsx index b281f5a..55001d6 100644 --- a/services/frontend/src/widgets/Sidebar.tsx +++ b/services/frontend/src/widgets/Sidebar.tsx @@ -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, }); diff --git a/services/frontend/src/widgets/mascot/MascotChatbot.tsx b/services/frontend/src/widgets/mascot/MascotChatbot.tsx index 64bc358..bc9be48 100644 --- a/services/frontend/src/widgets/mascot/MascotChatbot.tsx +++ b/services/frontend/src/widgets/mascot/MascotChatbot.tsx @@ -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({ />
@@ -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 = { 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: diff --git a/services/frontend/src/widgets/mascot/MascotImage.tsx b/services/frontend/src/widgets/mascot/MascotImage.tsx index cdd1aed..cd15690 100644 --- a/services/frontend/src/widgets/mascot/MascotImage.tsx +++ b/services/frontend/src/widgets/mascot/MascotImage.tsx @@ -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 diff --git a/services/frontend/vite.config.ts b/services/frontend/vite.config.ts index 90d1e41..0699450 100644 --- a/services/frontend/vite.config.ts +++ b/services/frontend/vite.config.ts @@ -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", + ], }, });