382 lines
11 KiB
TypeScript
382 lines
11 KiB
TypeScript
import { createChildLogger } from "@bete/shared/logger";
|
|
import type { Client, Message } from "discord.js-selfbot-v13";
|
|
import { config } from "../../shared/config/config.js";
|
|
import { queueMessageAnalysis } from "../ai-moderation/aiAnalyzer.js";
|
|
import { processAttachmentUpload } from "../attachment-upload/attachmentUploader.js";
|
|
import type { EventBroadcaster } from "../event-broadcaster/eventBroadcaster.js";
|
|
import {
|
|
getDisplayContent,
|
|
getMessageLocation,
|
|
getMessageMetadata,
|
|
isAgeRestrictedMessage,
|
|
} from "../message-capture/messageMetadata.js";
|
|
import {
|
|
getMessageById,
|
|
insertAttachment,
|
|
insertMessageEdit,
|
|
updateMessageAsDeleted,
|
|
updateMessageAsEdited,
|
|
upsertMessageForCapture,
|
|
} from "../message-capture/messageStore.js";
|
|
import type {
|
|
AttachmentRecord,
|
|
MessageRecord,
|
|
} from "../message-capture/types.js";
|
|
|
|
const logger = createChildLogger("message-capture");
|
|
|
|
let _eventBroadcaster: EventBroadcaster | undefined;
|
|
|
|
export function setEventBroadcaster(broadcaster: EventBroadcaster | undefined) {
|
|
_eventBroadcaster = broadcaster;
|
|
}
|
|
|
|
export interface TextCaptureTarget {
|
|
guildId?: string;
|
|
channelId?: string;
|
|
}
|
|
|
|
export interface MessageLocationInput {
|
|
guildId?: string | null;
|
|
channelId?: string | null;
|
|
}
|
|
|
|
const EXCLUDED_CHANNEL_IDS = new Set([
|
|
"1310988070996414494",
|
|
"1265679542144467035",
|
|
"1310867899745046558",
|
|
"1323365288447574128",
|
|
]);
|
|
|
|
function getParentChannelId(
|
|
message: MessageLocationInput,
|
|
): string | null | undefined {
|
|
try {
|
|
const m = message as {
|
|
channel?: { isThread?: () => boolean; parentId?: string };
|
|
};
|
|
if (m.channel?.isThread?.() && m.channel.parentId)
|
|
return m.channel.parentId;
|
|
} catch {
|
|
// Not a thread or can't resolve — fall back to channelId
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function shouldCaptureMessageLocation(
|
|
message: MessageLocationInput,
|
|
target: TextCaptureTarget,
|
|
): boolean {
|
|
const effectiveId = getParentChannelId(message) ?? message.channelId;
|
|
if (effectiveId && EXCLUDED_CHANNEL_IDS.has(effectiveId)) return false;
|
|
if (!message.guildId || message.guildId !== target.guildId) return false;
|
|
if (target.channelId && message.channelId !== target.channelId) return false;
|
|
return true;
|
|
}
|
|
|
|
function getTextCaptureTarget(): TextCaptureTarget {
|
|
return {
|
|
guildId: config.EFFECTIVE_TEXT_GUILD_ID,
|
|
channelId: config.TEXT_CHANNEL_ID,
|
|
};
|
|
}
|
|
|
|
function getTextCaptureTargets(): TextCaptureTarget[] {
|
|
const { EFFECTIVE_MONITOR_GUILD_IDS, TEXT_CHANNEL_ID } = config as any;
|
|
if (EFFECTIVE_MONITOR_GUILD_IDS?.length) {
|
|
if (TEXT_CHANNEL_ID) {
|
|
return EFFECTIVE_MONITOR_GUILD_IDS.map((guildId: string) => ({ guildId, channelId: TEXT_CHANNEL_ID }));
|
|
}
|
|
return EFFECTIVE_MONITOR_GUILD_IDS.map((guildId: string) => ({ guildId }));
|
|
}
|
|
// Fallback
|
|
const target = getTextCaptureTarget();
|
|
return target.guildId ? [target] : [];
|
|
}
|
|
|
|
function shouldCaptureForAnyTarget(
|
|
message: MessageLocationInput,
|
|
targets: TextCaptureTarget[],
|
|
): boolean {
|
|
if (targets.length === 0) return false;
|
|
return targets.some((target) => shouldCaptureMessageLocation(message, target));
|
|
}
|
|
|
|
function requireMessageGuildId(message: Message): string {
|
|
if (!message.guildId) {
|
|
throw new Error(`Message ${message.id} is missing guildId`);
|
|
}
|
|
return message.guildId;
|
|
}
|
|
|
|
function buildMessageRecord(
|
|
message: Message,
|
|
type: "text" | "edited" | "deleted",
|
|
): MessageRecord {
|
|
const location = getMessageLocation(message);
|
|
const metadata = getMessageMetadata(message);
|
|
const guildId = requireMessageGuildId(message);
|
|
const ref = message.reference;
|
|
|
|
// is_reply: type === 'REPLY' OR reference type === 'DEFAULT'
|
|
// is_forward: reference type === 'FORWARD'
|
|
// is_crosspost: message flags has CROSSPOSTED
|
|
const msgType = message.type as string;
|
|
const refType = (ref?.type as string | undefined) ?? null;
|
|
const isReply =
|
|
msgType === "REPLY" || (refType === "DEFAULT" && msgType !== "FORWARD")
|
|
? true
|
|
: null;
|
|
const isForward = refType === "FORWARD" ? true : null;
|
|
const isCrosspost =
|
|
message.flags?.has(1 << 1) || msgType === "CROSSPOSTED" ? true : null;
|
|
|
|
return {
|
|
id: message.id,
|
|
guild_id: 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,
|
|
is_reply: isReply,
|
|
is_forward: isForward,
|
|
is_crosspost: isCrosspost,
|
|
reference_message_id: ref?.messageId ?? null,
|
|
reference_channel_id: ref?.channelId ?? null,
|
|
reference_guild_id: ref?.guildId ?? null,
|
|
metadata: JSON.stringify(metadata),
|
|
};
|
|
}
|
|
|
|
function buildAttachmentRecord(
|
|
message: Message,
|
|
location: ReturnType<typeof getMessageLocation>,
|
|
attachment: {
|
|
id: string;
|
|
name: string | null;
|
|
size: number;
|
|
contentType: string | null;
|
|
url: string;
|
|
},
|
|
): AttachmentRecord {
|
|
const guildId = requireMessageGuildId(message);
|
|
|
|
return {
|
|
id: attachment.id,
|
|
message_id: message.id,
|
|
guild_id: 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,
|
|
};
|
|
}
|
|
|
|
export async function captureMessage(
|
|
message: Message,
|
|
type: "text" | "edited" | "deleted",
|
|
options: { source?: "live" | "backlog" } = {},
|
|
): Promise<void> {
|
|
const location = getMessageLocation(message);
|
|
const messageRecord = buildMessageRecord(message, type);
|
|
|
|
const inserted = await upsertMessageForCapture(messageRecord);
|
|
if (!inserted) {
|
|
return;
|
|
}
|
|
|
|
const isBacklog = options.source === "backlog";
|
|
|
|
if (_eventBroadcaster && !isBacklog) {
|
|
_eventBroadcaster.messageCreated(messageRecord);
|
|
}
|
|
|
|
const attachmentUploadTasks: Promise<void>[] = [];
|
|
|
|
if (message.attachments.size > 0) {
|
|
for (const [, attachment] of message.attachments) {
|
|
const attachmentRecord = buildAttachmentRecord(message, location, {
|
|
id: attachment.id,
|
|
name: attachment.name,
|
|
size: attachment.size,
|
|
contentType: attachment.contentType,
|
|
url: attachment.url,
|
|
});
|
|
|
|
await insertAttachment(attachmentRecord);
|
|
|
|
if (!isBacklog) {
|
|
attachmentUploadTasks.push(
|
|
processAttachmentUpload(
|
|
attachment.id,
|
|
attachment.url,
|
|
attachment.name || "unknown",
|
|
{
|
|
contentType: attachment.contentType ?? undefined,
|
|
refreshDiscordUrl: async () => {
|
|
const freshMessage = await message.channel.messages.fetch(
|
|
message.id,
|
|
);
|
|
const freshAttachment = freshMessage.attachments.get(
|
|
attachment.id,
|
|
);
|
|
return freshAttachment?.url ?? null;
|
|
},
|
|
},
|
|
).catch((err: unknown) => {
|
|
logger.error(
|
|
{ attachmentId: attachment.id, error: err },
|
|
"Failed to initiate attachment upload",
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (_eventBroadcaster) {
|
|
_eventBroadcaster.attachmentCreated(attachmentRecord);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!isBacklog) {
|
|
// AI analysis starts immediately — attachment upload runs in parallel.
|
|
// Media analysis path downloads images directly from Discord CDN,
|
|
// so it does NOT depend on the upload completing first.
|
|
queueMessageAnalysis(message.id);
|
|
|
|
if (attachmentUploadTasks.length > 0) {
|
|
Promise.allSettled(attachmentUploadTasks).catch((err: unknown) => {
|
|
logger.error(
|
|
{ messageId: message.id, error: err },
|
|
"Attachment upload tasks failed",
|
|
);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
export function registerMessageCapture(client: Client): void {
|
|
const targets = getTextCaptureTargets();
|
|
|
|
client.on("messageCreate", async (message) => {
|
|
if (!shouldCaptureForAnyTarget(message, targets)) return;
|
|
if (message.author?.bot) return;
|
|
if (isAgeRestrictedMessage(message)) return;
|
|
|
|
try {
|
|
await captureMessage(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 (!shouldCaptureForAnyTarget(newMessage, targets))
|
|
return;
|
|
if (newMessage.author?.bot) return;
|
|
if (isAgeRestrictedMessage(newMessage as Message)) return;
|
|
|
|
try {
|
|
const existing = await getMessageById(newMessage.id);
|
|
|
|
if (existing) {
|
|
const newContent = getDisplayContent(newMessage as Message);
|
|
const existingContent = existing.edited_content ?? existing.content;
|
|
|
|
// Skip if the displayed text is identical — Discord fires `messageUpdate`
|
|
// for embed resolution (link previews) which does NOT change the message
|
|
// body. Re-setting ai_status + re-queuing LLM in that case wastes a call
|
|
// and risks overwriting a valid completed analysis with a duplicate.
|
|
if (newContent === existingContent) {
|
|
logger.debug(
|
|
{ messageId: newMessage.id },
|
|
"messageUpdate skipped: content unchanged (embed resolution or no-op)",
|
|
);
|
|
return;
|
|
}
|
|
|
|
const editedAt = Date.now();
|
|
const oldContent = existing.edited_content ?? existing.content ?? "";
|
|
|
|
// Save edit history snapshot before overwriting
|
|
if (oldContent) {
|
|
insertMessageEdit(newMessage.id, oldContent, editedAt).catch((err: unknown) => {
|
|
logger.error({ messageId: newMessage.id, error: err }, "Failed to save edit history");
|
|
});
|
|
}
|
|
|
|
await updateMessageAsEdited(
|
|
newMessage.id,
|
|
getDisplayContent(newMessage as Message),
|
|
editedAt,
|
|
);
|
|
queueMessageAnalysis(newMessage.id);
|
|
|
|
if (_eventBroadcaster) {
|
|
_eventBroadcaster.messageUpdated({
|
|
id: newMessage.id,
|
|
edited_content: getDisplayContent(newMessage as Message),
|
|
edited_at: editedAt,
|
|
});
|
|
}
|
|
} else if (newMessage.author) {
|
|
await captureMessage(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 (!shouldCaptureForAnyTarget(message, targets)) return;
|
|
if (!message.author) return;
|
|
|
|
try {
|
|
const deletedAt = Date.now();
|
|
await updateMessageAsDeleted(message.id, deletedAt);
|
|
|
|
if (_eventBroadcaster) {
|
|
_eventBroadcaster.messageDeleted({
|
|
id: message.id,
|
|
deleted_at: deletedAt,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logger.error(
|
|
{
|
|
messageId: message.id,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
"Failed to capture message deletion",
|
|
);
|
|
}
|
|
});
|
|
}
|