This commit is contained in:
132
Discord Backup Bot/src/backfill.js
Normal file
132
Discord Backup Bot/src/backfill.js
Normal 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 };
|
||||
30
Discord Backup Bot/src/db.js
Normal file
30
Discord Backup Bot/src/db.js
Normal 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 };
|
||||
15
Discord Backup Bot/src/discord.js
Normal file
15
Discord Backup Bot/src/discord.js
Normal 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 };
|
||||
69
Discord Backup Bot/src/downloader.js
Normal file
69
Discord Backup Bot/src/downloader.js
Normal 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 };
|
||||
80
Discord Backup Bot/src/index.js
Normal file
80
Discord Backup Bot/src/index.js
Normal 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);
|
||||
});
|
||||
153
Discord Backup Bot/src/live.js
Normal file
153
Discord Backup Bot/src/live.js
Normal 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 };
|
||||
68
Discord Backup Bot/src/schema.sql
Normal file
68
Discord Backup Bot/src/schema.sql
Normal 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
|
||||
);
|
||||
142
Discord Backup Bot/src/writers.js
Normal file
142
Discord Backup Bot/src/writers.js
Normal 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 };
|
||||
Reference in New Issue
Block a user