import type { Client, Message, TextChannel, ThreadChannel } from "discord.js-selfbot-v13"; import { createChildLogger } from "../logger"; import type { SqliteDatabase } from "../muxer-queue"; import { config } from "../config"; import { insertMessage, insertAttachment } from "./messageStore"; import { processAttachmentUpload } from "./attachmentUploader"; import type { MessageRecord, AttachmentRecord } from "./types"; const logger = createChildLogger("message-capture"); function getMessageLocation(message: Message): { channelId: string; threadId: string | null; threadName: string | null; } { const channel = message.channel as TextChannel | ThreadChannel; if (!channel.isThread?.()) { return { channelId: message.channelId, threadId: null, threadName: null }; } return { channelId: channel.parentId ?? message.channelId, threadId: channel.id, threadName: channel.name, }; } function getStickerMetadata(message: Message): Array<{ id: string; name: string; url: string; }> { return Array.from(message.stickers.values()).map((sticker) => ({ id: sticker.id, name: sticker.name, url: sticker.url, })); } function getDisplayContent(message: Message): string { if (message.content.trim().length > 0) return message.content; const stickers = getStickerMetadata(message); if (stickers.length > 0) { return stickers.map((sticker) => `[Sticker: ${sticker.name}]`).join(" "); } return ""; } async function captureMessage( db: SqliteDatabase, message: Message, type: "text" | "edited" | "deleted", ): Promise { const location = getMessageLocation(message); const stickers = getStickerMetadata(message); const metadata = { stickers, threadName: location.threadName, }; const messageRecord: MessageRecord = { id: message.id, guild_id: message.guildId!, channel_id: location.channelId, thread_id: location.threadId, user_id: message.author!.id, username: message.author!.username, avatar_url: message.author!.avatarURL() || null, content: getDisplayContent(message), edited_content: null, created_at: message.createdTimestamp, edited_at: null, deleted_at: null, type, metadata: JSON.stringify(metadata), }; insertMessage(db, messageRecord); const broadcaster = globalThis as any; if (broadcaster.broadcastMessageCreated) { broadcaster.broadcastMessageCreated({ ...messageRecord, type: "text", }); } if (message.attachments.size > 0) { for (const [, attachment] of message.attachments) { const attachmentRecord: AttachmentRecord = { id: attachment.id, message_id: message.id, guild_id: message.guildId!, channel_id: location.channelId, thread_id: location.threadId, user_id: message.author!.id, filename: attachment.name || "unknown", size: attachment.size, type: attachment.contentType || "application/octet-stream", discord_url: attachment.url, uploaded_url: null, upload_status: "pending", upload_error: null, created_at: Date.now(), uploaded_at: null, }; insertAttachment(db, attachmentRecord); processAttachmentUpload(db, attachment.id, attachment.url, attachment.name || "unknown") .then(() => { if (broadcaster.broadcastAttachmentUploaded) { broadcaster.broadcastAttachmentUploaded({ id: attachment.id, message_id: message.id, filename: attachment.name || "unknown", channel_id: location.channelId, created_at: Date.now(), }); } }) .catch((error) => { logger.error( { attachmentId: attachment.id, error: error instanceof Error ? error.message : String(error) }, "Background attachment upload failed", ); }); } } logger.info( { messageId: message.id, channelId: message.channelId, attachmentCount: message.attachments.size, }, "Message captured", ); } export function registerMessageCapture(client: Client, db: SqliteDatabase): void { client.on("messageCreate", async (message) => { if (!message.guildId || message.guildId !== config.MONITOR_GUILD_ID) return; if (message.author?.bot) return; try { await captureMessage(db, message, "text"); } catch (error) { logger.error( { messageId: message.id, error: error instanceof Error ? error.message : String(error) }, "Failed to capture message", ); } }); client.on("messageUpdate", async (_oldMessage, newMessage) => { if (!newMessage.guildId || newMessage.guildId !== config.MONITOR_GUILD_ID) return; if (newMessage.author?.bot) return; try { const { updateMessageAsEdited } = await import("./messageStore"); const existing = db .prepare("SELECT id FROM messages WHERE id = ?") .get(newMessage.id) as { id: string } | undefined; if (existing) { const editedAt = Date.now(); updateMessageAsEdited(db, newMessage.id, getDisplayContent(newMessage as Message), editedAt); const broadcaster = globalThis as any; if (broadcaster.broadcastMessageUpdated) { broadcaster.broadcastMessageUpdated({ id: newMessage.id, edited_content: getDisplayContent(newMessage as Message), edited_at: editedAt, }); } } else if (newMessage.author) { await captureMessage(db, newMessage as Message, "text"); } } catch (error) { logger.error( { messageId: newMessage.id, error: error instanceof Error ? error.message : String(error) }, "Failed to capture message update", ); } }); client.on("messageDelete", async (message) => { if (!message.guildId || message.guildId !== config.MONITOR_GUILD_ID) return; if (!message.author) return; try { const { updateMessageAsDeleted } = await import("./messageStore"); const deletedAt = Date.now(); updateMessageAsDeleted(db, message.id, deletedAt); const broadcaster = globalThis as any; if (broadcaster.broadcastMessageDeleted) { broadcaster.broadcastMessageDeleted({ id: message.id, deleted_at: deletedAt, }); } logger.info({ messageId: message.id }, "Message deletion captured"); } catch (error) { logger.error( { messageId: message.id, error: error instanceof Error ? error.message : String(error) }, "Failed to capture message deletion", ); } }); logger.info("Message capture handlers registered"); }