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:
co-authored by
Claude Opus 4.8
parent
67d66bb5dd
commit
3614d32701
@@ -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);
|
||||
|
||||
|
||||
@@ -37,33 +37,7 @@ export interface ServerInsights {
|
||||
}
|
||||
|
||||
export class MascotChatRepository {
|
||||
private initialized = false;
|
||||
|
||||
async ensureSchema(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
const pool = getPool();
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS mascot_chat_messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id TEXT NOT NULL,
|
||||
user_message TEXT NOT NULL,
|
||||
mascot_response TEXT NOT NULL,
|
||||
context JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_mascot_chat_messages_user_created
|
||||
ON mascot_chat_messages (user_id, created_at DESC)
|
||||
`);
|
||||
|
||||
this.initialized = true;
|
||||
logger.info("Mascot chat schema ready");
|
||||
}
|
||||
|
||||
async saveConversation(input: SaveConversationInput): Promise<void> {
|
||||
await this.ensureSchema();
|
||||
const pool = getPool();
|
||||
|
||||
await pool.query(
|
||||
@@ -88,7 +62,6 @@ export class MascotChatRepository {
|
||||
userId: string,
|
||||
limit: number,
|
||||
): Promise<MascotChatHistoryRow[]> {
|
||||
await this.ensureSchema();
|
||||
const pool = getPool();
|
||||
|
||||
const { rows } = await pool.query<MascotChatHistoryRow>(
|
||||
@@ -107,7 +80,6 @@ export class MascotChatRepository {
|
||||
}
|
||||
|
||||
async clearChatHistory(userId: string): Promise<void> {
|
||||
await this.ensureSchema();
|
||||
const pool = getPool();
|
||||
|
||||
const { rowCount } = await pool.query(
|
||||
@@ -122,7 +94,6 @@ export class MascotChatRepository {
|
||||
guildId?: string,
|
||||
channelId?: string,
|
||||
): Promise<ServerInsights> {
|
||||
await this.ensureSchema();
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
"tag": "0006_replace_base64_with_image_url",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1781174400000,
|
||||
"tag": "0007_mascot_chat_table",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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<unknown>;
|
||||
}
|
||||
|
||||
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` +
|
||||
|
||||
@@ -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<LLMResponseChunk>) {
|
||||
const choice = chunk?.choices?.[0];
|
||||
const textChunk =
|
||||
choice?.delta?.content ||
|
||||
|
||||
@@ -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<string, unknown>),
|
||||
)
|
||||
);
|
||||
});
|
||||
if (arrayKey) {
|
||||
parsed.results = (parsed as any)[arrayKey];
|
||||
parsed.results = parsed[arrayKey];
|
||||
} else {
|
||||
parsed = { results: [parsed] };
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ export async function pruneExpiredTexts(): Promise<number> {
|
||||
`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) },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<T> {
|
||||
data: T[];
|
||||
nextCursor: string | null;
|
||||
|
||||
@@ -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<T> {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user