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:
co-authored by
Claude Opus 4.8
parent
9b41eb9c12
commit
3b2709455e
@@ -10,26 +10,36 @@
|
||||
*/
|
||||
|
||||
type BroadcastFn = (data: unknown) => void;
|
||||
type BroadcastRawFn = (type: string, data: unknown) => void;
|
||||
|
||||
// Extend globalThis with broadcast function types
|
||||
declare global {
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: intentional global broadcast registry
|
||||
var broadcastMessageCreated: BroadcastFn | undefined;
|
||||
var broadcastMessageUpdated: BroadcastFn | undefined;
|
||||
var broadcastMessageDeleted: BroadcastFn | undefined;
|
||||
var broadcastAttachmentUploaded: BroadcastFn | undefined;
|
||||
var __broadcastFns:
|
||||
| {
|
||||
messageCreated: BroadcastFn;
|
||||
messageUpdated: BroadcastFn;
|
||||
messageDeleted: BroadcastFn;
|
||||
attachmentUploaded: BroadcastFn;
|
||||
raw: BroadcastRawFn;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
|
||||
const noop: BroadcastFn = () => {};
|
||||
const noopRaw: BroadcastRawFn = () => {};
|
||||
|
||||
export const broadcastMessageCreated: BroadcastFn = (...args) =>
|
||||
(globalThis.broadcastMessageCreated ?? noop)(...args);
|
||||
export const broadcastMessageCreated: BroadcastFn = (data) =>
|
||||
(globalThis.__broadcastFns?.messageCreated ?? noop)(data);
|
||||
|
||||
export const broadcastMessageUpdated: BroadcastFn = (...args) =>
|
||||
(globalThis.broadcastMessageUpdated ?? noop)(...args);
|
||||
export const broadcastMessageUpdated: BroadcastFn = (data) =>
|
||||
(globalThis.__broadcastFns?.messageUpdated ?? noop)(data);
|
||||
|
||||
export const broadcastMessageDeleted: BroadcastFn = (...args) =>
|
||||
(globalThis.broadcastMessageDeleted ?? noop)(...args);
|
||||
export const broadcastMessageDeleted: BroadcastFn = (data) =>
|
||||
(globalThis.__broadcastFns?.messageDeleted ?? noop)(data);
|
||||
|
||||
export const broadcastAttachmentUploaded: BroadcastFn = (...args) =>
|
||||
(globalThis.broadcastAttachmentUploaded ?? noop)(...args);
|
||||
export const broadcastAttachmentUploaded: BroadcastFn = (data) =>
|
||||
(globalThis.__broadcastFns?.attachmentUploaded ?? noop)(data);
|
||||
|
||||
export const broadcastRaw: BroadcastRawFn = (type, data) =>
|
||||
(globalThis.__broadcastFns?.raw ?? noopRaw)(type, data);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import Redis from "ioredis";
|
||||
import { config } from "../shared/config/index.js";
|
||||
import { createChildLogger } from "../shared/logger/index.js";
|
||||
import { broadcastRaw } from "./broadcast.js";
|
||||
|
||||
const logger = createChildLogger("ws.redis-bridge");
|
||||
|
||||
interface ChannelMapping {
|
||||
channel: string;
|
||||
eventType: string;
|
||||
}
|
||||
|
||||
const SUBSCRIPTIONS: ChannelMapping[] = [
|
||||
{ channel: "discord:message:created", eventType: "message_created" },
|
||||
{ channel: "discord:message:updated", eventType: "message_updated" },
|
||||
{ channel: "discord:message:deleted", eventType: "message_deleted" },
|
||||
{ channel: "discord:message:analyzed", eventType: "message_analyzed" },
|
||||
{ channel: "discord:attachment:uploaded", eventType: "attachment_uploaded" },
|
||||
{ channel: "discord:voice:started", eventType: "voice_recording_started" },
|
||||
{ channel: "discord:voice:stopped", eventType: "voice_recording_stopped" },
|
||||
{ channel: "discord:voice:uploaded", eventType: "voice_recording_uploaded" },
|
||||
];
|
||||
|
||||
let subscriber: Redis | null = null;
|
||||
|
||||
function createSubscriber(): Redis {
|
||||
if (config.REDIS_URL) {
|
||||
return new Redis(config.REDIS_URL, { keyPrefix: "" });
|
||||
}
|
||||
return new Redis({
|
||||
host: config.REDIS_HOST,
|
||||
port: config.REDIS_PORT,
|
||||
keyPrefix: "",
|
||||
});
|
||||
}
|
||||
|
||||
function handleSubscriptionMessage(channel: string, message: string): void {
|
||||
const mapping = SUBSCRIPTIONS.find((m) => m.channel === channel);
|
||||
if (!mapping) {
|
||||
logger.warn({ channel }, "Received message for unmapped Redis channel");
|
||||
return;
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(message);
|
||||
} catch (err) {
|
||||
logger.error({ channel, err }, "Failed to parse Redis message as JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug({ channel, eventType: mapping.eventType }, "Broadcasting Redis event");
|
||||
broadcastRaw(mapping.eventType, data);
|
||||
}
|
||||
|
||||
export async function startRedisBridge(): Promise<void> {
|
||||
if (!config.REDIS_URL && !config.REDIS_HOST) {
|
||||
logger.info("Redis not configured, skipping Redis bridge");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
subscriber = createSubscriber();
|
||||
|
||||
subscriber.on("error", (err: Error) => {
|
||||
logger.error({ err }, "Redis subscriber error");
|
||||
});
|
||||
|
||||
subscriber.on("connect", () => {
|
||||
logger.info("Redis subscriber connected");
|
||||
});
|
||||
|
||||
subscriber.on("reconnecting", () => {
|
||||
logger.warn("Redis subscriber reconnecting…");
|
||||
});
|
||||
|
||||
subscriber.on("close", () => {
|
||||
logger.warn("Redis subscriber connection closed");
|
||||
});
|
||||
|
||||
subscriber.on("message", handleSubscriptionMessage);
|
||||
|
||||
await subscriber.ping();
|
||||
logger.info("Redis ping OK");
|
||||
|
||||
const channels = SUBSCRIPTIONS.map((m) => m.channel);
|
||||
await subscriber.subscribe(...channels);
|
||||
logger.info({ channels }, "Subscribed to Redis channels");
|
||||
|
||||
logger.info("Redis bridge started");
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to start Redis bridge");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopRedisBridge(): Promise<void> {
|
||||
if (!subscriber) {
|
||||
logger.debug("Redis bridge not running, nothing to stop");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await subscriber.quit();
|
||||
logger.info("Redis bridge stopped");
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Error stopping Redis bridge");
|
||||
} finally {
|
||||
subscriber.disconnect();
|
||||
subscriber = null;
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,18 @@ interface BroadcastEvent {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
type BroadcastFn = (data: unknown) => void;
|
||||
|
||||
// Extend globalThis with broadcast function types
|
||||
declare global {
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: intentional global broadcast registry
|
||||
var broadcastMessageCreated: BroadcastFn | undefined;
|
||||
var broadcastMessageUpdated: BroadcastFn | undefined;
|
||||
var broadcastMessageDeleted: BroadcastFn | undefined;
|
||||
var broadcastAttachmentUploaded: BroadcastFn | undefined;
|
||||
var __broadcastFns:
|
||||
| {
|
||||
messageCreated: (data: unknown) => void;
|
||||
messageUpdated: (data: unknown) => void;
|
||||
messageDeleted: (data: unknown) => void;
|
||||
attachmentUploaded: (data: unknown) => void;
|
||||
raw: (type: string, data: unknown) => void;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export function createWebSocketServer(server: Server): WebSocketServer {
|
||||
@@ -87,18 +90,18 @@ export function createWebSocketServer(server: Server): WebSocketServer {
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.broadcastMessageCreated = (data: unknown) =>
|
||||
broadcast({ type: "message_created", data });
|
||||
globalThis.broadcastMessageUpdated = (data: unknown) =>
|
||||
broadcast({ type: "message_updated", data });
|
||||
globalThis.broadcastMessageDeleted = (data: unknown) =>
|
||||
broadcast({ type: "message_deleted", data });
|
||||
globalThis.broadcastAttachmentUploaded = (data: unknown) =>
|
||||
broadcast({ type: "attachment_uploaded", data });
|
||||
globalThis.__broadcastFns = {
|
||||
messageCreated: (data: unknown) => broadcast({ type: "message_created", data }),
|
||||
messageUpdated: (data: unknown) => broadcast({ type: "message_updated", data }),
|
||||
messageDeleted: (data: unknown) => broadcast({ type: "message_deleted", data }),
|
||||
attachmentUploaded: (data: unknown) => broadcast({ type: "attachment_uploaded", data }),
|
||||
raw: (type: string, data: unknown) => broadcast({ type, data }),
|
||||
};
|
||||
|
||||
// Cleanup on close
|
||||
wss.on("close", () => {
|
||||
clearInterval(heartbeatInterval);
|
||||
globalThis.__broadcastFns = undefined;
|
||||
});
|
||||
|
||||
logger.info({ path: "/ws" }, "WebSocket server created");
|
||||
|
||||
Reference in New Issue
Block a user