2026-06-09 16:56:44 +07:00
|
|
|
import type { PageResult } from "@bete/shared";
|
2026-06-09 19:46:08 +07:00
|
|
|
import { pgAttachmentsTable, pgMessagesTable } from "@bete/shared";
|
2026-06-02 21:06:42 +07:00
|
|
|
import { createChildLogger } from "@bete/shared/logger";
|
2026-07-08 01:37:46 +07:00
|
|
|
import {
|
|
|
|
|
and,
|
|
|
|
|
desc,
|
|
|
|
|
eq,
|
|
|
|
|
inArray,
|
|
|
|
|
isNull,
|
|
|
|
|
like,
|
|
|
|
|
lt,
|
|
|
|
|
ne,
|
|
|
|
|
notInArray,
|
|
|
|
|
or,
|
|
|
|
|
type SQL,
|
|
|
|
|
} from "drizzle-orm";
|
2026-07-27 21:54:31 +07:00
|
|
|
import { config } from "../../shared/config/index.js";
|
2026-06-09 17:34:18 +07:00
|
|
|
import { getDatabase } from "../../shared/database/index.js";
|
2026-06-09 19:46:08 +07:00
|
|
|
import { mapMessageRow } from "../../shared/utils/messageMapper.js";
|
2026-06-01 21:44:29 +07:00
|
|
|
import type {
|
|
|
|
|
MessageCreate,
|
|
|
|
|
MessageQuery,
|
|
|
|
|
MessageUpdate,
|
|
|
|
|
} from "./messages.schema.js";
|
|
|
|
|
|
2026-07-05 06:58:17 +07:00
|
|
|
/**
|
2026-07-27 21:54:31 +07:00
|
|
|
* Thread/channel IDs to exclude from all message queries.
|
2026-07-05 06:58:17 +07:00
|
|
|
* Messages in these threads (e.g. bot/selfbot spam) are skipped
|
|
|
|
|
* both at capture time (discord-gateway) and when serving data
|
2026-07-27 21:54:31 +07:00
|
|
|
* (backend API). Configured via EXCLUDED_THREAD_IDS and EXCLUDED_CHANNEL_IDS.
|
2026-07-05 06:58:17 +07:00
|
|
|
*/
|
2026-07-27 21:54:31 +07:00
|
|
|
const EXCLUDED_THREAD_IDS = config.EXCLUDED_THREAD_IDS;
|
2026-07-05 06:58:17 +07:00
|
|
|
|
2026-06-01 21:44:29 +07:00
|
|
|
const logger = createChildLogger("messages.repository");
|
|
|
|
|
|
2026-06-02 00:32:38 +07:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class MessagesRepository {
|
2026-06-09 10:16:04 +07:00
|
|
|
async findMany(
|
|
|
|
|
query: MessageQuery,
|
|
|
|
|
): Promise<PageResult<ReturnType<typeof mapMessageRow>>> {
|
2026-06-09 17:34:18 +07:00
|
|
|
const db = getDatabase();
|
2026-06-02 00:32:38 +07:00
|
|
|
const limit = query.limit ?? 50;
|
2026-06-09 17:34:18 +07:00
|
|
|
const conditions: SQL[] = [];
|
2026-06-02 00:32:38 +07:00
|
|
|
|
|
|
|
|
if (query.guildId) {
|
2026-06-09 19:46:08 +07:00
|
|
|
conditions.push(eq(pgMessagesTable.guild_id, query.guildId));
|
2026-06-02 00:32:38 +07:00
|
|
|
}
|
|
|
|
|
if (query.channelId) {
|
2026-06-09 19:46:08 +07:00
|
|
|
conditions.push(eq(pgMessagesTable.channel_id, query.channelId));
|
2026-06-02 00:32:38 +07:00
|
|
|
}
|
|
|
|
|
if (query.userId) {
|
2026-06-09 19:46:08 +07:00
|
|
|
conditions.push(eq(pgMessagesTable.user_id, query.userId));
|
2026-06-02 00:32:38 +07:00
|
|
|
}
|
|
|
|
|
if (query.status) {
|
2026-06-09 19:46:08 +07:00
|
|
|
conditions.push(eq(pgMessagesTable.ai_status, query.status));
|
2026-06-02 00:32:38 +07:00
|
|
|
}
|
|
|
|
|
if (query.cursor) {
|
2026-06-09 19:46:08 +07:00
|
|
|
conditions.push(lt(pgMessagesTable.created_at, Number(query.cursor)));
|
2026-06-02 00:32:38 +07:00
|
|
|
}
|
|
|
|
|
|
2026-07-05 06:58:17 +07:00
|
|
|
// Exclude spam threads (NULL-safe: non-thread messages are kept)
|
|
|
|
|
if (EXCLUDED_THREAD_IDS.length > 0) {
|
|
|
|
|
conditions.push(
|
|
|
|
|
or(
|
|
|
|
|
isNull(pgMessagesTable.thread_id),
|
|
|
|
|
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
|
|
|
|
)!,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 17:34:18 +07:00
|
|
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select()
|
2026-06-09 19:46:08 +07:00
|
|
|
.from(pgMessagesTable)
|
2026-06-09 17:34:18 +07:00
|
|
|
.where(where)
|
2026-06-09 19:46:08 +07:00
|
|
|
.orderBy(desc(pgMessagesTable.created_at))
|
2026-06-09 17:34:18 +07:00
|
|
|
.limit(limit + 1);
|
2026-06-02 00:32:38 +07:00
|
|
|
|
2026-06-09 17:34:18 +07:00
|
|
|
const data = rows
|
|
|
|
|
.slice(0, limit)
|
|
|
|
|
.map((r) => mapMessageRow(r as Record<string, unknown>));
|
2026-06-09 10:16:04 +07:00
|
|
|
const nextCursor =
|
|
|
|
|
rows.length > limit ? String(rows[limit].created_at) : null;
|
2026-06-02 00:32:38 +07:00
|
|
|
|
|
|
|
|
logger.debug({ count: data.length, nextCursor }, "Found messages");
|
|
|
|
|
return { data, nextCursor };
|
2026-06-01 21:44:29 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async findById(id: string) {
|
2026-06-09 17:34:18 +07:00
|
|
|
const db = getDatabase();
|
|
|
|
|
const [row] = await db
|
|
|
|
|
.select()
|
2026-06-09 19:46:08 +07:00
|
|
|
.from(pgMessagesTable)
|
|
|
|
|
.where(eq(pgMessagesTable.id, id))
|
2026-06-09 17:34:18 +07:00
|
|
|
.limit(1);
|
2026-06-01 21:44:29 +07:00
|
|
|
|
2026-06-09 17:34:18 +07:00
|
|
|
if (!row) return null;
|
|
|
|
|
return mapMessageRow(row as Record<string, unknown>);
|
2026-06-01 21:44:29 +07:00
|
|
|
}
|
|
|
|
|
|
2026-06-02 00:32:38 +07:00
|
|
|
async findByChannel(
|
|
|
|
|
channelId: string,
|
|
|
|
|
query: MessageQuery,
|
|
|
|
|
): Promise<PageResult<ReturnType<typeof mapMessageRow>>> {
|
2026-06-09 17:34:18 +07:00
|
|
|
const db = getDatabase();
|
2026-06-02 00:32:38 +07:00
|
|
|
const limit = query.limit ?? 50;
|
2026-06-09 19:46:08 +07:00
|
|
|
const conditions: SQL[] = [eq(pgMessagesTable.channel_id, channelId)];
|
2026-06-01 21:44:29 +07:00
|
|
|
|
2026-06-02 00:32:38 +07:00
|
|
|
if (query.cursor) {
|
2026-06-09 19:46:08 +07:00
|
|
|
conditions.push(lt(pgMessagesTable.created_at, Number(query.cursor)));
|
2026-06-02 00:32:38 +07:00
|
|
|
}
|
|
|
|
|
|
2026-07-05 06:58:17 +07:00
|
|
|
// Exclude spam threads (NULL-safe)
|
|
|
|
|
if (EXCLUDED_THREAD_IDS.length > 0) {
|
|
|
|
|
conditions.push(
|
|
|
|
|
or(
|
|
|
|
|
isNull(pgMessagesTable.thread_id),
|
|
|
|
|
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
|
|
|
|
)!,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 17:34:18 +07:00
|
|
|
const rows = await db
|
|
|
|
|
.select()
|
2026-06-09 19:46:08 +07:00
|
|
|
.from(pgMessagesTable)
|
2026-06-09 17:34:18 +07:00
|
|
|
.where(and(...conditions))
|
2026-06-09 19:46:08 +07:00
|
|
|
.orderBy(desc(pgMessagesTable.created_at))
|
2026-06-09 17:34:18 +07:00
|
|
|
.limit(limit + 1);
|
2026-06-02 00:32:38 +07:00
|
|
|
|
2026-06-09 17:34:18 +07:00
|
|
|
const data = rows
|
|
|
|
|
.slice(0, limit)
|
|
|
|
|
.map((r) => mapMessageRow(r as Record<string, unknown>));
|
2026-06-09 10:16:04 +07:00
|
|
|
const nextCursor =
|
|
|
|
|
rows.length > limit ? String(rows[limit].created_at) : null;
|
2026-06-02 00:32:38 +07:00
|
|
|
|
|
|
|
|
return { data, nextCursor };
|
2026-06-01 21:44:29 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async create(data: MessageCreate) {
|
2026-06-09 17:34:18 +07:00
|
|
|
const db = getDatabase();
|
2026-06-02 00:32:38 +07:00
|
|
|
const id = crypto.randomUUID();
|
2026-06-01 21:44:29 +07:00
|
|
|
|
2026-06-09 17:34:18 +07:00
|
|
|
const [row] = await db
|
2026-06-09 19:46:08 +07:00
|
|
|
.insert(pgMessagesTable)
|
2026-06-09 17:34:18 +07:00
|
|
|
.values({
|
|
|
|
|
id,
|
|
|
|
|
guild_id: data.guildId,
|
|
|
|
|
channel_id: data.channelId,
|
|
|
|
|
thread_id: data.threadId ?? null,
|
|
|
|
|
user_id: data.userId,
|
|
|
|
|
username: data.username,
|
|
|
|
|
avatar_url: data.avatarUrl ?? null,
|
|
|
|
|
content: data.content,
|
|
|
|
|
edited_content: null,
|
|
|
|
|
created_at: Date.now(),
|
|
|
|
|
edited_at: null,
|
|
|
|
|
deleted_at: null,
|
|
|
|
|
type: data.type ?? "text",
|
|
|
|
|
metadata: null,
|
|
|
|
|
ai_status: "pending",
|
2026-06-22 09:46:06 +07:00
|
|
|
is_reply: data.isReply ?? false,
|
|
|
|
|
is_forward: data.isForward ?? false,
|
|
|
|
|
is_crosspost: data.isCrosspost ?? false,
|
|
|
|
|
reference_message_id: data.referenceMessageId ?? null,
|
|
|
|
|
reference_channel_id: data.referenceChannelId ?? null,
|
|
|
|
|
reference_guild_id: data.referenceGuildId ?? null,
|
2026-06-09 17:34:18 +07:00
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
return mapMessageRow(row as Record<string, unknown>);
|
2026-06-01 21:44:29 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async update(id: string, data: MessageUpdate) {
|
2026-06-09 17:34:18 +07:00
|
|
|
const db = getDatabase();
|
2026-06-01 21:44:29 +07:00
|
|
|
|
2026-06-09 19:46:08 +07:00
|
|
|
const setData: Partial<typeof pgMessagesTable.$inferInsert> = {};
|
2026-06-02 00:32:38 +07:00
|
|
|
|
2026-06-09 17:34:18 +07:00
|
|
|
if (data.editedContent !== undefined) {
|
|
|
|
|
setData.edited_content = data.editedContent;
|
|
|
|
|
}
|
|
|
|
|
if (data.aiStatus !== undefined) {
|
|
|
|
|
setData.ai_status = data.aiStatus;
|
|
|
|
|
}
|
|
|
|
|
if (data.aiAnalysis !== undefined) {
|
|
|
|
|
setData.ai_analysis = data.aiAnalysis;
|
|
|
|
|
}
|
|
|
|
|
if (data.aiCategories !== undefined) {
|
|
|
|
|
setData.ai_categories = data.aiCategories;
|
|
|
|
|
}
|
|
|
|
|
if (data.aiSeverity !== undefined) {
|
|
|
|
|
setData.ai_severity = data.aiSeverity;
|
|
|
|
|
}
|
|
|
|
|
if (data.aiConfidence !== undefined) {
|
|
|
|
|
setData.ai_confidence = data.aiConfidence;
|
2026-06-02 00:32:38 +07:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 17:34:18 +07:00
|
|
|
if (Object.keys(setData).length === 0) return this.findById(id);
|
2026-06-02 00:32:38 +07:00
|
|
|
|
2026-06-09 17:34:18 +07:00
|
|
|
const [row] = await db
|
2026-06-09 19:46:08 +07:00
|
|
|
.update(pgMessagesTable)
|
2026-06-09 17:34:18 +07:00
|
|
|
.set(setData)
|
2026-06-09 19:46:08 +07:00
|
|
|
.where(eq(pgMessagesTable.id, id))
|
2026-06-09 17:34:18 +07:00
|
|
|
.returning();
|
2026-06-02 00:32:38 +07:00
|
|
|
|
2026-06-09 17:34:18 +07:00
|
|
|
if (!row) return null;
|
|
|
|
|
return mapMessageRow(row as Record<string, unknown>);
|
2026-06-02 00:32:38 +07:00
|
|
|
}
|
|
|
|
|
|
2026-06-03 00:17:51 +07:00
|
|
|
/**
|
|
|
|
|
* Bulk-reset ai_status from 'error' to 'pending' so the DG recovery worker
|
|
|
|
|
* picks them up on its next poll cycle.
|
|
|
|
|
*
|
|
|
|
|
* Accepts optional scope filters (guildId, channelId) or a list of explicit
|
|
|
|
|
* message IDs. Returns the count of rows that were actually updated.
|
|
|
|
|
*/
|
|
|
|
|
async reanalyzeErrorBatch(opts: {
|
|
|
|
|
guildId?: string;
|
|
|
|
|
channelId?: string;
|
|
|
|
|
messageIds?: string[];
|
|
|
|
|
}): Promise<number> {
|
2026-06-09 17:34:18 +07:00
|
|
|
const db = getDatabase();
|
2026-06-09 19:46:08 +07:00
|
|
|
const conditions: SQL[] = [eq(pgMessagesTable.ai_status, "error")];
|
2026-06-03 00:17:51 +07:00
|
|
|
|
|
|
|
|
if (opts.messageIds && opts.messageIds.length > 0) {
|
2026-06-09 19:46:08 +07:00
|
|
|
conditions.push(inArray(pgMessagesTable.id, opts.messageIds));
|
2026-06-03 00:17:51 +07:00
|
|
|
}
|
|
|
|
|
if (opts.guildId) {
|
2026-06-09 19:46:08 +07:00
|
|
|
conditions.push(eq(pgMessagesTable.guild_id, opts.guildId));
|
2026-06-03 00:17:51 +07:00
|
|
|
}
|
|
|
|
|
if (opts.channelId) {
|
2026-06-09 19:46:08 +07:00
|
|
|
conditions.push(eq(pgMessagesTable.channel_id, opts.channelId));
|
2026-06-03 00:17:51 +07:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 17:34:18 +07:00
|
|
|
const result = await db
|
2026-06-09 19:46:08 +07:00
|
|
|
.update(pgMessagesTable)
|
2026-06-09 17:34:18 +07:00
|
|
|
.set({ ai_status: "pending" })
|
|
|
|
|
.where(and(...conditions));
|
2026-06-03 00:17:51 +07:00
|
|
|
|
2026-06-09 17:34:18 +07:00
|
|
|
const count = result.rowCount ?? 0;
|
|
|
|
|
logger.info({ count, ...opts }, "Batch reanalyze triggered");
|
|
|
|
|
return count;
|
2026-06-03 00:17:51 +07:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 10:16:04 +07:00
|
|
|
/**
|
|
|
|
|
* Mark a single message for re-analysis by resetting ai_status to 'pending'.
|
|
|
|
|
* Skips messages already in 'pending' state to avoid write amplification.
|
|
|
|
|
*/
|
|
|
|
|
async markForReanalysis(id: string): Promise<void> {
|
2026-06-09 17:34:18 +07:00
|
|
|
const db = getDatabase();
|
|
|
|
|
await db
|
2026-06-09 19:46:08 +07:00
|
|
|
.update(pgMessagesTable)
|
2026-06-09 17:34:18 +07:00
|
|
|
.set({ ai_status: "pending" })
|
2026-06-09 19:46:08 +07:00
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(pgMessagesTable.id, id),
|
|
|
|
|
ne(pgMessagesTable.ai_status, "pending"),
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-06-09 10:16:04 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Retrieve messages flagged for review (ai_status IN ('warn', 'flagged')).
|
|
|
|
|
* Optionally filtered by channelId, with configurable limit.
|
|
|
|
|
*/
|
|
|
|
|
async getReviewMessages(
|
|
|
|
|
channelId?: string,
|
|
|
|
|
limit: number = 20,
|
|
|
|
|
): Promise<Record<string, unknown>[]> {
|
2026-06-09 17:34:18 +07:00
|
|
|
const db = getDatabase();
|
|
|
|
|
const conditions: SQL[] = [
|
2026-06-09 19:46:08 +07:00
|
|
|
inArray(pgMessagesTable.ai_status, ["warn", "flagged"]),
|
2026-06-09 17:34:18 +07:00
|
|
|
];
|
2026-06-09 10:16:04 +07:00
|
|
|
|
|
|
|
|
if (channelId) {
|
2026-06-09 19:46:08 +07:00
|
|
|
conditions.push(eq(pgMessagesTable.channel_id, channelId));
|
2026-06-09 10:16:04 +07:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 17:34:18 +07:00
|
|
|
const rows = await db
|
|
|
|
|
.select({
|
2026-06-09 19:46:08 +07:00
|
|
|
id: pgMessagesTable.id,
|
|
|
|
|
guild_id: pgMessagesTable.guild_id,
|
|
|
|
|
channel_id: pgMessagesTable.channel_id,
|
|
|
|
|
user_id: pgMessagesTable.user_id,
|
|
|
|
|
username: pgMessagesTable.username,
|
|
|
|
|
avatar_url: pgMessagesTable.avatar_url,
|
|
|
|
|
content: pgMessagesTable.content,
|
|
|
|
|
type: pgMessagesTable.type,
|
|
|
|
|
created_at: pgMessagesTable.created_at,
|
|
|
|
|
ai_status: pgMessagesTable.ai_status,
|
|
|
|
|
ai_severity: pgMessagesTable.ai_severity,
|
|
|
|
|
ai_confidence: pgMessagesTable.ai_confidence,
|
|
|
|
|
ai_analysis: pgMessagesTable.ai_analysis,
|
2026-06-22 09:46:06 +07:00
|
|
|
is_reply: pgMessagesTable.is_reply,
|
|
|
|
|
is_forward: pgMessagesTable.is_forward,
|
|
|
|
|
is_crosspost: pgMessagesTable.is_crosspost,
|
|
|
|
|
reference_message_id: pgMessagesTable.reference_message_id,
|
|
|
|
|
reference_channel_id: pgMessagesTable.reference_channel_id,
|
|
|
|
|
reference_guild_id: pgMessagesTable.reference_guild_id,
|
2026-06-09 17:34:18 +07:00
|
|
|
})
|
2026-06-09 19:46:08 +07:00
|
|
|
.from(pgMessagesTable)
|
2026-06-09 17:34:18 +07:00
|
|
|
.where(and(...conditions))
|
2026-06-09 19:46:08 +07:00
|
|
|
.orderBy(desc(pgMessagesTable.created_at))
|
2026-06-09 17:34:18 +07:00
|
|
|
.limit(limit);
|
|
|
|
|
|
|
|
|
|
return rows as unknown as Record<string, unknown>[];
|
2026-06-09 10:16:04 +07:00
|
|
|
}
|
|
|
|
|
|
2026-06-02 00:32:38 +07:00
|
|
|
async delete(id: string): Promise<boolean> {
|
2026-06-09 17:34:18 +07:00
|
|
|
const db = getDatabase();
|
2026-06-09 19:46:08 +07:00
|
|
|
const result = await db
|
|
|
|
|
.delete(pgMessagesTable)
|
|
|
|
|
.where(eq(pgMessagesTable.id, id));
|
2026-06-09 17:34:18 +07:00
|
|
|
|
|
|
|
|
return (result.rowCount ?? 0) > 0;
|
2026-06-02 00:32:38 +07:00
|
|
|
}
|
|
|
|
|
|
2026-07-05 04:48:29 +07:00
|
|
|
async getImageMessages(
|
|
|
|
|
guildId: string,
|
|
|
|
|
limit: number = 50,
|
|
|
|
|
): Promise<PageResult<ReturnType<typeof mapMessageRow>>> {
|
|
|
|
|
const db = getDatabase();
|
|
|
|
|
|
|
|
|
|
// Subquery: find distinct message_ids from attachments with image MIME type
|
|
|
|
|
const imageMsgIds = db
|
|
|
|
|
.select({ id: pgAttachmentsTable.message_id })
|
|
|
|
|
.from(pgAttachmentsTable)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(pgAttachmentsTable.guild_id, guildId),
|
|
|
|
|
like(pgAttachmentsTable.type, "image/%"),
|
2026-07-05 06:58:17 +07:00
|
|
|
// Exclude spam threads (NULL-safe for non-thread messages)
|
|
|
|
|
...(EXCLUDED_THREAD_IDS.length > 0
|
|
|
|
|
? [
|
|
|
|
|
or(
|
|
|
|
|
isNull(pgAttachmentsTable.thread_id),
|
2026-07-08 01:37:46 +07:00
|
|
|
notInArray(pgAttachmentsTable.thread_id, EXCLUDED_THREAD_IDS),
|
2026-07-05 06:58:17 +07:00
|
|
|
)!,
|
|
|
|
|
]
|
|
|
|
|
: []),
|
2026-07-05 04:48:29 +07:00
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.orderBy(desc(pgAttachmentsTable.created_at))
|
|
|
|
|
.limit(limit + 1);
|
|
|
|
|
|
|
|
|
|
// Fetch full message rows for those IDs
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select()
|
|
|
|
|
.from(pgMessagesTable)
|
|
|
|
|
.where(inArray(pgMessagesTable.id, imageMsgIds))
|
|
|
|
|
.orderBy(desc(pgMessagesTable.created_at))
|
|
|
|
|
.limit(limit + 1);
|
|
|
|
|
|
|
|
|
|
const data = rows
|
|
|
|
|
.slice(0, limit)
|
|
|
|
|
.map((r) => mapMessageRow(r as Record<string, unknown>));
|
|
|
|
|
const nextCursor =
|
|
|
|
|
rows.length > limit ? String(rows[limit].created_at) : null;
|
|
|
|
|
|
|
|
|
|
logger.debug({ count: data.length, nextCursor }, "Found image messages");
|
|
|
|
|
return { data, nextCursor };
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 00:32:38 +07:00
|
|
|
async getAttachmentsByChannel(
|
|
|
|
|
channelId: string,
|
|
|
|
|
query: MessageQuery,
|
|
|
|
|
): Promise<PageResult<AttachmentResult>> {
|
2026-06-09 17:34:18 +07:00
|
|
|
const db = getDatabase();
|
2026-06-02 00:32:38 +07:00
|
|
|
const limit = query.limit ?? 50;
|
2026-06-09 19:46:08 +07:00
|
|
|
const conditions: SQL[] = [eq(pgAttachmentsTable.channel_id, channelId)];
|
2026-06-02 00:32:38 +07:00
|
|
|
|
|
|
|
|
if (query.cursor) {
|
2026-06-09 19:46:08 +07:00
|
|
|
conditions.push(lt(pgAttachmentsTable.created_at, Number(query.cursor)));
|
2026-06-02 00:32:38 +07:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 17:34:18 +07:00
|
|
|
const rows = await db
|
|
|
|
|
.select()
|
2026-06-09 19:46:08 +07:00
|
|
|
.from(pgAttachmentsTable)
|
2026-06-09 17:34:18 +07:00
|
|
|
.where(and(...conditions))
|
2026-06-09 19:46:08 +07:00
|
|
|
.orderBy(desc(pgAttachmentsTable.created_at))
|
2026-06-09 17:34:18 +07:00
|
|
|
.limit(limit + 1);
|
2026-06-02 00:32:38 +07:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}));
|
|
|
|
|
|
2026-06-09 10:16:04 +07:00
|
|
|
const nextCursor =
|
|
|
|
|
data.length > limit ? String(data[limit].created_at) : null;
|
2026-06-02 00:32:38 +07:00
|
|
|
const trimmed = data.slice(0, limit);
|
|
|
|
|
|
|
|
|
|
return { data: trimmed, nextCursor };
|
2026-06-01 21:44:29 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const messagesRepository = new MessagesRepository();
|