bump
Some checks failed
Build and Release / build-and-release (push) Has been cancelled

This commit is contained in:
Bryan1029384756
2026-04-20 17:09:04 -05:00
parent 82f8a12e27
commit b83360db35
48 changed files with 6079 additions and 49 deletions

View File

@@ -0,0 +1,132 @@
const { writeMessageCore, emojiKey, updateNewestIfGreater } = require('./writers');
async function backfillTarget(db, stmts, target) {
stmts.insertChannel.run(
target.id,
target.name ?? null,
target.parentId ?? null,
target.isThread?.() ? 1 : 0,
);
let row = stmts.getChannel.get(target.id);
if (row.newest_fetched_id) {
await walkForward(db, stmts, target, row.newest_fetched_id);
}
row = stmts.getChannel.get(target.id);
if (!row.backfill_complete) {
await walkBackward(db, stmts, target, row.oldest_fetched_id);
stmts.markBackfillComplete.run(target.id);
}
}
async function backfillChannelAndThreads(db, stmts, channel) {
console.log(`[backfill] channel ${channel.name} (${channel.id})`);
await backfillTarget(db, stmts, channel);
if (typeof channel.threads?.fetchActive === 'function') {
try {
const active = await channel.threads.fetchActive();
for (const thread of active.threads.values()) {
console.log(`[backfill] active thread ${thread.name} (${thread.id})`);
await backfillTarget(db, stmts, thread);
}
} catch (err) {
console.error(`[backfill] active threads for ${channel.id}:`, err.message);
}
try {
let before;
while (true) {
const batch = await channel.threads.fetchArchived({ type: 'public', before, limit: 100 });
for (const thread of batch.threads.values()) {
console.log(`[backfill] archived thread ${thread.name} (${thread.id})`);
stmts.updateArchived.run(1, thread.id);
await backfillTarget(db, stmts, thread);
}
if (!batch.hasMore) break;
const threadArr = [...batch.threads.values()];
const last = threadArr[threadArr.length - 1];
before = last.archivedAt ?? last.archiveTimestamp;
if (!before) break;
}
} catch (err) {
console.error(`[backfill] archived threads for ${channel.id}:`, err.message);
}
}
}
async function walkForward(db, stmts, target, afterId) {
let cursor = afterId;
while (true) {
const batch = await target.messages.fetch({ limit: 100, after: cursor, cache: false });
if (batch.size === 0) return;
const messages = [...batch.values()].sort((a, b) =>
BigInt(a.id) < BigInt(b.id) ? -1 : 1
);
await writeBatch(db, stmts, target, messages);
cursor = messages[messages.length - 1].id;
updateNewestIfGreater(stmts, target.id, cursor);
}
}
async function walkBackward(db, stmts, target, beforeId) {
let cursor = beforeId;
let emptyRuns = 0;
while (true) {
const opts = { limit: 100, cache: false };
if (cursor) opts.before = cursor;
const batch = await target.messages.fetch(opts);
if (batch.size === 0) {
emptyRuns++;
if (emptyRuns >= 2) return;
console.log(`[backfill] empty batch for ${target.id}, retrying once after 3s`);
await new Promise((r) => setTimeout(r, 3000));
continue;
}
emptyRuns = 0;
const messages = [...batch.values()].sort((a, b) =>
BigInt(a.id) < BigInt(b.id) ? 1 : -1
);
await writeBatch(db, stmts, target, messages);
const newestInBatch = messages[0].id;
const oldestInBatch = messages[messages.length - 1].id;
updateNewestIfGreater(stmts, target.id, newestInBatch);
stmts.updateOldest.run(oldestInBatch, target.id);
cursor = oldestInBatch;
}
}
async function writeBatch(db, stmts, target, messages) {
const reactionData = [];
for (const msg of messages) {
if (!msg.reactions?.cache) continue;
for (const reaction of msg.reactions.cache.values()) {
try {
const users = await reaction.users.fetch();
reactionData.push({
messageId: msg.id,
emoji: emojiKey(reaction.emoji),
userIds: [...users.keys()],
});
} catch (err) {
console.error(`[backfill] fetch reactions for ${msg.id}:`, err.message);
}
}
}
const tx = db.transaction(() => {
for (const msg of messages) {
writeMessageCore(stmts, msg);
}
for (const r of reactionData) {
for (const userId of r.userIds) {
stmts.upsertReaction.run(r.messageId, r.emoji, userId, null);
}
}
});
tx();
}
module.exports = { backfillChannelAndThreads };

