From 3614d3270195e8d9d204c3dd5c6e80846d24e9a9 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Tue, 9 Jun 2026 13:07:01 +0700 Subject: [PATCH] fix: resolve architecture disconnects and codebase weaknesses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Standardize MessageRecord types — single source of truth from @bete/shared - Clean up config: remove unused GUILD_ID/TEXT_GUILD_ID/TEXT_CHANNEL_ID, fix WEBSERVER_PORT default (3001), remove default admin password - Move mascot_chat_messages table to Drizzle schema with proper migration - Remove runtime DDL (CREATE TABLE IF NOT EXISTS) from mascot-chat repository - Remove phantom analytics/ module from documentation - Add better-sqlite3 dependency to root devDependencies - Replace 'as any' casts with proper type assertions across AI moderation - Add error logging to silent catch blocks in LLM client - Apply Biome formatting and import organization Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 2 - package.json | 2 + packages/shared/src/config/index.ts | 43 +++++++++++------- packages/shared/src/index.ts | 4 +- pnpm-lock.yaml | 44 +++++++++++++++++-- .../mascot-chat/mascot-chat.controller.ts | 10 +++-- .../mascot-chat/mascot-chat.repository.ts | 29 ------------ services/backend/src/shared/redis/index.ts | 2 +- .../migrations/0007_mascot_chat_table.sql | 12 +++++ .../drizzle/migrations/meta/_journal.json | 7 +++ .../src/modules/ai-moderation/aiAnalyzer.ts | 4 +- .../ai-moderation/autoDeleteManager.ts | 8 +++- .../src/modules/ai-moderation/llmClient.ts | 20 ++++++++- .../ai-moderation/llmModerationClient.ts | 20 ++++++--- .../modules/ai-moderation/textCacheStore.ts | 2 +- .../src/modules/message-capture/types.ts | 38 ++++++++-------- .../src/shared/config/config.ts | 5 ++- .../src/shared/database/schema.ts | 32 ++++++++++++++ services/frontend/package.json | 1 + .../frontend/src/entities/message/types.ts | 42 +++--------------- services/frontend/src/shared/api/client.ts | 29 ++---------- 21 files changed, 206 insertions(+), 150 deletions(-) create mode 100644 services/discord-gateway/drizzle/migrations/0007_mascot_chat_table.sql diff --git a/CLAUDE.md b/CLAUDE.md index c7d08f1..133aee5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,7 +111,6 @@ Express 5 + Helmet HTTP server with WebSocket (ws) on port 3001 (default). - `recordings/` — Voice recording listing - `ui-state/` — Persistent UI state for dashboard - `config/` — App config endpoint -- `analytics/` — Analytics (schema defined) **WebSocket events (outbound to frontend):** - `message_created`, `message_updated`, `message_deleted`, `message_analyzed` @@ -222,7 +221,6 @@ React 19 + Vite 8 + Tailwind CSS 4 + TypeScript dashboard. - Hooks: `useVoiceControl`, `useMediaControl` - `messages/` — Message list with filters - Hooks: `useMessages` - - `analytics/` — Analytics (hook scaffolded) - `shared/` - `api/client.ts` — All HTTP API calls + types - `ws/socket.ts` — WebSocket singleton with `useDashboardSocket` hook diff --git a/package.json b/package.json index 6710315..ebc704d 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ }, "devDependencies": { "@biomejs/biome": "latest", + "@types/better-sqlite3": "^7.6.13", + "better-sqlite3": "^11.9.1", "drizzle-kit": "^0.31.10", "tsx": "^4.22.2", "typescript": "^5.9.3" diff --git a/packages/shared/src/config/index.ts b/packages/shared/src/config/index.ts index adfae9c..b9dabf7 100644 --- a/packages/shared/src/config/index.ts +++ b/packages/shared/src/config/index.ts @@ -5,8 +5,8 @@ * Individual services re-export from here; they do NOT define their own schemas. */ -import { ConfigError } from "../errors/index.js"; import { z } from "zod"; +import { ConfigError } from "../errors/index.js"; export const configSchema = z .object({ @@ -18,14 +18,9 @@ export const configSchema = z MONITOR_GUILD_ID: z.string().min(1).optional(), // ── Legacy voice ───────────────────────────────────────────────────── - GUILD_ID: z.string().min(1).optional(), VOICE_GUILD_ID: z.string().min(1).optional(), VOICE_CHANNEL_ID: z.string().min(1).optional(), - // ── Text capture legacy ────────────────────────────────────────────── - TEXT_GUILD_ID: z.string().min(1).optional(), - TEXT_CHANNEL_ID: z.string().min(1).optional(), - // ── Recording ──────────────────────────────────────────────────────── RECORDINGS_DIR: z.string().default("./recordings"), RECORDING_SEGMENT_MS: z.coerce.number().positive().default(5000), @@ -35,7 +30,10 @@ export const configSchema = z DECODER_COOLDOWN_MS: z.coerce.number().positive().default(30000), // ── Audio ──────────────────────────────────────────────────────────── - AUDIO_STREAM_SILENCE_DURATION_MS: z.coerce.number().positive().default(3000), + AUDIO_STREAM_SILENCE_DURATION_MS: z.coerce + .number() + .positive() + .default(3000), PACKET_FILTER_MIN_SIZE: z.coerce.number().positive().default(8), OPUS_FRAME_SIZE: z.coerce.number().positive().default(960), AUDIO_SAMPLE_RATE: z.coerce.number().positive().default(48000), @@ -43,7 +41,7 @@ export const configSchema = z AVATAR_SIZE: z.coerce.number().positive().default(64), // ── Server ─────────────────────────────────────────────────────────── - WEBSERVER_PORT: z.coerce.number().positive().default(3000), + WEBSERVER_PORT: z.coerce.number().positive().default(3001), NODE_ENV: z .enum(["development", "production", "test"]) .default("development"), @@ -55,7 +53,7 @@ export const configSchema = z .optional() .transform((v) => v === "true") .default(false), - ADMIN_PASSWORD: z.string().default("admin123"), + ADMIN_PASSWORD: z.string(), // ── Database (PostgreSQL) ──────────────────────────────────────────── DATABASE_URL: z.string().optional(), @@ -104,7 +102,11 @@ export const configSchema = z AI_LLM_MODEL: z.string().default("text"), AI_LLM_VISION_MODEL: z.string().optional(), AI_LLM_MAX_CONCURRENT: z.coerce.number().int().positive().default(5), - AI_LLM_IMAGE_MAX_DIMENSION: z.coerce.number().int().positive().default(1024), + AI_LLM_IMAGE_MAX_DIMENSION: z.coerce + .number() + .int() + .positive() + .default(1024), AI_LLM_TEXT_BATCH_SIZE: z.coerce.number().int().positive().default(20), AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS: z.coerce .number() @@ -114,14 +116,21 @@ export const configSchema = z // ── AI Analysis Timing ────────────────────────────────────────────── AI_ANALYSIS_DEBOUNCE_MS: z.coerce.number().positive().default(500), - AI_ANALYSIS_RECOVERY_INTERVAL_MS: z.coerce.number().positive().default(15000), + AI_ANALYSIS_RECOVERY_INTERVAL_MS: z.coerce + .number() + .positive() + .default(15000), AI_ANALYSIS_ERROR_COOLDOWN_MS: z.coerce.number().positive().default(30000), // ── AI Analysis Batch ─────────────────────────────────────────────── AI_ANALYSIS_MAX_BATCH_SIZE: z.coerce.number().int().positive().default(200), AI_ANALYSIS_MAX_CONTEXT_TOKENS: z.coerce.number().positive().default(8000), AI_ANALYSIS_MAX_TARGET_TOKENS: z.coerce.number().positive().default(4000), - AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT: z.coerce.number().int().positive().default(20), + AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT: z.coerce + .number() + .int() + .positive() + .default(20), AI_ANALYSIS_PROCESSING_TIMEOUT_MS: z.coerce .number() .positive() @@ -159,7 +168,9 @@ export const configSchema = z .default(false), AUTO_DELETE_FLAGGED_DELAY_MS: z.coerce.number().min(0).default(0), AUTO_DELETE_MIN_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.5), - AUTO_DELETE_ALLOWED_SEVERITIES: z.string().default("critical,high,medium,low"), + AUTO_DELETE_ALLOWED_SEVERITIES: z + .string() + .default("critical,high,medium,low"), AUTO_DELETE_ALLOWED_CATEGORIES: z.string().default(""), AUTO_DELETE_EXCLUDED_CHANNEL_IDS: z.string().default(""), AUTO_DELETE_EXCLUDED_USER_IDS: z.string().default(""), @@ -215,15 +226,13 @@ export type AppConfig = z.infer & { EFFECTIVE_VOICE_GUILD_ID?: string; }; -export function loadConfig( - env: NodeJS.ProcessEnv = process.env, -): AppConfig { +export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { try { const parsed = configSchema.parse(env); return { ...parsed, EFFECTIVE_TEXT_GUILD_ID: parsed.MONITOR_GUILD_ID, - EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID ?? parsed.GUILD_ID, + EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID, }; } catch (error) { if (error instanceof z.ZodError) { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 813871a..bfb3692 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,5 +1,5 @@ +export * from "./config/index.js"; export * from "./errors/index.js"; export * from "./logger/index.js"; -export * from "./utils/index.js"; export * from "./moderation-types.js"; -export * from "./config/index.js"; +export * from "./utils/index.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92b6206..f48dad6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,12 @@ importers: '@biomejs/biome': specifier: latest version: 2.4.16 + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 + better-sqlite3: + specifier: ^11.9.1 + version: 11.10.0 drizzle-kit: specifier: ^0.31.10 version: 0.31.10 @@ -60,7 +66,7 @@ importers: version: 17.4.2 drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0) + version: 0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.21.0) express: specifier: ^5.2.1 version: 5.2.1 @@ -136,7 +142,7 @@ importers: version: 17.4.2 drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0) + version: 0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.21.0) imghash: specifier: ^1.1.4 version: 1.1.4 @@ -216,6 +222,9 @@ importers: services/frontend: dependencies: + '@bete/shared': + specifier: workspace:* + version: link:../../packages/shared '@radix-ui/react-scroll-area': specifier: ^1.2.10 version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -2180,6 +2189,9 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -2487,9 +2499,15 @@ packages: before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} + better-sqlite3@11.10.0: + resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + bintrees@1.0.2: resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} @@ -3191,6 +3209,9 @@ packages: resolution: {integrity: sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw==} engines: {node: '>=6'} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -6879,6 +6900,10 @@ snapshots: tslib: 2.8.1 optional: true + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 25.9.0 + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -7185,10 +7210,19 @@ snapshots: before-after-hook@2.2.3: {} + better-sqlite3@11.10.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + bintrees@1.0.2: {} bl@4.1.0: @@ -7555,10 +7589,12 @@ snapshots: esbuild: 0.25.12 tsx: 4.22.1 - drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0): + drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.21.0): optionalDependencies: '@opentelemetry/api': 1.9.1 + '@types/better-sqlite3': 7.6.13 '@types/pg': 8.20.0 + better-sqlite3: 11.10.0 pg: 8.21.0 dunder-proto@1.0.1: @@ -7923,6 +7959,8 @@ snapshots: file-type@10.11.0: {} + file-uri-to-path@1.0.0: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 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 3355ab8..1e94189 100644 --- a/services/backend/src/modules/mascot-chat/mascot-chat.controller.ts +++ b/services/backend/src/modules/mascot-chat/mascot-chat.controller.ts @@ -4,6 +4,10 @@ import { mascotChatService } from "./mascot-chat.service.js"; const logger = createChildLogger("mascot-chat.controller"); +interface AuthenticatedRequest extends Request { + userId?: string; +} + export async function handleMascotChat(req: Request, res: Response) { try { const { message, context } = req.body; @@ -16,7 +20,7 @@ export async function handleMascotChat(req: Request, res: Response) { } // Get user ID from auth middleware (if available) - const userId = (req as any).userId || "anonymous"; + const userId = (req as AuthenticatedRequest).userId || "anonymous"; logger.debug( { userId, messageLength: message.length, context }, @@ -56,7 +60,7 @@ export async function handleMascotChat(req: Request, res: Response) { export async function getMascotChatHistory(req: Request, res: Response) { try { - const userId = (req as any).userId || "anonymous"; + const userId = (req as AuthenticatedRequest).userId || "anonymous"; const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); const history = await mascotChatService.getChatHistory(userId, limit); @@ -76,7 +80,7 @@ export async function getMascotChatHistory(req: Request, res: Response) { export async function clearMascotChatHistory(req: Request, res: Response) { try { - const userId = (req as any).userId || "anonymous"; + const userId = (req as AuthenticatedRequest).userId || "anonymous"; await mascotChatService.clearChatHistory(userId); diff --git a/services/backend/src/modules/mascot-chat/mascot-chat.repository.ts b/services/backend/src/modules/mascot-chat/mascot-chat.repository.ts index 1e516aa..92168b8 100644 --- a/services/backend/src/modules/mascot-chat/mascot-chat.repository.ts +++ b/services/backend/src/modules/mascot-chat/mascot-chat.repository.ts @@ -37,33 +37,7 @@ export interface ServerInsights { } 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( @@ -88,7 +62,6 @@ export class MascotChatRepository { userId: string, limit: number, ): Promise { - await this.ensureSchema(); const pool = getPool(); const { rows } = await pool.query( @@ -107,7 +80,6 @@ export class MascotChatRepository { } async clearChatHistory(userId: string): Promise { - await this.ensureSchema(); const pool = getPool(); const { rowCount } = await pool.query( @@ -122,7 +94,6 @@ export class MascotChatRepository { guildId?: string, channelId?: string, ): Promise { - await this.ensureSchema(); const pool = getPool(); try { diff --git a/services/backend/src/shared/redis/index.ts b/services/backend/src/shared/redis/index.ts index c4c4b9e..6b24c48 100644 --- a/services/backend/src/shared/redis/index.ts +++ b/services/backend/src/shared/redis/index.ts @@ -31,7 +31,7 @@ let publisherClient: Redis | null = null; let subscriberClient: Redis | null = null; function ensureRedisConfig(): boolean { - return !!(config.REDIS_URL); + return !!config.REDIS_URL; } function createClient(): Redis { diff --git a/services/discord-gateway/drizzle/migrations/0007_mascot_chat_table.sql b/services/discord-gateway/drizzle/migrations/0007_mascot_chat_table.sql new file mode 100644 index 0000000..e464d4c --- /dev/null +++ b/services/discord-gateway/drizzle/migrations/0007_mascot_chat_table.sql @@ -0,0 +1,12 @@ +-- Move mascot_chat_messages from runtime CREATE TABLE to Drizzle migration +-- Previously created at runtime by mascot-chat.repository.ts ensureSchema() +CREATE TABLE IF NOT EXISTS "mascot_chat_messages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "user_message" text NOT NULL, + "mascot_response" text NOT NULL, + "context" jsonb DEFAULT '{}' NOT NULL, + "created_at" timestamptz DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_mascot_chat_messages_user_created" ON "mascot_chat_messages" USING btree ("user_id", "created_at" DESC); diff --git a/services/discord-gateway/drizzle/migrations/meta/_journal.json b/services/discord-gateway/drizzle/migrations/meta/_journal.json index 537747c..53b1641 100644 --- a/services/discord-gateway/drizzle/migrations/meta/_journal.json +++ b/services/discord-gateway/drizzle/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1780900000000, "tag": "0006_replace_base64_with_image_url", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1781174400000, + "tag": "0007_mascot_chat_table", + "breakpoints": true } ] } \ No newline at end of file diff --git a/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts b/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts index 1f647b9..53cb6c4 100644 --- a/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts +++ b/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts @@ -413,7 +413,7 @@ async function processIndividualFallback( type: "individual", message, skipNormalAnalysis: false, - } as any)) as + } as unknown)) as | { ok: true; results: AnalysisResult[] } | { ok: false; results: AnalysisResult[]; error: string }; @@ -443,7 +443,7 @@ async function processIndividualFallback( type: "individual", message, skipNormalAnalysis: true, - } as any)) as + } as unknown)) as | { ok: true; results: AnalysisResult[] } | { ok: false; results: AnalysisResult[]; error: string }; diff --git a/services/discord-gateway/src/modules/ai-moderation/autoDeleteManager.ts b/services/discord-gateway/src/modules/ai-moderation/autoDeleteManager.ts index 7da1e0c..11fa184 100644 --- a/services/discord-gateway/src/modules/ai-moderation/autoDeleteManager.ts +++ b/services/discord-gateway/src/modules/ai-moderation/autoDeleteManager.ts @@ -4,6 +4,10 @@ import { config } from "../../shared/config/config.js"; import { createModerationAction } from "../message-capture/messageStore.js"; import type { MessageRecord } from "../message-capture/types.js"; +interface ChannelWithSend { + send: (content: string | object, options?: unknown) => Promise; +} + const logger = createChildLogger("auto-delete-manager"); const parseStringList = (value?: string | null): string[] => { @@ -353,7 +357,7 @@ export async function attemptAutoDeleteFlaggedMessage( if ( logChannel && "send" in logChannel && - typeof (logChannel as any).send === "function" + typeof (logChannel as ChannelWithSend).send === "function" ) { const severity = message.ai_severity ?? "none"; const categories = @@ -362,7 +366,7 @@ export async function attemptAutoDeleteFlaggedMessage( 0, 200, ); - await (logChannel as any).send( + await (logChannel as ChannelWithSend).send( `**🧹 Auto-Delete** — Pesan dari <@${message.user_id}> di <#${channelId}>\n` + `**Status:** ${message.ai_status}\n` + `**Severitas:** ${severity}\n` + diff --git a/services/discord-gateway/src/modules/ai-moderation/llmClient.ts b/services/discord-gateway/src/modules/ai-moderation/llmClient.ts index e21732b..8d653c2 100644 --- a/services/discord-gateway/src/modules/ai-moderation/llmClient.ts +++ b/services/discord-gateway/src/modules/ai-moderation/llmClient.ts @@ -14,6 +14,24 @@ import { withLlmConcurrency } from "./concurrencyLimiter.js"; const log = createChildLogger("llm-client"); +/** + * Covers all LLM response chunk shapes the streaming handler supports. + * Different providers (OpenAI, Anthropic-compatible, local LLMs) may return + * content in different fields — we try them all via optional chaining. + */ +type LLMResponseChunk = { + choices?: Array<{ + delta?: { content?: string | null }; + message?: { content?: string | null }; + finish_reason?: string | null; + text?: string; + }>; + message?: { content?: string | null }; + content?: string; + response?: string; + finish_reason?: string; +}; + // --------------------------------------------------------------------------- // Lazy singleton — created on first use so that config is always resolved. // --------------------------------------------------------------------------- @@ -112,7 +130,7 @@ export async function llmChat( if (currentParams.stream) { let content = ""; let finishReason = "stop"; - for await (const chunk of response as any) { + for await (const chunk of response as unknown as AsyncIterable) { const choice = chunk?.choices?.[0]; const textChunk = choice?.delta?.content || diff --git a/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts b/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts index e113786..712f692 100644 --- a/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts +++ b/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts @@ -179,7 +179,12 @@ export function extractJson(content: string): unknown { if (parsed && typeof parsed === "object") { return parsed; } - } catch (_) {} + } catch (err) { + log.debug( + { err: err instanceof Error ? err.message : String(err) }, + "Failed to parse JSON from code block — trying next block", + ); + } } for (let start = 0; start < content.length; start++) { @@ -224,7 +229,12 @@ export function extractJson(content: string): unknown { if (parsed && typeof parsed === "object") { return parsed; } - } catch (_) {} + } catch (err) { + log.debug( + { err: err instanceof Error ? err.message : String(err) }, + "Failed to parse JSON candidate — trying next position", + ); + } break; } } @@ -266,7 +276,7 @@ export function parseModerationResponse( parsed = { results: [parsed] }; } else { const arrayKey = Object.keys(parsed).find((key) => { - const val = (parsed as any)[key]; + const val = parsed[key]; return ( Array.isArray(val) && val.length > 0 && @@ -274,12 +284,12 @@ export function parseModerationResponse( (item: unknown) => typeof item === "object" && item !== null && - "message_id" in (item as any), + "message_id" in (item as Record), ) ); }); if (arrayKey) { - parsed.results = (parsed as any)[arrayKey]; + parsed.results = parsed[arrayKey]; } else { parsed = { results: [parsed] }; } diff --git a/services/discord-gateway/src/modules/ai-moderation/textCacheStore.ts b/services/discord-gateway/src/modules/ai-moderation/textCacheStore.ts index 1ef6c5b..752a5be 100644 --- a/services/discord-gateway/src/modules/ai-moderation/textCacheStore.ts +++ b/services/discord-gateway/src/modules/ai-moderation/textCacheStore.ts @@ -100,7 +100,7 @@ export async function pruneExpiredTexts(): Promise { `DELETE FROM text_analysis_cache WHERE expires_at < $1`, [Date.now()], ); - return (result as any).rowCount ?? 0; + return (result as unknown as { rowCount?: number }).rowCount ?? 0; } catch (error) { logger.error( { error: error instanceof Error ? error.message : String(error) }, diff --git a/services/discord-gateway/src/modules/message-capture/types.ts b/services/discord-gateway/src/modules/message-capture/types.ts index fa00ab6..7086b84 100644 --- a/services/discord-gateway/src/modules/message-capture/types.ts +++ b/services/discord-gateway/src/modules/message-capture/types.ts @@ -1,42 +1,42 @@ import type fs from "node:fs"; -import type prism from "prism-media"; import type { - AIStatus, - AISeverity, AIRecommendedAction, + AISeverity, + AIStatus, + AnalysisQueueStatus, + AttachmentRecord, BroadcasterClient, + MessageRecord, ModerationBroadcaster, RoleMetadata, UserMetadata, - MessageRecord, - AttachmentRecord, VoiceRecordingUploadData, - AnalysisQueueStatus, } from "@bete/shared"; +import type prism from "prism-media"; // Re-export all shared types for backward compatibility export type { - AIStatus, - AISeverity, AIRecommendedAction, - BroadcasterClient, - ModerationBroadcaster, - RoleMetadata, - UserMetadata, - MessageRecord, + AISeverity, + AIStatus, + AnalysisQueueStatus, + AnalysisResult, AttachmentRecord, - VoiceSegmentRecord, + BroadcasterClient, DashboardMessage, MessageQuery, - PageResult, - AnalysisResult, - VoiceRecordingUploadData, - AnalysisQueueStatus, + MessageRecord, MessageReview, ModerationAction, + ModerationActionType, + ModerationBroadcaster, + PageResult, RetentionPolicy, ReviewStatus, - ModerationActionType, + RoleMetadata, + UserMetadata, + VoiceRecordingUploadData, + VoiceSegmentRecord, } from "@bete/shared"; // Types that are LOCAL ONLY (not in shared) — keep here diff --git a/services/discord-gateway/src/shared/config/config.ts b/services/discord-gateway/src/shared/config/config.ts index 92a3d00..475c2a8 100644 --- a/services/discord-gateway/src/shared/config/config.ts +++ b/services/discord-gateway/src/shared/config/config.ts @@ -1,6 +1,9 @@ import "dotenv/config"; import type { AppConfig as SharedAppConfig } from "@bete/shared/config"; -import { config as sharedConfig, loadConfig as sharedLoadConfig } from "@bete/shared/config"; +import { + config as sharedConfig, + loadConfig as sharedLoadConfig, +} from "@bete/shared/config"; // Re-export the unified config with EFFECTIVE_* fields added export type AppConfig = SharedAppConfig & { diff --git a/services/discord-gateway/src/shared/database/schema.ts b/services/discord-gateway/src/shared/database/schema.ts index a0330c9..8ce7a81 100644 --- a/services/discord-gateway/src/shared/database/schema.ts +++ b/services/discord-gateway/src/shared/database/schema.ts @@ -4,9 +4,12 @@ import { foreignKey as pgForeignKey, index as pgIndex, integer as pgInteger, + jsonb as pgJsonb, real as pgReal, pgTable, text as pgText, + timestamp as pgTimestamp, + uuid as pgUuid, } from "drizzle-orm/pg-core"; // PostgreSQL Schema @@ -492,6 +495,30 @@ export const pgCorrectedModerationsTable = pgTable( }), ); +/** + * Mascot Chat Messages Table (PostgreSQL) + * Stores AI mascot chat conversation history + */ +export const pgMascotChatMessagesTable = pgTable( + "mascot_chat_messages", + { + id: pgUuid("id").defaultRandom().primaryKey(), + user_id: pgText("user_id").notNull(), + user_message: pgText("user_message").notNull(), + mascot_response: pgText("mascot_response").notNull(), + context: pgJsonb("context").notNull().default("{}"), + created_at: pgTimestamp("created_at", { withTimezone: true, mode: "date" }) + .notNull() + .defaultNow(), + }, + (table) => ({ + userCreatedIdx: pgIndex("idx_mascot_chat_messages_user_created").on( + table.user_id, + table.created_at.desc(), + ), + }), +); + // Runtime table exports // ===================== @@ -509,6 +536,7 @@ export const stickerCacheTable = pgStickerCacheTable; export const correctedModerationsTable = pgCorrectedModerationsTable; export const userReputationsTable = pgUserReputationsTable; export const channelCulturesTable = pgChannelCulturesTable; +export const mascotChatMessagesTable = pgMascotChatMessagesTable; // Export table types for use in queries export type MuxerJob = typeof muxerJobsTable.$inferSelect; @@ -550,3 +578,7 @@ export type UserReputationInsert = typeof userReputationsTable.$inferInsert; export type ChannelCulture = typeof channelCulturesTable.$inferSelect; export type ChannelCultureInsert = typeof channelCulturesTable.$inferInsert; + +export type MascotChatMessage = typeof mascotChatMessagesTable.$inferSelect; +export type MascotChatMessageInsert = + typeof mascotChatMessagesTable.$inferInsert; diff --git a/services/frontend/package.json b/services/frontend/package.json index 7dad222..5632a4c 100644 --- a/services/frontend/package.json +++ b/services/frontend/package.json @@ -12,6 +12,7 @@ "format": "biome format --write src/" }, "dependencies": { + "@bete/shared": "workspace:*", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", diff --git a/services/frontend/src/entities/message/types.ts b/services/frontend/src/entities/message/types.ts index 841b58a..31e3365 100644 --- a/services/frontend/src/entities/message/types.ts +++ b/services/frontend/src/entities/message/types.ts @@ -1,12 +1,9 @@ -export type AIStatus = "pending" | "clean" | "warn" | "flagged" | "error"; -export type AISeverity = "none" | "low" | "medium" | "high" | "critical"; -export type AIRecommendedAction = - | "none" - | "monitor" - | "warn" - | "review" - | "delete" - | "escalate"; +export type { + AIRecommendedAction, + AISeverity, + AIStatus, + MessageRecord, +} from "@bete/shared"; export interface MessageMetadata { stickers?: Array<{ name?: string; url?: string }>; @@ -30,33 +27,6 @@ export function parseMetadata(value: string | null): MessageMetadata { } } -export interface MessageRecord { - id: string; - guild_id: string; - channel_id: string; - thread_id: string | null; - user_id: string; - username: string; - avatar_url: string | null; - content: string; - edited_content: string | null; - created_at: number; - edited_at: number | null; - deleted_at: number | null; - type: "text" | "edited" | "deleted"; - metadata: string | null; - ai_status?: AIStatus | null; - ai_moderation_flags?: string | null; - ai_moderation_score?: number | null; - ai_analysis?: string | null; - ai_categories?: string | null; - ai_severity?: AISeverity | null; - ai_confidence?: number | null; - ai_recommended_action?: AIRecommendedAction | null; - ai_analyzed_at?: number | null; - ai_error?: string | null; -} - export interface PageResult { data: T[]; nextCursor: string | null; diff --git a/services/frontend/src/shared/api/client.ts b/services/frontend/src/shared/api/client.ts index c011f85..d562144 100644 --- a/services/frontend/src/shared/api/client.ts +++ b/services/frontend/src/shared/api/client.ts @@ -1,5 +1,7 @@ // ─── Shared HTTP client — all API endpoints in one file ────────────────────── +import type { MessageRecord } from "@bete/shared"; + const BE_API_URL = import.meta.env.VITE_BE_API_URL || "http://localhost:3001"; const BE_WS_URL = import.meta.env.VITE_BE_WS_URL || "ws://localhost:3001"; @@ -57,32 +59,7 @@ export interface PageResult { nextCursor: string | null; } -export interface MessageRecord { - id: string; - guild_id: string; - channel_id: string; - thread_id: string | null; - user_id: string; - username: string; - avatar_url: string | null; - content: string; - edited_content: string | null; - created_at: number; - edited_at: number | null; - deleted_at: number | null; - type: "text" | "edited" | "deleted"; - metadata: string | null; - ai_status?: 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; -} +export type { MessageRecord }; export interface Guild { id: string;