Files
GMW/src/moderation/messageCapture.ts
T

211 lines
5.7 KiB
TypeScript
Raw Normal View History

import type { Client, Message } from "discord.js-selfbot-v13";
import { config } from "../config";
import { createChildLogger } from "../logger";
import { queueMessageAnalysis } from "./aiAnalyzer";
import {
getDisplayContent,
getMessageLocation,
getMessageMetadata,
} from "./messageMetadata";
2026-05-14 20:03:02 +07:00
import {
getMessageById,
insertAttachment,
updateMessageAsDeleted,
updateMessageAsEdited,
upsertMessageForCapture,
2026-05-14 20:03:02 +07:00
} from "./messageStore";
import type {
AttachmentRecord,
MessageRecord,
ModerationBroadcaster,
} from "./types";
const logger = createChildLogger("message-capture");
type ModerationGlobal = typeof globalThis & {
moderationBroadcaster?: ModerationBroadcaster;
};
function getModerationBroadcaster(): ModerationBroadcaster | undefined {
return (globalThis as ModerationGlobal).moderationBroadcaster;
}
export interface TextCaptureTarget {
guildId?: string;
channelId?: string;
}
export interface MessageLocationInput {
guildId?: string | null;
channelId?: string | null;
}
export function shouldCaptureMessageLocation(
message: MessageLocationInput,
target: TextCaptureTarget,
): boolean {
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,
};
}
export async function captureMessage(
message: Message,
type: "text" | "edited" | "deleted",
options: { source?: "live" | "backlog" } = {},
): Promise<void> {
const location = getMessageLocation(message);
const metadata = getMessageMetadata(message);
const messageRecord: MessageRecord = {
id: message.id,
guild_id: message.guildId!,
channel_id: location.channelId,
thread_id: location.threadId,
2026-05-15 04:25:06 +07:00
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),
};
const inserted = await upsertMessageForCapture(messageRecord);
if (!inserted) {
return;
}
const isBacklog = options.source === "backlog";
if (!isBacklog) {
queueMessageAnalysis(message.id);
}
const broadcaster = getModerationBroadcaster();
if (broadcaster && !isBacklog) {
broadcaster.messageCreated(messageRecord);
}
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,
2026-05-15 04:25:06 +07:00
user_id: message.author?.id,
filename: attachment.name || "unknown",
size: attachment.size,
type: attachment.contentType || "application/octet-stream",
discord_url: attachment.url,
uploaded_url: attachment.url,
upload_status: "uploaded",
upload_error: null,
created_at: Date.now(),
uploaded_at: Date.now(),
};
await insertAttachment(attachmentRecord);
2026-05-14 20:03:02 +07:00
if (broadcaster) {
broadcaster.attachmentCreated(attachmentRecord);
}
}
}
}
export function registerMessageCapture(client: Client): void {
client.on("messageCreate", async (message) => {
if (!shouldCaptureMessageLocation(message, getTextCaptureTarget())) return;
if (message.author?.bot) 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 (!shouldCaptureMessageLocation(newMessage, getTextCaptureTarget()))
return;
if (newMessage.author?.bot) return;
try {
2026-05-14 20:03:02 +07:00
const existing = await getMessageById(newMessage.id);
2026-05-14 20:03:02 +07:00
if (existing) {
const editedAt = Date.now();
await updateMessageAsEdited(
newMessage.id,
getDisplayContent(newMessage as Message),
editedAt,
);
queueMessageAnalysis(newMessage.id);
const broadcaster = getModerationBroadcaster();
2026-05-14 20:03:02 +07:00
if (broadcaster) {
broadcaster.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 (!shouldCaptureMessageLocation(message, getTextCaptureTarget())) return;
if (!message.author) return;
try {
const deletedAt = Date.now();
await updateMessageAsDeleted(message.id, deletedAt);
const broadcaster = getModerationBroadcaster();
2026-05-14 20:03:02 +07:00
if (broadcaster) {
broadcaster.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",
);
}
});
}