From 8310e58239312a10b43debd7963a202096a84635 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Mon, 1 Jun 2026 11:16:07 +0700 Subject: [PATCH] feat(config): add API route for app configuration and update related logic for monitor guild --- frontend/src/App.tsx | 30 +++++++++++++++++----- frontend/src/api/client.ts | 8 ++++++ src/config.ts | 3 ++- src/http/app.ts | 2 ++ src/moderation/messageStore.ts | 46 ++++++++++++++++++++++++---------- src/routes/analysisRoutes.ts | 16 ++++++++++++ src/routes/analyticsRoutes.ts | 45 +++++++++++++++++++++++++++------ src/routes/appConfigRoutes.ts | 15 +++++++++++ src/routes/messageRoutes.ts | 15 ++++++++++- tests/config.test.ts | 6 ++--- 10 files changed, 154 insertions(+), 32 deletions(-) create mode 100644 src/routes/appConfigRoutes.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 372b536..c0c97e1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { mergeMessages, useMessages } from "./hooks/useMessages"; import { useMediaControl } from "./hooks/useMediaControl"; import { useUIState } from "./hooks/useUIState"; import { useVoiceControl } from "./hooks/useVoiceControl"; +import { getAppConfig } from "./api/client"; import type { MessageRecord } from "./types/messages"; import type { DashboardTab } from "./types/ui"; import type { ActiveSpeaker } from "./types/voice"; @@ -47,6 +48,7 @@ export default function App() { const [isListening, setIsListening] = useState(false); const [isStreaming, setIsStreaming] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(!!localStorage.getItem("admin-password")); + const [monitorGuildId, setMonitorGuildId] = useState(""); const audioContextListenRef = useRef(null); const audioContextTransmitRef = useRef(null); const streamRef = useRef(null); @@ -56,10 +58,11 @@ export default function App() { const activeTab = uiState.activeTab || "live"; const selectedVoiceGuild = uiState.selectedVoiceGuild || uiState.selectedGuild || ""; const selectedVoiceChannel = uiState.selectedVoiceChannel || ""; - const selectedTextGuild = uiState.selectedTextGuild || uiState.selectedGuild || ""; + const selectedTextGuild = monitorGuildId || uiState.selectedTextGuild || uiState.selectedGuild || ""; const selectedTextChannel = uiState.selectedTextChannel || ""; - const selectedAnalyticsGuild = uiState.selectedAnalyticsGuild || uiState.selectedGuild || ""; + const selectedAnalyticsGuild = monitorGuildId || uiState.selectedAnalyticsGuild || uiState.selectedGuild || ""; const selectedAnalyticsChannel = uiState.selectedAnalyticsChannel || ""; + const monitorGuild = monitorGuildId ? voice.guilds.find((guild) => guild.id === monitorGuildId) : undefined; const handleIncomingPcm = useCallback((data: ArrayBuffer) => { const headerView = new DataView(data, 0, 4); @@ -91,6 +94,22 @@ export default function App() { window.dispatchEvent(new CustomEvent("analytics_refresh")); }, []); + useEffect(() => { + getAppConfig() + .then((config) => { + if (config.monitorGuildId) { + setMonitorGuildId(config.monitorGuildId); + patchUIState({ + selectedTextGuild: config.monitorGuildId, + selectedAnalyticsGuild: config.monitorGuildId, + selectedTextChannel: "", + selectedAnalyticsChannel: "", + }); + } + }) + .catch(() => undefined); + }, [patchUIState]); + const socket = useDashboardSocket({ onUIState: (state) => setUIState((prev) => ({ ...prev, ...state })), onUserState: setActiveSpeakers, @@ -164,8 +183,7 @@ export default function App() { }, [isStreaming, startStreamingLocal, stopStreamingLocal, patchUIState]); useEffect(() => { if (selectedVoiceGuild) voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined); }, [selectedVoiceGuild]); - useEffect(() => { if (selectedTextGuild) voice.loadTextTargets(selectedTextGuild).catch(() => undefined); }, [selectedTextGuild]); - useEffect(() => { if (selectedAnalyticsGuild) voice.loadTextTargets(selectedAnalyticsGuild).catch(() => undefined); }, [selectedAnalyticsGuild]); + useEffect(() => { if (monitorGuildId) voice.loadTextTargets(monitorGuildId).catch(() => undefined); }, [monitorGuildId]); useEffect(() => { if (selectedTextChannel) messages.fetchMessages(selectedTextChannel).catch(() => undefined); }, [selectedTextChannel]); const toggleListening = useCallback(async () => { @@ -232,7 +250,7 @@ export default function App() { ) ) : activeTab === "messages" ? ( { export async function getGuilds(): Promise { return request("/api/guilds"); } + +export async function getAppConfig(): Promise { + return request("/api/config"); +} diff --git a/src/config.ts b/src/config.ts index 2da206d..bb976d6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -214,7 +214,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { const parsed = configSchema.parse(env); return { ...parsed, - EFFECTIVE_TEXT_GUILD_ID: parsed.TEXT_GUILD_ID ?? parsed.MONITOR_GUILD_ID, + // AI text capture and analytics are pinned to the monitor guild. + EFFECTIVE_TEXT_GUILD_ID: parsed.MONITOR_GUILD_ID, EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID ?? parsed.GUILD_ID, }; } catch (error) { diff --git a/src/http/app.ts b/src/http/app.ts index 4718d9e..0836ad4 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -12,6 +12,7 @@ import type { createChildLogger } from "../logger.js"; import type { MediaController } from "../media/mediaController.js"; import type { ModerationBroadcaster } from "../moderation/types.js"; import { createAnalysisRoutes } from "../routes/analysisRoutes.js"; +import { createAppConfigRoutes } from "../routes/appConfigRoutes.js"; import { createAnalyticsRoutes } from "../routes/analyticsRoutes.js"; import { createMediaRoutes } from "../routes/mediaRoutes.js"; import { createMessageRoutes } from "../routes/messageRoutes.js"; @@ -115,6 +116,7 @@ export function createHttpApp(options: CreateHttpAppOptions) { ); app.use("/api", createMessageRoutes()); app.use("/api", createAnalysisRoutes()); + app.use("/api", createAppConfigRoutes()); app.use("/api", createReviewRoutes()); app.use("/api", createAnalyticsRoutes()); app.use("/api", createSyncRoutes(options.client)); diff --git a/src/moderation/messageStore.ts b/src/moderation/messageStore.ts index 5449a38..a84eca1 100644 --- a/src/moderation/messageStore.ts +++ b/src/moderation/messageStore.ts @@ -235,18 +235,25 @@ export async function getMessagesByChannel( channelId: string, limit: number = 50, offset: number = 0, + guildId?: string, ): Promise { try { const database = db(); + const conditions: SQL[] = [ + or( + eq(messagesTable.channel_id, channelId), + eq(messagesTable.thread_id, channelId), + ) as SQL, + ]; + + if (guildId) { + conditions.push(eq(messagesTable.guild_id, guildId)); + } + const rows = await database .select() .from(messagesTable) - .where( - or( - eq(messagesTable.channel_id, channelId), - eq(messagesTable.thread_id, channelId), - ), - ) + .where(and(...conditions)) // P3: add secondary sort by id for stable pagination .orderBy(desc(messagesTable.created_at), desc(messagesTable.id)) .limit(limit) @@ -290,18 +297,25 @@ export async function getAttachmentsByChannel( channelId: string, limit: number = 50, offset: number = 0, + guildId?: string, ): Promise { try { const database = db(); + const conditions: SQL[] = [ + or( + eq(attachmentsTable.channel_id, channelId), + eq(attachmentsTable.thread_id, channelId), + ) as SQL, + ]; + + if (guildId) { + conditions.push(eq(attachmentsTable.guild_id, guildId)); + } + const rows = await database .select() .from(attachmentsTable) - .where( - or( - eq(attachmentsTable.channel_id, channelId), - eq(attachmentsTable.thread_id, channelId), - ), - ) + .where(and(...conditions)) .orderBy(desc(attachmentsTable.created_at)) .limit(limit) .offset(offset); @@ -732,15 +746,20 @@ export async function getAttachmentsForMessages( export async function searchMessages(input: { query: string; channelId?: string; + guildId?: string; limit?: number; }): Promise { try { - const { query, channelId, limit = 20 } = input; + const { query, channelId, guildId, limit = 20 } = input; const database = db(); const searchPattern = `%${query}%`; const conditions: (SQL | undefined)[] = [isNull(messagesTable.deleted_at)]; + if (guildId) { + conditions.push(eq(messagesTable.guild_id, guildId)); + } + if (channelId) { conditions.push(channelOrThreadCondition(channelId)); } @@ -767,6 +786,7 @@ export async function searchMessages(input: { { query: input.query, channelId: input.channelId, + guildId: input.guildId, error: error instanceof Error ? error.message : String(error), }, "Failed to search messages", diff --git a/src/routes/analysisRoutes.ts b/src/routes/analysisRoutes.ts index aafa685..de07ff9 100644 --- a/src/routes/analysisRoutes.ts +++ b/src/routes/analysisRoutes.ts @@ -1,5 +1,6 @@ import type { Router } from "express"; import express from "express"; +import { config } from "../config.js"; import { AppError } from "../errors.js"; import { getAnalysisQueueStatus, @@ -7,6 +8,7 @@ import { } from "../moderation/aiAnalyzer.js"; import { searchMessages, + getMessageById, updateMessageAIAnalysis, } from "../moderation/messageStore.js"; import type { MessageRecord } from "../moderation/types.js"; @@ -49,6 +51,7 @@ export function createAnalysisRoutes(): Router { const results = await searchMessages({ query: q, + guildId: config.MONITOR_GUILD_ID, channelId, limit: limitNum, }); @@ -78,6 +81,19 @@ export function createAnalysisRoutes(): Router { throw new AppError("Message ID is required", "MISSING_MESSAGE_ID", 400); } + const existing = await getMessageById(id); + if (!existing) { + throw new AppError("Message not found", "MESSAGE_NOT_FOUND", 404); + } + + if (existing.guild_id !== config.MONITOR_GUILD_ID) { + throw new AppError( + "Message is outside the monitor guild", + "INVALID_GUILD", + 403, + ); + } + // P3: Single UPDATE + RETURNING instead of GET + UPDATE + GET const updated = await updateMessageAIAnalysis(id, { status: "pending", diff --git a/src/routes/analyticsRoutes.ts b/src/routes/analyticsRoutes.ts index 63994d0..96b3224 100644 --- a/src/routes/analyticsRoutes.ts +++ b/src/routes/analyticsRoutes.ts @@ -1,5 +1,6 @@ import type { Router } from "express"; import express from "express"; +import { config } from "../config.js"; import { AppError } from "../errors.js"; import { getActivityHeatmap, @@ -15,6 +16,26 @@ import { export function createAnalyticsRoutes(): Router { const router = express.Router(); + function assertMonitorGuild(guildId?: string): string { + if (!config.MONITOR_GUILD_ID) { + throw new AppError( + "MONITOR_GUILD_ID is required for analytics", + "MISSING_MONITOR_GUILD_ID", + 400, + ); + } + + if (guildId && guildId !== config.MONITOR_GUILD_ID) { + throw new AppError( + "Analytics are restricted to the monitor guild", + "INVALID_GUILD", + 403, + ); + } + + return config.MONITOR_GUILD_ID; + } + // GET /api/analytics/overview - Full analytics dashboard data // Query params: guildId (required), channelId, hours (default 24) router.get("/analytics/overview", async (req, res, next) => { @@ -34,9 +55,10 @@ export function createAnalyticsRoutes(): Router { } const hoursNum = hours ? Math.min(parseInt(hours) || 24, 168) : 24; + const monitorGuildId = assertMonitorGuild(guildId); const overview = await getAnalyticsOverview({ - guildId, + guildId: monitorGuildId, channelId, hours: hoursNum, }); @@ -66,9 +88,10 @@ export function createAnalyticsRoutes(): Router { } const hoursNum = hours ? Math.min(parseInt(hours) || 24, 168) : 24; + const monitorGuildId = assertMonitorGuild(guildId); const stats = await getHourlyStats({ - guildId, + guildId: monitorGuildId, channelId, hours: hoursNum, }); @@ -98,9 +121,10 @@ export function createAnalyticsRoutes(): Router { } const hoursNum = hours ? Math.min(parseInt(hours) || 24, 168) : 24; + const monitorGuildId = assertMonitorGuild(guildId); const topics = await getTopicTrends({ - guildId, + guildId: monitorGuildId, channelId, hours: hoursNum, }); @@ -132,9 +156,10 @@ export function createAnalyticsRoutes(): Router { const hoursNum = hours ? Math.min(parseInt(hours) || 24, 168) : 24; const limitNum = limit ? Math.min(parseInt(limit) || 20, 100) : 20; + const monitorGuildId = assertMonitorGuild(guildId); const users = await getUserLeaderboard({ - guildId, + guildId: monitorGuildId, channelId, hours: hoursNum, limit: limitNum, @@ -165,9 +190,10 @@ export function createAnalyticsRoutes(): Router { } const hoursNum = hours ? Math.min(parseInt(hours) || 24, 168) : 24; + const monitorGuildId = assertMonitorGuild(guildId); const stats = await getModerationStats({ - guildId, + guildId: monitorGuildId, channelId, hours: hoursNum, }); @@ -199,9 +225,10 @@ export function createAnalyticsRoutes(): Router { const hoursNum = hours ? Math.min(parseInt(hours) || 24, 168) : 24; const limitNum = limit ? Math.min(parseInt(limit) || 20, 100) : 20; + const monitorGuildId = assertMonitorGuild(guildId); const violators = await getTopViolators({ - guildId, + guildId: monitorGuildId, channelId, hours: hoursNum, limit: limitNum, @@ -232,9 +259,10 @@ export function createAnalyticsRoutes(): Router { } const hoursNum = hours ? Math.min(parseInt(hours) || 168, 720) : 168; + const monitorGuildId = assertMonitorGuild(guildId); const trend = await getDailyTrend({ - guildId, + guildId: monitorGuildId, channelId, hours: hoursNum, }); @@ -264,9 +292,10 @@ export function createAnalyticsRoutes(): Router { } const hoursNum = hours ? Math.min(parseInt(hours) || 168, 720) : 168; + const monitorGuildId = assertMonitorGuild(guildId); const heatmap = await getActivityHeatmap({ - guildId, + guildId: monitorGuildId, channelId, hours: hoursNum, }); diff --git a/src/routes/appConfigRoutes.ts b/src/routes/appConfigRoutes.ts new file mode 100644 index 0000000..93fd775 --- /dev/null +++ b/src/routes/appConfigRoutes.ts @@ -0,0 +1,15 @@ +import type { Router } from "express"; +import express from "express"; +import { config } from "../config.js"; + +export function createAppConfigRoutes(): Router { + const router = express.Router(); + + router.get("/config", (_req, res) => { + res.json({ + monitorGuildId: config.MONITOR_GUILD_ID ?? null, + }); + }); + + return router; +} \ No newline at end of file diff --git a/src/routes/messageRoutes.ts b/src/routes/messageRoutes.ts index 610b7e1..1fe5502 100644 --- a/src/routes/messageRoutes.ts +++ b/src/routes/messageRoutes.ts @@ -1,5 +1,6 @@ import type { Router } from "express"; import express from "express"; +import { config } from "../config.js"; import { AppError } from "../errors.js"; import { getAttachmentsByChannel, @@ -52,6 +53,7 @@ export function createMessageRoutes(): Router { }; const targetChannel = channelId || channel; + const monitorGuildId = config.MONITOR_GUILD_ID; const limitNum = Math.min(parseInt(limit) || 50, 100); const offsetNum = parseInt(offset) || 0; @@ -68,6 +70,7 @@ export function createMessageRoutes(): Router { targetChannel, limitNum, offsetNum, + monitorGuildId, ); res.json({ type: "image", @@ -77,6 +80,7 @@ export function createMessageRoutes(): Router { }); } else if (channelId || cursor || status) { const result = await listMessages({ + guildId: monitorGuildId, channelId: targetChannel, cursor, limit: limitNum, @@ -101,6 +105,7 @@ export function createMessageRoutes(): Router { targetChannel, limitNum, offsetNum, + monitorGuildId, ); res.json({ type: "text", @@ -129,6 +134,14 @@ export function createMessageRoutes(): Router { throw new AppError("Message not found", "MESSAGE_NOT_FOUND", 404); } + if (message.guild_id !== config.MONITOR_GUILD_ID) { + throw new AppError( + "Message is outside the monitor guild", + "INVALID_GUILD", + 403, + ); + } + res.json(message); } catch (error) { next(error); @@ -158,7 +171,7 @@ export function createMessageRoutes(): Router { const limitNum = Math.min(parseInt(limit) || 50, 100); const query: Omit = { - guildId, + guildId: guildId || config.MONITOR_GUILD_ID, channelId, threadId, userId, diff --git a/tests/config.test.ts b/tests/config.test.ts index 6ab15eb..a404309 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -88,11 +88,11 @@ describe("loadConfig", () => { expect(config.VOICE_CHANNEL_ID).toBe("voice-channel"); }); - it("uses explicit split text and voice config before legacy values", async () => { + it("pins text capture to the monitor guild even when legacy text config is present", async () => { process.env = { ...originalEnv, DISCORD_TOKEN: "token", - MONITOR_GUILD_ID: "legacy-text-guild", + MONITOR_GUILD_ID: "monitor-guild", GUILD_ID: "legacy-voice-guild", TEXT_GUILD_ID: "text-guild", TEXT_CHANNEL_ID: "text-channel", @@ -104,7 +104,7 @@ describe("loadConfig", () => { const { loadConfig } = await import("../src/config"); const config = loadConfig(process.env); - expect(config.EFFECTIVE_TEXT_GUILD_ID).toBe("text-guild"); + expect(config.EFFECTIVE_TEXT_GUILD_ID).toBe("monitor-guild"); expect(config.TEXT_CHANNEL_ID).toBe("text-channel"); expect(config.EFFECTIVE_VOICE_GUILD_ID).toBe("voice-guild"); });