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
@@ -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();
}