feat: implement all real backend endpoints, Redis pub/sub bridge, and gateway command handler

Backend (real implementations, no more stubs):
- Redis pub/sub bridge: subscribes to discord-gateway events (message/attachment/voice) and broadcasts to WS clients
- Redis command channel: backend publishes voice/media commands, discord-gateway executes and replies
- Voice service: connectVoice/disconnectVoice/getVoiceStatus via Redis commands with graceful fallback
- Media service: queue/skip/stop/volume via Redis commands, reads status from Redis cache
- Messages repository: ALL 7 methods now use real PostgreSQL queries (findMany, findById, findByChannel, create, update, delete, getAttachmentsByChannel)
- Analytics: period returns {start,end} epoch millis, overview includes hourly/topics/top_users, worst_flags as string[]
- Health check: actually queries SELECT 1 against database
- VoiceStatus type fixed: {connected, activeGuildId, activeChannelId, activeChannelName}
- Guild type: includes icon: string | null
- asyncHandler: accepts Promise<unknown> instead of Promise<void>

Discord Gateway:
- CommandHandler: subscribes to 'backend:command' Redis channel, executes voice/media commands, publishes replies
- Publishes voice:status and media:status to Redis for backend caching
- Shutdown handler updated to close command handler

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-02 00:32:38 +07:00
co-authored by Claude Opus 4.8
parent 9b41eb9c12
commit 3b2709455e
16 changed files with 1326 additions and 148 deletions
@@ -35,6 +35,8 @@ export class AnalyticsRepository {
async getOverview(guildId: string, channelId?: string, hours = 24) {
logger.debug({ guildId, channelId, hours }, "Getting analytics overview");
const pool = getPool();
const now = Date.now();
const start = now - hours * 3_600_000;
const filter = buildTimeFilter(guildId, channelId, hours);
const { rows } = await pool.query(
@@ -57,8 +59,13 @@ export class AnalyticsRepository {
const row = rows[0] as Record<string, unknown> | undefined;
// Fetch hourly stats, topics, and top violators to include in overview
const hourly = await this.getHourlyStats(guildId, channelId, hours);
const topics = await this.getTopics(guildId, channelId, hours);
const topUsers = await this.getTopViolators(guildId, channelId, hours, 5);
return {
period: { hours },
period: { start, end: now },
messages: {
total: Number(row?.total_messages ?? 0),
clean: Number(row?.clean ?? 0),
@@ -68,9 +75,9 @@ export class AnalyticsRepository {
pending: Number(row?.pending ?? 0),
average_score: Number(row?.average_score ?? 0),
},
hourly: [],
topics: [],
top_users: [],
hourly,
topics,
top_users: topUsers,
active_users_count: Number(row?.active_users_count ?? 0),
total_channels: Number(row?.total_channels ?? 0),
};
@@ -185,7 +192,9 @@ export class AnalyticsRepository {
flagged_count: Number(r.flagged_count ?? 0),
warned_count: Number(r.warned_count ?? 0),
violation_score: Number(r.violation_score ?? 0),
worst_flags: (r.worst_flags as string | null) ?? null,
worst_flags: (r.worst_flags as string | null)
? (r.worst_flags as string).split(",").map((s) => s.trim()).filter(Boolean)
: [],
last_violation: Number(r.last_violation ?? 0),
}));
}
@@ -1,3 +1,4 @@
import { getPool } from "../../shared/database/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
const logger = createChildLogger("health.repository");
@@ -5,11 +6,15 @@ const logger = createChildLogger("health.repository");
export class HealthRepository {
async checkDatabaseConnection() {
try {
// TODO: Implement actual health check
logger.debug("Running database health check");
const pool = getPool();
await pool.query("SELECT 1 AS result");
logger.debug("Database health check passed");
return { connected: true };
} catch (err) {
logger.error({ err }, "Database health check failed");
return { connected: false };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
logger.error({ error: message }, "Database health check failed");
return { connected: false, error: message };
}
}
}
@@ -2,16 +2,10 @@ import type { Request, Response, Router } from "express";
import express from "express";
import { createChildLogger } from "../../shared/logger/index.js";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { queue, skip, stop, setVolume, getStatus } from "./media.service.js";
const logger = createChildLogger("media.routes");
const stubResponse = {
playing: false,
musicVolume: 1.0,
current: null,
queue: [],
};
export function createMediaRouter(): Router {
const router = express.Router();
@@ -20,16 +14,27 @@ export function createMediaRouter(): Router {
"/media/status",
asyncHandler(async (_req: Request, res: Response) => {
logger.debug("Media status requested");
res.json(stubResponse);
const status = await getStatus();
res.json(status);
}),
);
// POST /api/media/queue
router.post(
"/media/queue",
asyncHandler(async (_req: Request, res: Response) => {
logger.debug("Media queue requested (stub)");
res.json(stubResponse);
asyncHandler(async (req: Request, res: Response) => {
const source = req.body?.source as string | undefined;
if (!source) {
res.status(400).json({
error: "VALIDATION_ERROR",
message: "source is required",
});
return;
}
const mode = (req.body?.mode as "music" | "screen") ?? "music";
logger.debug({ source, mode }, "Media queue requested");
const state = await queue(source, mode);
res.json(state);
}),
);
@@ -37,8 +42,9 @@ export function createMediaRouter(): Router {
router.post(
"/media/skip",
asyncHandler(async (_req: Request, res: Response) => {
logger.debug("Media skip requested (stub)");
res.json(stubResponse);
logger.debug("Media skip requested");
const state = await skip();
res.json(state);
}),
);
@@ -46,17 +52,27 @@ export function createMediaRouter(): Router {
router.post(
"/media/stop",
asyncHandler(async (_req: Request, res: Response) => {
logger.debug("Media stop requested (stub)");
res.json(stubResponse);
logger.debug("Media stop requested");
const state = await stop();
res.json(state);
}),
);
// POST /api/media/volume
router.post(
"/media/volume",
asyncHandler(async (_req: Request, res: Response) => {
logger.debug("Media volume requested (stub)");
res.json(stubResponse);
asyncHandler(async (req: Request, res: Response) => {
const volume = Number(req.body?.volume ?? 1.0);
if (Number.isNaN(volume) || volume < 0 || volume > 1) {
res.status(400).json({
error: "VALIDATION_ERROR",
message: "volume must be a number between 0 and 1",
});
return;
}
logger.debug({ volume }, "Media volume requested");
const state = await setVolume(volume);
res.json(state);
}),
);
@@ -1,9 +1,160 @@
import { createChildLogger } from "../../shared/logger/index.js";
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
const logger = createChildLogger("media.service");
export class MediaService {
// TODO: Implement media service methods
// ---------------------------------------------------------------------------
// Types — match frontend exactly
// ---------------------------------------------------------------------------
export interface MediaItem {
id?: string;
source: string;
title: string;
mode?: "music" | "screen";
durationMs?: number | null;
thumbnailUrl?: string | null;
}
export const mediaService = new MediaService();
export interface MediaState {
playing: boolean;
musicVolume: number;
current: MediaItem | null;
queue: MediaItem[];
}
// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------
const DEFAULT_COMMAND_TIMEOUT_MS = 5000;
const DEFAULT_STATE: MediaState = {
playing: false,
musicVolume: 1.0,
current: null,
queue: [],
};
// ---------------------------------------------------------------------------
// Service methods
// ---------------------------------------------------------------------------
/**
* Read media status from Redis key "media:status" set by discord-gateway.
*/
export async function getStatus(): Promise<MediaState> {
const cached = await readRedisStatus("media:status");
if (cached) {
return {
playing: Boolean(cached.playing),
musicVolume: Number(cached.musicVolume ?? 1.0),
current: (cached.current as MediaItem | null) ?? null,
queue: (cached.queue as MediaItem[]) ?? [],
};
}
return DEFAULT_STATE;
}
/**
* Queue a media source via Redis command to discord-gateway.
*/
export async function queue(
source: string,
mode: "music" | "screen" = "music",
): Promise<MediaState> {
const reply = await publishCommand<MediaState>(
"media:queue",
{ source, mode },
DEFAULT_COMMAND_TIMEOUT_MS,
);
if (reply?.success && reply.data) {
return {
playing: reply.data.playing,
musicVolume: reply.data.musicVolume,
current: reply.data.current ?? null,
queue: reply.data.queue ?? [],
};
}
logger.warn(
{ source, mode },
"discord-gateway unreachable, returning current media status",
);
return getStatus();
}
/**
* Skip current track via Redis command to discord-gateway.
*/
export async function skip(): Promise<MediaState> {
const reply = await publishCommand<MediaState>(
"media:skip",
{},
DEFAULT_COMMAND_TIMEOUT_MS,
);
if (reply?.success && reply.data) {
return {
playing: reply.data.playing,
musicVolume: reply.data.musicVolume,
current: reply.data.current ?? null,
queue: reply.data.queue ?? [],
};
}
logger.warn("discord-gateway unreachable, returning current media status");
return getStatus();
}
/**
* Stop playback via Redis command to discord-gateway.
*/
export async function stop(): Promise<MediaState> {
const reply = await publishCommand<MediaState>(
"media:stop",
{},
DEFAULT_COMMAND_TIMEOUT_MS,
);
if (reply?.success && reply.data) {
return {
playing: reply.data.playing,
musicVolume: reply.data.musicVolume,
current: reply.data.current ?? null,
queue: reply.data.queue ?? [],
};
}
logger.warn("discord-gateway unreachable, returning current media status");
return getStatus();
}
/**
* Set volume via Redis command to discord-gateway.
*/
export async function setVolume(volume: number): Promise<MediaState> {
const reply = await publishCommand<MediaState>(
"media:volume",
{ volume },
DEFAULT_COMMAND_TIMEOUT_MS,
);
if (reply?.success && reply.data) {
return {
playing: reply.data.playing,
musicVolume: reply.data.musicVolume,
current: reply.data.current ?? null,
queue: reply.data.queue ?? [],
};
}
logger.warn(
{ volume },
"discord-gateway unreachable, returning current media status",
);
return getStatus();
}
@@ -1,4 +1,4 @@
import { getDatabase } from "../../shared/database/index.js";
import { getPool } from "../../shared/database/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import type {
MessageCreate,
@@ -8,78 +8,266 @@ import type {
const logger = createChildLogger("messages.repository");
export class MessagesRepository {
async findMany(query: MessageQuery) {
const db = getDatabase();
logger.debug({ query }, "Finding messages");
export interface PageResult<T> {
data: T[];
nextCursor: string | null;
}
// TODO: Implement actual Drizzle ORM queries
// This is a placeholder that will be filled in when schema is migrated
return {
messages: [],
total: 0,
hasMore: false,
};
export interface AttachmentResult {
id: string;
message_id: string;
guild_id: string;
channel_id: string;
thread_id: string | null;
user_id: string;
filename: string;
size: number;
type: string;
discord_url: string;
uploaded_url: string | null;
upload_status: string;
upload_error: string | null;
created_at: number;
uploaded_at: number | null;
}
function mapMessageRow(row: Record<string, unknown>) {
return {
id: String(row.id ?? ""),
guild_id: String(row.guild_id ?? ""),
channel_id: String(row.channel_id ?? ""),
thread_id: (row.thread_id as string | null) ?? null,
user_id: String(row.user_id ?? ""),
username: String(row.username ?? ""),
avatar_url: (row.avatar_url as string | null) ?? null,
content: String(row.content ?? ""),
edited_content: (row.edited_content as string | null) ?? null,
created_at: Number(row.created_at ?? 0),
edited_at: (row.edited_at as number | null) ?? null,
deleted_at: (row.deleted_at as number | null) ?? null,
type: String(row.type ?? "text"),
metadata: (row.metadata as string | null) ?? null,
ai_status: (row.ai_status as string | null) ?? null,
ai_moderation_flags: (row.ai_moderation_flags as string | null) ?? null,
ai_moderation_score: (row.ai_moderation_score as number | null) ?? null,
ai_analysis: (row.ai_analysis as string | null) ?? null,
ai_categories: (row.ai_categories as string | null) ?? null,
ai_severity: (row.ai_severity as string | null) ?? null,
ai_confidence: (row.ai_confidence as number | null) ?? null,
ai_recommended_action: (row.ai_recommended_action as string | null) ?? null,
ai_analyzed_at: (row.ai_analyzed_at as number | null) ?? null,
ai_error: (row.ai_error as string | null) ?? null,
};
}
export class MessagesRepository {
async findMany(query: MessageQuery): Promise<PageResult<ReturnType<typeof mapMessageRow>>> {
const pool = getPool();
const limit = query.limit ?? 50;
const clauses: string[] = [];
const params: (string | number)[] = [];
let p = 1;
if (query.guildId) {
clauses.push(`guild_id = $${p}`);
params.push(query.guildId);
p++;
}
if (query.channelId) {
clauses.push(`channel_id = $${p}`);
params.push(query.channelId);
p++;
}
if (query.userId) {
clauses.push(`user_id = $${p}`);
params.push(query.userId);
p++;
}
if (query.status) {
clauses.push(`ai_status = $${p}`);
params.push(query.status);
p++;
}
if (query.cursor) {
clauses.push(`created_at < $${p}`);
params.push(Number(query.cursor));
p++;
}
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
const { rows } = await pool.query(
`SELECT * FROM messages ${where} ORDER BY created_at DESC LIMIT $${p}`,
[...params, limit + 1],
);
const data = rows.slice(0, limit).map(mapMessageRow);
const nextCursor = rows.length > limit ? String(rows[limit].created_at) : null;
logger.debug({ count: data.length, nextCursor }, "Found messages");
return { data, nextCursor };
}
async findById(id: string) {
const db = getDatabase();
logger.debug({ id }, "Finding message by ID");
const pool = getPool();
const { rows } = await pool.query(
`SELECT * FROM messages WHERE id = $1`,
[id],
);
// TODO: Implement actual Drizzle ORM query
return null;
if (rows.length === 0) return null;
return mapMessageRow(rows[0] as Record<string, unknown>);
}
async findByChannel(channelId: string, query: MessageQuery) {
const db = getDatabase();
logger.debug({ channelId, query }, "Finding messages by channel");
async findByChannel(
channelId: string,
query: MessageQuery,
): Promise<PageResult<ReturnType<typeof mapMessageRow>>> {
const pool = getPool();
const limit = query.limit ?? 50;
const clauses: string[] = ["channel_id = $1"];
const params: (string | number)[] = [channelId];
let p = 2;
// TODO: Implement actual Drizzle ORM queries
return {
messages: [],
total: 0,
hasMore: false,
};
if (query.cursor) {
clauses.push(`created_at < $${p}`);
params.push(Number(query.cursor));
p++;
}
const { rows } = await pool.query(
`SELECT * FROM messages ${clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY created_at DESC LIMIT $${p}`,
[...params, limit + 1],
);
const data = rows.slice(0, limit).map(mapMessageRow);
const nextCursor = rows.length > limit ? String(rows[limit].created_at) : null;
return { data, nextCursor };
}
async create(data: MessageCreate) {
const db = getDatabase();
logger.debug({ data }, "Creating message");
const pool = getPool();
const id = crypto.randomUUID();
const { rows } = await pool.query(
`INSERT INTO messages (
id, guild_id, channel_id, thread_id, user_id, username, avatar_url,
content, edited_content, created_at, edited_at, deleted_at, type,
metadata, ai_status
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING *`,
[
id,
data.guildId,
data.channelId,
data.threadId ?? null,
data.userId,
data.username,
data.avatarUrl ?? null,
data.content,
null,
Date.now(),
null,
null,
data.type,
null,
"pending",
],
);
// TODO: Implement actual Drizzle ORM insert
return {
id: "msg_" + Date.now(),
...data,
createdAt: Date.now(),
};
return mapMessageRow(rows[0] as Record<string, unknown>);
}
async update(id: string, data: MessageUpdate) {
const db = getDatabase();
logger.debug({ id, data }, "Updating message");
const pool = getPool();
// TODO: Implement actual Drizzle ORM update
return null;
}
async delete(id: string) {
const db = getDatabase();
logger.debug({ id }, "Deleting message");
// TODO: Implement actual Drizzle ORM delete
return true;
}
async getAttachmentsByChannel(channelId: string, query: MessageQuery) {
const db = getDatabase();
logger.debug({ channelId, query }, "Getting attachments by channel");
// TODO: Implement actual Drizzle ORM queries
return {
attachments: [],
total: 0,
hasMore: false,
// Map camelCase schema keys to snake_case DB columns
const columnMap: Record<keyof MessageUpdate, string> = {
editedContent: "edited_content",
aiStatus: "ai_status",
aiAnalysis: "ai_analysis",
aiCategories: "ai_categories",
aiSeverity: "ai_severity",
aiConfidence: "ai_confidence",
};
const sets: string[] = [];
const params: unknown[] = [];
let p = 1;
const keys = Object.keys(data) as (keyof MessageUpdate)[];
for (const key of keys) {
const val = data[key];
if (val !== undefined) {
sets.push(`${columnMap[key]} = $${p}`);
params.push(val);
p++;
}
}
if (sets.length === 0) return this.findById(id);
params.push(id);
const { rows } = await pool.query(
`UPDATE messages SET ${sets.join(", ")} WHERE id = $${p} RETURNING *`,
params,
);
if (rows.length === 0) return null;
return mapMessageRow(rows[0] as Record<string, unknown>);
}
async delete(id: string): Promise<boolean> {
const pool = getPool();
const { rowCount } = await pool.query(
`DELETE FROM messages WHERE id = $1`,
[id],
);
return (rowCount ?? 0) > 0;
}
async getAttachmentsByChannel(
channelId: string,
query: MessageQuery,
): Promise<PageResult<AttachmentResult>> {
const pool = getPool();
const limit = query.limit ?? 50;
const clauses: string[] = ["channel_id = $1"];
const params: (string | number)[] = [channelId];
let p = 2;
if (query.cursor) {
clauses.push(`created_at < $${p}`);
params.push(Number(query.cursor));
p++;
}
const { rows } = await pool.query(
`SELECT * FROM attachments ${clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY created_at DESC LIMIT $${p}`,
[...params, limit + 1],
);
const data = rows.map((r) => ({
id: String(r.id ?? ""),
message_id: String(r.message_id ?? ""),
guild_id: String(r.guild_id ?? ""),
channel_id: String(r.channel_id ?? ""),
thread_id: (r.thread_id as string | null) ?? null,
user_id: String(r.user_id ?? ""),
filename: String(r.filename ?? ""),
size: Number(r.size ?? 0),
type: String(r.type ?? ""),
discord_url: String(r.discord_url ?? ""),
uploaded_url: (r.uploaded_url as string | null) ?? null,
upload_status: String(r.upload_status ?? "pending"),
upload_error: (r.upload_error as string | null) ?? null,
created_at: Number(r.created_at ?? 0),
uploaded_at: (r.uploaded_at as number | null) ?? null,
}));
const nextCursor = data.length > limit ? String(data[limit].created_at) : null;
const trimmed = data.slice(0, limit);
return { data: trimmed, nextCursor };
}
}
@@ -7,7 +7,7 @@ import {
} from "./voice.service.js";
export async function handleGetVoiceStatus(_req: Request, res: Response) {
const status = getVoiceStatus();
const status = await getVoiceStatus();
res.json(status);
}
@@ -1,8 +1,14 @@
import { getDatabase } from "../../shared/database/index.js";
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");
export interface Guild {
id: string;
name: string;
icon: string | null;
}
export interface Channel {
@@ -13,27 +19,117 @@ export interface Channel {
export interface VoiceStatus {
connected: boolean;
guildId: string | null;
channelId: string | null;
users: Array<{ id: string; name: string }>;
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;
}
}
/**
* Get guilds from database (distinct guild_id from messages).
*/
export async function getGuilds(): Promise<Guild[]> {
const db = getDatabase();
const result = await db.execute(
"SELECT DISTINCT guild_id FROM messages ORDER BY guild_id",
const pool = getPool();
const { rows } = await pool.query(
`SELECT DISTINCT guild_id FROM messages ORDER BY guild_id`,
);
if (!result?.rows?.length) {
return [];
}
return result.rows.map((row: Record<string, unknown>) => ({
return rows.map((row: Record<string, unknown>) => ({
id: String(row.guild_id ?? ""),
name: `Guild ${String(row.guild_id).slice(0, 8)}`,
icon: null,
}));
}
@@ -41,16 +137,13 @@ export async function getGuilds(): Promise<Guild[]> {
* Get text channels from database (distinct channel_id for a guild).
*/
export async function getTextChannels(guildId: string): Promise<Channel[]> {
const db = getDatabase();
const result = await db.execute(
`SELECT DISTINCT channel_id FROM messages WHERE guild_id = '${guildId.replace(/'/g, "''")}' ORDER BY channel_id`,
const pool = getPool();
const { rows } = await pool.query(
`SELECT DISTINCT channel_id FROM messages WHERE guild_id = $1 ORDER BY channel_id`,
[guildId],
);
if (!result?.rows?.length) {
return [];
}
return result.rows.map((row: Record<string, unknown>) => ({
return rows.map((row: Record<string, unknown>) => ({
id: String(row.channel_id ?? ""),
name: `Channel ${String(row.channel_id).slice(0, 8)}`,
type: "text" as const,
@@ -58,37 +151,66 @@ export async function getTextChannels(guildId: string): Promise<Channel[]> {
}
/**
* Get voice channels — not available via API-only backend.
* Get voice channels — query from discord-gateway via Redis command.
*/
export async function getVoiceChannels(_guildId: string): Promise<Channel[]> {
return [];
export async function getVoiceChannels(guildId: string): Promise<Channel[]> {
const channels = await sendCommand<Channel[]>("voice:channels", { guildId });
return channels ?? [];
}
/**
* Get current voice connection status.
* Get current voice connection status from Redis cache set by discord-gateway.
*/
export function getVoiceStatus(): VoiceStatus {
export async function getVoiceStatus(): Promise<VoiceStatus> {
const cached = await readStatus<VoiceStatus>("voice:status");
if (cached) return cached;
return {
connected: false,
guildId: null,
channelId: null,
users: [],
activeGuildId: null,
activeChannelId: null,
activeChannelName: null,
};
}
/**
* Connect to a voice channel — not supported via API-only backend.
* Connect to a voice channel via Redis command to discord-gateway.
*/
export async function connectVoice(
_guildId: string,
_channelId: string,
guildId: string,
channelId: string,
): Promise<VoiceStatus> {
return getVoiceStatus();
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,
}
);
}
/**
* Disconnect from voice — not supported via API-only backend.
* Disconnect from voice via Redis command to discord-gateway.
*/
export async function disconnectVoice(): Promise<VoiceStatus> {
return getVoiceStatus();
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,
}
);
}