From 3a7b005d95e185db443681daf92767546e79bcae Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Tue, 9 Jun 2026 16:36:23 +0700 Subject: [PATCH] fix: resolve architecture disconnects and codebase weaknesses - Add TEXT_CHANNEL_ID and TEXT_GUILD_ID to config schema (fix silent channel monitoring) - Remove dead files: message-capture/broadcaster.ts, voice-recording/index.ts - Fix WebSocket voice_command payload to forward from frontend - Implement moderation:action handler in commandHandler - Fix useMascotChat to use canonical request() wrapper - Fix useAudioPlayback userId hash collision (use string not parseInt) - Add catch blocks to useMediaControl.skip/stop - Add typed broadcast functions (messageAnalyzed, voicePcmData, voiceActiveUser) - Apply Biome formatting and lint fixes Co-Authored-By: Claude Opus 4.8 --- packages/shared/src/config/index.ts | 4 +- services/backend/src/ws/broadcast.ts | 12 ++ services/backend/src/ws/server.ts | 10 +- services/backend/tests/placeholder.test.ts | 2 +- .../modules/command-handler/commandHandler.ts | 126 ++++++++++++++++++ .../modules/message-capture/broadcaster.ts | 82 ------------ .../src/modules/voice-recording/index.ts | 5 - .../src/shared/config/config.ts | 7 +- .../discord-gateway/tests/placeholder.test.ts | 2 +- .../features/live/hooks/useMediaControl.ts | 8 ++ services/frontend/src/shared/api/client.ts | 4 + .../src/shared/hooks/useAudioPlayback.ts | 7 +- .../src/shared/hooks/useMascotChat.ts | 11 +- 13 files changed, 171 insertions(+), 109 deletions(-) delete mode 100644 services/discord-gateway/src/modules/message-capture/broadcaster.ts delete mode 100644 services/discord-gateway/src/modules/voice-recording/index.ts diff --git a/packages/shared/src/config/index.ts b/packages/shared/src/config/index.ts index b9dabf7..30e60e2 100644 --- a/packages/shared/src/config/index.ts +++ b/packages/shared/src/config/index.ts @@ -16,6 +16,8 @@ export const configSchema = z .min(1, "DISCORD_TOKEN is required") .transform((value) => value.replace(/^("|')|(?:("|'))$/g, "")), MONITOR_GUILD_ID: z.string().min(1).optional(), + TEXT_GUILD_ID: z.string().min(1).optional(), + TEXT_CHANNEL_ID: z.string().min(1).optional(), // ── Legacy voice ───────────────────────────────────────────────────── VOICE_GUILD_ID: z.string().min(1).optional(), @@ -231,7 +233,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { const parsed = configSchema.parse(env); return { ...parsed, - EFFECTIVE_TEXT_GUILD_ID: parsed.MONITOR_GUILD_ID, + EFFECTIVE_TEXT_GUILD_ID: parsed.TEXT_GUILD_ID ?? parsed.MONITOR_GUILD_ID, EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID, }; } catch (error) { diff --git a/services/backend/src/ws/broadcast.ts b/services/backend/src/ws/broadcast.ts index 963759f..3379f13 100644 --- a/services/backend/src/ws/broadcast.ts +++ b/services/backend/src/ws/broadcast.ts @@ -17,7 +17,10 @@ export interface BroadcastFunctions { messageCreated: BroadcastFn; messageUpdated: BroadcastFn; messageDeleted: BroadcastFn; + messageAnalyzed: BroadcastFn; attachmentUploaded: BroadcastFn; + voicePcmData: BroadcastFn; + voiceActiveUser: BroadcastFn; raw: BroadcastRawFn; binary: BroadcastBinaryFn; } @@ -53,6 +56,15 @@ export const broadcastMessageDeleted: BroadcastFn = (data) => export const broadcastAttachmentUploaded: BroadcastFn = (data) => (_fns?.attachmentUploaded ?? noop)(data); +export const broadcastMessageAnalyzed: BroadcastFn = (data) => + (_fns?.messageAnalyzed ?? noop)(data); + +export const broadcastVoicePcmData: BroadcastFn = (data) => + (_fns?.voicePcmData ?? noop)(data); + +export const broadcastVoiceActiveUser: BroadcastFn = (data) => + (_fns?.voiceActiveUser ?? noop)(data); + export const broadcastRaw: BroadcastRawFn = (type, data) => (_fns?.raw ?? noopRaw)(type, data); diff --git a/services/backend/src/ws/server.ts b/services/backend/src/ws/server.ts index 920201a..a17587c 100644 --- a/services/backend/src/ws/server.ts +++ b/services/backend/src/ws/server.ts @@ -96,7 +96,7 @@ export function createWebSocketServer(server: Server): WebSocketServer { }, ); } else if (message.type === "voice_command" && message.command) { - // Forward voice commands to discord-gateway + // Forward voice commands to discord-gateway with payload import("../shared/redis/index.js").then( ({ getCommandPublisher }) => { const publisher = getCommandPublisher(); @@ -107,7 +107,7 @@ export function createWebSocketServer(server: Server): WebSocketServer { JSON.stringify({ id: commandId, type: message.command, - payload: {}, + payload: message.payload ?? {}, replyChannel: `reply:${commandId}`, }), ) @@ -186,8 +186,14 @@ export function createWebSocketServer(server: Server): WebSocketServer { broadcast({ type: "message_updated", data }), messageDeleted: (data: unknown) => broadcast({ type: "message_deleted", data }), + messageAnalyzed: (data: unknown) => + broadcast({ type: "message_analyzed", data }), attachmentUploaded: (data: unknown) => broadcast({ type: "attachment_uploaded", data }), + voicePcmData: (data: unknown) => + broadcast({ type: "voice_pcm_data", data }), + voiceActiveUser: (data: unknown) => + broadcast({ type: "voice_active_user", data }), raw: (type: string, data: unknown) => broadcast({ type, data }), binary: broadcastBinary, }); diff --git a/services/backend/tests/placeholder.test.ts b/services/backend/tests/placeholder.test.ts index d2a554c..dee1837 100644 --- a/services/backend/tests/placeholder.test.ts +++ b/services/backend/tests/placeholder.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; describe("backend", () => { it("should load without errors", () => { diff --git a/services/discord-gateway/src/modules/command-handler/commandHandler.ts b/services/discord-gateway/src/modules/command-handler/commandHandler.ts index 44fdf05..1cc33a3 100644 --- a/services/discord-gateway/src/modules/command-handler/commandHandler.ts +++ b/services/discord-gateway/src/modules/command-handler/commandHandler.ts @@ -2,6 +2,7 @@ import { createChildLogger } from "@bete/shared/logger"; import type { Client } from "discord.js-selfbot-v13"; import Redis from "ioredis"; import { config } from "../../shared/config/config.js"; +import { createModerationAction } from "../message-capture/messageStore.js"; import { discordPlayer } from "../voice-recording/player.js"; import { voiceTransmitter } from "../voice-recording/transmitter.js"; import type { VoiceController } from "../voice-recording/voiceController.js"; @@ -155,6 +156,9 @@ export class CommandHandler { case "media:volume": reply = await this.handleMediaVolume(cmd); break; + case "moderation:action": + reply = await this.handleModerationAction(cmd); + break; default: logger.warn({ type: cmd.type }, "Unknown command type"); reply = { @@ -437,6 +441,128 @@ export class CommandHandler { } } + private async handleModerationAction( + cmd: BackendCommand, + ): Promise { + const payload = cmd.payload as { + message_id?: string; + user_id?: string; + guild_id?: string; + channel_id?: string; + action_type?: string; + reason?: string; + executed_by?: string; + }; + + if ( + !payload.message_id || + !payload.user_id || + !payload.guild_id || + !payload.action_type + ) { + return { + id: cmd.id, + success: false, + data: null, + error: "message_id, user_id, guild_id, and action_type are required", + }; + } + + const validActions = [ + "delete_message", + "mute_user", + "warn_user", + "kick_user", + "ban_user", + ] as const; + if ( + !validActions.includes( + payload.action_type as (typeof validActions)[number], + ) + ) { + return { + id: cmd.id, + success: false, + data: null, + error: `Invalid action_type: ${payload.action_type}. Must be one of: ${validActions.join(", ")}`, + }; + } + + try { + // For delete_message, also actually delete via Discord if client is available + if (payload.action_type === "delete_message" && this.client) { + try { + const channelId = String(cmd.payload.channel_id ?? ""); + if (channelId) { + const channel = await this.client.channels.fetch(channelId); + if (channel?.isText()) { + const msg = await channel.messages + .fetch(payload.message_id) + .catch(() => null); + if (msg) { + await msg.delete().catch((err: unknown) => { + logger.warn( + { error: err, messageId: payload.message_id }, + "Failed to delete message via Discord", + ); + }); + } + } + } + } catch (err) { + logger.warn( + { error: err, messageId: payload.message_id }, + "Failed to fetch channel/message for deletion", + ); + } + } + + const action = await createModerationAction({ + message_id: payload.message_id, + user_id: payload.user_id, + guild_id: payload.guild_id, + action_type: payload.action_type as + | "delete_message" + | "mute_user" + | "warn_user" + | "kick_user" + | "ban_user", + reason: payload.reason ?? null, + executed_by: payload.executed_by ?? "command-handler", + status: "executed", + error: null, + executed_at: Date.now(), + }); + + logger.info( + { + actionId: action.id, + actionType: payload.action_type, + userId: payload.user_id, + }, + "Moderation action executed", + ); + + return { + id: cmd.id, + success: true, + data: action, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error( + { error: message, commandId: cmd.id }, + "Failed to execute moderation action", + ); + return { + id: cmd.id, + success: false, + data: null, + error: message, + }; + } + } + // ---- Status publishing ---- private publishVoiceStatus(): void { diff --git a/services/discord-gateway/src/modules/message-capture/broadcaster.ts b/services/discord-gateway/src/modules/message-capture/broadcaster.ts deleted file mode 100644 index fab261e..0000000 --- a/services/discord-gateway/src/modules/message-capture/broadcaster.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { createChildLogger } from "@bete/shared/logger"; -import type { WebSocket } from "ws"; -import type { - AnalysisQueueStatus, - AttachmentRecord, - MessageRecord, - ModerationWsEvent, -} from "../message-capture/types.js"; -import type { MediaState } from "../voice-recording/mediaTypes.js"; - -export type BroadcasterClient = Pick; - -const log = createChildLogger("broadcaster"); - -function sendJson( - clients: Set, - event: ModerationWsEvent, -): void { - const payload = JSON.stringify({ ...event, timestamp: Date.now() }); - for (const client of clients) { - if (client.readyState === 1) { - try { - client.send(payload); - } catch (error) { - log.warn( - { error, eventType: event.type }, - "Failed to send event to client", - ); - } - } - } -} - -export function createBroadcaster() { - const clients = new Set(); - - return { - addClient(client: BroadcasterClient) { - clients.add(client); - log.debug({ clientCount: clients.size }, "Client added"); - }, - removeClient(client: BroadcasterClient) { - clients.delete(client); - log.debug({ clientCount: clients.size }, "Client removed"); - }, - clientCount() { - return clients.size; - }, - getClients() { - return Array.from(clients); - }, - uiState(state: unknown) { - sendJson(clients, { type: "ui_state", state }); - }, - userState(users: unknown[]) { - sendJson(clients, { type: "user_state", users }); - }, - messageCreated(data: MessageRecord) { - sendJson(clients, { type: "message_created", data }); - }, - messageUpdated(data: Partial & { id: string }) { - sendJson(clients, { type: "message_updated", data }); - }, - messageDeleted(data: { id: string; deleted_at: number }) { - sendJson(clients, { type: "message_deleted", data }); - }, - messageAnalyzed(data: MessageRecord) { - sendJson(clients, { type: "message_analyzed", data }); - }, - attachmentCreated(data: AttachmentRecord) { - sendJson(clients, { type: "attachment_created", data }); - }, - analysisQueueStatus(data: AnalysisQueueStatus) { - sendJson(clients, { type: "analysis_queue_status", data }); - }, - mediaState(state: MediaState) { - sendJson(clients, { type: "media_state", state }); - }, - }; -} - -export type ModerationBroadcaster = ReturnType; diff --git a/services/discord-gateway/src/modules/voice-recording/index.ts b/services/discord-gateway/src/modules/voice-recording/index.ts deleted file mode 100644 index 3b53a75..0000000 --- a/services/discord-gateway/src/modules/voice-recording/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { OpusDecoder } from "./recorder/decoder.js"; -export { SegmentManager } from "./recorder/segment.js"; -export { startRecording, stopRecording } from "./recorder.js"; -export { voiceTransmitter } from "./transmitter.js"; -export { VoiceController } from "./voiceController.js"; diff --git a/services/discord-gateway/src/shared/config/config.ts b/services/discord-gateway/src/shared/config/config.ts index 475c2a8..b96dd87 100644 --- a/services/discord-gateway/src/shared/config/config.ts +++ b/services/discord-gateway/src/shared/config/config.ts @@ -1,9 +1,6 @@ import "dotenv/config"; import type { AppConfig as SharedAppConfig } from "@bete/shared/config"; -import { - config as sharedConfig, - loadConfig as sharedLoadConfig, -} from "@bete/shared/config"; +import { loadConfig as sharedLoadConfig } from "@bete/shared/config"; // Re-export the unified config with EFFECTIVE_* fields added export type AppConfig = SharedAppConfig & { @@ -15,7 +12,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { const parsed = sharedLoadConfig(env); return { ...parsed, - EFFECTIVE_TEXT_GUILD_ID: parsed.MONITOR_GUILD_ID, + EFFECTIVE_TEXT_GUILD_ID: parsed.TEXT_GUILD_ID ?? parsed.MONITOR_GUILD_ID, EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID ?? parsed.GUILD_ID, }; } diff --git a/services/discord-gateway/tests/placeholder.test.ts b/services/discord-gateway/tests/placeholder.test.ts index d42501e..dbe9144 100644 --- a/services/discord-gateway/tests/placeholder.test.ts +++ b/services/discord-gateway/tests/placeholder.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; describe("discord-gateway", () => { it("should load without errors", () => { diff --git a/services/frontend/src/features/live/hooks/useMediaControl.ts b/services/frontend/src/features/live/hooks/useMediaControl.ts index a2322a5..e96560c 100644 --- a/services/frontend/src/features/live/hooks/useMediaControl.ts +++ b/services/frontend/src/features/live/hooks/useMediaControl.ts @@ -52,6 +52,10 @@ export function useMediaControl() { const state = await skipMedia(); setMediaState(state); return state; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + throw err; } finally { setLoading(false); } @@ -64,6 +68,10 @@ export function useMediaControl() { const state = await stopMedia(); setMediaState(state); return state; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + throw err; } finally { setLoading(false); } diff --git a/services/frontend/src/shared/api/client.ts b/services/frontend/src/shared/api/client.ts index d562144..68b5382 100644 --- a/services/frontend/src/shared/api/client.ts +++ b/services/frontend/src/shared/api/client.ts @@ -124,6 +124,10 @@ export interface AppConfig { monitorGuildId: string | null; } +export interface ChatResponse { + response?: string; +} + export type DashboardTab = "live" | "messages"; // ─── Messages ──────────────────────────────────────────────────────────────── diff --git a/services/frontend/src/shared/hooks/useAudioPlayback.ts b/services/frontend/src/shared/hooks/useAudioPlayback.ts index f840db1..76eafc6 100644 --- a/services/frontend/src/shared/hooks/useAudioPlayback.ts +++ b/services/frontend/src/shared/hooks/useAudioPlayback.ts @@ -10,7 +10,7 @@ export function useAudioPlayback() { Array.from({ length: 32 }, () => 0.04), ); const audioContextRef = useRef(null); - const userTimelinesRef = useRef(new Map()); + const userTimelinesRef = useRef(new Map()); const handleIncomingPcm = useCallback( (data: { userId: string; pcm: string }) => { @@ -57,13 +57,12 @@ export function useAudioPlayback() { source.connect(audioContext.destination); // Schedule playback per user to avoid overlaps - const userIdHash = parseInt(data.userId, 10); const currentTime = audioContext.currentTime; - let nextStart = userTimelinesRef.current.get(userIdHash) || 0; + let nextStart = userTimelinesRef.current.get(data.userId) || 0; if (nextStart < currentTime) nextStart = currentTime + 0.05; source.start(nextStart); userTimelinesRef.current.set( - userIdHash, + data.userId, nextStart + audioBuffer.duration, ); }, diff --git a/services/frontend/src/shared/hooks/useMascotChat.ts b/services/frontend/src/shared/hooks/useMascotChat.ts index 00ae0a7..a1998f9 100644 --- a/services/frontend/src/shared/hooks/useMascotChat.ts +++ b/services/frontend/src/shared/hooks/useMascotChat.ts @@ -1,4 +1,6 @@ import { useCallback, useState } from "react"; +import type { ChatResponse } from "../api/client"; +import { request } from "../api/client"; import { createChildLogger } from "../logger"; const logger = createChildLogger("useMascotChat"); @@ -18,17 +20,10 @@ export function useMascotChat(context?: ChatContext) { const handleSendMessage = useCallback( async (message: string): Promise => { try { - const response = await fetch("/api/mascot/chat", { + const data = await request("/api/chat", { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message, context }), }); - - if (!response.ok) { - throw new Error(`Mascot backend responded with ${response.status}`); - } - - const data = (await response.json()) as { response?: string }; return data.response || fallbackResponse(message, context); } catch (error) { logger.warn("Mascot backend unavailable, using fallback", { error });