2026-06-02 00:32:38 +07:00
|
|
|
import Redis from "ioredis";
|
|
|
|
|
import { getPool } from "../../shared/database/index.js";
|
|
|
|
|
import { config } from "../../shared/config/index.js";
|
|
|
|
|
import { createChildLogger } from "../../shared/logger/index.js";
|
|
|
|
|
|
|
|
|
|
const logger = createChildLogger("voice.service");
|
2026-06-01 21:44:29 +07:00
|
|
|
|
2026-06-02 00:11:29 +07:00
|
|
|
export interface Guild {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
2026-06-02 00:32:38 +07:00
|
|
|
icon: string | null;
|
2026-06-01 21:44:29 +07:00
|
|
|
}
|
|
|
|
|
|
2026-06-02 00:11:29 +07:00
|
|
|
export interface Channel {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
type: "voice" | "text";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface VoiceStatus {
|
|
|
|
|
connected: boolean;
|
2026-06-02 00:32:38 +07:00
|
|
|
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<T = unknown>(
|
|
|
|
|
type: string,
|
|
|
|
|
payload: Record<string, unknown>,
|
|
|
|
|
timeoutMs = 10000,
|
|
|
|
|
): Promise<T | null> {
|
|
|
|
|
const redis = getCommandRedis();
|
|
|
|
|
const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
|
|
|
const replyChannel = `backend:command:reply:${id}`;
|
|
|
|
|
|
|
|
|
|
return new Promise<T | null>((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<T>(key: string): Promise<T | null> {
|
|
|
|
|
try {
|
|
|
|
|
const val = await getStatusRedis().get(key);
|
|
|
|
|
return val ? (JSON.parse(val) as T) : null;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-06-02 00:11:29 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get guilds from database (distinct guild_id from messages).
|
|
|
|
|
*/
|
|
|
|
|
export async function getGuilds(): Promise<Guild[]> {
|
2026-06-02 00:32:38 +07:00
|
|
|
const pool = getPool();
|
|
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
`SELECT DISTINCT guild_id FROM messages ORDER BY guild_id`,
|
2026-06-02 00:11:29 +07:00
|
|
|
);
|
|
|
|
|
|
2026-06-02 00:32:38 +07:00
|
|
|
return rows.map((row: Record<string, unknown>) => ({
|
2026-06-02 00:11:29 +07:00
|
|
|
id: String(row.guild_id ?? ""),
|
|
|
|
|
name: `Guild ${String(row.guild_id).slice(0, 8)}`,
|
2026-06-02 00:32:38 +07:00
|
|
|
icon: null,
|
2026-06-02 00:11:29 +07:00
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get text channels from database (distinct channel_id for a guild).
|
|
|
|
|
*/
|
|
|
|
|
export async function getTextChannels(guildId: string): Promise<Channel[]> {
|
2026-06-02 00:32:38 +07:00
|
|
|
const pool = getPool();
|
|
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
`SELECT DISTINCT channel_id FROM messages WHERE guild_id = $1 ORDER BY channel_id`,
|
|
|
|
|
[guildId],
|
2026-06-02 00:11:29 +07:00
|
|
|
);
|
|
|
|
|
|
2026-06-02 00:32:38 +07:00
|
|
|
return rows.map((row: Record<string, unknown>) => ({
|
2026-06-02 00:11:29 +07:00
|
|
|
id: String(row.channel_id ?? ""),
|
|
|
|
|
name: `Channel ${String(row.channel_id).slice(0, 8)}`,
|
|
|
|
|
type: "text" as const,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-02 00:32:38 +07:00
|
|
|
* Get voice channels — query from discord-gateway via Redis command.
|
2026-06-02 00:11:29 +07:00
|
|
|
*/
|
2026-06-02 00:32:38 +07:00
|
|
|
export async function getVoiceChannels(guildId: string): Promise<Channel[]> {
|
|
|
|
|
const channels = await sendCommand<Channel[]>("voice:channels", { guildId });
|
|
|
|
|
return channels ?? [];
|
2026-06-02 00:11:29 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-02 00:32:38 +07:00
|
|
|
* Get current voice connection status from Redis cache set by discord-gateway.
|
2026-06-02 00:11:29 +07:00
|
|
|
*/
|
2026-06-02 00:32:38 +07:00
|
|
|
export async function getVoiceStatus(): Promise<VoiceStatus> {
|
|
|
|
|
const cached = await readStatus<VoiceStatus>("voice:status");
|
|
|
|
|
if (cached) return cached;
|
2026-06-02 00:11:29 +07:00
|
|
|
return {
|
|
|
|
|
connected: false,
|
2026-06-02 00:32:38 +07:00
|
|
|
activeGuildId: null,
|
|
|
|
|
activeChannelId: null,
|
|
|
|
|
activeChannelName: null,
|
2026-06-02 00:11:29 +07:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-02 00:32:38 +07:00
|
|
|
* Connect to a voice channel via Redis command to discord-gateway.
|
2026-06-02 00:11:29 +07:00
|
|
|
*/
|
|
|
|
|
export async function connectVoice(
|
2026-06-02 00:32:38 +07:00
|
|
|
guildId: string,
|
|
|
|
|
channelId: string,
|
2026-06-02 00:11:29 +07:00
|
|
|
): Promise<VoiceStatus> {
|
2026-06-02 00:32:38 +07:00
|
|
|
const result = await sendCommand<VoiceStatus>("voice:connect", {
|
|
|
|
|
guildId,
|
|
|
|
|
channelId,
|
|
|
|
|
});
|
|
|
|
|
if (result) return result;
|
|
|
|
|
|
|
|
|
|
// Fallback: read from Redis status key
|
|
|
|
|
const cached = await readStatus<VoiceStatus>("voice:status");
|
|
|
|
|
return (
|
|
|
|
|
cached ?? {
|
|
|
|
|
connected: false,
|
|
|
|
|
activeGuildId: null,
|
|
|
|
|
activeChannelId: null,
|
|
|
|
|
activeChannelName: null,
|
|
|
|
|
}
|
|
|
|
|
);
|
2026-06-02 00:11:29 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-02 00:32:38 +07:00
|
|
|
* Disconnect from voice via Redis command to discord-gateway.
|
2026-06-02 00:11:29 +07:00
|
|
|
*/
|
|
|
|
|
export async function disconnectVoice(): Promise<VoiceStatus> {
|
2026-06-02 00:32:38 +07:00
|
|
|
const result = await sendCommand<VoiceStatus>("voice:disconnect", {});
|
|
|
|
|
if (result) return result;
|
|
|
|
|
|
|
|
|
|
const cached = await readStatus<VoiceStatus>("voice:status");
|
|
|
|
|
return (
|
|
|
|
|
cached ?? {
|
|
|
|
|
connected: false,
|
|
|
|
|
activeGuildId: null,
|
|
|
|
|
activeChannelId: null,
|
|
|
|
|
activeChannelName: null,
|
|
|
|
|
}
|
|
|
|
|
);
|
2026-06-02 00:11:29 +07:00
|
|
|
}
|