From 5f419a6f0f4118891038ae50610e1c30650b0ec4 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Tue, 2 Jun 2026 11:02:54 +0700 Subject: [PATCH] feat: real guild/channel names from Discord + remove selectors in Messages/Analytics - discord-gateway: add redis command handlers for guilds:list, guilds:text-channels, voice:channels - backend: replace postgres synthetic names with redis commands to gateway (with fallback) - frontend: remove guild/channel dropdowns from messages and analytics tabs - frontend: auto-load all channels from monitor guild via guildId query param - frontend: show guild name in messages/analytics headers instead of selector - live tab: keeps guild/channel selectors with real discord names Co-authored-by: Claude Opus 4.8 --- .../src/modules/voice/voice.service.ts | 30 +++- .../modules/command-handler/commandHandler.ts | 156 ++++++++++++++++-- services/frontend/src/App.tsx | 73 +++----- .../analytics/components/ControlBar.tsx | 43 ++--- .../frontend/src/features/analytics/index.tsx | 34 +--- .../features/messages/hooks/useMessages.ts | 32 ++-- .../frontend/src/features/messages/index.tsx | 57 ++----- 7 files changed, 243 insertions(+), 182 deletions(-) diff --git a/services/backend/src/modules/voice/voice.service.ts b/services/backend/src/modules/voice/voice.service.ts index 4712293..c2c7eec 100644 --- a/services/backend/src/modules/voice/voice.service.ts +++ b/services/backend/src/modules/voice/voice.service.ts @@ -1,6 +1,6 @@ import Redis from "ioredis"; -import { getPool } from "../../shared/database/index.js"; import { config } from "../../shared/config/index.js"; +import { getPool } from "../../shared/database/index.js"; import { createChildLogger } from "../../shared/logger/index.js"; const logger = createChildLogger("voice.service"); @@ -100,7 +100,10 @@ async function sendCommand( redis.on("message", handler); redis - .publish("backend:command", JSON.stringify({ id, type, payload, replyChannel })) + .publish( + "backend:command", + JSON.stringify({ id, type, payload, replyChannel }), + ) .catch(() => { clearTimeout(timer); resolve(null); @@ -118,9 +121,17 @@ async function readStatus(key: string): Promise { } /** - * Get guilds from database (distinct guild_id from messages). + * Get guilds — query from discord-gateway via Redis command for real names. + * Falls back to database (distinct guild_id from messages) if gateway unreachable. */ export async function getGuilds(): Promise { + const fromGateway = await sendCommand("guilds:list", {}); + if (fromGateway && fromGateway.length > 0) return fromGateway; + + // Fallback: Postgres with synthetic names + logger.warn( + "discord-gateway unreachable, falling back to Postgres for guilds", + ); const pool = getPool(); const { rows } = await pool.query( `SELECT DISTINCT guild_id FROM messages ORDER BY guild_id`, @@ -134,9 +145,20 @@ export async function getGuilds(): Promise { } /** - * Get text channels from database (distinct channel_id for a guild). + * Get text channels — query from discord-gateway via Redis command for real names. + * Falls back to database if gateway unreachable. */ export async function getTextChannels(guildId: string): Promise { + const fromGateway = await sendCommand("guilds:text-channels", { + guildId, + }); + if (fromGateway && fromGateway.length > 0) return fromGateway; + + // Fallback: Postgres with synthetic names + logger.warn( + { guildId }, + "discord-gateway unreachable, falling back to Postgres for text channels", + ); const pool = getPool(); const { rows } = await pool.query( `SELECT DISTINCT channel_id FROM messages WHERE guild_id = $1 ORDER BY channel_id`, diff --git a/services/discord-gateway/src/modules/command-handler/commandHandler.ts b/services/discord-gateway/src/modules/command-handler/commandHandler.ts index eda3c40..380c518 100644 --- a/services/discord-gateway/src/modules/command-handler/commandHandler.ts +++ b/services/discord-gateway/src/modules/command-handler/commandHandler.ts @@ -1,9 +1,9 @@ -import Redis from "ioredis"; import type { Client } from "discord.js-selfbot-v13"; -import type { VoiceController } from "../voice-recording/voiceController.js"; -import { discordPlayer } from "../voice-recording/player.js"; +import Redis from "ioredis"; import { config } from "../../shared/config/config.js"; import { createChildLogger } from "../../shared/logger/logger.js"; +import { discordPlayer } from "../voice-recording/player.js"; +import type { VoiceController } from "../voice-recording/voiceController.js"; const logger = createChildLogger("command-handler"); @@ -125,6 +125,15 @@ export class CommandHandler { case "voice:disconnect": reply = await this.handleVoiceDisconnect(cmd); break; + case "voice:channels": + reply = await this.handleVoiceChannels(cmd); + break; + case "guilds:list": + reply = await this.handleListGuilds(cmd); + break; + case "guilds:text-channels": + reply = await this.handleTextChannels(cmd); + break; case "media:queue": reply = await this.handleMediaQueue(cmd); break; @@ -148,7 +157,10 @@ export class CommandHandler { } } catch (err) { const message = err instanceof Error ? err.message : String(err); - logger.error({ commandId: cmd.id, error: message }, "Command execution failed"); + logger.error( + { commandId: cmd.id, error: message }, + "Command execution failed", + ); reply = { id: cmd.id, success: false, @@ -175,7 +187,12 @@ export class CommandHandler { private async handleVoiceConnect(cmd: BackendCommand): Promise { if (!this.client || !this.voiceController) { - return { id: cmd.id, success: false, data: null, error: "Gateway not initialized" }; + return { + id: cmd.id, + success: false, + data: null, + error: "Gateway not initialized", + }; } const guildId = String(cmd.payload.guildId ?? ""); @@ -194,15 +211,124 @@ export class CommandHandler { return { id: cmd.id, success: true, data: status }; } - private async handleVoiceDisconnect(cmd: BackendCommand): Promise { + private async handleVoiceDisconnect( + cmd: BackendCommand, + ): Promise { if (!this.voiceController) { - return { id: cmd.id, success: false, data: null, error: "Gateway not initialized" }; + return { + id: cmd.id, + success: false, + data: null, + error: "Gateway not initialized", + }; } const status = await this.voiceController.disconnect(); return { id: cmd.id, success: true, data: status }; } + private async handleVoiceChannels( + cmd: BackendCommand, + ): Promise { + if (!this.client) { + return { + id: cmd.id, + success: false, + data: null, + error: "Gateway not initialized", + }; + } + + const guildId = String(cmd.payload.guildId ?? ""); + if (!guildId) { + return { + id: cmd.id, + success: false, + data: null, + error: "guildId is required", + }; + } + + try { + const guild = await this.client.guilds.fetch(guildId); + const channels = await guild.channels.fetch(); + const voiceChannels = channels + .filter((c) => c?.type === "GUILD_VOICE") + .map((c) => ({ + id: c.id, + name: c.name, + type: "voice" as const, + })); + + return { id: cmd.id, success: true, data: voiceChannels }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { id: cmd.id, success: false, data: null, error: msg }; + } + } + + private async handleListGuilds(cmd: BackendCommand): Promise { + if (!this.client) { + return { + id: cmd.id, + success: false, + data: null, + error: "Gateway not initialized", + }; + } + + try { + const guilds = this.client.guilds.cache.map((g) => ({ + id: g.id, + name: g.name, + icon: g.iconURL() ?? null, + })); + + return { id: cmd.id, success: true, data: guilds }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { id: cmd.id, success: false, data: null, error: msg }; + } + } + + private async handleTextChannels(cmd: BackendCommand): Promise { + if (!this.client) { + return { + id: cmd.id, + success: false, + data: null, + error: "Gateway not initialized", + }; + } + + const guildId = String(cmd.payload.guildId ?? ""); + if (!guildId) { + return { + id: cmd.id, + success: false, + data: null, + error: "guildId is required", + }; + } + + try { + const guild = await this.client.guilds.fetch(guildId); + const channels = await guild.channels.fetch(); + const textChannels = channels + .filter((c) => c?.type === "GUILD_TEXT") + .map((c) => ({ + id: c.id, + name: c.name, + type: "text" as const, + })); + + return { id: cmd.id, success: true, data: textChannels }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { id: cmd.id, success: false, data: null, error: msg }; + } + } + private async handleMediaQueue(_cmd: BackendCommand): Promise { // Media queueing is handled at a higher level (frontend / backend streams // audio directly). Log the request for now. @@ -235,7 +361,11 @@ export class CommandHandler { }; } discordPlayer.setMusicVolume(volume); - return { id: cmd.id, success: true, data: { volume: discordPlayer.getMusicVolume() } }; + return { + id: cmd.id, + success: true, + data: { volume: discordPlayer.getMusicVolume() }, + }; } // ---- Status publishing ---- @@ -243,7 +373,12 @@ export class CommandHandler { private publishVoiceStatus(): void { const status: VoiceStatusPayload = this.voiceController ? this.voiceController.getStatus() - : { connected: false, activeGuildId: null, activeChannelId: null, activeChannelName: null }; + : { + connected: false, + activeGuildId: null, + activeChannelId: null, + activeChannelName: null, + }; this.setKey(VOICE_STATUS_KEY, JSON.stringify(status)); } @@ -265,7 +400,8 @@ export class CommandHandler { */ private setKey(key: string, value: string): void { const redis = new Redis(config.REDIS_URL); - redis.set(key, value) + redis + .set(key, value) .catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); logger.warn({ key, error: msg }, "Failed to update Redis status key"); diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx index f4d5ee4..7a84dd4 100644 --- a/services/frontend/src/App.tsx +++ b/services/frontend/src/App.tsx @@ -64,14 +64,13 @@ export default function App() { const activeTab = uiState.activeTab || "live"; const selectedVoiceGuild = uiState.selectedVoiceGuild || uiState.selectedGuild || ""; - const selectedTextGuild = - monitorGuildId || uiState.selectedTextGuild || uiState.selectedGuild || ""; - const selectedTextChannel = uiState.selectedTextChannel || ""; - const monitorGuild = useMemo( + + // Resolve monitor guild name from the full guild list (has real names now) + const monitorGuildName = useMemo( () => monitorGuildId - ? voice.guilds.find((g) => g.id === monitorGuildId) - : undefined, + ? (voice.guilds.find((g) => g.id === monitorGuildId)?.name ?? null) + : null, [monitorGuildId, voice.guilds], ); @@ -97,7 +96,9 @@ export default function App() { onMessageAnalyzed: (m) => messages.setMessages((prev) => mergeMessages(prev, [m as MessageRecord])), onAttachmentUploaded: () => - messages.fetchMessages(selectedTextChannel).catch(() => undefined), + messages + .fetchMessages(monitorGuildId || undefined) + .catch(() => undefined), onMediaState: (state) => media.setMediaState(state as MediaState), onVoiceRecordingUploaded: (d) => window.dispatchEvent( @@ -107,43 +108,37 @@ export default function App() { const transmit = useAudioTransmit(socket.socketRef); + // Load app config on mount useEffect(() => { getAppConfig() .then((c) => { if (c.monitorGuildId) { setMonitorGuildId(c.monitorGuildId); - patchUIState({ - selectedTextGuild: c.monitorGuildId, - selectedAnalyticsGuild: c.monitorGuildId, - selectedTextChannel: "", - selectedAnalyticsChannel: "", - }); } }) .catch(() => undefined); - }, [patchUIState]); + }, []); + // Load voice channels when guild changes (Live tab) useEffect(() => { if (selectedVoiceGuild) voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined); }, [selectedVoiceGuild, voice.loadVoiceChannels]); + + // Auto-fetch messages for the monitor guild (all channels) useEffect(() => { if (monitorGuildId) - voice.loadTextTargets(monitorGuildId).catch(() => undefined); - }, [monitorGuildId, voice.loadTextTargets]); - useEffect(() => { - if (selectedTextChannel) - messages.fetchMessages(selectedTextChannel).catch(() => undefined); - }, [selectedTextChannel, messages.fetchMessages]); + messages.fetchMessages(monitorGuildId).catch(() => undefined); + }, [monitorGuildId, messages.fetchMessages]); - // Periodic refetch — ensures dashboard stays in sync even if WS events were missed + // Periodic refetch — keeps dashboard in sync even if WS events missed useEffect(() => { - if (!selectedTextChannel) return; + if (!monitorGuildId) return; const interval = setInterval(() => { - messages.fetchMessages(selectedTextChannel).catch(() => undefined); - }, 15_000); // every 15s (longer than WS, shorter than stale cache) + messages.fetchMessages(monitorGuildId).catch(() => undefined); + }, 15_000); return () => clearInterval(interval); - }, [selectedTextChannel, messages.fetchMessages]); + }, [monitorGuildId, messages.fetchMessages]); return ( - patchUIState({ selectedTextGuild: id, selectedTextChannel: "" }) - } - onChannelChange={(id) => patchUIState({ selectedTextChannel: id })} onReanalyze={messages.reanalyze} onLoadMore={messages.loadMore} hasMore={messages.hasMore} @@ -218,23 +206,8 @@ export default function App() { } > - patchUIState({ - selectedAnalyticsGuild: id, - selectedAnalyticsChannel: "", - }) - } - onChannelChange={(id) => - patchUIState({ selectedAnalyticsChannel: id }) - } + guildId={monitorGuildId} + guildName={monitorGuildName} /> diff --git a/services/frontend/src/features/analytics/components/ControlBar.tsx b/services/frontend/src/features/analytics/components/ControlBar.tsx index aae8002..092f5eb 100644 --- a/services/frontend/src/features/analytics/components/ControlBar.tsx +++ b/services/frontend/src/features/analytics/components/ControlBar.tsx @@ -1,5 +1,4 @@ import { Activity, BarChart3 } from "lucide-react"; -import type { Channel, Guild } from "../../../shared/api/client"; import { cn } from "../../../shared/lib/utils"; import { Button, @@ -8,7 +7,6 @@ import { CardDescription, CardHeader, CardTitle, - Select, } from "../../../shared/ui"; const TIME_RANGES = [ @@ -22,27 +20,17 @@ const TIME_RANGES = [ ]; interface ControlBarProps { - guilds: Guild[]; - channels: Channel[]; - selectedGuild: string; - selectedChannel: string; + guildName: string | null; hours: number; isFetching: boolean; - onGuildChange: (guildId: string) => void; - onChannelChange: (channelId: string) => void; onHoursChange: (hours: number) => void; onRefresh: () => void; } export function ControlBar({ - guilds, - channels, - selectedGuild, - selectedChannel, + guildName, hours, isFetching, - onGuildChange, - onChannelChange, onHoursChange, onRefresh, }: ControlBarProps) { @@ -54,28 +42,19 @@ export function ControlBar({ Analisis Moderasi - Pantau statistik, tren topik, dan aktivitas user. + {guildName ? ( + <> + Pantau statistik, tren topik, dan aktivitas user di seluruh + channel{" "} + {guildName}. + + ) : ( + "Pantau statistik, tren topik, dan aktivitas user." + )}
- onChannelChange(e.target.value)} - placeholder="Semua channel" - options={[ - { value: "", label: "Semua channel" }, - ...channels.map((c) => ({ value: c.id, label: c.name })), - ]} - className="min-w-[160px]" - />
{TIME_RANGES.map((tr) => (