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
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user