View File

@@ -0,0 +1,30 @@
const { DatabaseSync } = require('node:sqlite');
const path = require('node:path');
const fs = require('node:fs');
function openDb(dataDir) {
fs.mkdirSync(dataDir, { recursive: true });
const db = new DatabaseSync(path.join(dataDir, 'backup.db'));
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA synchronous = NORMAL');
db.exec('PRAGMA foreign_keys = ON');
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
db.exec(schema);
db.transaction = (fn) => (...args) => {
db.exec('BEGIN');
try {
const result = fn(...args);
db.exec('COMMIT');
return result;
} catch (err) {
try { db.exec('ROLLBACK'); } catch (_) { /* nested */ }
throw err;
}
};
return db;
}
module.exports = { openDb };

View File

@@ -0,0 +1,15 @@
const { Client, GatewayIntentBits, Partials } = require('discord.js');
function createClient() {
return new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMessageReactions,
],
partials: [Partials.Message, Partials.Channel, Partials.Reaction],
});
}
module.exports = { createClient };

View File

@@ -0,0 +1,69 @@
const path = require('node:path');
const fs = require('node:fs');
const { Readable } = require('node:stream');
const { pipeline } = require('node:stream/promises');
async function runDownloader(stmts, dataDir, concurrency, getRunning) {
const attachmentsDir = path.join(dataDir, 'attachments');
fs.mkdirSync(attachmentsDir, { recursive: true });
while (getRunning()) {
const pending = stmts.pendingAttachments.all(concurrency * 4);
if (pending.length === 0) {
await sleep(5000);
continue;
}
const queue = [...pending];
const workers = Array.from({ length: concurrency }, () =>
worker(queue, stmts, attachmentsDir, dataDir, getRunning)
);
await Promise.all(workers);
}
}
async function worker(queue, stmts, attachmentsDir, dataDir, getRunning) {
while (queue.length > 0 && getRunning()) {
const item = queue.shift();
if (!item) return;
await downloadOne(item, stmts, attachmentsDir, dataDir);
}
}
async function downloadOne(item, stmts, attachmentsDir, dataDir) {
const destDir = path.join(attachmentsDir, item.channel_id, item.message_id, item.id);
const destPath = path.join(destDir, safeFilename(item.filename));
for (let attempt = 0; attempt < 3; attempt++) {
try {
fs.mkdirSync(destDir, { recursive: true });
const res = await fetch(item.url);
if (res.status === 404 || res.status === 403 || res.status === 410) {
console.error(`[download] ${item.filename} → HTTP ${res.status} (permanent)`);
stmts.markAttachmentFailed.run(item.id);
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const tmpPath = destPath + '.part';
await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(tmpPath));
fs.renameSync(tmpPath, destPath);
const relPath = path.relative(dataDir, destPath);
stmts.markAttachmentDownloaded.run(relPath, item.id);
return;
} catch (err) {
console.error(`[download] ${item.filename} attempt ${attempt + 1}: ${err.message}`);
if (attempt < 2) await sleep(1000 * (attempt + 1));
}
}
stmts.markAttachmentFailed.run(item.id);
}
function safeFilename(name) {
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').slice(0, 200);
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
module.exports = { runDownloader };

View File

@@ -0,0 +1,80 @@
const fs = require('node:fs');
const path = require('node:path');
const { openDb } = require('./db');
const { prepareStatements } = require('./writers');
const { createClient } = require('./discord');
const { backfillChannelAndThreads } = require('./backfill');
const { attachHandlers } = require('./live');
const { runDownloader } = require('./downloader');
function loadConfig() {
const configPath = path.resolve(process.cwd(), 'config.json');
if (!fs.existsSync(configPath)) {
throw new Error(`config.json not found at ${configPath}. Copy config.example.json and fill it in.`);
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
if (!config.token) throw new Error('config.json missing "token"');
if (!config.guildId) throw new Error('config.json missing "guildId"');
if (!Array.isArray(config.channelIds) || config.channelIds.length === 0) {
throw new Error('config.json must have a non-empty "channelIds" array');
}
return config;
}
async function main() {
const config = loadConfig();
const dataDir = path.resolve(process.cwd(), config.dataDir ?? './data');
const db = openDb(dataDir);
const stmts = prepareStatements(db);
const client = createClient();
attachHandlers(client, db, stmts, config.channelIds);
let running = true;
const shutdown = () => {
if (!running) return;
console.log('[bot] shutting down');
running = false;
client.destroy();
try {
db.close();
} catch (err) {
console.error('[bot] db close:', err.message);
}
setTimeout(() => process.exit(0), 500).unref();
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
client.once('ready', async (c) => {
console.log(`[bot] logged in as ${c.user.tag}`);
runDownloader(stmts, dataDir, config.attachmentConcurrency ?? 3, () => running).catch((err) => {
console.error('[downloader] fatal:', err);
});
const guild = await c.guilds.fetch(config.guildId);
for (const channelId of config.channelIds) {
if (!running) return;
try {
const channel = await guild.channels.fetch(channelId);
if (!channel) {
console.error(`[backfill] channel ${channelId} not found or not accessible`);
continue;
}
await backfillChannelAndThreads(db, stmts, channel);
console.log(`[backfill] complete for ${channel.name}`);
} catch (err) {
console.error(`[backfill] ${channelId}:`, err.message);
}
}
console.log('[backfill] all channels complete; live listener active');
});
await client.login(config.token);
}
main().catch((err) => {
console.error('fatal:', err);
process.exit(1);
});

View File

@@ -0,0 +1,153 @@
const { Events } = require('discord.js');
const { writeMessageCore, emojiKey, updateNewestIfGreater } = require('./writers');
function attachHandlers(client, db, stmts, channelIds) {
const channelSet = new Set(channelIds);
const parentOf = (channel) => (channel?.isThread?.() ? channel.parentId : null);
const inScope = (channelId, parentId) =>
channelSet.has(channelId) || (parentId && channelSet.has(parentId));
client.on(Events.MessageCreate, (msg) => {
const parentId = parentOf(msg.channel);
if (!inScope(msg.channelId, parentId)) return;
const tx = db.transaction(() => {
if (msg.channel?.isThread?.()) {
stmts.insertChannel.run(msg.channel.id, msg.channel.name, msg.channel.parentId, 1);
}
writeMessageCore(stmts, msg);
updateNewestIfGreater(stmts, msg.channelId, msg.id);
});
try {
tx();
} catch (err) {
console.error('[live] messageCreate:', err.message);
}
});
client.on(Events.MessageUpdate, (_oldMsg, newMsg) => {
const parentId = parentOf(newMsg.channel);
if (!inScope(newMsg.channelId, parentId)) return;
const editedAt = newMsg.editedTimestamp ?? Date.now();
const tx = db.transaction(() => {
const existing = stmts.existingContent.get(newMsg.id);
if (existing) {
stmts.insertEdit.run(newMsg.id, existing.content, editedAt);
stmts.updateContent.run(newMsg.content ?? '', editedAt, newMsg.id);
} else {
writeMessageCore(stmts, newMsg);
}
});
try {
tx();
} catch (err) {
console.error('[live] messageUpdate:', err.message);
}
});
client.on(Events.MessageDelete, (msg) => {
const parentId = parentOf(msg.channel);
if (!inScope(msg.channelId, parentId)) return;
const now = Date.now();
const tx = db.transaction(() => {
stmts.insertDeletedPlaceholder.run(
msg.id,
msg.channelId,
msg.author?.id ?? null,
msg.content ?? '',
msg.createdTimestamp ?? now,
now,
);
stmts.markDeleted.run(now, msg.id);
});
try {
tx();
} catch (err) {
console.error('[live] messageDelete:', err.message);
}
});
client.on(Events.MessageBulkDelete, (messages) => {
const now = Date.now();
const tx = db.transaction(() => {
for (const msg of messages.values()) {
const parentId = parentOf(msg.channel);
if (!inScope(msg.channelId, parentId)) continue;
stmts.insertDeletedPlaceholder.run(
msg.id,
msg.channelId,
msg.author?.id ?? null,
msg.content ?? '',
msg.createdTimestamp ?? now,
now,
);
stmts.markDeleted.run(now, msg.id);
}
});
try {
tx();
} catch (err) {
console.error('[live] messageDeleteBulk:', err.message);
}
});
client.on(Events.MessageReactionAdd, (reaction, user) => {
const parentId = parentOf(reaction.message.channel);
if (!inScope(reaction.message.channelId, parentId)) return;
try {
stmts.upsertReaction.run(reaction.message.id, emojiKey(reaction.emoji), user.id, Date.now());
} catch (err) {
console.error('[live] reactionAdd:', err.message);
}
});
client.on(Events.MessageReactionRemove, (reaction, user) => {
const parentId = parentOf(reaction.message.channel);
if (!inScope(reaction.message.channelId, parentId)) return;
try {
stmts.removeReaction.run(Date.now(), reaction.message.id, emojiKey(reaction.emoji), user.id);
} catch (err) {
console.error('[live] reactionRemove:', err.message);
}
});
client.on(Events.MessageReactionRemoveAll, (msg) => {
const parentId = parentOf(msg.channel);
if (!inScope(msg.channelId, parentId)) return;
try {
stmts.removeAllReactions.run(Date.now(), msg.id);
} catch (err) {
console.error('[live] reactionRemoveAll:', err.message);
}
});
client.on(Events.ThreadCreate, (thread) => {
if (!channelSet.has(thread.parentId)) return;
try {
stmts.insertChannel.run(thread.id, thread.name, thread.parentId, 1);
} catch (err) {
console.error('[live] threadCreate:', err.message);
}
});
client.on(Events.ThreadUpdate, (_oldT, newT) => {
if (!channelSet.has(newT.parentId)) return;
try {
stmts.insertChannel.run(newT.id, newT.name, newT.parentId, 1);
stmts.updateArchived.run(newT.archived ? 1 : 0, newT.id);
} catch (err) {
console.error('[live] threadUpdate:', err.message);
}
});
client.on(Events.ThreadDelete, (thread) => {
if (!channelSet.has(thread.parentId)) return;
try {
stmts.updateArchived.run(1, thread.id);
} catch (err) {
console.error('[live] threadDelete:', err.message);
}
});
}
module.exports = { attachHandlers };

View File

@@ -0,0 +1,68 @@
CREATE TABLE IF NOT EXISTS channels (
id TEXT PRIMARY KEY,
name TEXT,
parent_channel_id TEXT,
is_thread INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
oldest_fetched_id TEXT,
newest_fetched_id TEXT,
backfill_complete INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL,
author_id TEXT,
content TEXT,
created_at INTEGER NOT NULL,
edited_at INTEGER,
deleted_at INTEGER,
replied_to_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel_id, created_at);
CREATE TABLE IF NOT EXISTS message_edits (
message_id TEXT NOT NULL,
content TEXT,
edited_at INTEGER NOT NULL,
PRIMARY KEY (message_id, edited_at)
);
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL,
filename TEXT NOT NULL,
url TEXT NOT NULL,
local_path TEXT,
size INTEGER,
content_type TEXT,
downloaded INTEGER NOT NULL DEFAULT 0,
download_failed INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id);
CREATE INDEX IF NOT EXISTS idx_attachments_pending ON attachments(downloaded)
WHERE downloaded = 0 AND download_failed = 0;
CREATE TABLE IF NOT EXISTS reactions (
message_id TEXT NOT NULL,
emoji TEXT NOT NULL,
user_id TEXT NOT NULL,
added_at INTEGER,
removed_at INTEGER,
PRIMARY KEY (message_id, emoji, user_id)
);
CREATE TABLE IF NOT EXISTS embeds (
message_id TEXT NOT NULL,
idx INTEGER NOT NULL,
data_json TEXT NOT NULL,
PRIMARY KEY (message_id, idx)
);
CREATE TABLE IF NOT EXISTS authors (
id TEXT PRIMARY KEY,
username TEXT,
display_name TEXT,
avatar_url TEXT,
last_seen INTEGER
);

View File

@@ -0,0 +1,142 @@
function prepareStatements(db) {
return {
insertChannel: db.prepare(`
INSERT INTO channels (id, name, parent_channel_id, is_thread)
VALUES (?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
parent_channel_id = excluded.parent_channel_id,
is_thread = excluded.is_thread
`),
updateArchived: db.prepare('UPDATE channels SET archived = ? WHERE id = ?'),
updateOldest: db.prepare('UPDATE channels SET oldest_fetched_id = ? WHERE id = ?'),
updateNewest: db.prepare(`
UPDATE channels
SET newest_fetched_id = CASE
WHEN newest_fetched_id IS NULL THEN ?
WHEN CAST(? AS INTEGER) > CAST(newest_fetched_id AS INTEGER) THEN ?
ELSE newest_fetched_id
END
WHERE id = ?
`),
markBackfillComplete: db.prepare('UPDATE channels SET backfill_complete = 1 WHERE id = ?'),
getChannel: db.prepare('SELECT * FROM channels WHERE id = ?'),
insertMessage: db.prepare(`
INSERT OR IGNORE INTO messages
(id, channel_id, author_id, content, created_at, edited_at, replied_to_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
`),
insertDeletedPlaceholder: db.prepare(`
INSERT OR IGNORE INTO messages
(id, channel_id, author_id, content, created_at, deleted_at)
VALUES (?, ?, ?, ?, ?, ?)
`),
markDeleted: db.prepare('UPDATE messages SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL'),
updateContent: db.prepare('UPDATE messages SET content = ?, edited_at = ? WHERE id = ?'),
existingContent: db.prepare('SELECT content FROM messages WHERE id = ?'),
insertEdit: db.prepare(`
INSERT OR IGNORE INTO message_edits (message_id, content, edited_at) VALUES (?, ?, ?)
`),
insertAttachment: db.prepare(`
INSERT OR IGNORE INTO attachments (id, message_id, filename, url, size, content_type)
VALUES (?, ?, ?, ?, ?, ?)
`),
markAttachmentDownloaded: db.prepare(
'UPDATE attachments SET downloaded = 1, local_path = ? WHERE id = ?'
),
markAttachmentFailed: db.prepare('UPDATE attachments SET download_failed = 1 WHERE id = ?'),
pendingAttachments: db.prepare(`
SELECT a.id, a.message_id, a.filename, a.url, m.channel_id
FROM attachments a JOIN messages m ON m.id = a.message_id
WHERE a.downloaded = 0 AND a.download_failed = 0
LIMIT ?
`),
upsertReaction: db.prepare(`
INSERT INTO reactions (message_id, emoji, user_id, added_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(message_id, emoji, user_id) DO UPDATE SET
added_at = COALESCE(reactions.added_at, excluded.added_at),
removed_at = NULL
`),
removeReaction: db.prepare(`
UPDATE reactions SET removed_at = ?
WHERE message_id = ? AND emoji = ? AND user_id = ? AND removed_at IS NULL
`),
removeAllReactions: db.prepare(`
UPDATE reactions SET removed_at = ?
WHERE message_id = ? AND removed_at IS NULL
`),
insertEmbed: db.prepare(`
INSERT OR REPLACE INTO embeds (message_id, idx, data_json) VALUES (?, ?, ?)
`),
upsertAuthor: db.prepare(`
INSERT INTO authors (id, username, display_name, avatar_url, last_seen)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
username = excluded.username,
display_name = excluded.display_name,
avatar_url = excluded.avatar_url,
last_seen = MAX(COALESCE(authors.last_seen, 0), COALESCE(excluded.last_seen, 0))
`),
};
}
function emojiKey(emoji) {
if (emoji.id) {
return `<${emoji.animated ? 'a' : ''}:${emoji.name}:${emoji.id}>`;
}
return emoji.name;
}
function writeMessageCore(stmts, msg) {
const createdAt = msg.createdTimestamp;
const editedAt = msg.editedTimestamp ?? null;
if (msg.author) {
stmts.upsertAuthor.run(
msg.author.id,
msg.author.username ?? null,
msg.member?.displayName ?? msg.author.globalName ?? null,
msg.author.displayAvatarURL?.() ?? null,
createdAt,
);
}
stmts.insertMessage.run(
msg.id,
msg.channelId,
msg.author?.id ?? null,
msg.content ?? '',
createdAt,
editedAt,
msg.reference?.messageId ?? null,
);
for (const att of msg.attachments.values()) {
stmts.insertAttachment.run(
att.id,
msg.id,
att.name,
att.url,
att.size ?? null,
att.contentType ?? null,
);
}
let idx = 0;
for (const embed of msg.embeds) {
const data = typeof embed.toJSON === 'function' ? embed.toJSON() : embed;
stmts.insertEmbed.run(msg.id, idx++, JSON.stringify(data));
}
}
function updateNewestIfGreater(stmts, channelId, messageId) {
stmts.updateNewest.run(messageId, messageId, messageId, channelId);
}
module.exports = { prepareStatements, emojiKey, writeMessageCore, updateNewestIfGreater };