import Redis from "ioredis"; 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"); export interface Guild { id: string; name: string; icon: string | null; } export interface Channel { id: string; name: string; type: "voice" | "text"; } export interface VoiceStatus { connected: boolean; activeGuildId: string | null; activeChannelId: string | null; activeChannelName: string | null; } interface CommandReply { id: string; success: boolean; data: unknown; error?: string; } // --- Redis command client --- let commandRedis: Redis | null = null; let statusRedis: Redis | null = null; function getCommandRedis(): Redis { if (!commandRedis) { commandRedis = config.REDIS_URL ? new Redis(config.REDIS_URL, { keyPrefix: "" }) : new Redis({ host: config.REDIS_HOST, port: config.REDIS_PORT, keyPrefix: "", }); } return commandRedis; } function getStatusRedis(): Redis { if (!statusRedis) { statusRedis = config.REDIS_URL ? new Redis(config.REDIS_URL, { keyPrefix: "" }) : new Redis({ host: config.REDIS_HOST, port: config.REDIS_PORT, keyPrefix: "", }); } return statusRedis; } async function sendCommand( type: string, payload: Record, timeoutMs = 10000, ): Promise { const redis = getCommandRedis(); const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const replyChannel = `backend:command:reply:${id}`; return new Promise((resolve) => { const timer = setTimeout(() => { redis.unsubscribe(replyChannel).catch(() => {}); resolve(null); }, timeoutMs); redis.subscribe(replyChannel, (err) => { if (err) { clearTimeout(timer); resolve(null); return; } }); const handler = (_ch: string, msg: string) => { if (_ch === replyChannel) { clearTimeout(timer); redis.unsubscribe(replyChannel).catch(() => {}); try { const reply: CommandReply = JSON.parse(msg); resolve(reply.success ? (reply.data as T) : null); } catch { resolve(null); } } }; redis.on("message", handler); redis .publish( "backend:command", JSON.stringify({ id, type, payload, replyChannel }), ) .catch(() => { clearTimeout(timer); resolve(null); }); }); } async function readStatus(key: string): Promise { try { const val = await getStatusRedis().get(key); return val ? (JSON.parse(val) as T) : null; } catch { return null; } } /** * 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`, ); return rows.map((row: Record) => ({ id: String(row.guild_id ?? ""), name: `Guild ${String(row.guild_id).slice(0, 8)}`, icon: null, })); } /** * 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`, [guildId], ); return rows.map((row: Record) => ({ id: String(row.channel_id ?? ""), name: `Channel ${String(row.channel_id).slice(0, 8)}`, type: "text" as const, })); } /** * Get voice channels — query from discord-gateway via Redis command. */ export async function getVoiceChannels(guildId: string): Promise { const channels = await sendCommand("voice:channels", { guildId }); return channels ?? []; } /** * Get current voice connection status from Redis cache set by discord-gateway. */ export async function getVoiceStatus(): Promise { const cached = await readStatus("voice:status"); if (cached) return cached; return { connected: false, activeGuildId: null, activeChannelId: null, activeChannelName: null, }; } /** * Connect to a voice channel via Redis command to discord-gateway. */ export async function connectVoice( guildId: string, channelId: string, ): Promise { const result = await sendCommand("voice:connect", { guildId, channelId, }); if (result) return result; // Fallback: read from Redis status key const cached = await readStatus("voice:status"); return ( cached ?? { connected: false, activeGuildId: null, activeChannelId: null, activeChannelName: null, } ); } /** * Disconnect from voice via Redis command to discord-gateway. */ export async function disconnectVoice(): Promise { const result = await sendCommand("voice:disconnect", {}); if (result) return result; const cached = await readStatus("voice:status"); return ( cached ?? { connected: false, activeGuildId: null, activeChannelId: null, activeChannelName: null, } ); }