fix: auto-fix code quality [skip ci]

This commit is contained in:
asepharyana
2026-09-23 22:11:00 +07:00
parent 771783d991
commit 401155b501
12 changed files with 200 additions and 235 deletions
+9 -2
View File
@@ -6,7 +6,13 @@ import { describe, expect, it } from "vitest";
const BASE = process.env.API_BASE ?? "http://localhost:4001/api"; const BASE = process.env.API_BASE ?? "http://localhost:4001/api";
async function api(path: string, init?: RequestInit) { /** Result of a JSON API call: status + parsed body (or null for 204/empty). */
interface ApiResult {
status: number;
body: Record<string, unknown> | null;
}
async function api(path: string, init?: RequestInit): Promise<ApiResult> {
const res = await fetch(`${BASE}${path}`, { const res = await fetch(`${BASE}${path}`, {
...init, ...init,
headers: { "Content-Type": "application/json", ...init?.headers }, headers: { "Content-Type": "application/json", ...init?.headers },
@@ -23,7 +29,8 @@ describe("API Health", () => {
}); });
it("GET /metrics returns prometheus text", async () => { it("GET /metrics returns prometheus text", async () => {
const res = await fetch(`${BASE.replace("/api", "")}/api/metrics`); const base = BASE.endsWith("/api") ? BASE.slice(0, -4) : BASE;
const res = await fetch(`${base}/api/metrics`);
expect(res.status).toBe(200); expect(res.status).toBe(200);
const text = await res.text(); const text = await res.text();
expect(text).toContain("nodejs"); expect(text).toContain("nodejs");
@@ -51,10 +51,51 @@ export interface AttachmentResult {
uploaded_at: number | null; uploaded_at: number | null;
} }
type MessageRow = ReturnType<typeof mapMessageRow>;
export type { MessageRow };
/**
* Build the NULL-safe "exclude spam threads" condition. Non-thread messages
* (NULL thread_id) are always kept; thread messages are kept only when their
* thread is not in the configured exclusion list.
*/
function excludeSpamThreads(): SQL | undefined {
if (EXCLUDED_THREAD_IDS.length === 0) return undefined;
return or(
isNull(pgMessagesTable.thread_id),
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
);
}
/** Normalize a raw attachment DB row to the API shape. */
function mapAttachmentRow(r: Record<string, unknown>): AttachmentResult {
return {
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,
};
}
/** Select the first `limit + 1` rows so the caller can derive the next cursor. */
function cursorLimit(limit: number): number {
return limit + 1;
}
export class MessagesRepository { export class MessagesRepository {
async findMany( async findMany(query: MessageQuery): Promise<PageResult<MessageRow>> {
query: MessageQuery,
): Promise<PageResult<ReturnType<typeof mapMessageRow>>> {
const db = getDatabase(); const db = getDatabase();
const limit = query.limit ?? 50; const limit = query.limit ?? 50;
const conditions: SQL[] = []; const conditions: SQL[] = [];
@@ -76,13 +117,8 @@ export class MessagesRepository {
} }
// Exclude spam threads (NULL-safe: non-thread messages are kept) // Exclude spam threads (NULL-safe: non-thread messages are kept)
if (EXCLUDED_THREAD_IDS.length > 0) { const excludeThreads = excludeSpamThreads();
const excludeThreads = or( if (excludeThreads) conditions.push(excludeThreads);
isNull(pgMessagesTable.thread_id),
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
);
if (excludeThreads) conditions.push(excludeThreads);
}
const where = conditions.length > 0 ? and(...conditions) : undefined; const where = conditions.length > 0 ? and(...conditions) : undefined;
const rows = await db const rows = await db
@@ -90,7 +126,7 @@ export class MessagesRepository {
.from(pgMessagesTable) .from(pgMessagesTable)
.where(where) .where(where)
.orderBy(desc(pgMessagesTable.created_at)) .orderBy(desc(pgMessagesTable.created_at))
.limit(limit + 1); .limit(cursorLimit(limit));
const data = rows const data = rows
.slice(0, limit) .slice(0, limit)
@@ -138,7 +174,7 @@ export class MessagesRepository {
async findByChannel( async findByChannel(
channelId: string, channelId: string,
query: MessageQuery, query: MessageQuery,
): Promise<PageResult<ReturnType<typeof mapMessageRow>>> { ): Promise<PageResult<MessageRow>> {
const db = getDatabase(); const db = getDatabase();
const limit = query.limit ?? 50; const limit = query.limit ?? 50;
const conditions: SQL[] = [eq(pgMessagesTable.channel_id, channelId)]; const conditions: SQL[] = [eq(pgMessagesTable.channel_id, channelId)];
@@ -148,20 +184,15 @@ export class MessagesRepository {
} }
// Exclude spam threads (NULL-safe) // Exclude spam threads (NULL-safe)
if (EXCLUDED_THREAD_IDS.length > 0) { const excludeThreads = excludeSpamThreads();
const excludeThreads = or( if (excludeThreads) conditions.push(excludeThreads);
isNull(pgMessagesTable.thread_id),
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
);
if (excludeThreads) conditions.push(excludeThreads);
}
const rows = await db const rows = await db
.select() .select()
.from(pgMessagesTable) .from(pgMessagesTable)
.where(and(...conditions)) .where(and(...conditions))
.orderBy(desc(pgMessagesTable.created_at)) .orderBy(desc(pgMessagesTable.created_at))
.limit(limit + 1); .limit(cursorLimit(limit));
const data = rows const data = rows
.slice(0, limit) .slice(0, limit)
@@ -181,7 +212,7 @@ export class MessagesRepository {
async *streamMany( async *streamMany(
query: MessageQuery, query: MessageQuery,
pageSize = 50, pageSize = 50,
): AsyncGenerator<ReturnType<typeof mapMessageRow>, void, unknown> { ): AsyncGenerator<MessageRow, void, unknown> {
const conditions: SQL[] = []; const conditions: SQL[] = [];
if (query.guildId) { if (query.guildId) {
@@ -196,13 +227,8 @@ export class MessagesRepository {
if (query.status) { if (query.status) {
conditions.push(eq(pgMessagesTable.ai_status, query.status)); conditions.push(eq(pgMessagesTable.ai_status, query.status));
} }
if (EXCLUDED_THREAD_IDS.length > 0) { const excludeThreads = excludeSpamThreads();
const excludeThreads = or( if (excludeThreads) conditions.push(excludeThreads);
isNull(pgMessagesTable.thread_id),
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
);
if (excludeThreads) conditions.push(excludeThreads);
}
const where = conditions.length > 0 ? and(...conditions) : undefined; const where = conditions.length > 0 ? and(...conditions) : undefined;
let cursor: string | undefined = query.cursor; let cursor: string | undefined = query.cursor;
@@ -221,7 +247,7 @@ export class MessagesRepository {
.from(pgMessagesTable) .from(pgMessagesTable)
.where(pageWhere) .where(pageWhere)
.orderBy(desc(pgMessagesTable.created_at)) .orderBy(desc(pgMessagesTable.created_at))
.limit(pageSize + 1); .limit(cursorLimit(pageSize));
if (rows.length === 0) return; if (rows.length === 0) return;
@@ -366,31 +392,30 @@ export class MessagesRepository {
async getImageMessages( async getImageMessages(
guildId: string, guildId: string,
limit: number = 50, limit: number = 50,
): Promise<PageResult<ReturnType<typeof mapMessageRow>>> { ): Promise<PageResult<MessageRow>> {
const db = getDatabase(); const db = getDatabase();
// Subquery: find distinct message_ids from attachments with image MIME type // Subquery: find distinct message_ids from attachments with image MIME type
const attachmentConditions: SQL[] = [
eq(pgAttachmentsTable.guild_id, guildId),
like(pgAttachmentsTable.type, "image/%"),
];
// Exclude spam threads (NULL-safe for non-thread messages)
const excludeThreads =
EXCLUDED_THREAD_IDS.length > 0
? or(
isNull(pgAttachmentsTable.thread_id),
notInArray(pgAttachmentsTable.thread_id, EXCLUDED_THREAD_IDS),
)
: undefined;
if (excludeThreads) attachmentConditions.push(excludeThreads);
const imageMsgIds = db const imageMsgIds = db
.select({ id: pgAttachmentsTable.message_id }) .select({ id: pgAttachmentsTable.message_id })
.from(pgAttachmentsTable) .from(pgAttachmentsTable)
.where( .where(and(...attachmentConditions))
and(
eq(pgAttachmentsTable.guild_id, guildId),
like(pgAttachmentsTable.type, "image/%"),
// Exclude spam threads (NULL-safe for non-thread messages)
...(EXCLUDED_THREAD_IDS.length > 0
? (() => {
const excludeThreads = or(
isNull(pgAttachmentsTable.thread_id),
notInArray(pgAttachmentsTable.thread_id, EXCLUDED_THREAD_IDS),
);
return excludeThreads ? [excludeThreads] : [];
})()
: []),
),
)
.orderBy(desc(pgAttachmentsTable.created_at)) .orderBy(desc(pgAttachmentsTable.created_at))
.limit(limit + 1); .limit(cursorLimit(limit));
// Fetch full message rows for those IDs // Fetch full message rows for those IDs
const rows = await db const rows = await db
@@ -398,7 +423,7 @@ export class MessagesRepository {
.from(pgMessagesTable) .from(pgMessagesTable)
.where(inArray(pgMessagesTable.id, imageMsgIds)) .where(inArray(pgMessagesTable.id, imageMsgIds))
.orderBy(desc(pgMessagesTable.created_at)) .orderBy(desc(pgMessagesTable.created_at))
.limit(limit + 1); .limit(cursorLimit(limit));
const data = rows const data = rows
.slice(0, limit) .slice(0, limit)
@@ -433,28 +458,16 @@ export class MessagesRepository {
.from(pgAttachmentsTable) .from(pgAttachmentsTable)
.where(and(...conditions)) .where(and(...conditions))
.orderBy(desc(pgAttachmentsTable.created_at)) .orderBy(desc(pgAttachmentsTable.created_at))
.limit(limit + 1); .limit(cursorLimit(limit));
const data = rows.map((r) => ({ const data = rows.map((r) =>
id: String(r.id ?? ""), mapAttachmentRow(r as Record<string, unknown>),
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,
}));
// nextCursor derives from the fetched-but-untrimmed overflow row (index
// `limit`), matching the other cursor-paginated queries.
const nextCursor = const nextCursor =
data.length > limit ? String(data[limit].created_at) : null; rows.length > limit ? String(rows[limit].created_at) : null;
const trimmed = data.slice(0, limit); const trimmed = data.slice(0, limit);
return { data: trimmed, nextCursor }; return { data: trimmed, nextCursor };
@@ -2,14 +2,16 @@ import { config } from "@/shared/config/index";
import { NotFoundError, ValidationError } from "@/shared/errors/index"; import { NotFoundError, ValidationError } from "@/shared/errors/index";
import { createChildLogger } from "@/shared/logger/index"; import { createChildLogger } from "@/shared/logger/index";
import { embedQuery } from "./embed.js"; import { embedQuery } from "./embed.js";
import { messagesRepository } from "./messages.repository.js"; import { type MessageRow, messagesRepository } from "./messages.repository.js";
import type { MessageQuery, SemanticSearchQuery } from "./messages.schema.js"; import type { MessageQuery, SemanticSearchQuery } from "./messages.schema.js";
import { searchArchive } from "./qdrant.js"; import { searchArchive } from "./qdrant.js";
const logger = createChildLogger("messages.service"); const logger = createChildLogger("messages.service");
export class MessagesService { export class MessagesService {
async listMessages(query: MessageQuery) { async listMessages(
query: MessageQuery,
): Promise<Awaited<ReturnType<typeof messagesRepository.findMany>>> {
if (!query.channelId && !query.guildId) { if (!query.channelId && !query.guildId) {
throw new ValidationError("Either channelId or guildId is required"); throw new ValidationError("Either channelId or guildId is required");
} }
@@ -26,7 +28,10 @@ export class MessagesService {
return messagesRepository.streamMany(query, pageSize); return messagesRepository.streamMany(query, pageSize);
} }
async getMessagesByChannel(channelId: string, query: MessageQuery) { async getMessagesByChannel(
channelId: string,
query: MessageQuery,
): Promise<Awaited<ReturnType<typeof messagesRepository.findByChannel>>> {
if (!channelId) { if (!channelId) {
throw new ValidationError("channelId is required"); throw new ValidationError("channelId is required");
} }
@@ -35,7 +40,14 @@ export class MessagesService {
return messagesRepository.findByChannel(channelId, query); return messagesRepository.findByChannel(channelId, query);
} }
async getMessageById(id: string) { async getMessageById(id: string): Promise<
NonNullable<Awaited<ReturnType<typeof messagesRepository.findById>>> & {
edit_count: number;
edit_history: Awaited<
ReturnType<typeof messagesRepository.getEditHistory>
>;
}
> {
if (!id) { if (!id) {
throw new ValidationError("message ID is required"); throw new ValidationError("message ID is required");
} }
@@ -56,7 +68,12 @@ export class MessagesService {
}; };
} }
async getAttachmentsByChannel(channelId: string, query: MessageQuery) { async getAttachmentsByChannel(
channelId: string,
query: MessageQuery,
): Promise<
Awaited<ReturnType<typeof messagesRepository.getAttachmentsByChannel>>
> {
if (!channelId) { if (!channelId) {
throw new ValidationError("channelId is required"); throw new ValidationError("channelId is required");
} }
@@ -111,22 +128,31 @@ export class MessagesService {
return { results, nextCursor: null }; return { results, nextCursor: null };
} }
async getActivity(days = 30) { async getActivity(
days = 30,
): Promise<Awaited<ReturnType<typeof messagesRepository.getActivity>>> {
return messagesRepository.getActivity(days); return messagesRepository.getActivity(days);
} }
async getRecentEdits(limit = 50, channelId?: string) { async getRecentEdits(
limit = 50,
channelId?: string,
): Promise<Awaited<ReturnType<typeof messagesRepository.getRecentEdits>>> {
logger.debug({ limit, channelId }, "Getting recent message edits"); logger.debug({ limit, channelId }, "Getting recent message edits");
return messagesRepository.getRecentEdits(limit, channelId); return messagesRepository.getRecentEdits(limit, channelId);
} }
/** Distinct guilds present in the message archive (guild picker). */ /** Distinct guilds present in the message archive (guild picker). */
async getGuilds() { async getGuilds(): Promise<
Awaited<ReturnType<typeof messagesRepository.listGuilds>>
> {
return messagesRepository.listGuilds(); return messagesRepository.listGuilds();
} }
/** Text channels for a guild (channel picker). */ /** Text channels for a guild (channel picker). */
async getTextChannels(guildId: string) { async getTextChannels(
guildId: string,
): Promise<Awaited<ReturnType<typeof messagesRepository.listTextChannels>>> {
return messagesRepository.listTextChannels(guildId); return messagesRepository.listTextChannels(guildId);
} }
} }
-2
View File
@@ -222,8 +222,6 @@ const moderationRouter = {
.handler(({ input }) => moderationService.getCoverage(input.days)), .handler(({ input }) => moderationService.getCoverage(input.days)),
}; };
// ── Recordings (removed) / Voice (removed) ─────────────────────
// Music/media playback (media.mjs) was removed with the voice feature.
// ── Analysis (search) ────────────────────────────────────────────── // ── Analysis (search) ──────────────────────────────────────────────
const analysisRouter = { const analysisRouter = {
search: os search: os
+2 -3
View File
@@ -229,9 +229,8 @@ export const configSchema = z
.default(true), .default(true),
}) })
.superRefine((value, ctx) => { .superRefine((value, ctx) => {
if (!value.AI_ANALYSIS_ENABLED) { // AI analysis requires credentials to run.
// skip: AI analysis not enabled if (value.AI_ANALYSIS_ENABLED && !value.AI_LLM_API_KEY) {
} else if (!value.AI_LLM_API_KEY) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ["AI_LLM_API_KEY"], path: ["AI_LLM_API_KEY"],
+1 -7
View File
@@ -46,17 +46,11 @@ export function clearBroadcastFunctions(): void {
logger.info("Broadcast functions cleared"); logger.info("Broadcast functions cleared");
} }
function shouldLog(type: string): boolean {
if (!_enabled) return false;
// Avoid logging high-volume events
return true;
}
/** /**
* Broadcast a JSON event to all connected WebSocket clients. * Broadcast a JSON event to all connected WebSocket clients.
*/ */
export function broadcastEvent(type: string, data: unknown): void { export function broadcastEvent(type: string, data: unknown): void {
if (shouldLog(type)) { if (_enabled) {
logger.debug({ event: type }, "Broadcasting event"); logger.debug({ event: type }, "Broadcasting event");
} }
_broadcast?.(type, data); _broadcast?.(type, data);
+10 -7
View File
@@ -9,6 +9,14 @@ const logger = createChildLogger("ws.redis-bridge");
/** Channels we subscribe to = all keys in DISCORD_CHANNEL_TO_WS_EVENT */ /** Channels we subscribe to = all keys in DISCORD_CHANNEL_TO_WS_EVENT */
const SUBSCRIPTION_CHANNELS = Object.keys(DISCORD_CHANNEL_TO_WS_EVENT); const SUBSCRIPTION_CHANNELS = Object.keys(DISCORD_CHANNEL_TO_WS_EVENT);
/** Shape of the DiscordGatewayEvent envelope published by the gateway. */
interface GatewayEnvelope {
type?: string;
data?: unknown;
timestamp?: number;
source?: string;
}
let subscriber: Redis | null = null; let subscriber: Redis | null = null;
function createSubscriber(): Redis { function createSubscriber(): Redis {
@@ -22,14 +30,9 @@ function handleSubscriptionMessage(channel: string, message: string): void {
return; return;
} }
let envelope: { let envelope: GatewayEnvelope;
type?: string;
data?: unknown;
timestamp?: number;
source?: string;
};
try { try {
envelope = JSON.parse(message); envelope = JSON.parse(message) as GatewayEnvelope;
} catch (err) { } catch (err) {
logger.error({ channel, err }, "Failed to parse Redis message as JSON"); logger.error({ channel, err }, "Failed to parse Redis message as JSON");
return; return;
+24 -23
View File
@@ -1,6 +1,7 @@
import type { IncomingMessage, Server } from "node:http"; import type { IncomingMessage, Server } from "node:http";
import type { Duplex } from "node:stream"; import type { Duplex } from "node:stream";
import { WebSocket, WebSocketServer } from "ws"; import { WebSocket, WebSocketServer } from "ws";
import type { MessageQuery } from "../modules/messages/messages.schema.js";
import { messagesService } from "../modules/messages/messages.service.js"; import { messagesService } from "../modules/messages/messages.service.js";
import { createChildLogger } from "../shared/logger/index.js"; import { createChildLogger } from "../shared/logger/index.js";
import { setBroadcastFunctions } from "./broadcast.js"; import { setBroadcastFunctions } from "./broadcast.js";
@@ -18,6 +19,14 @@ interface JsonMessage {
payload?: Record<string, unknown>; payload?: Record<string, unknown>;
} }
/** Payload accepted by the `stream_messages` JSON command. */
interface StreamMessagesPayload {
guildId?: string;
channelId?: string;
cursor?: string;
limit?: number;
}
// Track the active WebSocket server for lifecycle management // Track the active WebSocket server for lifecycle management
let _wss: WebSocketServer | null = null; let _wss: WebSocketServer | null = null;
@@ -81,12 +90,7 @@ export function createWebSocketServer(server: Server): WebSocketServer {
jsonHandlers.set("stream_messages", async (ws, message) => { jsonHandlers.set("stream_messages", async (ws, message) => {
if (ws.readyState !== WebSocket.OPEN) return; if (ws.readyState !== WebSocket.OPEN) return;
const payload = (message.payload ?? {}) as { const payload = (message.payload ?? {}) as StreamMessagesPayload;
guildId?: string;
channelId?: string;
cursor?: string;
limit?: number;
};
const guildId = payload.guildId; const guildId = payload.guildId;
const channelId = payload.channelId; const channelId = payload.channelId;
if (!guildId && !channelId) { if (!guildId && !channelId) {
@@ -97,6 +101,17 @@ export function createWebSocketServer(server: Server): WebSocketServer {
const pageSize = 50; // internal DB page size; still emitted one frame at a time const pageSize = 50; // internal DB page size; still emitted one frame at a time
const maxFrames = Math.min(payload.limit ?? 200, 500); const maxFrames = Math.min(payload.limit ?? 200, 500);
/** Send the end-of-stream frame, reporting sent count + next cursor. */
function sendEnd(data: Record<string, unknown>): void {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(
JSON.stringify({
type: "message_snapshot_end",
data,
}),
);
}
let sent = 0; let sent = 0;
let nextCursor: string | null = null; let nextCursor: string | null = null;
try { try {
@@ -105,7 +120,7 @@ export function createWebSocketServer(server: Server): WebSocketServer {
guildId, guildId,
channelId, channelId,
cursor: payload.cursor, cursor: payload.cursor,
} as never, } as MessageQuery,
pageSize, pageSize,
)) { )) {
if (ws.readyState !== WebSocket.OPEN) break; if (ws.readyState !== WebSocket.OPEN) break;
@@ -122,24 +137,10 @@ export function createWebSocketServer(server: Server): WebSocketServer {
sent++; sent++;
if (sent >= maxFrames) break; if (sent >= maxFrames) break;
} }
if (ws.readyState === WebSocket.OPEN) { sendEnd({ sent, nextCursor });
ws.send(
JSON.stringify({
type: "message_snapshot_end",
data: { sent, nextCursor },
}),
);
}
} catch (err) { } catch (err) {
logger.error({ err }, "stream_messages failed"); logger.error({ err }, "stream_messages failed");
if (ws.readyState === WebSocket.OPEN) { sendEnd({ sent, nextCursor, error: true });
ws.send(
JSON.stringify({
type: "message_snapshot_end",
data: { sent, nextCursor, error: true },
}),
);
}
} }
}); });
+1 -105
View File
@@ -1,6 +1,4 @@
import { Client } from "discord.js-selfbot-v13"; import { Client } from "discord.js-selfbot-v13";
import { inArray, lt } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { ConfigError, DatabaseError } from "@/shared/errors/index"; import { ConfigError, DatabaseError } from "@/shared/errors/index";
import { createChildLogger } from "@/shared/logger/index"; import { createChildLogger } from "@/shared/logger/index";
import { import {
@@ -36,117 +34,15 @@ import { registerPresenceCapture } from "../modules/user-presence/index.js";
import { config } from "../shared/config/config.js"; import { config } from "../shared/config/config.js";
import { import {
closeDatabase, closeDatabase,
getDatabase,
initializeDatabase, initializeDatabase,
} from "../shared/database/drizzle.js"; } from "../shared/database/drizzle.js";
import { runMigrations } from "../shared/database/migrate.js"; import { runMigrations } from "../shared/database/migrate.js";
import type * as schema from "../shared/database/schema.js";
import { attachmentsTable, messagesTable } from "../shared/database/schema.js";
import { createDiscordClientOptions } from "../shared/discord/clientOptions.js"; import { createDiscordClientOptions } from "../shared/discord/clientOptions.js";
import { startRetentionCleanup } from "./retention.js";
import { createGracefulShutdown } from "./shutdown.js"; import { createGracefulShutdown } from "./shutdown.js";
const logger = createChildLogger("discord-gateway"); const logger = createChildLogger("discord-gateway");
// ─── Retention Cleanup ─────────────────────────────────────────────────────
async function deleteExpiredRecords(
table: any,
timestampField: any,
days: number | undefined,
dryRun: boolean,
label: string,
): Promise<void> {
if (!days || days <= 0) {
logger.debug({ label }, `Retention disabled for ${label}`);
return;
}
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
const db = getDatabase() as unknown as NodePgDatabase<typeof schema>;
const expired = await db
.select({ id: table.id })
.from(table)
.where(lt(timestampField, cutoff))
.limit(1000);
if (expired.length === 0) {
logger.debug({ label }, `No expired ${label} found`);
return;
}
logger.info({ count: expired.length, label }, `Found expired ${label}`);
if (dryRun) {
logger.info(
{ count: expired.length, label },
`[DRY RUN] Would delete ${expired.length} ${label}`,
);
return;
}
try {
await db.delete(table).where(
inArray(
table.id,
expired.map((r) => r.id),
),
);
logger.info({ count: expired.length, label }, `Deleted expired ${label}`);
} catch (err) {
logger.error({ err, label }, `Failed to delete expired ${label}`);
}
}
function startRetentionCleanup(): void {
const intervalMs = config.RETENTION_CLEANUP_INTERVAL_MS;
const dryRun = config.RETENTION_DRY_RUN;
logger.info(
{
intervalMs,
dryRun,
messagesDays: config.RETENTION_MESSAGES_DAYS,
attachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
},
"Starting retention cleanup scheduler",
);
async function runCleanupTick(): Promise<void> {
await deleteExpiredRecords(
messagesTable,
messagesTable.created_at,
config.RETENTION_MESSAGES_DAYS,
dryRun,
"messages",
);
await deleteExpiredRecords(
attachmentsTable,
attachmentsTable.created_at,
config.RETENTION_ATTACHMENTS_DAYS,
dryRun,
"attachments",
);
}
// Run immediately on start, then schedule
runCleanupTick().catch((error) => {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Initial retention cleanup tick failed",
);
});
setInterval(() => {
runCleanupTick().catch((error) => {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Retention cleanup tick failed",
);
});
}, intervalMs);
}
// ─── Bootstrap ───────────────────────────────────────────────────────────── // ─── Bootstrap ─────────────────────────────────────────────────────────────
export async function initializeDiscordGateway() { export async function initializeDiscordGateway() {
+22 -4
View File
@@ -1,5 +1,8 @@
import { inArray, lt } from "drizzle-orm"; import { inArray, lt } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import type {
NodePgDatabase,
NodePgQueryResultHKT,
} from "drizzle-orm/node-postgres";
import { createChildLogger } from "@/shared/logger/index"; import { createChildLogger } from "@/shared/logger/index";
import { config } from "../shared/config/config.js"; import { config } from "../shared/config/config.js";
import { getDatabase } from "../shared/database/drizzle.js"; import { getDatabase } from "../shared/database/drizzle.js";
@@ -8,11 +11,26 @@ import { attachmentsTable, messagesTable } from "../shared/database/schema.js";
const logger = createChildLogger("discord-gateway"); const logger = createChildLogger("discord-gateway");
/** DB handle typed with the full schema so table/column refs resolve. */
type GatewayDatabase = NodePgDatabase<typeof schema>;
/** Tables eligible for retention cleanup: string `id` + numeric `created_at`. */
type RetentionTable = typeof messagesTable | typeof attachmentsTable;
type RetentionTimestampColumn =
| typeof messagesTable.created_at
| typeof attachmentsTable.created_at;
// ─── Retention Cleanup ───────────────────────────────────────────────────── // ─── Retention Cleanup ─────────────────────────────────────────────────────
/**
* Delete rows older than `days` in `table`, in batches of up to 1000 ids.
* When `dryRun` is set, logs what would be deleted without deleting.
* Returns immediately (no-op) when `days` is unset or <= 0.
*/
async function deleteExpiredRecords( async function deleteExpiredRecords(
table: any, table: RetentionTable,
timestampField: any, timestampField: RetentionTimestampColumn,
days: number | undefined, days: number | undefined,
dryRun: boolean, dryRun: boolean,
label: string, label: string,
@@ -23,7 +41,7 @@ async function deleteExpiredRecords(
} }
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000; const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
const db = getDatabase() as unknown as NodePgDatabase<typeof schema>; const db = getDatabase() as unknown as GatewayDatabase;
const expired = await db const expired = await db
.select({ id: table.id }) .select({ id: table.id })
+7 -2
View File
@@ -18,10 +18,15 @@ export interface GracefulShutdownOptions {
stopMetricsServer?: StopMetricsServer; stopMetricsServer?: StopMetricsServer;
} }
export function createGracefulShutdown(options: GracefulShutdownOptions) { export type GracefulShutdown = (signal: string) => Promise<void>;
/** Create a shutdown handler that can only be triggered once. */
export function createGracefulShutdown(
options: GracefulShutdownOptions,
): GracefulShutdown {
let isShuttingDown = false; let isShuttingDown = false;
return async function gracefulShutdown(signal: string) { return async function gracefulShutdown(signal: string): Promise<void> {
if (isShuttingDown) { if (isShuttingDown) {
options.logger.warn(`Already shutting down, ignoring ${signal}`); options.logger.warn(`Already shutting down, ignoring ${signal}`);
return; return;
@@ -32,9 +32,9 @@ export class CommandHandler {
// connection for SUBSCRIBE mode — a subscribed connection cannot perform // connection for SUBSCRIBE mode — a subscribed connection cannot perform
// publish/set operations. This connection listens on backend:command for // publish/set operations. This connection listens on backend:command for
// inbound requests from the backend. // inbound requests from the backend.
this.redisSub = new Redis(config.REDIS_URL); // Dedicated Redis connection needed because: Redis requires a dedicated this.redisSub = new Redis(config.REDIS_URL);
// PUBLISH connection (cannot share with redisSub which is in SUBSCRIBE mode). // A second dedicated connection for PUBLISH — a connection in SUBSCRIBE
// Handles command reply publishing and voice/media status key updates. // mode cannot publish, so replies go out on this one.
this.redisPub = new Redis(config.REDIS_URL); this.redisPub = new Redis(config.REDIS_URL);
this.redisSub.on("error", (err) => { this.redisSub.on("error", (err) => {
@@ -85,6 +85,11 @@ export class CommandHandler {
// ---- Command dispatch ---- // ---- Command dispatch ----
/** Normalize an unknown thrown value to a readable message. */
private static errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
private async handleCommand(raw: string): Promise<void> { private async handleCommand(raw: string): Promise<void> {
let cmd: CommandMessage; let cmd: CommandMessage;
try { try {
@@ -112,7 +117,7 @@ export class CommandHandler {
}; };
} }
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = CommandHandler.errorMessage(err);
logger.error( logger.error(
{ commandId: cmd.id, error: message }, { commandId: cmd.id, error: message },
"Command execution failed", "Command execution failed",