fix: resolve architecture disconnects and codebase weaknesses

- 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 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-09 13:07:01 +07:00
co-authored by Claude Opus 4.8
parent 67d66bb5dd
commit 3614d32701
21 changed files with 206 additions and 150 deletions
-2
View File
@@ -111,7 +111,6 @@ Express 5 + Helmet HTTP server with WebSocket (ws) on port 3001 (default).
- `recordings/` — Voice recording listing - `recordings/` — Voice recording listing
- `ui-state/` — Persistent UI state for dashboard - `ui-state/` — Persistent UI state for dashboard
- `config/` — App config endpoint - `config/` — App config endpoint
- `analytics/` — Analytics (schema defined)
**WebSocket events (outbound to frontend):** **WebSocket events (outbound to frontend):**
- `message_created`, `message_updated`, `message_deleted`, `message_analyzed` - `message_created`, `message_updated`, `message_deleted`, `message_analyzed`
@@ -222,7 +221,6 @@ React 19 + Vite 8 + Tailwind CSS 4 + TypeScript dashboard.
- Hooks: `useVoiceControl`, `useMediaControl` - Hooks: `useVoiceControl`, `useMediaControl`
- `messages/` — Message list with filters - `messages/` — Message list with filters
- Hooks: `useMessages` - Hooks: `useMessages`
- `analytics/` — Analytics (hook scaffolded)
- `shared/` - `shared/`
- `api/client.ts` — All HTTP API calls + types - `api/client.ts` — All HTTP API calls + types
- `ws/socket.ts` — WebSocket singleton with `useDashboardSocket` hook - `ws/socket.ts` — WebSocket singleton with `useDashboardSocket` hook
+2
View File
@@ -22,6 +22,8 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "latest", "@biomejs/biome": "latest",
"@types/better-sqlite3": "^7.6.13",
"better-sqlite3": "^11.9.1",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"tsx": "^4.22.2", "tsx": "^4.22.2",
"typescript": "^5.9.3" "typescript": "^5.9.3"
+26 -17
View File
@@ -5,8 +5,8 @@
* Individual services re-export from here; they do NOT define their own schemas. * Individual services re-export from here; they do NOT define their own schemas.
*/ */
import { ConfigError } from "../errors/index.js";
import { z } from "zod"; import { z } from "zod";
import { ConfigError } from "../errors/index.js";
export const configSchema = z export const configSchema = z
.object({ .object({
@@ -18,14 +18,9 @@ export const configSchema = z
MONITOR_GUILD_ID: z.string().min(1).optional(), MONITOR_GUILD_ID: z.string().min(1).optional(),
// ── Legacy voice ───────────────────────────────────────────────────── // ── Legacy voice ─────────────────────────────────────────────────────
GUILD_ID: z.string().min(1).optional(),
VOICE_GUILD_ID: z.string().min(1).optional(), VOICE_GUILD_ID: z.string().min(1).optional(),
VOICE_CHANNEL_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 ──────────────────────────────────────────────────────── // ── Recording ────────────────────────────────────────────────────────
RECORDINGS_DIR: z.string().default("./recordings"), RECORDINGS_DIR: z.string().default("./recordings"),
RECORDING_SEGMENT_MS: z.coerce.number().positive().default(5000), 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), DECODER_COOLDOWN_MS: z.coerce.number().positive().default(30000),
// ── Audio ──────────────────────────────────────────────────────────── // ── 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), PACKET_FILTER_MIN_SIZE: z.coerce.number().positive().default(8),
OPUS_FRAME_SIZE: z.coerce.number().positive().default(960), OPUS_FRAME_SIZE: z.coerce.number().positive().default(960),
AUDIO_SAMPLE_RATE: z.coerce.number().positive().default(48000), 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), AVATAR_SIZE: z.coerce.number().positive().default(64),
// ── Server ─────────────────────────────────────────────────────────── // ── Server ───────────────────────────────────────────────────────────
WEBSERVER_PORT: z.coerce.number().positive().default(3000), WEBSERVER_PORT: z.coerce.number().positive().default(3001),
NODE_ENV: z NODE_ENV: z
.enum(["development", "production", "test"]) .enum(["development", "production", "test"])
.default("development"), .default("development"),
@@ -55,7 +53,7 @@ export const configSchema = z
.optional() .optional()
.transform((v) => v === "true") .transform((v) => v === "true")
.default(false), .default(false),
ADMIN_PASSWORD: z.string().default("admin123"), ADMIN_PASSWORD: z.string(),
// ── Database (PostgreSQL) ──────────────────────────────────────────── // ── Database (PostgreSQL) ────────────────────────────────────────────
DATABASE_URL: z.string().optional(), DATABASE_URL: z.string().optional(),
@@ -104,7 +102,11 @@ export const configSchema = z
AI_LLM_MODEL: z.string().default("text"), AI_LLM_MODEL: z.string().default("text"),
AI_LLM_VISION_MODEL: z.string().optional(), AI_LLM_VISION_MODEL: z.string().optional(),
AI_LLM_MAX_CONCURRENT: z.coerce.number().int().positive().default(5), 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_TEXT_BATCH_SIZE: z.coerce.number().int().positive().default(20),
AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS: z.coerce AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS: z.coerce
.number() .number()
@@ -114,14 +116,21 @@ export const configSchema = z
// ── AI Analysis Timing ────────────────────────────────────────────── // ── AI Analysis Timing ──────────────────────────────────────────────
AI_ANALYSIS_DEBOUNCE_MS: z.coerce.number().positive().default(500), 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_ERROR_COOLDOWN_MS: z.coerce.number().positive().default(30000),
// ── AI Analysis Batch ─────────────────────────────────────────────── // ── AI Analysis Batch ───────────────────────────────────────────────
AI_ANALYSIS_MAX_BATCH_SIZE: z.coerce.number().int().positive().default(200), 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_CONTEXT_TOKENS: z.coerce.number().positive().default(8000),
AI_ANALYSIS_MAX_TARGET_TOKENS: z.coerce.number().positive().default(4000), 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 AI_ANALYSIS_PROCESSING_TIMEOUT_MS: z.coerce
.number() .number()
.positive() .positive()
@@ -159,7 +168,9 @@ export const configSchema = z
.default(false), .default(false),
AUTO_DELETE_FLAGGED_DELAY_MS: z.coerce.number().min(0).default(0), 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_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_ALLOWED_CATEGORIES: z.string().default(""),
AUTO_DELETE_EXCLUDED_CHANNEL_IDS: z.string().default(""), AUTO_DELETE_EXCLUDED_CHANNEL_IDS: z.string().default(""),
AUTO_DELETE_EXCLUDED_USER_IDS: z.string().default(""), AUTO_DELETE_EXCLUDED_USER_IDS: z.string().default(""),
@@ -215,15 +226,13 @@ export type AppConfig = z.infer<typeof configSchema> & {
EFFECTIVE_VOICE_GUILD_ID?: string; EFFECTIVE_VOICE_GUILD_ID?: string;
}; };
export function loadConfig( export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
env: NodeJS.ProcessEnv = process.env,
): AppConfig {
try { try {
const parsed = configSchema.parse(env); const parsed = configSchema.parse(env);
return { return {
...parsed, ...parsed,
EFFECTIVE_TEXT_GUILD_ID: parsed.MONITOR_GUILD_ID, 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) { } catch (error) {
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
+2 -2
View File
@@ -1,5 +1,5 @@
export * from "./config/index.js";
export * from "./errors/index.js"; export * from "./errors/index.js";
export * from "./logger/index.js"; export * from "./logger/index.js";
export * from "./utils/index.js";
export * from "./moderation-types.js"; export * from "./moderation-types.js";
export * from "./config/index.js"; export * from "./utils/index.js";
+41 -3
View File
@@ -15,6 +15,12 @@ importers:
'@biomejs/biome': '@biomejs/biome':
specifier: latest specifier: latest
version: 2.4.16 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: drizzle-kit:
specifier: ^0.31.10 specifier: ^0.31.10
version: 0.31.10 version: 0.31.10
@@ -60,7 +66,7 @@ importers:
version: 17.4.2 version: 17.4.2
drizzle-orm: drizzle-orm:
specifier: ^0.45.2 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: express:
specifier: ^5.2.1 specifier: ^5.2.1
version: 5.2.1 version: 5.2.1
@@ -136,7 +142,7 @@ importers:
version: 17.4.2 version: 17.4.2
drizzle-orm: drizzle-orm:
specifier: ^0.45.2 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: imghash:
specifier: ^1.1.4 specifier: ^1.1.4
version: 1.1.4 version: 1.1.4
@@ -216,6 +222,9 @@ importers:
services/frontend: services/frontend:
dependencies: dependencies:
'@bete/shared':
specifier: workspace:*
version: link:../../packages/shared
'@radix-ui/react-scroll-area': '@radix-ui/react-scroll-area':
specifier: ^1.2.10 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) 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': '@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
'@types/better-sqlite3@7.6.13':
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
'@types/body-parser@1.19.6': '@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
@@ -2487,9 +2499,15 @@ packages:
before-after-hook@2.2.3: before-after-hook@2.2.3:
resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} 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: bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
bintrees@1.0.2: bintrees@1.0.2:
resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==}
@@ -3191,6 +3209,9 @@ packages:
resolution: {integrity: sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw==} resolution: {integrity: sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw==}
engines: {node: '>=6'} engines: {node: '>=6'}
file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
fill-range@7.1.1: fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -6879,6 +6900,10 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@types/better-sqlite3@7.6.13':
dependencies:
'@types/node': 25.9.0
'@types/body-parser@1.19.6': '@types/body-parser@1.19.6':
dependencies: dependencies:
'@types/connect': 3.4.38 '@types/connect': 3.4.38
@@ -7185,10 +7210,19 @@ snapshots:
before-after-hook@2.2.3: {} 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: bidi-js@1.0.3:
dependencies: dependencies:
require-from-string: 2.0.2 require-from-string: 2.0.2
bindings@1.5.0:
dependencies:
file-uri-to-path: 1.0.0
bintrees@1.0.2: {} bintrees@1.0.2: {}
bl@4.1.0: bl@4.1.0:
@@ -7555,10 +7589,12 @@ snapshots:
esbuild: 0.25.12 esbuild: 0.25.12
tsx: 4.22.1 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: optionalDependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
'@types/better-sqlite3': 7.6.13
'@types/pg': 8.20.0 '@types/pg': 8.20.0
better-sqlite3: 11.10.0
pg: 8.21.0 pg: 8.21.0
dunder-proto@1.0.1: dunder-proto@1.0.1:
@@ -7923,6 +7959,8 @@ snapshots:
file-type@10.11.0: {} file-type@10.11.0: {}
file-uri-to-path@1.0.0: {}
fill-range@7.1.1: fill-range@7.1.1:
dependencies: dependencies:
to-regex-range: 5.0.1 to-regex-range: 5.0.1
@@ -4,6 +4,10 @@ import { mascotChatService } from "./mascot-chat.service.js";
const logger = createChildLogger("mascot-chat.controller"); const logger = createChildLogger("mascot-chat.controller");
interface AuthenticatedRequest extends Request {
userId?: string;
}
export async function handleMascotChat(req: Request, res: Response) { export async function handleMascotChat(req: Request, res: Response) {
try { try {
const { message, context } = req.body; 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) // Get user ID from auth middleware (if available)
const userId = (req as any).userId || "anonymous"; const userId = (req as AuthenticatedRequest).userId || "anonymous";
logger.debug( logger.debug(
{ userId, messageLength: message.length, context }, { 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) { export async function getMascotChatHistory(req: Request, res: Response) {
try { 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 limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
const history = await mascotChatService.getChatHistory(userId, limit); 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) { export async function clearMascotChatHistory(req: Request, res: Response) {
try { try {
const userId = (req as any).userId || "anonymous"; const userId = (req as AuthenticatedRequest).userId || "anonymous";
await mascotChatService.clearChatHistory(userId); await mascotChatService.clearChatHistory(userId);
@@ -37,33 +37,7 @@ export interface ServerInsights {
} }
export class MascotChatRepository { export class MascotChatRepository {
private initialized = false;
async ensureSchema(): Promise<void> {
if (this.initialized) return;
const pool = getPool();
await pool.query(`
CREATE TABLE IF NOT EXISTS mascot_chat_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
user_message TEXT NOT NULL,
mascot_response TEXT NOT NULL,
context JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_mascot_chat_messages_user_created
ON mascot_chat_messages (user_id, created_at DESC)
`);
this.initialized = true;
logger.info("Mascot chat schema ready");
}
async saveConversation(input: SaveConversationInput): Promise<void> { async saveConversation(input: SaveConversationInput): Promise<void> {
await this.ensureSchema();
const pool = getPool(); const pool = getPool();
await pool.query( await pool.query(
@@ -88,7 +62,6 @@ export class MascotChatRepository {
userId: string, userId: string,
limit: number, limit: number,
): Promise<MascotChatHistoryRow[]> { ): Promise<MascotChatHistoryRow[]> {
await this.ensureSchema();
const pool = getPool(); const pool = getPool();
const { rows } = await pool.query<MascotChatHistoryRow>( const { rows } = await pool.query<MascotChatHistoryRow>(
@@ -107,7 +80,6 @@ export class MascotChatRepository {
} }
async clearChatHistory(userId: string): Promise<void> { async clearChatHistory(userId: string): Promise<void> {
await this.ensureSchema();
const pool = getPool(); const pool = getPool();
const { rowCount } = await pool.query( const { rowCount } = await pool.query(
@@ -122,7 +94,6 @@ export class MascotChatRepository {
guildId?: string, guildId?: string,
channelId?: string, channelId?: string,
): Promise<ServerInsights> { ): Promise<ServerInsights> {
await this.ensureSchema();
const pool = getPool(); const pool = getPool();
try { try {
+1 -1
View File
@@ -31,7 +31,7 @@ let publisherClient: Redis | null = null;
let subscriberClient: Redis | null = null; let subscriberClient: Redis | null = null;
function ensureRedisConfig(): boolean { function ensureRedisConfig(): boolean {
return !!(config.REDIS_URL); return !!config.REDIS_URL;
} }
function createClient(): Redis { function createClient(): Redis {
@@ -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);
@@ -50,6 +50,13 @@
"when": 1780900000000, "when": 1780900000000,
"tag": "0006_replace_base64_with_image_url", "tag": "0006_replace_base64_with_image_url",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1781174400000,
"tag": "0007_mascot_chat_table",
"breakpoints": true
} }
] ]
} }
@@ -413,7 +413,7 @@ async function processIndividualFallback(
type: "individual", type: "individual",
message, message,
skipNormalAnalysis: false, skipNormalAnalysis: false,
} as any)) as } as unknown)) as
| { ok: true; results: AnalysisResult[] } | { ok: true; results: AnalysisResult[] }
| { ok: false; results: AnalysisResult[]; error: string }; | { ok: false; results: AnalysisResult[]; error: string };
@@ -443,7 +443,7 @@ async function processIndividualFallback(
type: "individual", type: "individual",
message, message,
skipNormalAnalysis: true, skipNormalAnalysis: true,
} as any)) as } as unknown)) as
| { ok: true; results: AnalysisResult[] } | { ok: true; results: AnalysisResult[] }
| { ok: false; results: AnalysisResult[]; error: string }; | { ok: false; results: AnalysisResult[]; error: string };
@@ -4,6 +4,10 @@ import { config } from "../../shared/config/config.js";
import { createModerationAction } from "../message-capture/messageStore.js"; import { createModerationAction } from "../message-capture/messageStore.js";
import type { MessageRecord } from "../message-capture/types.js"; import type { MessageRecord } from "../message-capture/types.js";
interface ChannelWithSend {
send: (content: string | object, options?: unknown) => Promise<unknown>;
}
const logger = createChildLogger("auto-delete-manager"); const logger = createChildLogger("auto-delete-manager");
const parseStringList = (value?: string | null): string[] => { const parseStringList = (value?: string | null): string[] => {
@@ -353,7 +357,7 @@ export async function attemptAutoDeleteFlaggedMessage(
if ( if (
logChannel && logChannel &&
"send" in logChannel && "send" in logChannel &&
typeof (logChannel as any).send === "function" typeof (logChannel as ChannelWithSend).send === "function"
) { ) {
const severity = message.ai_severity ?? "none"; const severity = message.ai_severity ?? "none";
const categories = const categories =
@@ -362,7 +366,7 @@ export async function attemptAutoDeleteFlaggedMessage(
0, 0,
200, 200,
); );
await (logChannel as any).send( await (logChannel as ChannelWithSend).send(
`**🧹 Auto-Delete** — Pesan dari <@${message.user_id}> di <#${channelId}>\n` + `**🧹 Auto-Delete** — Pesan dari <@${message.user_id}> di <#${channelId}>\n` +
`**Status:** ${message.ai_status}\n` + `**Status:** ${message.ai_status}\n` +
`**Severitas:** ${severity}\n` + `**Severitas:** ${severity}\n` +
@@ -14,6 +14,24 @@ import { withLlmConcurrency } from "./concurrencyLimiter.js";
const log = createChildLogger("llm-client"); 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. // Lazy singleton — created on first use so that config is always resolved.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -112,7 +130,7 @@ export async function llmChat(
if (currentParams.stream) { if (currentParams.stream) {
let content = ""; let content = "";
let finishReason = "stop"; let finishReason = "stop";
for await (const chunk of response as any) { for await (const chunk of response as unknown as AsyncIterable<LLMResponseChunk>) {
const choice = chunk?.choices?.[0]; const choice = chunk?.choices?.[0];
const textChunk = const textChunk =
choice?.delta?.content || choice?.delta?.content ||
@@ -179,7 +179,12 @@ export function extractJson(content: string): unknown {
if (parsed && typeof parsed === "object") { if (parsed && typeof parsed === "object") {
return parsed; 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++) { for (let start = 0; start < content.length; start++) {
@@ -224,7 +229,12 @@ export function extractJson(content: string): unknown {
if (parsed && typeof parsed === "object") { if (parsed && typeof parsed === "object") {
return parsed; return parsed;
} }
} catch (_) {} } catch (err) {
log.debug(
{ err: err instanceof Error ? err.message : String(err) },
"Failed to parse JSON candidate — trying next position",
);
}
break; break;
} }
} }
@@ -266,7 +276,7 @@ export function parseModerationResponse(
parsed = { results: [parsed] }; parsed = { results: [parsed] };
} else { } else {
const arrayKey = Object.keys(parsed).find((key) => { const arrayKey = Object.keys(parsed).find((key) => {
const val = (parsed as any)[key]; const val = parsed[key];
return ( return (
Array.isArray(val) && Array.isArray(val) &&
val.length > 0 && val.length > 0 &&
@@ -274,12 +284,12 @@ export function parseModerationResponse(
(item: unknown) => (item: unknown) =>
typeof item === "object" && typeof item === "object" &&
item !== null && item !== null &&
"message_id" in (item as any), "message_id" in (item as Record<string, unknown>),
) )
); );
}); });
if (arrayKey) { if (arrayKey) {
parsed.results = (parsed as any)[arrayKey]; parsed.results = parsed[arrayKey];
} else { } else {
parsed = { results: [parsed] }; parsed = { results: [parsed] };
} }
@@ -100,7 +100,7 @@ export async function pruneExpiredTexts(): Promise<number> {
`DELETE FROM text_analysis_cache WHERE expires_at < $1`, `DELETE FROM text_analysis_cache WHERE expires_at < $1`,
[Date.now()], [Date.now()],
); );
return (result as any).rowCount ?? 0; return (result as unknown as { rowCount?: number }).rowCount ?? 0;
} catch (error) { } catch (error) {
logger.error( logger.error(
{ error: error instanceof Error ? error.message : String(error) }, { error: error instanceof Error ? error.message : String(error) },
@@ -1,42 +1,42 @@
import type fs from "node:fs"; import type fs from "node:fs";
import type prism from "prism-media";
import type { import type {
AIStatus,
AISeverity,
AIRecommendedAction, AIRecommendedAction,
AISeverity,
AIStatus,
AnalysisQueueStatus,
AttachmentRecord,
BroadcasterClient, BroadcasterClient,
MessageRecord,
ModerationBroadcaster, ModerationBroadcaster,
RoleMetadata, RoleMetadata,
UserMetadata, UserMetadata,
MessageRecord,
AttachmentRecord,
VoiceRecordingUploadData, VoiceRecordingUploadData,
AnalysisQueueStatus,
} from "@bete/shared"; } from "@bete/shared";
import type prism from "prism-media";
// Re-export all shared types for backward compatibility // Re-export all shared types for backward compatibility
export type { export type {
AIStatus,
AISeverity,
AIRecommendedAction, AIRecommendedAction,
BroadcasterClient, AISeverity,
ModerationBroadcaster, AIStatus,
RoleMetadata, AnalysisQueueStatus,
UserMetadata, AnalysisResult,
MessageRecord,
AttachmentRecord, AttachmentRecord,
VoiceSegmentRecord, BroadcasterClient,
DashboardMessage, DashboardMessage,
MessageQuery, MessageQuery,
PageResult, MessageRecord,
AnalysisResult,
VoiceRecordingUploadData,
AnalysisQueueStatus,
MessageReview, MessageReview,
ModerationAction, ModerationAction,
ModerationActionType,
ModerationBroadcaster,
PageResult,
RetentionPolicy, RetentionPolicy,
ReviewStatus, ReviewStatus,
ModerationActionType, RoleMetadata,
UserMetadata,
VoiceRecordingUploadData,
VoiceSegmentRecord,
} from "@bete/shared"; } from "@bete/shared";
// Types that are LOCAL ONLY (not in shared) — keep here // Types that are LOCAL ONLY (not in shared) — keep here
@@ -1,6 +1,9 @@
import "dotenv/config"; import "dotenv/config";
import type { AppConfig as SharedAppConfig } from "@bete/shared/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 // Re-export the unified config with EFFECTIVE_* fields added
export type AppConfig = SharedAppConfig & { export type AppConfig = SharedAppConfig & {
@@ -4,9 +4,12 @@ import {
foreignKey as pgForeignKey, foreignKey as pgForeignKey,
index as pgIndex, index as pgIndex,
integer as pgInteger, integer as pgInteger,
jsonb as pgJsonb,
real as pgReal, real as pgReal,
pgTable, pgTable,
text as pgText, text as pgText,
timestamp as pgTimestamp,
uuid as pgUuid,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
// PostgreSQL Schema // 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 // Runtime table exports
// ===================== // =====================
@@ -509,6 +536,7 @@ export const stickerCacheTable = pgStickerCacheTable;
export const correctedModerationsTable = pgCorrectedModerationsTable; export const correctedModerationsTable = pgCorrectedModerationsTable;
export const userReputationsTable = pgUserReputationsTable; export const userReputationsTable = pgUserReputationsTable;
export const channelCulturesTable = pgChannelCulturesTable; export const channelCulturesTable = pgChannelCulturesTable;
export const mascotChatMessagesTable = pgMascotChatMessagesTable;
// Export table types for use in queries // Export table types for use in queries
export type MuxerJob = typeof muxerJobsTable.$inferSelect; export type MuxerJob = typeof muxerJobsTable.$inferSelect;
@@ -550,3 +578,7 @@ export type UserReputationInsert = typeof userReputationsTable.$inferInsert;
export type ChannelCulture = typeof channelCulturesTable.$inferSelect; export type ChannelCulture = typeof channelCulturesTable.$inferSelect;
export type ChannelCultureInsert = typeof channelCulturesTable.$inferInsert; export type ChannelCultureInsert = typeof channelCulturesTable.$inferInsert;
export type MascotChatMessage = typeof mascotChatMessagesTable.$inferSelect;
export type MascotChatMessageInsert =
typeof mascotChatMessagesTable.$inferInsert;
+1
View File
@@ -12,6 +12,7 @@
"format": "biome format --write src/" "format": "biome format --write src/"
}, },
"dependencies": { "dependencies": {
"@bete/shared": "workspace:*",
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
@@ -1,12 +1,9 @@
export type AIStatus = "pending" | "clean" | "warn" | "flagged" | "error"; export type {
export type AISeverity = "none" | "low" | "medium" | "high" | "critical"; AIRecommendedAction,
export type AIRecommendedAction = AISeverity,
| "none" AIStatus,
| "monitor" MessageRecord,
| "warn" } from "@bete/shared";
| "review"
| "delete"
| "escalate";
export interface MessageMetadata { export interface MessageMetadata {
stickers?: Array<{ name?: string; url?: string }>; 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<T> { export interface PageResult<T> {
data: T[]; data: T[];
nextCursor: string | null; nextCursor: string | null;
+3 -26
View File
@@ -1,5 +1,7 @@
// ─── Shared HTTP client — all API endpoints in one file ────────────────────── // ─── 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_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"; const BE_WS_URL = import.meta.env.VITE_BE_WS_URL || "ws://localhost:3001";
@@ -57,32 +59,7 @@ export interface PageResult<T> {
nextCursor: string | null; nextCursor: string | null;
} }
export interface MessageRecord { export type { 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 interface Guild { export interface Guild {
id: string; id: string;