feat(moderation): implement backlog message synchronization and enhance message metadata handling
This commit is contained in:
@@ -35,4 +35,6 @@ PICSER_UPLOAD_URL=https://picser.asepharyana.tech/api/upload
|
|||||||
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000
|
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000
|
||||||
ATTACHMENT_MAX_SIZE_MB=100
|
ATTACHMENT_MAX_SIZE_MB=100
|
||||||
ATTACHMENT_RETRY_ATTEMPTS=3
|
ATTACHMENT_RETRY_ATTEMPTS=3
|
||||||
|
BACKLOG_SYNC_HOURS=24
|
||||||
|
BACKLOG_SYNC_BATCH_SIZE=100
|
||||||
|
|
||||||
|
|||||||
@@ -377,8 +377,13 @@
|
|||||||
.time { color: var(--faint); font: 600 11px/1 "JetBrains Mono", monospace; white-space: nowrap; }
|
.time { color: var(--faint); font: 600 11px/1 "JetBrains Mono", monospace; white-space: nowrap; }
|
||||||
.message-text { color: #dbe6f7; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
|
.message-text { color: #dbe6f7; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
|
||||||
|
|
||||||
.sticker-strip { display: flex; gap: 10px; flex-wrap: wrap; }
|
.sticker-strip, .attachment-strip { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||||
.sticker-img { width: 96px; height: 96px; object-fit: contain; border-radius: 16px; background: rgba(0,0,0,0.22); border: 1px solid var(--line); padding: 8px; }
|
.sticker-img { width: 96px; height: 96px; object-fit: contain; border-radius: 16px; background: rgba(0,0,0,0.22); border: 1px solid var(--line); padding: 8px; }
|
||||||
|
.attachment-chip { color: var(--cyan); text-decoration: none; border: 1px solid var(--line); border-radius: 14px; padding: 8px 10px; font: 700 12px/1 "JetBrains Mono", monospace; background: rgba(0,229,255,0.06); }
|
||||||
|
.embed-card { border-left: 4px solid var(--blue); border-radius: 16px; padding: 12px; background: rgba(98,117,255,0.08); display: grid; gap: 8px; }
|
||||||
|
.embed-title { font-weight: 900; color: var(--text); }
|
||||||
|
.embed-description { color: var(--muted); line-height: 1.5; white-space: pre-wrap; }
|
||||||
|
.embed-image { max-width: 360px; width: 100%; border-radius: 14px; border: 1px solid var(--line); }
|
||||||
|
|
||||||
.badges { display: flex; gap: 8px; flex-wrap: wrap; }
|
.badges { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
.badge {
|
.badge {
|
||||||
@@ -770,27 +775,93 @@
|
|||||||
const text = document.createElement('div');
|
const text = document.createElement('div');
|
||||||
text.className = 'message-text';
|
text.className = 'message-text';
|
||||||
text.textContent = msg.edited_content || msg.content || '(empty message)';
|
text.textContent = msg.edited_content || msg.content || '(empty message)';
|
||||||
const stickers = document.createElement('div');
|
const stickers = renderStickers(metadata.stickers || []);
|
||||||
stickers.className = 'sticker-strip';
|
const embeds = renderEmbeds(metadata.embeds || []);
|
||||||
for (const sticker of metadata.stickers || []) {
|
const attachments = renderAttachments(metadata.attachments || []);
|
||||||
const img = document.createElement('img');
|
|
||||||
img.className = 'sticker-img';
|
|
||||||
img.src = sticker.url;
|
|
||||||
img.alt = sticker.name;
|
|
||||||
stickers.appendChild(img);
|
|
||||||
}
|
|
||||||
const badges = document.createElement('div');
|
const badges = document.createElement('div');
|
||||||
badges.className = 'badges';
|
badges.className = 'badges';
|
||||||
if (msg.thread_id) appendBadge(badges, metadata.threadName ? `thread: ${metadata.threadName}` : 'thread', '');
|
if (metadata.reference?.messageId) appendBadge(badges, 'reply', '');
|
||||||
|
if (msg.thread_id) appendBadge(badges, metadata.channel?.threadName ? `thread: ${metadata.channel.threadName}` : 'thread', '');
|
||||||
if (msg.edited_at) appendBadge(badges, 'edited', 'edit');
|
if (msg.edited_at) appendBadge(badges, 'edited', 'edit');
|
||||||
if (msg.deleted_at) appendBadge(badges, 'deleted', 'delete');
|
if (msg.deleted_at) appendBadge(badges, 'deleted', 'delete');
|
||||||
card.append(head, text);
|
card.append(head, text);
|
||||||
if (stickers.childElementCount > 0) card.appendChild(stickers);
|
if (stickers.childElementCount > 0) card.appendChild(stickers);
|
||||||
|
if (embeds.childElementCount > 0) card.appendChild(embeds);
|
||||||
|
if (attachments.childElementCount > 0) card.appendChild(attachments);
|
||||||
card.appendChild(badges);
|
card.appendChild(badges);
|
||||||
el.textList.appendChild(card);
|
el.textList.appendChild(card);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderStickers(stickers) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'sticker-strip';
|
||||||
|
for (const sticker of stickers) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.className = 'sticker-img';
|
||||||
|
img.src = sticker.url;
|
||||||
|
img.alt = sticker.name;
|
||||||
|
wrap.appendChild(img);
|
||||||
|
}
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmbeds(embeds) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'feed';
|
||||||
|
for (const embed of embeds) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'embed-card';
|
||||||
|
if (embed.title) {
|
||||||
|
const title = document.createElement(embed.url ? 'a' : 'div');
|
||||||
|
title.className = 'embed-title';
|
||||||
|
title.textContent = embed.title;
|
||||||
|
if (embed.url) {
|
||||||
|
title.href = embed.url;
|
||||||
|
title.target = '_blank';
|
||||||
|
title.rel = 'noreferrer';
|
||||||
|
}
|
||||||
|
card.appendChild(title);
|
||||||
|
}
|
||||||
|
if (embed.description) {
|
||||||
|
const desc = document.createElement('div');
|
||||||
|
desc.className = 'embed-description';
|
||||||
|
desc.textContent = embed.description;
|
||||||
|
card.appendChild(desc);
|
||||||
|
}
|
||||||
|
for (const field of embed.fields || []) {
|
||||||
|
const fieldNode = document.createElement('div');
|
||||||
|
fieldNode.className = 'embed-description';
|
||||||
|
fieldNode.textContent = `${field.name}: ${field.value}`;
|
||||||
|
card.appendChild(fieldNode);
|
||||||
|
}
|
||||||
|
if (embed.image || embed.thumbnail) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.className = 'embed-image';
|
||||||
|
img.src = embed.image || embed.thumbnail;
|
||||||
|
img.alt = embed.title || 'embed image';
|
||||||
|
card.appendChild(img);
|
||||||
|
}
|
||||||
|
wrap.appendChild(card);
|
||||||
|
}
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAttachments(attachments) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'attachment-strip';
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.className = 'attachment-chip';
|
||||||
|
link.href = attachment.url;
|
||||||
|
link.target = '_blank';
|
||||||
|
link.rel = 'noreferrer';
|
||||||
|
link.textContent = `${attachment.name} (${(attachment.size / 1024).toFixed(1)}KB)`;
|
||||||
|
wrap.appendChild(link);
|
||||||
|
}
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
function renderImages() {
|
function renderImages() {
|
||||||
el.imageGrid.replaceChildren();
|
el.imageGrid.replaceChildren();
|
||||||
if (!state.selectedChannel) return appendEmpty(el.imageGrid, 'Select channel to view image captures');
|
if (!state.selectedChannel) return appendEmpty(el.imageGrid, 'Select channel to view image captures');
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ const configSchema = z.object({
|
|||||||
ATTACHMENT_UPLOAD_TIMEOUT_MS: z.coerce.number().positive().default(30000),
|
ATTACHMENT_UPLOAD_TIMEOUT_MS: z.coerce.number().positive().default(30000),
|
||||||
ATTACHMENT_MAX_SIZE_MB: z.coerce.number().positive().default(100),
|
ATTACHMENT_MAX_SIZE_MB: z.coerce.number().positive().default(100),
|
||||||
ATTACHMENT_RETRY_ATTEMPTS: z.coerce.number().positive().default(3),
|
ATTACHMENT_RETRY_ATTEMPTS: z.coerce.number().positive().default(3),
|
||||||
|
BACKLOG_SYNC_HOURS: z.coerce.number().positive().default(24),
|
||||||
|
BACKLOG_SYNC_BATCH_SIZE: z.coerce.number().int().positive().max(100).default(100),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppConfig = z.infer<typeof configSchema>;
|
export type AppConfig = z.infer<typeof configSchema>;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { discordPlayer } from "./player";
|
|||||||
import { VoiceController } from "./voiceController";
|
import { VoiceController } from "./voiceController";
|
||||||
import { startWebserver } from "./webserver";
|
import { startWebserver } from "./webserver";
|
||||||
import { registerMessageCapture } from "./moderation/messageCapture";
|
import { registerMessageCapture } from "./moderation/messageCapture";
|
||||||
|
import { syncBacklogMessages } from "./moderation/backlogSync";
|
||||||
import { getDatabase } from "./muxer-queue";
|
import { getDatabase } from "./muxer-queue";
|
||||||
|
|
||||||
const logger = createChildLogger("bot");
|
const logger = createChildLogger("bot");
|
||||||
@@ -55,6 +56,9 @@ async function gracefulShutdown(signal: string) {
|
|||||||
client.on("ready", async () => {
|
client.on("ready", async () => {
|
||||||
logger.info({ user: client.user?.tag }, "Bot logged in");
|
logger.info({ user: client.user?.tag }, "Bot logged in");
|
||||||
registerMessageCapture(client, db);
|
registerMessageCapture(client, db);
|
||||||
|
syncBacklogMessages(client, db).catch((error) => {
|
||||||
|
logger.warn({ error }, "Backlog sync failed");
|
||||||
|
});
|
||||||
startWebserver(config.WEBSERVER_PORT, client, voiceController);
|
startWebserver(config.WEBSERVER_PORT, client, voiceController);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
120
src/moderation/backlogSync.ts
Normal file
120
src/moderation/backlogSync.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import type { Client, Message } from "discord.js-selfbot-v13";
|
||||||
|
import { config } from "../config";
|
||||||
|
import { createChildLogger } from "../logger";
|
||||||
|
import type { SqliteDatabase } from "../muxer-queue";
|
||||||
|
import { captureMessage } from "./messageCapture";
|
||||||
|
|
||||||
|
const logger = createChildLogger("backlog-sync");
|
||||||
|
|
||||||
|
function isWatchableChannel(channel: { type?: string; messages?: unknown }): boolean {
|
||||||
|
return Boolean(
|
||||||
|
channel.messages &&
|
||||||
|
["GUILD_TEXT", "GUILD_PUBLIC_THREAD", "GUILD_PRIVATE_THREAD"].includes(
|
||||||
|
channel.type ?? "",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectWatchableChannels(guild: any): Promise<any[]> {
|
||||||
|
const channels: any[] = [];
|
||||||
|
for (const channel of guild.channels.cache.values()) {
|
||||||
|
if (isWatchableChannel(channel)) {
|
||||||
|
channels.push(channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel.threads?.fetch) {
|
||||||
|
for (const archived of [false, true]) {
|
||||||
|
const fetched = await channel.threads
|
||||||
|
.fetch({ archived, limit: 100 })
|
||||||
|
.catch(() => null);
|
||||||
|
if (!fetched?.threads) continue;
|
||||||
|
for (const thread of fetched.threads.values()) {
|
||||||
|
if (isWatchableChannel(thread)) channels.push(thread);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(new Map(channels.map((channel) => [channel.id, channel])).values());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncChannelMessages(
|
||||||
|
db: SqliteDatabase,
|
||||||
|
channel: any,
|
||||||
|
cutoffTime: number,
|
||||||
|
): Promise<number> {
|
||||||
|
let before: string | undefined;
|
||||||
|
let synced = 0;
|
||||||
|
let shouldContinue = true;
|
||||||
|
|
||||||
|
while (shouldContinue) {
|
||||||
|
const batch = await channel.messages.fetch({
|
||||||
|
limit: config.BACKLOG_SYNC_BATCH_SIZE,
|
||||||
|
...(before ? { before } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (batch.size === 0) break;
|
||||||
|
|
||||||
|
const messages = Array.from(batch.values()) as Message[];
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.author?.bot) continue;
|
||||||
|
if (message.createdTimestamp < cutoffTime) {
|
||||||
|
shouldContinue = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await captureMessage(db, message, "text");
|
||||||
|
synced++;
|
||||||
|
}
|
||||||
|
|
||||||
|
before = messages[messages.length - 1]?.id;
|
||||||
|
if (!before || batch.size < config.BACKLOG_SYNC_BATCH_SIZE) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return synced;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncBacklogMessages(
|
||||||
|
client: Client,
|
||||||
|
db: SqliteDatabase,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!config.MONITOR_GUILD_ID) {
|
||||||
|
logger.warn("MONITOR_GUILD_ID not configured, skipping backlog sync");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = client.guilds.cache.get(config.MONITOR_GUILD_ID);
|
||||||
|
if (!guild) {
|
||||||
|
logger.warn({ guildId: config.MONITOR_GUILD_ID }, "Monitor guild not found, skipping backlog sync");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cutoffTime = Date.now() - config.BACKLOG_SYNC_HOURS * 60 * 60 * 1000;
|
||||||
|
await guild.channels.fetch().catch(() => null);
|
||||||
|
|
||||||
|
const channels = await collectWatchableChannels(guild);
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
logger.info(
|
||||||
|
{ guildId: guild.id, channels: channels.length, hours: config.BACKLOG_SYNC_HOURS },
|
||||||
|
"Starting message backlog sync",
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const channel of channels) {
|
||||||
|
try {
|
||||||
|
const count = await syncChannelMessages(db, channel as any, cutoffTime);
|
||||||
|
total += count;
|
||||||
|
logger.info({ channelId: channel.id, count }, "Backlog channel sync completed");
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
channelId: channel.id,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
"Backlog channel sync failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ total }, "Message backlog sync completed");
|
||||||
|
}
|
||||||
@@ -1,64 +1,21 @@
|
|||||||
import type { Client, Message, TextChannel, ThreadChannel } from "discord.js-selfbot-v13";
|
import type { Client, Message } from "discord.js-selfbot-v13";
|
||||||
import { createChildLogger } from "../logger";
|
import { createChildLogger } from "../logger";
|
||||||
import type { SqliteDatabase } from "../muxer-queue";
|
import type { SqliteDatabase } from "../muxer-queue";
|
||||||
import { config } from "../config";
|
import { config } from "../config";
|
||||||
import { insertMessage, insertAttachment } from "./messageStore";
|
import { insertMessage, insertAttachment } from "./messageStore";
|
||||||
import { processAttachmentUpload } from "./attachmentUploader";
|
import { processAttachmentUpload } from "./attachmentUploader";
|
||||||
|
import { getDisplayContent, getMessageLocation, getMessageMetadata } from "./messageMetadata";
|
||||||
import type { MessageRecord, AttachmentRecord } from "./types";
|
import type { MessageRecord, AttachmentRecord } from "./types";
|
||||||
|
|
||||||
const logger = createChildLogger("message-capture");
|
const logger = createChildLogger("message-capture");
|
||||||
|
|
||||||
function getMessageLocation(message: Message): {
|
export async function captureMessage(
|
||||||
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,
|
db: SqliteDatabase,
|
||||||
message: Message,
|
message: Message,
|
||||||
type: "text" | "edited" | "deleted",
|
type: "text" | "edited" | "deleted",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const location = getMessageLocation(message);
|
const location = getMessageLocation(message);
|
||||||
const stickers = getStickerMetadata(message);
|
const metadata = getMessageMetadata(message);
|
||||||
const metadata = {
|
|
||||||
stickers,
|
|
||||||
threadName: location.threadName,
|
|
||||||
};
|
|
||||||
|
|
||||||
const messageRecord: MessageRecord = {
|
const messageRecord: MessageRecord = {
|
||||||
id: message.id,
|
id: message.id,
|
||||||
|
|||||||
167
src/moderation/messageMetadata.ts
Normal file
167
src/moderation/messageMetadata.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import type { Message, TextChannel, ThreadChannel } from "discord.js-selfbot-v13";
|
||||||
|
|
||||||
|
export interface MessageLocation {
|
||||||
|
channelId: string;
|
||||||
|
threadId: string | null;
|
||||||
|
threadName: string | null;
|
||||||
|
channelName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RichMessageMetadata {
|
||||||
|
stickers: Array<{ id: string; name: string; url: string; format: string | null }>;
|
||||||
|
embeds: Array<{
|
||||||
|
title: string | null;
|
||||||
|
description: string | null;
|
||||||
|
url: string | null;
|
||||||
|
color: number | null;
|
||||||
|
image: string | null;
|
||||||
|
thumbnail: string | null;
|
||||||
|
author: { name: string | null; url: string | null; iconURL: string | null } | null;
|
||||||
|
footer: { text: string | null; iconURL: string | null } | null;
|
||||||
|
fields: Array<{ name: string; value: string; inline: boolean }>;
|
||||||
|
}>;
|
||||||
|
attachments: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
contentType: string | null;
|
||||||
|
size: number;
|
||||||
|
}>;
|
||||||
|
author: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
tag: string | null;
|
||||||
|
avatarURL: string | null;
|
||||||
|
bot: boolean;
|
||||||
|
};
|
||||||
|
member: {
|
||||||
|
displayName: string | null;
|
||||||
|
roles: Array<{ id: string; name: string }>;
|
||||||
|
joinedTimestamp: number | null;
|
||||||
|
} | null;
|
||||||
|
channel: MessageLocation;
|
||||||
|
reference: {
|
||||||
|
messageId: string | null;
|
||||||
|
channelId: string | null;
|
||||||
|
guildId: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessageLocation(message: Message): MessageLocation {
|
||||||
|
const channel = message.channel as TextChannel | ThreadChannel;
|
||||||
|
if (!channel.isThread?.()) {
|
||||||
|
return {
|
||||||
|
channelId: message.channelId,
|
||||||
|
threadId: null,
|
||||||
|
threadName: null,
|
||||||
|
channelName: "name" in channel ? channel.name : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
channelId: channel.parentId ?? message.channelId,
|
||||||
|
threadId: channel.id,
|
||||||
|
threadName: channel.name,
|
||||||
|
channelName: channel.parent?.name ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStickerMetadata(message: Message): RichMessageMetadata["stickers"] {
|
||||||
|
return Array.from(message.stickers.values()).map((sticker) => ({
|
||||||
|
id: sticker.id,
|
||||||
|
name: sticker.name,
|
||||||
|
url: sticker.url,
|
||||||
|
format: sticker.format ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAttachmentMetadata(message: Message): RichMessageMetadata["attachments"] {
|
||||||
|
return Array.from(message.attachments.values()).map((attachment) => ({
|
||||||
|
id: attachment.id,
|
||||||
|
name: attachment.name || "unknown",
|
||||||
|
url: attachment.url,
|
||||||
|
contentType: attachment.contentType ?? null,
|
||||||
|
size: attachment.size,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEmbedMetadata(message: Message): RichMessageMetadata["embeds"] {
|
||||||
|
return message.embeds.map((embed) => ({
|
||||||
|
title: embed.title ?? null,
|
||||||
|
description: embed.description ?? null,
|
||||||
|
url: embed.url ?? null,
|
||||||
|
color: embed.color ?? null,
|
||||||
|
image: embed.image?.url ?? null,
|
||||||
|
thumbnail: embed.thumbnail?.url ?? null,
|
||||||
|
author: embed.author
|
||||||
|
? {
|
||||||
|
name: embed.author.name ?? null,
|
||||||
|
url: embed.author.url ?? null,
|
||||||
|
iconURL: embed.author.iconURL ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
footer: embed.footer
|
||||||
|
? {
|
||||||
|
text: embed.footer.text ?? null,
|
||||||
|
iconURL: embed.footer.iconURL ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
fields: embed.fields.map((field) => ({
|
||||||
|
name: field.name,
|
||||||
|
value: field.value,
|
||||||
|
inline: Boolean(field.inline),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessageMetadata(message: Message): RichMessageMetadata {
|
||||||
|
const member = message.member;
|
||||||
|
return {
|
||||||
|
stickers: getStickerMetadata(message),
|
||||||
|
embeds: getEmbedMetadata(message),
|
||||||
|
attachments: getAttachmentMetadata(message),
|
||||||
|
author: {
|
||||||
|
id: message.author.id,
|
||||||
|
username: message.author.username,
|
||||||
|
tag: "tag" in message.author ? message.author.tag : null,
|
||||||
|
avatarURL: message.author.avatarURL() ?? null,
|
||||||
|
bot: Boolean(message.author.bot),
|
||||||
|
},
|
||||||
|
member: member
|
||||||
|
? {
|
||||||
|
displayName: member.displayName ?? null,
|
||||||
|
roles: member.roles.cache.map((role) => ({ id: role.id, name: role.name })),
|
||||||
|
joinedTimestamp: member.joinedTimestamp ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
channel: getMessageLocation(message),
|
||||||
|
reference: message.reference
|
||||||
|
? {
|
||||||
|
messageId: message.reference.messageId ?? null,
|
||||||
|
channelId: message.reference.channelId ?? null,
|
||||||
|
guildId: message.reference.guildId ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments = getAttachmentMetadata(message);
|
||||||
|
if (attachments.length > 0) {
|
||||||
|
return attachments.map((attachment) => `[Attachment: ${attachment.name}]`).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const embeds = getEmbedMetadata(message);
|
||||||
|
if (embeds.length > 0) {
|
||||||
|
return embeds.map((embed) => embed.title || embed.description || "[Embed]").join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ const logger = createChildLogger("message-store");
|
|||||||
export function insertMessage(db: SqliteDatabase, message: MessageRecord): void {
|
export function insertMessage(db: SqliteDatabase, message: MessageRecord): void {
|
||||||
try {
|
try {
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO messages (
|
INSERT OR IGNORE INTO messages (
|
||||||
id, guild_id, channel_id, thread_id, user_id, username, avatar_url,
|
id, guild_id, channel_id, thread_id, user_id, username, avatar_url,
|
||||||
content, edited_content, created_at, edited_at, deleted_at, type, metadata
|
content, edited_content, created_at, edited_at, deleted_at, type, metadata
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
@@ -115,7 +115,7 @@ export function getMessagesByChannel(
|
|||||||
export function insertAttachment(db: SqliteDatabase, attachment: AttachmentRecord): void {
|
export function insertAttachment(db: SqliteDatabase, attachment: AttachmentRecord): void {
|
||||||
try {
|
try {
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO attachments (
|
INSERT OR IGNORE INTO attachments (
|
||||||
id, message_id, guild_id, channel_id, thread_id, user_id, filename, size, type,
|
id, message_id, guild_id, channel_id, thread_id, user_id, filename, size, type,
|
||||||
discord_url, uploaded_url, upload_status, upload_error, created_at, uploaded_at
|
discord_url, uploaded_url, upload_status, upload_error, created_at, uploaded_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
|||||||
@@ -73,12 +73,25 @@ export class VoiceController {
|
|||||||
const guild = this.getGuild(guildId);
|
const guild = this.getGuild(guildId);
|
||||||
await guild.channels.fetch().catch(() => null);
|
await guild.channels.fetch().catch(() => null);
|
||||||
|
|
||||||
return guild.channels.cache
|
const channels = [...guild.channels.cache.values()].filter((channel) =>
|
||||||
.filter((channel) =>
|
["GUILD_TEXT", "GUILD_PUBLIC_THREAD", "GUILD_PRIVATE_THREAD"].includes(
|
||||||
["GUILD_TEXT", "GUILD_PUBLIC_THREAD", "GUILD_PRIVATE_THREAD"].includes(
|
channel.type,
|
||||||
channel.type,
|
),
|
||||||
),
|
);
|
||||||
)
|
|
||||||
|
for (const channel of guild.channels.cache.values()) {
|
||||||
|
const threadParent = channel as typeof channel & {
|
||||||
|
threads?: { fetch: (options: { archived: boolean; limit: number }) => Promise<any> };
|
||||||
|
};
|
||||||
|
if (!threadParent.threads?.fetch) continue;
|
||||||
|
for (const archived of [false, true]) {
|
||||||
|
const fetched = await threadParent.threads.fetch({ archived, limit: 100 }).catch(() => null);
|
||||||
|
if (!fetched?.threads) continue;
|
||||||
|
channels.push(...fetched.threads.values());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(new Map(channels.map((channel) => [channel.id, channel])).values())
|
||||||
.map((channel) => {
|
.map((channel) => {
|
||||||
const parentName = channel.isThread?.() ? channel.parent?.name : null;
|
const parentName = channel.isThread?.() ? channel.parent?.name : null;
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user