fix: auto-fix code quality [skip ci]
This commit is contained in:
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,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() {
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user