// SQLite-Anbindung (better-sqlite3, synchron & schnell) — Schema wird beim Start angelegt import Database from 'better-sqlite3'; import crypto from 'node:crypto'; import { mkdirSync, statSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { config } from './config.js'; const dbFile = resolve(config.dbPath); mkdirSync(dirname(dbFile), { recursive: true }); export const db = new Database(dbFile); db.pragma('journal_mode = WAL'); /** * Antwortet die Datenbank überhaupt? Für `/health`. * * Bewusst eine echte Abfrage und nicht `existsSync` auf die Datei: die Datei * ist auch dann noch da, wenn das Dateisystem nur noch lesbar ist oder die * Verbindung hinüber ist. Einmal vorbereitet, damit die Prüfung selbst nichts * kostet. */ const pingStmt = db.prepare('SELECT 1 AS eins'); export const pingDb = () => pingStmt.get()?.eins === 1; db.exec(` CREATE TABLE IF NOT EXISTS commits ( sha TEXT PRIMARY KEY, repo TEXT NOT NULL, branch TEXT NOT NULL, message TEXT NOT NULL, author_name TEXT, author_user TEXT, url TEXT, committed_at TEXT NOT NULL, received_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_commits_repo_time ON commits (repo, committed_at DESC); CREATE TABLE IF NOT EXISTS devlogs ( message_id TEXT PRIMARY KEY, channel_id TEXT NOT NULL, content TEXT NOT NULL, author_name TEXT, posted_at TEXT NOT NULL, archived_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_devlogs_posted ON devlogs (posted_at DESC); `); // Migration: images-Spalte (JSON-Array lokaler Dateinamen) für bestehende DBs const devlogCols = db.prepare('PRAGMA table_info(devlogs)').all(); if (!devlogCols.some((c) => c.name === 'images')) { db.exec(`ALTER TABLE devlogs ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`); } // Releases (Gitea-Release-Webhook) — für Ankündigungen + Changelog-Seite db.exec(` CREATE TABLE IF NOT EXISTS releases ( repo TEXT NOT NULL, tag TEXT NOT NULL, name TEXT, body TEXT, url TEXT, prerelease INTEGER NOT NULL DEFAULT 0, published_at TEXT NOT NULL, PRIMARY KEY (repo, tag) ); `); // Volltextsuche (FTS5) über Devlogs — Trigger halten den Index synchron db.exec(` CREATE VIRTUAL TABLE IF NOT EXISTS devlogs_fts USING fts5(content, message_id UNINDEXED); CREATE TRIGGER IF NOT EXISTS devlogs_fts_ai AFTER INSERT ON devlogs BEGIN INSERT INTO devlogs_fts (rowid, content, message_id) VALUES (new.rowid, new.content, new.message_id); END; CREATE TRIGGER IF NOT EXISTS devlogs_fts_ad AFTER DELETE ON devlogs BEGIN DELETE FROM devlogs_fts WHERE rowid = old.rowid; END; `); // Bestehende Zeilen nachindexieren (einmalig nach der Migration) { const ftsCount = db.prepare('SELECT count(*) AS n FROM devlogs_fts').get().n; const devlogCount = db.prepare('SELECT count(*) AS n FROM devlogs').get().n; if (ftsCount !== devlogCount) { db.exec(` DELETE FROM devlogs_fts; INSERT INTO devlogs_fts (rowid, content, message_id) SELECT rowid, content, message_id FROM devlogs; `); } } // Bug-Reports aus Discord (/bug) — für den Rückkanal (Issue geschlossen → DM) db.exec(` CREATE TABLE IF NOT EXISTS bug_reports ( repo TEXT NOT NULL, issue_number INTEGER NOT NULL, discord_user_id TEXT NOT NULL, title TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (repo, issue_number) ); `); const insertBugReport = db.prepare(` INSERT OR REPLACE INTO bug_reports (repo, issue_number, discord_user_id, title) VALUES (@repo, @issue_number, @discord_user_id, @title) `); const getBugReportStmt = db.prepare( 'SELECT * FROM bug_reports WHERE repo = ? AND issue_number = ?' ); const deleteBugReportStmt = db.prepare( 'DELETE FROM bug_reports WHERE repo = ? AND issue_number = ?' ); export function saveBugReport(report) { insertBugReport.run(report); } /** Report holen und direkt entfernen (einmalige Benachrichtigung) */ export function takeBugReport(repo, issueNumber) { const row = getBugReportStmt.get(repo, issueNumber); if (row) deleteBugReportStmt.run(repo, issueNumber); return row ?? null; } // Community: Playtester, Starboard, Screenshot-Galerie db.exec(` CREATE TABLE IF NOT EXISTS playtesters ( user_id TEXT PRIMARY KEY, username TEXT, applied_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS starboard ( message_id TEXT PRIMARY KEY, board_message_id TEXT, starred_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS gallery ( message_id TEXT PRIMARY KEY, author_name TEXT, images TEXT NOT NULL, posted_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_gallery_posted ON gallery (posted_at DESC); `); const insertPlaytester = db.prepare( 'INSERT OR IGNORE INTO playtesters (user_id, username) VALUES (?, ?)' ); const deletePlaytester = db.prepare('DELETE FROM playtesters WHERE user_id = ?'); const listPlaytestersStmt = db.prepare( 'SELECT user_id, username, applied_at FROM playtesters ORDER BY applied_at' ); const hasPlaytesterStmt = db.prepare('SELECT 1 FROM playtesters WHERE user_id = ?'); export const addPlaytester = (userId, username) => insertPlaytester.run(userId, username).changes > 0; export const removePlaytester = (userId) => deletePlaytester.run(userId).changes > 0; export const listPlaytesters = () => listPlaytestersStmt.all(); export const isPlaytester = (userId) => Boolean(hasPlaytesterStmt.get(userId)); const hasStarboardStmt = db.prepare('SELECT 1 FROM starboard WHERE message_id = ?'); const insertStarboard = db.prepare( 'INSERT OR IGNORE INTO starboard (message_id, board_message_id) VALUES (?, ?)' ); export const hasStarboard = (id) => Boolean(hasStarboardStmt.get(id)); export const saveStarboard = (id, boardId) => insertStarboard.run(id, boardId); const insertGallery = db.prepare(` INSERT OR IGNORE INTO gallery (message_id, author_name, images, posted_at) VALUES (@message_id, @author_name, @images, @posted_at) `); const hasGalleryStmt = db.prepare('SELECT 1 FROM gallery WHERE message_id = ?'); const getGalleryImagesStmt = db.prepare('SELECT images FROM gallery WHERE message_id = ?'); const deleteGalleryStmt = db.prepare('DELETE FROM gallery WHERE message_id = ?'); const listGalleryStmt = db.prepare(` SELECT message_id, author_name, images, posted_at FROM gallery ORDER BY posted_at DESC LIMIT ? OFFSET ? `); const countGalleryStmt = db.prepare('SELECT count(*) AS n FROM gallery'); export const saveGalleryItem = (item) => insertGallery.run(item).changes > 0; export const hasGalleryItem = (id) => Boolean(hasGalleryStmt.get(id)); export function deleteGalleryItem(id) { const row = getGalleryImagesStmt.get(id); if (!row) return { deleted: false, images: [] }; deleteGalleryStmt.run(id); return { deleted: true, images: JSON.parse(row.images || '[]') }; } export const listGallery = (limit, offset) => ({ items: listGalleryStmt.all(limit, offset), total: countGalleryStmt.get().n, }); // Feature-Voting (/wunsch) + Giveaways db.exec(` CREATE TABLE IF NOT EXISTS giveaways ( message_id TEXT PRIMARY KEY, channel_id TEXT NOT NULL, prize TEXT NOT NULL, winners INTEGER NOT NULL DEFAULT 1, ends_at TEXT NOT NULL, ended INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS giveaway_entries ( giveaway_id TEXT NOT NULL, user_id TEXT NOT NULL, PRIMARY KEY (giveaway_id, user_id) ); `); // Wünsche haben eine eigene ID. Früher war die Discord-Nachrichten-ID der // Schlüssel — damit konnte ein Wunsch nur existieren, solange seine Nachricht // existiert, das Team konnte keinen von Hand eintragen, und zwei Doppler ließen // sich nicht zusammenführen. // // Der Zuspruch wird nicht mehr hochgezählt, sondern gezählt: eine Zeile je // Person in wish_votes, egal ob der Klick im Web oder als 👍 in Discord kam. // Der zusammengesetzte Schlüssel schließt Doppelstimmen damit aus, statt sie // nachträglich zu korrigieren. db.exec(` CREATE TABLE IF NOT EXISTS wishes ( id INTEGER PRIMARY KEY AUTOINCREMENT, message_id TEXT UNIQUE, author TEXT, author_id TEXT, idea TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'offen', status_grund TEXT, status_at TEXT, merged_into INTEGER, -- Alter Zuspruch aus der Zeit, als Reaktionen nur gezählt und nicht -- zugeordnet wurden. Rückwirkend ist nicht mehr feststellbar, wer das -- war, also bleibt die Zahl als Sockel stehen. bonus INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS wish_votes ( wish_id INTEGER NOT NULL, user_id TEXT NOT NULL, quelle TEXT NOT NULL DEFAULT 'web', created_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (wish_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_wish_votes_user ON wish_votes (user_id); `); // Einmalige Übernahme aus dem alten Aufbau (message_id als Schlüssel, Zuspruch // als hochgezählte Spalte). Läuft nur, solange die alte Form noch dasteht. { const spalten = db.prepare('PRAGMA table_info(wishes)').all().map((c) => c.name); if (spalten.includes('score')) { const umziehen = db.transaction(() => { db.exec('ALTER TABLE wishes RENAME TO wishes_alt'); db.exec('ALTER TABLE wish_votes RENAME TO wish_votes_alt'); // Der Index oben wurde gerade mit umbenannt und blockiert sonst // den gleichnamigen für die neue Tabelle db.exec('DROP INDEX IF EXISTS idx_wish_votes_user'); db.exec(` CREATE TABLE wishes ( id INTEGER PRIMARY KEY AUTOINCREMENT, message_id TEXT UNIQUE, author TEXT, author_id TEXT, idea TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'offen', status_grund TEXT, status_at TEXT, merged_into INTEGER, bonus INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE wish_votes ( wish_id INTEGER NOT NULL, user_id TEXT NOT NULL, quelle TEXT NOT NULL DEFAULT 'web', created_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (wish_id, user_id) ); CREATE INDEX idx_wish_votes_user ON wish_votes (user_id); `); db.exec(` INSERT INTO wishes (message_id, author, idea, created_at) SELECT message_id, author, idea, created_at FROM wishes_alt `); // Web-Stimmen behalten ihre Person db.exec(` INSERT OR IGNORE INTO wish_votes (wish_id, user_id, quelle) SELECT w.id, v.user_id, 'web' FROM wish_votes_alt v JOIN wishes w ON w.message_id = v.message_id `); // Was vom alten Zuspruch übrig bleibt, waren Discord-Reaktionen // ohne Namen — als Sockel erhalten, damit keine Zahl einbricht db.exec(` UPDATE wishes SET bonus = MAX(0, ( SELECT a.score - (SELECT COUNT(*) FROM wish_votes v WHERE v.wish_id = wishes.id) FROM wishes_alt a WHERE a.message_id = wishes.message_id )) `); db.exec('DROP TABLE wish_votes_alt'); db.exec('DROP TABLE wishes_alt'); }); umziehen(); const n = db.prepare('SELECT count(*) AS n FROM wishes').get().n; console.log(`[db] ${n} Wunsch/Wünsche auf eigene IDs umgestellt`); } } // Bereiche, in die ein Wunsch faellt — „Fahrzeuge", „Wirtschaft", „Technik". // Ohne Eintrag bleibt alles wie bisher: eine Liste ohne Schubladen. db.exec(` CREATE TABLE IF NOT EXISTS wish_categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, emoji TEXT NOT NULL DEFAULT '', sort INTEGER NOT NULL DEFAULT 0 ); `); { const spalten = db.prepare('PRAGMA table_info(wishes)').all().map((c) => c.name); if (!spalten.includes('category_id')) db.exec('ALTER TABLE wishes ADD COLUMN category_id INTEGER'); // Der Thread unter dem Wunsch-Post ist der Diskussionsort. Die Zahl der // Beitraege darin zaehlen wir mit, statt sie fuer jede Seitenansicht bei // Discord zu erfragen — 50 Threads je Aufruf waere nicht zu bezahlen. if (!spalten.includes('thread_id')) db.exec('ALTER TABLE wishes ADD COLUMN thread_id TEXT'); if (!spalten.includes('kommentare')) { db.exec('ALTER TABLE wishes ADD COLUMN kommentare INTEGER NOT NULL DEFAULT 0'); } } // Was im Thread unter einem Wunsch geschrieben wird. Gespiegelt, nicht // gezaehlt: die Detailseite soll die Unterhaltung zeigen koennen, und 50 // Threads je Seitenaufruf bei Discord abzufragen waere nicht zu bezahlen. db.exec(` CREATE TABLE IF NOT EXISTS wish_comments ( message_id TEXT PRIMARY KEY, wish_id INTEGER NOT NULL, author TEXT, author_id TEXT, avatar TEXT, content TEXT NOT NULL, images TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_wish_comments ON wish_comments (wish_id, created_at); `); { const spalten = db.prepare('PRAGMA table_info(wish_comments)').all().map((c) => c.name); if (!spalten.includes('images')) { db.exec(`ALTER TABLE wish_comments ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`); } } const insertCommentStmt = db.prepare(` INSERT OR REPLACE INTO wish_comments (message_id, wish_id, author, author_id, avatar, content, images, created_at) VALUES (@message_id, @wish_id, @author, @author_id, @avatar, @content, @images, @created_at) `); const updateCommentStmt = db.prepare('UPDATE wish_comments SET content = ? WHERE message_id = ?'); const deleteCommentStmt = db.prepare('DELETE FROM wish_comments WHERE message_id = ?'); const listCommentsStmt = db.prepare( 'SELECT * FROM wish_comments WHERE wish_id = ? ORDER BY created_at LIMIT ?' ); const dropCommentsStmt = db.prepare('DELETE FROM wish_comments WHERE wish_id = ?'); export const saveWishComment = (c) => insertCommentStmt.run(c); export const updateWishComment = (messageId, text) => updateCommentStmt.run(text, messageId).changes > 0; export const deleteWishComment = (messageId) => deleteCommentStmt.run(messageId).changes > 0; export const wishComments = (wishId, limit = 200) => listCommentsStmt.all(wishId, limit).map((c) => ({ ...c, images: JSON.parse(c.images || '[]') })); /** Alle Bilddateien eines Wunsches — zum Wegräumen beim Löschen */ const wishImagesStmt = db.prepare('SELECT images FROM wish_comments WHERE wish_id = ?'); export const wishCommentImages = (wishId) => wishImagesStmt.all(wishId).flatMap((r) => JSON.parse(r.images || '[]')); const commentImagesStmt = db.prepare('SELECT images FROM wish_comments WHERE message_id = ?'); export const wishCommentImagesOf = (messageId) => JSON.parse(commentImagesStmt.get(messageId)?.images || '[]'); const listWishCatsStmt = db.prepare('SELECT * FROM wish_categories ORDER BY sort, id'); const getWishCatStmt = db.prepare('SELECT * FROM wish_categories WHERE id = ?'); const insertWishCatStmt = db.prepare( 'INSERT INTO wish_categories (name, emoji, sort) VALUES (@name, @emoji, @sort)' ); const updateWishCatStmt = db.prepare( 'UPDATE wish_categories SET name = @name, emoji = @emoji, sort = @sort WHERE id = @id' ); const deleteWishCatStmt = db.prepare('DELETE FROM wish_categories WHERE id = ?'); // Wuensche im geloeschten Bereich bleiben, sie stehen dann nur ohne Schild da const orphanWishesStmt = db.prepare('UPDATE wishes SET category_id = NULL WHERE category_id = ?'); export const listWishCategories = () => listWishCatsStmt.all(); export const getWishCategory = (id) => getWishCatStmt.get(id) ?? null; export const createWishCategory = (c) => insertWishCatStmt.run(c).lastInsertRowid; export const updateWishCategory = (c) => updateWishCatStmt.run(c); export const deleteWishCategory = db.transaction((id) => { orphanWishesStmt.run(id); return deleteWishCatStmt.run(id).changes > 0; }); /** Zuspruch = eigene Stimmen + nicht mehr zuordenbarer Sockel aus der Altzeit */ const WUNSCH_FELDER = ` w.id, w.message_id, w.thread_id, w.idea, w.author, w.author_id, w.status, w.status_grund, w.status_at, w.created_at, w.category_id, (SELECT COUNT(*) FROM wish_comments c WHERE c.wish_id = w.id) AS kommentare, (SELECT k.name FROM wish_categories k WHERE k.id = w.category_id) AS kategorie, (SELECT k.emoji FROM wish_categories k WHERE k.id = w.category_id) AS kategorie_emoji, w.bonus + (SELECT COUNT(*) FROM wish_votes v WHERE v.wish_id = w.id) AS score, -- Bewegung: Stimmen der letzten sieben Tage. Ein alter Wunsch mit vielen -- Stimmen steht sonst fuer immer oben, egal ob noch jemand hinschaut. (SELECT COUNT(*) FROM wish_votes v WHERE v.wish_id = w.id AND v.created_at >= datetime('now', '-7 days')) AS bewegung `; const insertWish = db.prepare( 'INSERT INTO wishes (message_id, author, author_id, idea, category_id) VALUES (?, ?, ?, ?, ?)' ); const setWishThreadStmt = db.prepare('UPDATE wishes SET thread_id = ? WHERE id = ?'); const wishByThreadStmt = db.prepare('SELECT * FROM wishes WHERE thread_id = ?'); const setWishCategoryStmt = db.prepare('UPDATE wishes SET category_id = ? WHERE id = ?'); const setWishMessageStmt = db.prepare('UPDATE wishes SET message_id = ? WHERE id = ?'); const wishByMessageStmt = db.prepare('SELECT * FROM wishes WHERE message_id = ?'); const wishByIdStmt = db.prepare(`SELECT ${WUNSCH_FELDER} FROM wishes w WHERE w.id = ?`); // Zusammengeführte Doppler tauchen nirgends mehr auf — ihre Stimmen stehen beim Original const SORTIERUNG = { top: 'score DESC, w.created_at DESC', bewegung: 'bewegung DESC, score DESC', neu: 'w.created_at DESC', }; const topWishesStmts = Object.fromEntries(Object.entries(SORTIERUNG).map(([k, ord]) => [ k, db.prepare(`SELECT ${WUNSCH_FELDER} FROM wishes w WHERE w.merged_into IS NULL ORDER BY ${ord} LIMIT ?`), ])); const searchWishesStmt = db.prepare(` SELECT ${WUNSCH_FELDER} FROM wishes w WHERE w.merged_into IS NULL AND w.idea LIKE ? ORDER BY score DESC, w.created_at DESC LIMIT ? `); const deleteWishStmt = db.prepare('DELETE FROM wishes WHERE id = ?'); const setWishStatusStmt = db.prepare(` UPDATE wishes SET status = ?, status_grund = ?, status_at = datetime('now') WHERE id = ? `); const mergeWishStmt = db.prepare('UPDATE wishes SET merged_into = ? WHERE id = ?'); const moveVotesStmt = db.prepare( 'INSERT OR IGNORE INTO wish_votes (wish_id, user_id, quelle) SELECT ?, user_id, quelle FROM wish_votes WHERE wish_id = ?' ); const dropVotesStmt = db.prepare('DELETE FROM wish_votes WHERE wish_id = ?'); export const saveWish = (messageId, author, idea, authorId = null, categoryId = null) => insertWish.run(messageId, author, authorId, idea, categoryId ?? null).lastInsertRowid; export const setWishThread = (id, threadId) => setWishThreadStmt.run(threadId, id); export const setWishCategory = (id, categoryId) => setWishCategoryStmt.run(categoryId ?? null, id); export const wishByThread = (threadId) => wishByThreadStmt.get(threadId) ?? null; export const setWishMessage = (id, messageId) => setWishMessageStmt.run(messageId, id); export const wishByMessage = (messageId) => wishByMessageStmt.get(messageId) ?? null; export const getWish = (id) => wishByIdStmt.get(id) ?? null; export const topWishes = (limit = 20, sortierung = 'top') => (topWishesStmts[sortierung] ?? topWishesStmts.top).all(limit); export const searchWishes = (text, limit = 8) => searchWishesStmt.all(`%${text}%`, limit); export const deleteWish = db.transaction((id) => { dropVotesStmt.run(id); dropCommentsStmt.run(id); return deleteWishStmt.run(id).changes > 0; }); export const setWishStatus = (id, status, grund) => setWishStatusStmt.run(status, grund || null, id).changes > 0; /** * Doppler auf das Original zeigen lassen. Die Stimmen wandern mit; wer für * beide gestimmt hat, zählt beim Original weiterhin einmal (INSERT OR IGNORE * auf dem zusammengesetzten Schlüssel). */ export const mergeWish = db.transaction((doppler, original) => { moveVotesStmt.run(original, doppler); // Der Sockel aus der Altzeit wandert mit, sonst verschwindet Zuspruch const alt = db.prepare('SELECT bonus FROM wishes WHERE id = ?').get(doppler)?.bonus ?? 0; if (alt > 0) db.prepare('UPDATE wishes SET bonus = bonus + ? WHERE id = ?').run(alt, original); dropVotesStmt.run(doppler); mergeWishStmt.run(original, doppler); }); const hasWishVoteStmt = db.prepare('SELECT 1 FROM wish_votes WHERE wish_id = ? AND user_id = ?'); const insertWishVote = db.prepare( 'INSERT OR IGNORE INTO wish_votes (wish_id, user_id, quelle) VALUES (?, ?, ?)' ); const deleteWishVote = db.prepare('DELETE FROM wish_votes WHERE wish_id = ? AND user_id = ?'); const myWishVotesStmt = db.prepare('SELECT wish_id FROM wish_votes WHERE user_id = ?'); const wishVotersStmt = db.prepare('SELECT user_id FROM wish_votes WHERE wish_id = ?'); /** Stimme umschalten. @returns {boolean} true = zählt jetzt */ export function toggleWishVote(wishId, userId, quelle = 'web') { if (hasWishVoteStmt.get(wishId, userId)) { deleteWishVote.run(wishId, userId); return false; } insertWishVote.run(wishId, userId, quelle); return true; } export const addWishVote = (wishId, userId, quelle = 'web') => insertWishVote.run(wishId, userId, quelle).changes > 0; export const removeWishVote = (wishId, userId) => deleteWishVote.run(wishId, userId).changes > 0; export const myWishVotes = (userId) => myWishVotesStmt.all(userId).map((r) => r.wish_id); export const wishVoters = (wishId) => wishVotersStmt.all(wishId).map((r) => r.user_id); const insertGiveaway = db.prepare(` INSERT INTO giveaways (message_id, channel_id, prize, winners, ends_at) VALUES (@message_id, @channel_id, @prize, @winners, @ends_at) `); const dueGiveawaysStmt = db.prepare( `SELECT * FROM giveaways WHERE ended = 0 AND ends_at <= datetime('now')` ); const endGiveawayStmt = db.prepare('UPDATE giveaways SET ended = 1 WHERE message_id = ?'); const giveawayStmt = db.prepare('SELECT * FROM giveaways WHERE message_id = ?'); const toggleEntryInsert = db.prepare( 'INSERT OR IGNORE INTO giveaway_entries (giveaway_id, user_id) VALUES (?, ?)' ); const toggleEntryDelete = db.prepare( 'DELETE FROM giveaway_entries WHERE giveaway_id = ? AND user_id = ?' ); const entriesStmt = db.prepare('SELECT user_id FROM giveaway_entries WHERE giveaway_id = ?'); export const saveGiveaway = (g) => insertGiveaway.run(g); export const getGiveaway = (id) => giveawayStmt.get(id) ?? null; export const dueGiveaways = () => dueGiveawaysStmt.all(); export const markGiveawayEnded = (id) => endGiveawayStmt.run(id); export const giveawayEntries = (id) => entriesStmt.all(id).map((r) => r.user_id); /** Teilnahme togglen — true = jetzt drin, false = ausgetragen */ export function toggleGiveawayEntry(giveawayId, userId) { if (toggleEntryInsert.run(giveawayId, userId).changes > 0) return true; toggleEntryDelete.run(giveawayId, userId); return false; } /** Commits pro Tag der letzten 365 Tage (Contribution-Heatmap) */ export function commitHeatmap() { return db .prepare(` SELECT date(committed_at) AS day, count(*) AS n FROM commits WHERE committed_at >= datetime('now', '-365 days') GROUP BY day `) .all(); } // Alpha-Keys: Pool + Zuweisung an Playtester db.exec(` CREATE TABLE IF NOT EXISTS alpha_keys ( key TEXT PRIMARY KEY, assigned_to TEXT, assigned_at TEXT ); `); const insertAlphaKey = db.prepare('INSERT OR IGNORE INTO alpha_keys (key) VALUES (?)'); const freeKeyCountStmt = db.prepare('SELECT count(*) AS n FROM alpha_keys WHERE assigned_to IS NULL'); const assignedKeysStmt = db.prepare( `SELECT key, assigned_to, assigned_at FROM alpha_keys WHERE assigned_to IS NOT NULL ORDER BY assigned_at` ); const hasKeyForUserStmt = db.prepare('SELECT key FROM alpha_keys WHERE assigned_to = ?'); const reserveKeyStmt = db.prepare(` UPDATE alpha_keys SET assigned_to = ?, assigned_at = datetime('now') WHERE key = (SELECT key FROM alpha_keys WHERE assigned_to IS NULL LIMIT 1) RETURNING key `); const unreserveKeyStmt = db.prepare('UPDATE alpha_keys SET assigned_to = NULL, assigned_at = NULL WHERE key = ?'); const freeKeysStmt = db.prepare( 'SELECT key FROM alpha_keys WHERE assigned_to IS NULL ORDER BY key' ); // Nur freie Schlüssel löschen: einen vergebenen zu entfernen würde die Spur // verwischen, wer ihn hat — zurückziehen geht über unreserveAlphaKey. const deleteFreeKeyStmt = db.prepare('DELETE FROM alpha_keys WHERE key = ? AND assigned_to IS NULL'); export const addAlphaKeys = db.transaction((keys) => { let added = 0; for (const k of keys) added += insertAlphaKey.run(k).changes; return added; }); export const freeAlphaKeyCount = () => freeKeyCountStmt.get().n; export const assignedAlphaKeys = () => assignedKeysStmt.all(); export const alphaKeyOf = (userId) => hasKeyForUserStmt.get(userId)?.key ?? null; export const reserveAlphaKey = (userId) => reserveKeyStmt.get(userId)?.key ?? null; export const unreserveAlphaKey = (key) => unreserveKeyStmt.run(key); export const freeAlphaKeys = () => freeKeysStmt.all().map((r) => r.key); export const deleteFreeAlphaKey = (key) => deleteFreeKeyStmt.run(key).changes > 0; // Bewerbungs-Formulare (Modals) + eingereichte Bewerbungen db.exec(` CREATE TABLE IF NOT EXISTS app_forms ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', review_channel_id TEXT, approve_role_id TEXT, post_channel_id TEXT, message_id TEXT, questions TEXT NOT NULL DEFAULT '[]' ); CREATE TABLE IF NOT EXISTS applications ( id INTEGER PRIMARY KEY AUTOINCREMENT, form_id INTEGER NOT NULL, user_id TEXT NOT NULL, username TEXT, answers TEXT NOT NULL DEFAULT '[]', status TEXT NOT NULL DEFAULT 'open', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); `); // Migration: Annahme kann zusaetzlich ins Playtester-Programm eintragen { const cols = db.prepare('PRAGMA table_info(app_forms)').all().map((c) => c.name); if (!cols.includes('playtester')) { db.exec('ALTER TABLE app_forms ADD COLUMN playtester INTEGER NOT NULL DEFAULT 0'); } } const insertAppForm = db.prepare(` INSERT INTO app_forms (title, description, review_channel_id, approve_role_id, post_channel_id, questions, playtester) VALUES (@title, @description, @review_channel_id, @approve_role_id, @post_channel_id, @questions, @playtester) `); const updateAppFormStmt = db.prepare(` UPDATE app_forms SET title = @title, description = @description, review_channel_id = @review_channel_id, approve_role_id = @approve_role_id, post_channel_id = @post_channel_id, questions = @questions, playtester = @playtester WHERE id = @id `); const setAppFormMessageStmt = db.prepare('UPDATE app_forms SET message_id = ? WHERE id = ?'); const getAppFormStmt = db.prepare('SELECT * FROM app_forms WHERE id = ?'); const listAppFormsStmt = db.prepare('SELECT * FROM app_forms ORDER BY id'); const deleteAppFormStmt = db.prepare('DELETE FROM app_forms WHERE id = ?'); // Nur eines kann das Playtester-Formular sein — sonst weiss /playtester-setup // nicht, welches es posten soll const clearPlaytesterFormStmt = db.prepare('UPDATE app_forms SET playtester = 0 WHERE id != ?'); const playtesterFormStmt = db.prepare('SELECT * FROM app_forms WHERE playtester = 1 LIMIT 1'); export const createAppForm = (f) => insertAppForm.run(f).lastInsertRowid; export const updateAppForm = (f) => updateAppFormStmt.run(f).changes > 0; export const setAppFormMessage = (id, messageId) => setAppFormMessageStmt.run(messageId, id); export const getAppForm = (id) => getAppFormStmt.get(id) ?? null; export const listAppForms = () => listAppFormsStmt.all(); export const playtesterForm = () => playtesterFormStmt.get() ?? null; export const clearOtherPlaytesterForms = (id) => clearPlaytesterFormStmt.run(id); export const deleteAppForm = (id) => deleteAppFormStmt.run(id).changes > 0; const insertApplication = db.prepare(` INSERT INTO applications (form_id, user_id, username, answers) VALUES (?, ?, ?, ?) `); const getApplicationStmt = db.prepare('SELECT * FROM applications WHERE id = ?'); const openApplicationStmt = db.prepare( `SELECT * FROM applications WHERE form_id = ? AND user_id = ? AND status = 'open'` ); const setApplicationStatusStmt = db.prepare('UPDATE applications SET status = ? WHERE id = ?'); export const createApplication = (formId, userId, username, answers) => insertApplication.run(formId, userId, username, JSON.stringify(answers)).lastInsertRowid; export const getApplication = (id) => getApplicationStmt.get(id) ?? null; export const openApplicationOf = (formId, userId) => openApplicationStmt.get(formId, userId) ?? null; export const setApplicationStatus = (id, status) => setApplicationStatusStmt.run(status, id); // Triggers (Auto-Antworten), Reminders, Server-Aktivität, Temp-Voice db.exec(` CREATE TABLE IF NOT EXISTS triggers ( keyword TEXT PRIMARY KEY, reply TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS reminders ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, text TEXT NOT NULL, remind_at TEXT NOT NULL, sent INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS activity_daily ( day TEXT PRIMARY KEY, messages INTEGER NOT NULL DEFAULT 0, joins INTEGER NOT NULL DEFAULT 0, leaves INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS temp_voice ( channel_id TEXT PRIMARY KEY ); CREATE TABLE IF NOT EXISTS message_templates ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, payload TEXT NOT NULL ); `); // Migration: Temp-Voice-Besitzer (fürs Control-Panel) { const cols = db.prepare('PRAGMA table_info(temp_voice)').all().map((c) => c.name); if (!cols.includes('owner_id')) db.exec(`ALTER TABLE temp_voice ADD COLUMN owner_id TEXT NOT NULL DEFAULT ''`); } const upsertTemplate = db.prepare(` INSERT INTO message_templates (name, payload) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET payload = excluded.payload `); const listTemplatesStmt = db.prepare('SELECT id, name, payload FROM message_templates ORDER BY name'); const deleteTemplateStmt = db.prepare('DELETE FROM message_templates WHERE name = ?'); export const saveTemplate = (name, payload) => upsertTemplate.run(name, JSON.stringify(payload)); export const listTemplates = () => listTemplatesStmt.all().map((t) => ({ ...t, payload: JSON.parse(t.payload) })); export const deleteTemplate = (name) => deleteTemplateStmt.run(name).changes > 0; const upsertTrigger = db.prepare(` INSERT INTO triggers (keyword, reply) VALUES (?, ?) ON CONFLICT(keyword) DO UPDATE SET reply = excluded.reply `); const listTriggersStmt = db.prepare('SELECT keyword, reply FROM triggers ORDER BY keyword'); const deleteTriggerStmt = db.prepare('DELETE FROM triggers WHERE keyword = ?'); export const saveTrigger = (keyword, reply) => upsertTrigger.run(keyword, reply); export const listTriggers = () => listTriggersStmt.all(); export const deleteTrigger = (keyword) => deleteTriggerStmt.run(keyword).changes > 0; const insertReminder = db.prepare('INSERT INTO reminders (user_id, text, remind_at) VALUES (?, ?, ?)'); const dueRemindersStmt = db.prepare( `SELECT * FROM reminders WHERE sent = 0 AND remind_at <= datetime('now')` ); const markReminderSentStmt = db.prepare('UPDATE reminders SET sent = 1 WHERE id = ?'); export const createReminder = (userId, text, remindAt) => insertReminder.run(userId, text, remindAt).lastInsertRowid; export const dueReminders = () => dueRemindersStmt.all(); export const markReminderSent = (id) => markReminderSentStmt.run(id); const bumpActivityStmt = (col) => db.prepare(` INSERT INTO activity_daily (day, ${col}) VALUES (date('now'), 1) ON CONFLICT(day) DO UPDATE SET ${col} = ${col} + 1 `); const bumpMessages = bumpActivityStmt('messages'); const bumpJoins = bumpActivityStmt('joins'); const bumpLeaves = bumpActivityStmt('leaves'); export const countActivity = (kind) => (kind === 'join' ? bumpJoins : kind === 'leave' ? bumpLeaves : bumpMessages).run(); const activityRangeStmt = db.prepare( `SELECT day, messages, joins, leaves FROM activity_daily WHERE day >= date('now', ?) ORDER BY day` ); export const activityRange = (days = 30) => activityRangeStmt.all(`-${days} days`); const insertTempVoice = db.prepare('INSERT OR IGNORE INTO temp_voice (channel_id, owner_id) VALUES (?, ?)'); const tempVoiceOwnerStmt = db.prepare('SELECT owner_id FROM temp_voice WHERE channel_id = ?'); export const tempVoiceOwner = (id) => tempVoiceOwnerStmt.get(id)?.owner_id ?? null; const deleteTempVoiceStmt = db.prepare('DELETE FROM temp_voice WHERE channel_id = ?'); const listTempVoiceStmt = db.prepare('SELECT channel_id FROM temp_voice'); export const trackTempVoice = (id, ownerId = '') => insertTempVoice.run(id, ownerId); export const untrackTempVoice = (id) => deleteTempVoiceStmt.run(id); export const listTempVoice = () => listTempVoiceStmt.all().map((r) => r.channel_id); const isTempVoiceStmt = db.prepare('SELECT 1 FROM temp_voice WHERE channel_id = ?'); export const isTempVoice = (id) => Boolean(isTempVoiceStmt.get(id)); // Team: Web-Zugriff mit Bereichs-Rechten (Owner = ADMIN_DISCORD_ID darf immer alles) db.exec(` CREATE TABLE IF NOT EXISTS web_admins ( user_id TEXT PRIMARY KEY, username TEXT, scopes TEXT NOT NULL DEFAULT '' ); `); const upsertWebAdmin = db.prepare(` INSERT INTO web_admins (user_id, username, scopes) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, scopes = excluded.scopes `); const webAdminStmt = db.prepare('SELECT * FROM web_admins WHERE user_id = ?'); const listWebAdminsStmt = db.prepare('SELECT * FROM web_admins ORDER BY username'); const deleteWebAdminStmt = db.prepare('DELETE FROM web_admins WHERE user_id = ?'); export const saveWebAdmin = (userId, username, scopes) => upsertWebAdmin.run(userId, username, scopes.join(',')); export const webAdminScopes = (userId) => (webAdminStmt.get(userId)?.scopes ?? '').split(',').filter(Boolean); export const listWebAdmins = () => listWebAdminsStmt.all(); export const deleteWebAdmin = (userId) => deleteWebAdminStmt.run(userId).changes > 0; // Geburtstage: /geburtstag → morgendliche Gratulation + Tages-Rolle db.exec(` CREATE TABLE IF NOT EXISTS birthdays ( user_id TEXT PRIMARY KEY, username TEXT, day INTEGER NOT NULL, month INTEGER NOT NULL ); `); const upsertBirthday = db.prepare(` INSERT INTO birthdays (user_id, username, day, month) VALUES (?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, day = excluded.day, month = excluded.month `); const deleteBirthdayStmt = db.prepare('DELETE FROM birthdays WHERE user_id = ?'); const birthdaysTodayStmt = db.prepare('SELECT * FROM birthdays WHERE day = ? AND month = ?'); export const setBirthday = (userId, username, day, month) => upsertBirthday.run(userId, username, day, month); export const deleteBirthday = (userId) => deleteBirthdayStmt.run(userId).changes > 0; export const birthdaysToday = (day, month) => birthdaysTodayStmt.all(day, month); /** * DSGVO Art. 17: alle Selbstauskunfts-Daten eines Users löschen. * Bewusst NICHT gelöscht: Verwarnungen (berechtigtes Interesse an Moderation) * und Inhalte, die im Discord stehen (Devlogs, Galerie) — die gehören dort gelöscht. * @returns {Record} gelöschte Zeilen pro Bereich */ export function deleteUserData(userId) { const del = (sql) => db.prepare(sql).run(userId).changes; const result = { level: del('DELETE FROM levels WHERE user_id = ?'), geburtstag: del('DELETE FROM birthdays WHERE user_id = ?'), playtester: del('DELETE FROM playtesters WHERE user_id = ?'), votes: del('DELETE FROM wish_votes WHERE user_id = ?'), erinnerungen: del('DELETE FROM reminders WHERE user_id = ?'), rollen_backup: del('DELETE FROM sticky_roles WHERE user_id = ?'), giveaways: del('DELETE FROM giveaway_entries WHERE user_id = ?'), alpha_key: db.prepare( `UPDATE alpha_keys SET assigned_to = NULL, assigned_at = NULL WHERE assigned_to = ?` ).run(userId).changes, }; return result; } // Frei anlegbare Seiten (Regeln, Über uns, Mitmachen …) — Inhalte pflegen // ohne Code-Änderung. Rechtstexte laufen bewusst separat. db.exec(` CREATE TABLE IF NOT EXISTS pages ( slug TEXT PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', published INTEGER NOT NULL DEFAULT 0, in_menu INTEGER NOT NULL DEFAULT 1, sort INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); `); const upsertPage = db.prepare(` INSERT INTO pages (slug, title, content, published, in_menu, sort, updated_at) VALUES (@slug, @title, @content, @published, @in_menu, @sort, datetime('now')) ON CONFLICT(slug) DO UPDATE SET title = excluded.title, content = excluded.content, published = excluded.published, in_menu = excluded.in_menu, sort = excluded.sort, updated_at = datetime('now') `); const getPageStmt = db.prepare('SELECT * FROM pages WHERE slug = ?'); const listPagesStmt = db.prepare('SELECT * FROM pages ORDER BY sort, title'); const listPublishedPagesStmt = db.prepare( 'SELECT slug, title, in_menu, sort FROM pages WHERE published = 1 ORDER BY sort, title' ); const deletePageStmt = db.prepare('DELETE FROM pages WHERE slug = ?'); export const savePage = (p) => upsertPage.run(p); export const getPage = (slug) => getPageStmt.get(slug) ?? null; export const listPages = () => listPagesStmt.all(); export const listPublishedPages = () => listPublishedPagesStmt.all(); export const deletePage = (slug) => deletePageStmt.run(slug).changes > 0; // Portal: eigene Dienste (Gitea, Kanban, Cloud …) für die Kachel-Übersicht db.exec(` CREATE TABLE IF NOT EXISTS services ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, url TEXT NOT NULL, icon TEXT NOT NULL DEFAULT '🔗', description TEXT NOT NULL DEFAULT '', sort INTEGER NOT NULL DEFAULT 0 ); `); const insertService = db.prepare( 'INSERT INTO services (name, url, icon, description, sort) VALUES (@name, @url, @icon, @description, @sort)' ); const updateServiceStmt = db.prepare( 'UPDATE services SET name = @name, url = @url, icon = @icon, description = @description, sort = @sort WHERE id = @id' ); const listServicesStmt = db.prepare('SELECT * FROM services ORDER BY sort, name'); const deleteServiceStmt = db.prepare('DELETE FROM services WHERE id = ?'); export const createService = (s) => insertService.run(s).lastInsertRowid; export const updateService = (s) => updateServiceStmt.run(s).changes > 0; export const listServices = () => listServicesStmt.all(); export const deleteService = (id) => deleteServiceStmt.run(id).changes > 0; // SSO: Fremd-Apps (Kanban, Platform …), die den Discord-Login des Bots mitnutzen db.exec(` CREATE TABLE IF NOT EXISTS sso_apps ( slug TEXT PRIMARY KEY, name TEXT NOT NULL, redirect_prefix TEXT NOT NULL, secret TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), last_used TEXT ); `); const upsertSsoApp = db.prepare(` INSERT INTO sso_apps (slug, name, redirect_prefix, secret) VALUES (@slug, @name, @redirect_prefix, @secret) ON CONFLICT(slug) DO UPDATE SET name = excluded.name, redirect_prefix = excluded.redirect_prefix `); const getSsoAppStmt = db.prepare('SELECT * FROM sso_apps WHERE slug = ?'); const listSsoAppsStmt = db.prepare('SELECT slug, name, redirect_prefix, created_at, last_used FROM sso_apps ORDER BY name'); const deleteSsoAppStmt = db.prepare('DELETE FROM sso_apps WHERE slug = ?'); const touchSsoAppStmt = db.prepare(`UPDATE sso_apps SET last_used = datetime('now') WHERE slug = ?`); export const saveSsoApp = (app) => upsertSsoApp.run(app); export const getSsoApp = (slug) => getSsoAppStmt.get(slug) ?? null; export const listSsoApps = () => listSsoAppsStmt.all(); export const deleteSsoApp = (slug) => deleteSsoAppStmt.run(slug).changes > 0; export const touchSsoApp = (slug) => touchSsoAppStmt.run(slug); // Audit-Log: wer (Owner/Team) hat wann was im Webinterface geändert db.exec(` CREATE TABLE IF NOT EXISTS audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL DEFAULT (datetime('now')), user_id TEXT NOT NULL, username TEXT, action TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '' ); `); const insertAudit = db.prepare( 'INSERT INTO audit_log (user_id, username, action, detail) VALUES (?, ?, ?, ?)' ); const listAuditStmt = db.prepare('SELECT * FROM audit_log ORDER BY id DESC LIMIT ?'); const pruneAuditStmt = db.prepare(` DELETE FROM audit_log WHERE id NOT IN (SELECT id FROM audit_log ORDER BY id DESC LIMIT 500) `); export function logAudit(user, action, detail = '') { insertAudit.run(user?.id ?? '?', user?.username ?? '?', action, String(detail).slice(0, 300)); pruneAuditStmt.run(); } export const listAudit = (limit = 100) => listAuditStmt.all(limit); // Game-Server (DiscordGSM-Stil): strukturierte Server statt CSV-Setting db.exec(` CREATE TABLE IF NOT EXISTS gameservers ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'fivem', query_url TEXT NOT NULL, address TEXT NOT NULL DEFAULT '', message_id TEXT ); `); // Einmalige Migration aus dem alten "gameservers"-CSV-Setting ("Name=URL, …") { // settings-Tabelle kann hier noch fehlen (wird sonst erst weiter unten angelegt) db.exec('CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'); const hasServers = db.prepare('SELECT count(*) AS n FROM gameservers').get().n > 0; const legacy = db.prepare(`SELECT value FROM settings WHERE key = 'gameservers'`).get()?.value; if (!hasServers && legacy) { const ins = db.prepare(`INSERT INTO gameservers (name, type, query_url) VALUES (?, 'fivem', ?)`); for (const entry of legacy.split(',')) { const [name, ...rest] = entry.split('='); const url = rest.join('=').trim().replace(/\/$/, ''); if (name?.trim() && /^https?:\/\//.test(url)) ins.run(name.trim(), url); } db.prepare(`DELETE FROM settings WHERE key = 'gameservers'`).run(); } } // Migration v3: host/port (gamedig) + connect_url (Connect-Link im Embed) { const cols = db.prepare('PRAGMA table_info(gameservers)').all().map((c) => c.name); if (!cols.includes('host')) db.exec(`ALTER TABLE gameservers ADD COLUMN host TEXT NOT NULL DEFAULT ''`); if (!cols.includes('port')) db.exec('ALTER TABLE gameservers ADD COLUMN port INTEGER'); if (!cols.includes('connect_url')) db.exec(`ALTER TABLE gameservers ADD COLUMN connect_url TEXT NOT NULL DEFAULT ''`); if (!cols.includes('image_url')) db.exec(`ALTER TABLE gameservers ADD COLUMN image_url TEXT NOT NULL DEFAULT ''`); // Zugangs-Code für Spiele, die ohne nicht antworten (Farming Simulator, Terraria) if (!cols.includes('token')) db.exec(`ALTER TABLE gameservers ADD COLUMN token TEXT NOT NULL DEFAULT ''`); // Eigener Link für den Mod-Download. Vorher landete der beim Connect-Link, // weil es kein anderes Feld gab — und dann führte "Connect" zu einer ZIP. if (!cols.includes('mods_url')) db.exec(`ALTER TABLE gameservers ADD COLUMN mods_url TEXT NOT NULL DEFAULT ''`); // Freie Links: Regeln, TeamSpeak, Shop, was auch immer. Als JSON-Liste, // weil sonst für jeden neuen Zweck eine Spalte dazukäme — und genau so // sind connect_url und mods_url entstanden. if (!cols.includes('links')) db.exec(`ALTER TABLE gameservers ADD COLUMN links TEXT NOT NULL DEFAULT '[]'`); // Wer darf drauf: 'auto' nimmt, was die Abfrage sagt. Nur die wenigsten // Spiele melden das — und eine Whitelist meldet gar keins. if (!cols.includes('zugang')) db.exec(`ALTER TABLE gameservers ADD COLUMN zugang TEXT NOT NULL DEFAULT 'auto'`); // Migration v4: Wer hat diesen Eintrag angelegt? // // Leer = ein Mensch, über das Webinterface. Steht ein Name drin, kommt der // Eintrag aus dem d4rk_gameserver-Panel und wird von dort auch wieder // weggeräumt. // // DIESE SPALTE IST DIE GANZE SICHERUNG DAGEGEN, dass ein Häkchen im Panel // einen von Hand gepflegten Server löscht. Ohne sie müsste der Abgleich // raten, und die einzige verfügbare Grundlage wäre der Name — bei dem // ersten Server, der in beiden Werkzeugen gleich heißt, wäre der // handgepflegte Eintrag samt Bild, Links und Zugangs-Code weg. if (!cols.includes('panel_name')) db.exec(`ALTER TABLE gameservers ADD COLUMN panel_name TEXT NOT NULL DEFAULT ''`); } // Die Auswahl im Panel bot mal Spiel-IDs an, die es bei gamedig gar nicht gibt. // Wer eine davon gewählt hat, hatte einen Server, der dauerhaft offline stand, // ohne dass irgendwo ein Grund stand. Einmalig auf die echten IDs umbiegen. { const echteIds = { arkse: 'ase', ark: 'ase', sevendaystodie: 'sdtd', minecraftbe: 'mbe', terraria: 'terrariatshock', }; const um = db.prepare('UPDATE gameservers SET type = ? WHERE type = ?'); for (const [alt, neu] of Object.entries(echteIds)) um.run(neu, alt); } const insertGameserver = db.prepare(` INSERT INTO gameservers (name, type, query_url, address, host, port, connect_url, image_url, token, mods_url, links, zugang, panel_name) VALUES (@name, @type, @query_url, @address, @host, @port, @connect_url, @image_url, @token, @mods_url, @links, @zugang, @panel_name) `); const updateGameserverStmt = db.prepare(` UPDATE gameservers SET name = @name, type = @type, query_url = @query_url, address = @address, host = @host, port = @port, connect_url = @connect_url, image_url = @image_url, token = @token, mods_url = @mods_url, links = @links, zugang = @zugang WHERE id = @id `); const setGameserverMessageStmt = db.prepare('UPDATE gameservers SET message_id = ? WHERE id = ?'); const listGameserversStmt = db.prepare('SELECT * FROM gameservers ORDER BY id'); const getGameserverStmt = db.prepare('SELECT * FROM gameservers WHERE id = ?'); const deleteGameserverStmt = db.prepare('DELETE FROM gameservers WHERE id = ?'); // Jede neue Spalte bricht sonst jeden Aufrufer, der sie noch nicht kennt — // better-sqlite3 wirft bei einem fehlenden benannten Parameter. Hier einmal // auffüllen ist billiger, als das an jeder Aufrufstelle nachzuziehen. const gameserverFelder = { name: '', type: 'fivem', query_url: '', address: '', host: '', port: null, connect_url: '', image_url: '', token: '', mods_url: '', links: '[]', zugang: 'auto', // Leer = von Hand angelegt. Die UPDATE-Anweisung oben fasst die Spalte // absichtlich NICHT an: wer einen Panel-Server im Webinterface bearbeitet, // soll ihm damit nicht die Herkunft nehmen. (Ein überzähliger benannter // Parameter stört better-sqlite3 nicht -- nachgemessen.) panel_name: '', }; // Links dürfen als Liste hereinkommen — so gibt getGameserver sie ja auch // heraus. better-sqlite3 kann kein Array binden und würde sonst werfen. const vollstaendig = (s) => { const voll = { ...gameserverFelder, ...s }; if (Array.isArray(voll.links)) voll.links = JSON.stringify(voll.links); return voll; }; export const createGameserver = (s) => insertGameserver.run(vollstaendig(s)).lastInsertRowid; export const updateGameserver = (s) => updateGameserverStmt.run(vollstaendig(s)).changes > 0; export const setGameserverMessage = (id, messageId) => setGameserverMessageStmt.run(messageId, id); /** * Links kommen als JSON aus der Spalte und gehen als Liste weiter. * * Einmal hier statt an jeder Aufrufstelle: sonst parst jeder selbst, und der * erste, der es vergisst, mappt über einen String. Kaputtes JSON ergibt eine * leere Liste — ein Server ohne Links ist besser als ein Absturz im Monitor. */ function mitLinks(row) { if (!row) return null; let links = []; try { const roh = JSON.parse(row.links || '[]'); if (Array.isArray(roh)) links = roh.filter((l) => l?.url); } catch { /* kaputt gespeichert — dann eben ohne */ } return { ...row, links }; } export const listGameservers = () => listGameserversStmt.all().map(mitLinks); export const getGameserver = (id) => mitLinks(getGameserverStmt.get(id)); export const deleteGameserver = (id) => deleteGameserverStmt.run(id).changes > 0; // Spielerzahl-Verlauf für die öffentliche /server-Seite (Sample alle 2 min, 7 Tage Retention) db.exec(` CREATE TABLE IF NOT EXISTS player_history ( server_id INTEGER NOT NULL, ts TEXT NOT NULL DEFAULT (datetime('now')), online INTEGER NOT NULL, players INTEGER ); CREATE INDEX IF NOT EXISTS idx_ph_server_ts ON player_history (server_id, ts); `); const insertPlayerSample = db.prepare( 'INSERT INTO player_history (server_id, online, players) VALUES (?, ?, ?)' ); const playerHistoryStmt = db.prepare(` SELECT ts, online, players FROM player_history WHERE server_id = ? AND ts >= datetime('now', ?) ORDER BY ts `); const prunePlayerHistoryStmt = db.prepare(`DELETE FROM player_history WHERE ts < datetime('now', '-7 days')`); // LS-Modul: das gepflegte Karten-Embed und der letzte Besitzstand je Server. // Der Besitzstand ist nur da, um Käufe zu erkennen — ohne ihn müsste beim // Start jede bereits gekaufte Parzelle als „neu gekauft" gemeldet werden. db.exec(` CREATE TABLE IF NOT EXISTS ls_state ( server_id INTEGER PRIMARY KEY, message_id TEXT, geprueft TEXT NOT NULL DEFAULT (datetime('now')) ); `); const lsStateStmt = db.prepare('SELECT * FROM ls_state WHERE server_id = ?'); const lsMessageStmt = db.prepare(` INSERT INTO ls_state (server_id, message_id) VALUES (?, ?) ON CONFLICT(server_id) DO UPDATE SET message_id = excluded.message_id `); // Letzte gesehene Tageszeit — daran erkennt das Modul den Tageswechsel { const cols = db.prepare('PRAGMA table_info(ls_state)').all().map((c) => c.name); if (!cols.includes('tageszeit')) db.exec('ALTER TABLE ls_state ADD COLUMN tageszeit INTEGER'); } const lsTagStmt = db.prepare(` INSERT INTO ls_state (server_id, tageszeit) VALUES (?, ?) ON CONFLICT(server_id) DO UPDATE SET tageszeit = excluded.tageszeit `); export const setLsTageszeit = (serverId, ms) => lsTagStmt.run(serverId, ms); // Eine gepflegte Nachricht je Hof: { "1": "messageId", "2": … } { const cols = db.prepare('PRAGMA table_info(ls_state)').all().map((c) => c.name); if (!cols.includes('hof_messages')) db.exec(`ALTER TABLE ls_state ADD COLUMN hof_messages TEXT NOT NULL DEFAULT '{}'`); } const lsHofStmt = db.prepare(` INSERT INTO ls_state (server_id, hof_messages) VALUES (?, ?) ON CONFLICT(server_id) DO UPDATE SET hof_messages = excluded.hof_messages `); export const setLsHofMessages = (serverId, map) => lsHofStmt.run(serverId, JSON.stringify(map)); // Die zuletzt gesehene Modliste — daraus entsteht die Änderungsmeldung. // Dazu je Mod die Dateigrösse, sobald sie einmal bekannt ist: der Server // beantwortet kein HEAD, also lernt der Bot sie beim ersten Weiterleiten // eines Downloads statt 4 GB nur fürs Anzeigen zu holen. { const cols = db.prepare('PRAGMA table_info(ls_state)').all().map((c) => c.name); if (!cols.includes('mods')) db.exec('ALTER TABLE ls_state ADD COLUMN mods TEXT'); } const lsModsStmt = db.prepare(` INSERT INTO ls_state (server_id, mods) VALUES (?, ?) ON CONFLICT(server_id) DO UPDATE SET mods = excluded.mods `); export const setLsMods = (serverId, mods) => lsModsStmt.run(serverId, JSON.stringify(mods)); db.exec(` CREATE TABLE IF NOT EXISTS ls_mod_sizes ( server_id INTEGER NOT NULL, name TEXT NOT NULL, bytes INTEGER NOT NULL, gesehen TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (server_id, name) ); `); const lsModSizeStmt = db.prepare(` INSERT INTO ls_mod_sizes (server_id, name, bytes, gesehen) VALUES (?, ?, ?, datetime('now')) ON CONFLICT(server_id, name) DO UPDATE SET bytes = excluded.bytes, gesehen = datetime('now') `); export const setLsModSize = (serverId, name, bytes) => lsModSizeStmt.run(serverId, name, bytes); const lsModSizesStmt = db.prepare('SELECT name, bytes FROM ls_mod_sizes WHERE server_id = ?'); export const lsModSizes = (serverId) => Object.fromEntries(lsModSizesStmt.all(serverId).map((r) => [r.name, r.bytes])); const lsVergessenStmt = db.prepare('DELETE FROM ls_state WHERE server_id = ?'); export const lsState = (serverId) => { const r = lsStateStmt.get(serverId); if (!r) return null; // Kaputtes JSON darf den Durchlauf nicht anhalten — dann gilt eben // "noch nichts bekannt" und beim nächsten Mal steht wieder etwas drin. let hofMessages = {}; try { const roh = JSON.parse(r.hof_messages || '{}'); if (roh && typeof roh === 'object' && !Array.isArray(roh)) hofMessages = roh; } catch { /* kaputt gespeichert — dann werden die Embeds neu gepostet */ } let mods = null; try { const roh = r.mods ? JSON.parse(r.mods) : null; if (Array.isArray(roh)) mods = roh; } catch { /* kaputt gespeichert — gilt als „noch nichts bekannt" */ } return { ...r, hofMessages, mods }; }; export const setLsMessage = (serverId, messageId) => lsMessageStmt.run(serverId, messageId); export const forgetLsState = (serverId) => lsVergessenStmt.run(serverId).changes > 0; // Erreichbarkeit stundenweise zusammengefasst — für den Uptime-Balken. // Die Rohdaten (alle 2 min) wären über eine Woche rund 5000 Zeilen pro Server; // als Stundeneimer sind es höchstens 168 und die Seite bleibt schlank. const uptimeBucketsStmt = db.prepare(` SELECT strftime('%Y-%m-%dT%H', ts) AS stunde, COUNT(*) AS proben, SUM(online) AS an FROM player_history WHERE server_id = ? AND ts >= datetime('now', ?) GROUP BY stunde ORDER BY stunde `); // Wann war der Server zuletzt erreichbar? Nur so lange beantwortbar, wie der // Verlauf reicht (7 Tage) — davor gibt es keine Zeile mehr, und dann steht auf // der Seite bewusst nichts statt einer erfundenen Zahl. const letztesOnlineStmt = db.prepare( 'SELECT max(ts) AS ts FROM player_history WHERE server_id = ? AND online = 1' ); export const recordPlayerSample = (serverId, online, players) => insertPlayerSample.run(serverId, online ? 1 : 0, players); export const lastOnlineAt = (serverId) => letztesOnlineStmt.get(serverId)?.ts ?? null; export const playerHistory = (serverId, hours = 24) => playerHistoryStmt.all(serverId, `-${hours} hours`); export const uptimeBuckets = (serverId, hours = 168) => uptimeBucketsStmt.all(serverId, `-${hours} hours`); export const prunePlayerHistory = () => prunePlayerHistoryStmt.run(); // Überwachte Dienste: was der Wächter prüft und was auf der Statusseite steht. // Vorher war das eine kommagetrennte Einstellung — für eine öffentliche Seite // braucht jeder Eintrag einen Namen und die Entscheidung, ob er dort auftaucht. db.exec(` CREATE TABLE IF NOT EXISTS monitored_services ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, url TEXT NOT NULL UNIQUE, gruppe TEXT NOT NULL DEFAULT 'Dienste', oeffentlich INTEGER NOT NULL DEFAULT 0, sort INTEGER NOT NULL DEFAULT 0 ); `); // Watchdog: Erreichbarkeit der überwachten Adressen — gleiche Bauart wie beim // Game-Server, damit beide denselben Balken benutzen können. Schlüssel bleibt // die Adresse und nicht die Dienst-ID: so überlebt der Verlauf ein Umbenennen. db.exec(` CREATE TABLE IF NOT EXISTS watchdog_history ( url TEXT NOT NULL, ts TEXT NOT NULL DEFAULT (datetime('now')), ok INTEGER NOT NULL, ms INTEGER ); CREATE INDEX IF NOT EXISTS idx_wd_url_ts ON watchdog_history (url, ts); CREATE TABLE IF NOT EXISTS incidents ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL, begonnen TEXT NOT NULL DEFAULT (datetime('now')), beendet TEXT, grund TEXT ); CREATE INDEX IF NOT EXISTS idx_incidents_url ON incidents (url, begonnen DESC); `); const insertWatchdogSample = db.prepare('INSERT INTO watchdog_history (url, ok, ms) VALUES (?, ?, ?)'); const watchdogBucketsStmt = db.prepare(` SELECT strftime('%Y-%m-%dT%H', ts) AS stunde, COUNT(*) AS proben, SUM(ok) AS an, CAST(AVG(ms) AS INTEGER) AS schnitt FROM watchdog_history WHERE url = ? AND ts >= datetime('now', ?) GROUP BY stunde ORDER BY stunde `); // Tagesweise fürs 90-Tage-Bild: stündlich wären das über 2000 Striche, die auf // keinen Schirm passen. Statuspage macht es genauso — 90 Tage, ein Strich je Tag. const watchdogDaysStmt = db.prepare(` SELECT date(ts) AS tag, COUNT(*) AS proben, SUM(ok) AS an, CAST(AVG(ms) AS INTEGER) AS schnitt FROM watchdog_history WHERE url = ? AND ts >= date('now', ?) GROUP BY tag ORDER BY tag `); const pruneWatchdogStmt = db.prepare(`DELETE FROM watchdog_history WHERE ts < datetime('now', '-90 days')`); export const recordWatchdogSample = (url, ok, ms) => insertWatchdogSample.run(url, ok ? 1 : 0, Number.isFinite(ms) ? Math.round(ms) : null); export const watchdogBuckets = (url, hours = 168) => watchdogBucketsStmt.all(url, `-${hours} hours`); export const watchdogDays = (url, days = 90) => watchdogDaysStmt.all(url, `-${days} days`); export const pruneWatchdogHistory = () => pruneWatchdogStmt.run(); // Eigener Herzschlag. Der Wächter kann alles prüfen außer sich selbst — wenn // der Bot weg ist, prüft niemand mehr. Deshalb schreibt er im festen Takt eine // Zeile: was da steht, lief; wo nichts steht, lief er nicht. // // Der Takt ist absichtlich nicht einstellbar. Ein später geänderter Abstand // würde den alten Verlauf falsch aussehen lassen — die Zahl der erwarteten // Proben pro Stunde ließe sich rückwirkend nicht mehr bestimmen. db.exec(` CREATE TABLE IF NOT EXISTS bot_heartbeat ( ts TEXT NOT NULL DEFAULT (datetime('now')), ok INTEGER NOT NULL DEFAULT 1, ms INTEGER ); CREATE INDEX IF NOT EXISTS idx_heartbeat_ts ON bot_heartbeat (ts); `); const insertHeartbeatStmt = db.prepare('INSERT INTO bot_heartbeat (ok, ms) VALUES (?, ?)'); const heartbeatBucketsStmt = db.prepare(` SELECT strftime('%Y-%m-%dT%H', ts) AS stunde, COUNT(*) AS proben, SUM(ok) AS an, CAST(AVG(ms) AS INTEGER) AS schnitt FROM bot_heartbeat WHERE ts >= datetime('now', ?) GROUP BY stunde ORDER BY stunde `); const heartbeatFirstStmt = db.prepare('SELECT min(ts) AS erste FROM bot_heartbeat'); const pruneHeartbeatStmt = db.prepare(`DELETE FROM bot_heartbeat WHERE ts < datetime('now', '-90 days')`); export const recordHeartbeat = (ok, ms) => insertHeartbeatStmt.run(ok ? 1 : 0, Number.isFinite(ms) && ms >= 0 ? Math.round(ms) : null); export const heartbeatBuckets = (hours = 168) => heartbeatBucketsStmt.all(`-${hours} hours`); /** Zeitpunkt der ersten Aufzeichnung — davor ist „keine Daten", nicht „war aus" */ export const heartbeatFirst = () => heartbeatFirstStmt.get()?.erste ?? null; export const pruneHeartbeat = () => pruneHeartbeatStmt.run(); /* ── Störungen: pro Adresse ein offener Eintrag, bis es wieder läuft ── */ const openIncidentStmt = db.prepare('SELECT * FROM incidents WHERE url = ? AND beendet IS NULL'); const startIncidentStmt = db.prepare('INSERT INTO incidents (url, grund) VALUES (?, ?)'); const endIncidentStmt = db.prepare(`UPDATE incidents SET beendet = datetime('now') WHERE id = ?`); const listIncidentsStmt = db.prepare(` SELECT id, url, begonnen, beendet, grund, CAST((julianday(COALESCE(beendet, 'now')) - julianday(begonnen)) * 1440 AS INTEGER) AS minuten FROM incidents WHERE begonnen >= date('now', ?) ORDER BY begonnen DESC LIMIT ? `); const pruneIncidentsStmt = db.prepare(`DELETE FROM incidents WHERE beendet IS NOT NULL AND begonnen < date('now', '-90 days')`); /** Störung eröffnen — doppelte für dieselbe Adresse verhindert der offene Eintrag */ export function startIncident(url, grund) { if (openIncidentStmt.get(url)) return false; startIncidentStmt.run(url, grund ?? null); return true; } export function endIncident(url) { const offen = openIncidentStmt.get(url); if (!offen) return null; endIncidentStmt.run(offen.id); return offen; } export const listIncidents = (days = 90, limit = 50) => listIncidentsStmt.all(`-${days} days`, limit); export const pruneIncidents = () => pruneIncidentsStmt.run(); /* ── Überwachte Dienste ── */ const listServicesMonStmt = db.prepare('SELECT * FROM monitored_services ORDER BY sort, name'); const insertServiceMonStmt = db.prepare(` INSERT INTO monitored_services (name, url, gruppe, oeffentlich, sort) VALUES (@name, @url, @gruppe, @oeffentlich, @sort) ON CONFLICT(url) DO NOTHING `); const updateServiceMonStmt = db.prepare(` UPDATE monitored_services SET name = @name, url = @url, gruppe = @gruppe, oeffentlich = @oeffentlich, sort = @sort WHERE id = @id `); const deleteServiceMonStmt = db.prepare('DELETE FROM monitored_services WHERE id = ?'); export const listMonitored = () => listServicesMonStmt.all().map((r) => ({ ...r, oeffentlich: r.oeffentlich === 1 })); export const createMonitored = (s) => insertServiceMonStmt.run({ name: String(s.name || s.url || '').slice(0, 60), url: s.url, gruppe: s.gruppe || 'Dienste', oeffentlich: s.oeffentlich ? 1 : 0, sort: Number(s.sort) || 0, }).lastInsertRowid; export const updateMonitored = (s) => updateServiceMonStmt.run({ ...s, oeffentlich: s.oeffentlich ? 1 : 0 }); export const deleteMonitored = (id) => deleteServiceMonStmt.run(id).changes > 0; // Die einmalige Übernahme der alten Adressliste steht am Dateiende — sie liest // eine Einstellung, und getSetting() hängt an einem const, das erst weiter // unten entsteht. Hier aufgerufen stirbt der Bot beim Start. // Moderation: Verwarnungen + Sticky-Roles db.exec(` CREATE TABLE IF NOT EXISTS warns ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, moderator TEXT, reason TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_warns_user ON warns (user_id); CREATE TABLE IF NOT EXISTS sticky_roles ( user_id TEXT PRIMARY KEY, role_ids TEXT NOT NULL DEFAULT '[]', saved_at TEXT NOT NULL DEFAULT (datetime('now')) ); `); const insertWarn = db.prepare('INSERT INTO warns (user_id, moderator, reason) VALUES (?, ?, ?)'); const warnsOfStmt = db.prepare('SELECT * FROM warns WHERE user_id = ? ORDER BY id'); const deleteWarnStmt = db.prepare('DELETE FROM warns WHERE id = ?'); export const addWarn = (userId, moderator, reason) => insertWarn.run(userId, moderator, reason).lastInsertRowid; export const warnsOf = (userId) => warnsOfStmt.all(userId); export const deleteWarn = (id) => deleteWarnStmt.run(id).changes > 0; const upsertSticky = db.prepare(` INSERT INTO sticky_roles (user_id, role_ids, saved_at) VALUES (?, ?, datetime('now')) ON CONFLICT(user_id) DO UPDATE SET role_ids = excluded.role_ids, saved_at = excluded.saved_at `); const stickyOfStmt = db.prepare('SELECT role_ids FROM sticky_roles WHERE user_id = ?'); export const saveStickyRoles = (userId, roleIds) => upsertSticky.run(userId, JSON.stringify(roleIds)); export const stickyRolesOf = (userId) => JSON.parse(stickyOfStmt.get(userId)?.role_ids ?? '[]'); // Level-System: XP pro Nachricht (Cooldown), Bestenliste auf der Webseite db.exec(` CREATE TABLE IF NOT EXISTS levels ( user_id TEXT PRIMARY KEY, username TEXT, xp INTEGER NOT NULL DEFAULT 0, level INTEGER NOT NULL DEFAULT 0, last_xp_ms INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_levels_xp ON levels (xp DESC); `); const getLevelStmt = db.prepare('SELECT * FROM levels WHERE user_id = ?'); const upsertLevelStmt = db.prepare(` INSERT INTO levels (user_id, username, xp, level, last_xp_ms) VALUES (?, ?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, xp = excluded.xp, level = excluded.level, last_xp_ms = excluded.last_xp_ms `); const topLevelsStmt = db.prepare('SELECT user_id, username, xp, level FROM levels ORDER BY xp DESC LIMIT ?'); const rankOfStmt = db.prepare('SELECT count(*) + 1 AS rank FROM levels WHERE xp > (SELECT xp FROM levels WHERE user_id = ?)'); export const getLevelRow = (userId) => getLevelStmt.get(userId) ?? null; export const setLevelRow = (r) => upsertLevelStmt.run(r.user_id, r.username, r.xp, r.level, r.last_xp_ms); export const topLevels = (limit = 50) => topLevelsStmt.all(limit); export const rankOf = (userId) => (getLevelStmt.get(userId) ? rankOfStmt.get(userId).rank : null); // Tags (eigene Text-Befehle) + geplante Posts db.exec(` CREATE TABLE IF NOT EXISTS tags ( name TEXT PRIMARY KEY, content TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS scheduled_posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id TEXT NOT NULL, content TEXT NOT NULL, embed_title TEXT, type TEXT NOT NULL, date TEXT, time TEXT NOT NULL, weekday INTEGER, next_run_at TEXT NOT NULL, last_run_at TEXT, enabled INTEGER NOT NULL DEFAULT 1 ); `); const upsertTag = db.prepare(` INSERT INTO tags (name, content, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(name) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at `); const getTagStmt = db.prepare('SELECT * FROM tags WHERE name = ?'); const listTagsStmt = db.prepare('SELECT name, content, updated_at FROM tags ORDER BY name'); const deleteTagStmt = db.prepare('DELETE FROM tags WHERE name = ?'); export const saveTag = (name, content) => upsertTag.run(name, content); export const getTag = (name) => getTagStmt.get(name) ?? null; export const listTags = () => listTagsStmt.all(); export const deleteTag = (name) => deleteTagStmt.run(name).changes > 0; const insertScheduled = db.prepare(` INSERT INTO scheduled_posts (channel_id, content, embed_title, type, date, time, weekday, next_run_at) VALUES (@channel_id, @content, @embed_title, @type, @date, @time, @weekday, @next_run_at) `); const listScheduledStmt = db.prepare('SELECT * FROM scheduled_posts ORDER BY next_run_at'); const dueScheduledStmt = db.prepare( `SELECT * FROM scheduled_posts WHERE enabled = 1 AND next_run_at <= datetime('now')` ); const updateScheduledRunStmt = db.prepare( `UPDATE scheduled_posts SET next_run_at = ?, last_run_at = datetime('now'), enabled = ? WHERE id = ?` ); const deleteScheduledStmt = db.prepare('DELETE FROM scheduled_posts WHERE id = ?'); export const createScheduledPost = (p) => insertScheduled.run(p).lastInsertRowid; export const listScheduledPosts = () => listScheduledStmt.all(); export const dueScheduledPosts = () => dueScheduledStmt.all(); export const markScheduledRun = (id, nextRunAt, stillEnabled) => updateScheduledRunStmt.run(nextRunAt, stillEnabled ? 1 : 0, id); export const deleteScheduledPost = (id) => deleteScheduledStmt.run(id).changes > 0; // Rollen-Menüs (Carl-Bot-Ersatz): selbst zuweisbare Rollen per Button-Post db.exec(` CREATE TABLE IF NOT EXISTS role_menus ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', channel_id TEXT, message_id TEXT, exclusive INTEGER NOT NULL DEFAULT 0, entries TEXT NOT NULL DEFAULT '[]' ); `); const insertRoleMenu = db.prepare(` INSERT INTO role_menus (title, description, channel_id, exclusive, entries) VALUES (@title, @description, @channel_id, @exclusive, @entries) `); const updateRoleMenuStmt = db.prepare(` UPDATE role_menus SET title = @title, description = @description, channel_id = @channel_id, exclusive = @exclusive, entries = @entries WHERE id = @id `); const setRoleMenuMessageStmt = db.prepare('UPDATE role_menus SET message_id = ? WHERE id = ?'); const getRoleMenuStmt = db.prepare('SELECT * FROM role_menus WHERE id = ?'); const listRoleMenusStmt = db.prepare('SELECT * FROM role_menus ORDER BY id'); const deleteRoleMenuStmt = db.prepare('DELETE FROM role_menus WHERE id = ?'); export const createRoleMenu = (m) => insertRoleMenu.run(m).lastInsertRowid; export const updateRoleMenu = (m) => updateRoleMenuStmt.run(m).changes > 0; export const setRoleMenuMessage = (id, messageId) => setRoleMenuMessageStmt.run(messageId, id); export const getRoleMenu = (id) => getRoleMenuStmt.get(id) ?? null; export const listRoleMenus = () => listRoleMenusStmt.all(); export const deleteRoleMenu = (id) => deleteRoleMenuStmt.run(id).changes > 0; // Kurzes Gedaechtnis fuer Nachrichten — nur fuers Mod-Log. // // Discord schickt beim Loeschen nur eine Huelle, sobald die Nachricht nicht // mehr im Cache des Bots liegt; bei einem Post von gestern ist sie das nie. // Im Log stand dann „unbekannt" und „[kein Text]" — also genau nichts. // // Deshalb hier eine eigene, kurzlebige Kopie: Verfasser, Wortlaut, Anhaenge. // Die Aufbewahrung ist einstellbar und bewusst knapp — das hier ist ein // Werkzeug zum Nachvollziehen von Loeschungen, kein Archiv. db.exec(` CREATE TABLE IF NOT EXISTS message_cache ( message_id TEXT PRIMARY KEY, channel_id TEXT NOT NULL, author_id TEXT NOT NULL, author TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', anhaenge TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_msgcache_ts ON message_cache (created_at); `); const rememberMsgStmt = db.prepare(` INSERT OR REPLACE INTO message_cache (message_id, channel_id, author_id, author, content, anhaenge) VALUES (@message_id, @channel_id, @author_id, @author, @content, @anhaenge) `); const editMsgStmt = db.prepare('UPDATE message_cache SET content = ? WHERE message_id = ?'); const recallMsgStmt = db.prepare('SELECT * FROM message_cache WHERE message_id = ?'); const forgetMsgStmt = db.prepare('DELETE FROM message_cache WHERE message_id = ?'); const pruneMsgStmt = db.prepare(`DELETE FROM message_cache WHERE created_at < datetime('now', ?)`); const clearMsgStmt = db.prepare('DELETE FROM message_cache'); export const rememberMessage = (m) => rememberMsgStmt.run(m); export const editRememberedMessage = (id, text) => editMsgStmt.run(text, id).changes > 0; export const recallMessage = (id) => { const row = recallMsgStmt.get(id); return row ? { ...row, anhaenge: JSON.parse(row.anhaenge || '[]') } : null; }; export const forgetMessage = (id) => forgetMsgStmt.run(id); /** * Zu Altes vergessen. Bei 0 Tagen wird alles vergessen — „-0 days" ist genau * jetzt, und `< jetzt` liesse die eben geschriebene Zeile stehen. */ export const pruneMessageCache = (tage) => (tage > 0 ? pruneMsgStmt.run(`-${tage} days`).changes : clearMsgStmt.run().changes); // Ticket-System (Button → privater Thread) db.exec(` CREATE TABLE IF NOT EXISTS tickets ( thread_id TEXT PRIMARY KEY, user_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', opened_at TEXT NOT NULL DEFAULT (datetime('now')) ); `); // Migration: Anliegen (Kategorie) und wer sich darum kümmert { const cols = db.prepare('PRAGMA table_info(tickets)').all().map((c) => c.name); if (!cols.includes('category_id')) db.exec('ALTER TABLE tickets ADD COLUMN category_id INTEGER'); if (!cols.includes('claimed_by')) db.exec('ALTER TABLE tickets ADD COLUMN claimed_by TEXT'); if (!cols.includes('claimed_at')) db.exec('ALTER TABLE tickets ADD COLUMN claimed_at TEXT'); } const insertTicket = db.prepare( 'INSERT INTO tickets (thread_id, user_id, category_id) VALUES (?, ?, ?)' ); const getTicketStmt = db.prepare('SELECT * FROM tickets WHERE thread_id = ?'); const closeTicketStmt = db.prepare(`UPDATE tickets SET status = 'closed' WHERE thread_id = ?`); const openTicketByUserStmt = db.prepare( `SELECT * FROM tickets WHERE user_id = ? AND status = 'open'` ); // Übernehmen nur, solange niemand anders dran ist — die Bedingung gehört in die // Abfrage und nicht davor: zwei gleichzeitige Klicks würden sonst beide gewinnen. const claimTicketStmt = db.prepare(` UPDATE tickets SET claimed_by = ?, claimed_at = datetime('now') WHERE thread_id = ? AND claimed_by IS NULL `); const unclaimTicketStmt = db.prepare( 'UPDATE tickets SET claimed_by = NULL, claimed_at = NULL WHERE thread_id = ?' ); export const saveTicket = (threadId, userId, categoryId = null) => insertTicket.run(threadId, userId, categoryId ?? null); export const getTicket = (threadId) => getTicketStmt.get(threadId) ?? null; export const closeTicket = (threadId) => closeTicketStmt.run(threadId); export const openTicketOf = (userId) => openTicketByUserStmt.get(userId) ?? null; /** @returns {boolean} true, wenn dieser Aufruf das Ticket übernommen hat */ export const claimTicket = (threadId, userId) => claimTicketStmt.run(userId, threadId).changes > 0; export const unclaimTicket = (threadId) => unclaimTicketStmt.run(threadId); // Anliegen: „Bug melden", „Bewerbung", „Sonstiges" … Ohne Eintrag bleibt es beim // einen Knopf — wer nur eine Sorte Tickets hat, soll nichts einrichten müssen. db.exec(` CREATE TABLE IF NOT EXISTS ticket_categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, emoji TEXT NOT NULL DEFAULT '🎫', beschreibung TEXT NOT NULL DEFAULT '', ping_role_id TEXT, intro TEXT NOT NULL DEFAULT '', sort INTEGER NOT NULL DEFAULT 0 ); `); const listTicketCatsStmt = db.prepare('SELECT * FROM ticket_categories ORDER BY sort, id'); const getTicketCatStmt = db.prepare('SELECT * FROM ticket_categories WHERE id = ?'); const insertTicketCatStmt = db.prepare(` INSERT INTO ticket_categories (name, emoji, beschreibung, ping_role_id, intro, sort) VALUES (@name, @emoji, @beschreibung, @ping_role_id, @intro, @sort) `); const updateTicketCatStmt = db.prepare(` UPDATE ticket_categories SET name = @name, emoji = @emoji, beschreibung = @beschreibung, ping_role_id = @ping_role_id, intro = @intro, sort = @sort WHERE id = @id `); const deleteTicketCatStmt = db.prepare('DELETE FROM ticket_categories WHERE id = ?'); export const listTicketCategories = () => listTicketCatsStmt.all(); export const getTicketCategory = (id) => getTicketCatStmt.get(id) ?? null; export const createTicketCategory = (c) => insertTicketCatStmt.run(c).lastInsertRowid; export const updateTicketCategory = (c) => updateTicketCatStmt.run(c); export const deleteTicketCategory = (id) => deleteTicketCatStmt.run(id).changes > 0; // Modmail: DM-Konversationen ↔ Staff-Threads db.exec(` CREATE TABLE IF NOT EXISTS modmail ( user_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL UNIQUE, username TEXT, opened_at TEXT NOT NULL DEFAULT (datetime('now')) ); `); const upsertModmail = db.prepare(` INSERT INTO modmail (user_id, thread_id, username) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET thread_id = excluded.thread_id, username = excluded.username `); const modmailByUserStmt = db.prepare('SELECT * FROM modmail WHERE user_id = ?'); const modmailByThreadStmt = db.prepare('SELECT * FROM modmail WHERE thread_id = ?'); export const saveModmail = (userId, threadId, username) => upsertModmail.run(userId, threadId, username); export const modmailByUser = (userId) => modmailByUserStmt.get(userId) ?? null; export const modmailByThread = (threadId) => modmailByThreadStmt.get(threadId) ?? null; // API-Keys für externe Skripte (/api/v1/*) — nur der SHA-256-Hash wird gespeichert db.exec(` CREATE TABLE IF NOT EXISTS api_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, key_hash TEXT NOT NULL UNIQUE, scopes TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), last_used_at TEXT ); `); const insertApiKey = db.prepare( 'INSERT INTO api_keys (name, key_hash, scopes) VALUES (?, ?, ?)' ); const findApiKeyStmt = db.prepare('SELECT * FROM api_keys WHERE key_hash = ?'); const touchApiKeyStmt = db.prepare( `UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?` ); const listApiKeysStmt = db.prepare( 'SELECT id, name, scopes, created_at, last_used_at FROM api_keys ORDER BY id' ); const deleteApiKeyStmt = db.prepare('DELETE FROM api_keys WHERE id = ?'); const hashKey = (key) => crypto.createHash('sha256').update(key).digest('hex'); /** Neuen Key erzeugen — der Klartext-Key wird nur einmal zurückgegeben! */ export function createApiKey(name, scopes) { const key = `d4rk_${crypto.randomBytes(24).toString('hex')}`; const info = insertApiKey.run(name, hashKey(key), scopes.join(',')); return { id: info.lastInsertRowid, key }; } /** Key prüfen: gibt { id, name, scopes: [] } zurück oder null; aktualisiert last_used */ export function verifyApiKey(key) { if (!key?.startsWith('d4rk_')) return null; const row = findApiKeyStmt.get(hashKey(key)); if (!row) return null; touchApiKeyStmt.run(row.id); return { id: row.id, name: row.name, scopes: row.scopes.split(',') }; } export function listApiKeys() { return listApiKeysStmt.all(); } export function deleteApiKey(id) { return deleteApiKeyStmt.run(id).changes > 0; } // Laufzeit-Einstellungen (Settings-Seite im Webinterface) — überschreiben Env-Defaults db.exec('CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'); const getSettingStmt = db.prepare('SELECT value FROM settings WHERE key = ?'); const setSettingStmt = db.prepare(` INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value `); export function getSetting(key) { return getSettingStmt.get(key)?.value ?? null; } export function setSetting(key, value) { setSettingStmt.run(key, value); } // Linked Roles: OAuth-Tokens der Mitglieder, die ihr Konto verknüpft haben. // // Das Speichern ist nicht optional — ohne Refresh-Token bliebe die verknüpfte // Rolle für immer auf dem Stand des Verknüpfungs-Tages stehen, ein Level-Up // käme nie an. Discords eigene Anleitung sagt dasselbe. // // Die Tokens gelten NUR für role_connections.write und identify. Damit kann // man weder Nachrichten lesen noch im Namen des Users handeln. Trotzdem: // wer die Datenbank hat, hat diese Tokens — sie liegen im selben Volume wie // alles andere und werden beim Entkoppeln sofort gelöscht. db.exec(` CREATE TABLE IF NOT EXISTS role_connections ( user_id TEXT PRIMARY KEY, username TEXT, access_token TEXT NOT NULL, refresh_token TEXT NOT NULL, expires_at INTEGER NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); `); const upsertRoleConnStmt = db.prepare(` INSERT INTO role_connections (user_id, username, access_token, refresh_token, expires_at, updated_at) VALUES (@user_id, @username, @access_token, @refresh_token, @expires_at, datetime('now')) ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, access_token = excluded.access_token, refresh_token = excluded.refresh_token, expires_at = excluded.expires_at, updated_at = datetime('now') `); const getRoleConnStmt = db.prepare('SELECT * FROM role_connections WHERE user_id = ?'); const allRoleConnStmt = db.prepare('SELECT * FROM role_connections'); const deleteRoleConnStmt = db.prepare('DELETE FROM role_connections WHERE user_id = ?'); export const saveRoleConnection = (r) => upsertRoleConnStmt.run(r); export const getRoleConnection = (userId) => getRoleConnStmt.get(userId) ?? null; export const allRoleConnections = () => allRoleConnStmt.all(); export const deleteRoleConnection = (userId) => deleteRoleConnStmt.run(userId).changes > 0; // Einmalige Übernahme der alten kommagetrennten Watchdog-Liste in die // Dienst-Tabelle. Muss hier unten stehen, weil sie getSetting() braucht. // // Bewusst nicht öffentlich: was auf der Statusseite auftaucht, soll eine // bewusste Entscheidung sein und nicht durch eine Migration passieren. { const schonDa = db.prepare('SELECT count(*) AS n FROM monitored_services').get().n; const alt = getSetting('watchdog_urls') ?? ''; if (schonDa === 0 && alt.trim()) { const urls = alt.split(',').map((s) => s.trim()).filter(Boolean); for (const [i, url] of urls.entries()) { let name = url; try { name = new URL(url).hostname; } catch { /* bleibt die rohe Adresse */ } createMonitored({ name, url, gruppe: 'Dienste', oeffentlich: false, sort: i }); } if (urls.length > 0) { console.log(`[db] ${urls.length} Watchdog-Adresse(n) in die Dienst-Tabelle übernommen`); } } } const insertCommit = db.prepare(` INSERT OR IGNORE INTO commits (sha, repo, branch, message, author_name, author_user, url, committed_at) VALUES (@sha, @repo, @branch, @message, @author_name, @author_user, @url, @committed_at) `); /** Commits eines Pushes speichern — bereits bekannte SHAs werden ignoriert. Gibt Anzahl neuer Zeilen zurück. */ export const saveCommits = db.transaction((commits) => { let inserted = 0; for (const commit of commits) { inserted += insertCommit.run(commit).changes; } return inserted; }); const insertDevlog = db.prepare(` INSERT OR IGNORE INTO devlogs (message_id, channel_id, content, author_name, posted_at, images) VALUES (@message_id, @channel_id, @content, @author_name, @posted_at, @images) `); const hasDevlogStmt = db.prepare('SELECT 1 FROM devlogs WHERE message_id = ?'); /** Devlog speichern — bereits bekannte Message-IDs werden ignoriert. Gibt true zurück, wenn neu. */ export function saveDevlog(devlog) { return insertDevlog.run({ images: '[]', ...devlog }).changes > 0; } /** true, wenn die Message schon archiviert ist (spart z. B. erneute Bild-Downloads) */ export function hasDevlog(messageId) { return Boolean(hasDevlogStmt.get(messageId)); } const getDevlogImagesStmt = db.prepare('SELECT images FROM devlogs WHERE message_id = ?'); const deleteDevlogStmt = db.prepare('DELETE FROM devlogs WHERE message_id = ?'); /** Devlog löschen; gibt { deleted, images } zurück (Dateinamen fürs Aufräumen) */ export function deleteDevlog(messageId) { const row = getDevlogImagesStmt.get(messageId); if (!row) return { deleted: false, images: [] }; deleteDevlogStmt.run(messageId); return { deleted: true, images: JSON.parse(row.images || '[]') }; } // --- Lese-Queries für die Web-API (Feature 4) --- const selectDevlogs = db.prepare(` SELECT message_id, content, author_name, posted_at, images FROM devlogs ORDER BY posted_at DESC LIMIT ? OFFSET ? `); const countDevlogsStmt = db.prepare('SELECT COUNT(*) AS n FROM devlogs'); export function listDevlogs(limit, offset) { return { items: selectDevlogs.all(limit, offset), total: countDevlogsStmt.get().n }; } // Nach Projekt filtern. Das Projekt steht in der ersten Zeile („Devlog — X"), // es gibt dafür keine eigene Spalte — LIKE auf den Anfang reicht und kommt // ohne Migration aus. const selectDevlogsByProject = db.prepare(` SELECT message_id, content, author_name, posted_at, images FROM devlogs WHERE content LIKE ? ESCAPE '!' ORDER BY posted_at DESC LIMIT ? OFFSET ? `); const countDevlogsByProject = db.prepare( "SELECT COUNT(*) AS n FROM devlogs WHERE content LIKE ? ESCAPE '!'" ); export function listDevlogsByProject(project, limit, offset) { // Escape-Zeichen ist `!` und nicht der übliche Backslash: der müsste hier // zweimal entwertet werden (einmal für JavaScript, einmal für SQL) und // landet dadurch leicht als leere Zeichenkette in der Abfrage. const escaped = String(project).replace(/[!%_]/g, (c) => `!${c}`); const pattern = `Devlog — ${escaped}%`; return { items: selectDevlogsByProject.all(pattern, limit, offset), total: countDevlogsByProject.get(pattern).n, }; } /** Erste Zeile aller Devlogs — daraus leitet die API die Projektliste ab */ const selectDevlogHeadsStmt = db.prepare('SELECT content FROM devlogs'); export const devlogHeads = () => selectDevlogHeadsStmt.all().map((r) => String(r.content).split('\n', 1)[0]); const getDevlogStmt = db.prepare( 'SELECT message_id, content, author_name, posted_at, images FROM devlogs WHERE message_id = ?' ); export const getDevlog = (messageId) => getDevlogStmt.get(messageId) ?? null; const searchDevlogsStmt = db.prepare(` SELECT d.message_id, d.content, d.author_name, d.posted_at, d.images FROM devlogs_fts f JOIN devlogs d ON d.rowid = f.rowid WHERE devlogs_fts MATCH ? ORDER BY d.posted_at DESC LIMIT ? OFFSET ? `); const searchDevlogsCountStmt = db.prepare( 'SELECT count(*) AS n FROM devlogs_fts WHERE devlogs_fts MATCH ?' ); /** Suchbegriff in eine sichere FTS5-Query übersetzen: jede Eingabe als Präfix-Phrase */ function ftsQuery(input) { const terms = input.split(/\s+/).filter(Boolean).slice(0, 8); return terms.map((t) => `"${t.replaceAll('"', '')}"*`).join(' '); } export function searchDevlogs(input, limit, offset) { const query = ftsQuery(input); if (!query) return { items: [], total: 0 }; return { items: searchDevlogsStmt.all(query, limit, offset), total: searchDevlogsCountStmt.get(query).n, }; } const selectCommits = db.prepare(` SELECT sha, repo, branch, message, author_name, url, committed_at FROM commits ORDER BY committed_at DESC LIMIT ? OFFSET ? `); const countCommitsStmt = db.prepare('SELECT COUNT(*) AS n FROM commits'); export function listCommits(limit, offset) { return { items: selectCommits.all(limit, offset), total: countCommitsStmt.get().n }; } const upsertRelease = db.prepare(` INSERT INTO releases (repo, tag, name, body, url, prerelease, published_at) VALUES (@repo, @tag, @name, @body, @url, @prerelease, @published_at) ON CONFLICT(repo, tag) DO UPDATE SET name = excluded.name, body = excluded.body, url = excluded.url, prerelease = excluded.prerelease `); /** Release speichern/aktualisieren. Gibt true zurück, wenn neu (→ ankündigen). */ export function saveRelease(release) { const existed = db .prepare('SELECT 1 FROM releases WHERE repo = ? AND tag = ?') .get(release.repo, release.tag); upsertRelease.run(release); return !existed; } const selectReleases = db.prepare(` SELECT repo, tag, name, body, url, prerelease, published_at FROM releases ORDER BY published_at DESC LIMIT ? OFFSET ? `); const countReleasesStmt = db.prepare('SELECT count(*) AS n FROM releases'); export function listReleases(limit, offset) { return { items: selectReleases.all(limit, offset), total: countReleasesStmt.get().n }; } /** Zahlen der letzten 7 Tage für den Wochen-Rückblick */ export function weeklyStats() { const perDay = db .prepare(` SELECT date(committed_at) AS day, count(*) AS n FROM commits WHERE committed_at >= datetime('now', '-7 days') GROUP BY day `) .all(); const topRepos = db .prepare(` SELECT repo, count(*) AS n FROM commits WHERE committed_at >= datetime('now', '-7 days') GROUP BY repo ORDER BY n DESC LIMIT 3 `) .all(); const devlogs = db .prepare(`SELECT count(*) AS n FROM devlogs WHERE posted_at >= datetime('now', '-7 days')`) .get().n; return { perDay, topRepos, devlogs }; } const lastDevlogStmt = db.prepare('SELECT max(posted_at) AS ts FROM devlogs'); const lastCommitStmt = db.prepare('SELECT max(committed_at) AS ts FROM commits'); /** Statistiken fürs Status-Panel der Settings-Seite */ export function archiveStats() { return { devlogs: countDevlogsStmt.get().n, commits: countCommitsStmt.get().n, dbSizeBytes: statSync(dbFile).size, // Reine Zahlen sagen nicht, ob noch etwas passiert — das Datum schon lastDevlog: lastDevlogStmt.get().ts ?? null, lastCommit: lastCommitStmt.get().ts ?? null, }; } // ── Radio ───────────────────────────────────────────────────────────────── // // Sender sind frei eintragbar, nicht fest verdrahtet: es gibt keinen Grund, // warum jemand nur die Sender hören dürfen sollte, die ich kenne. Die URL ist // eindeutig — denselben Sender zweimal in der Liste hilft niemandem. db.exec(` CREATE TABLE IF NOT EXISTS radio_stations ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, url TEXT NOT NULL UNIQUE, emoji TEXT, sort INTEGER NOT NULL DEFAULT 0 ); `); const listStationsStmt = db.prepare('SELECT * FROM radio_stations ORDER BY sort, name'); const getStationStmt = db.prepare('SELECT * FROM radio_stations WHERE id = ?'); const insertStationStmt = db.prepare(` INSERT INTO radio_stations (name, url, emoji, sort) VALUES (@name, @url, @emoji, @sort) ON CONFLICT(url) DO NOTHING `); const updateStationStmt = db.prepare(` UPDATE radio_stations SET name = @name, url = @url, emoji = @emoji, sort = @sort WHERE id = @id `); const deleteStationStmt = db.prepare('DELETE FROM radio_stations WHERE id = ?'); export const listStations = () => listStationsStmt.all(); export const getStation = (id) => getStationStmt.get(id) ?? null; export const createStation = (s) => insertStationStmt.run({ name: String(s.name ?? '').slice(0, 60), url: String(s.url ?? ''), emoji: s.emoji ? String(s.emoji).slice(0, 8) : null, sort: Number(s.sort) || 0, }).lastInsertRowid; export const updateStation = (s) => updateStationStmt.run({ id: Number(s.id), name: String(s.name ?? '').slice(0, 60), url: String(s.url ?? ''), emoji: s.emoji ? String(s.emoji).slice(0, 8) : null, sort: Number(s.sort) || 0, }); export const deleteStation = (id) => deleteStationStmt.run(id).changes > 0; // Was gerade wo läuft. Steht in der Datenbank und nicht nur im Speicher, damit // der Bot nach einem Neustart von selbst zurückkommt — ein Deploy wirft ihn // sonst aus dem Sprachkanal und die Musik ist einfach weg. db.exec(` CREATE TABLE IF NOT EXISTS radio_state ( guild_id TEXT PRIMARY KEY, channel_id TEXT NOT NULL, station_id INTEGER, panel_channel_id TEXT, panel_message_id TEXT, seit TEXT NOT NULL DEFAULT (datetime('now')) ); `); const radioStateStmt = db.prepare('SELECT * FROM radio_state WHERE guild_id = ?'); const radioStatesStmt = db.prepare('SELECT * FROM radio_state'); const setRadioStateStmt = db.prepare(` INSERT INTO radio_state (guild_id, channel_id, station_id, panel_channel_id, panel_message_id, seit) VALUES (@guild_id, @channel_id, @station_id, @panel_channel_id, @panel_message_id, datetime('now')) ON CONFLICT(guild_id) DO UPDATE SET channel_id = excluded.channel_id, station_id = excluded.station_id, panel_channel_id = excluded.panel_channel_id, panel_message_id = excluded.panel_message_id `); const clearRadioStateStmt = db.prepare('DELETE FROM radio_state WHERE guild_id = ?'); export const radioState = (guildId) => radioStateStmt.get(guildId) ?? null; export const radioStates = () => radioStatesStmt.all(); export const setRadioState = (s) => setRadioStateStmt.run({ guild_id: String(s.guildId), channel_id: String(s.channelId), station_id: s.stationId != null ? Number(s.stationId) : null, panel_channel_id: s.panelChannelId ? String(s.panelChannelId) : null, panel_message_id: s.panelMessageId ? String(s.panelMessageId) : null, }); export const clearRadioState = (guildId) => clearRadioStateStmt.run(guildId).changes > 0; // ── Warteschlange (YouTube) ─────────────────────────────────────────────── // // Der ganze Unterschied zwischen einem Sender und einem YouTube-Titel ist, // dass der Titel endet. Daraus folgt alles Weitere: es muss etwas geben, das // danach kommt, und das ist diese Liste. // // Sie steht in der Datenbank und nicht nur im Speicher, aus demselben Grund // wie `radio_state`: ein Deploy soll die Musik unterbrechen, nicht die // Wünsche von fünf Leuten wegwerfen. // // `sort` statt einer verketteten Liste, weil das Panel umsortieren können soll // — und weil eine Liste, deren Reihenfolge nur aus `id` folgt, sich genau // einmal umsortieren lässt: gar nicht. db.exec(` CREATE TABLE IF NOT EXISTS radio_queue ( id INTEGER PRIMARY KEY AUTOINCREMENT, guild_id TEXT NOT NULL, video_id TEXT NOT NULL, url TEXT NOT NULL, titel TEXT NOT NULL, dauer INTEGER, kanal TEXT, thumb TEXT, wunsch_von TEXT, sort INTEGER NOT NULL DEFAULT 0, added_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS radio_queue_guild ON radio_queue (guild_id, sort, id); `); const queueListStmt = db.prepare( 'SELECT * FROM radio_queue WHERE guild_id = ? ORDER BY sort, id' ); const queueGetStmt = db.prepare('SELECT * FROM radio_queue WHERE id = ?'); const queueCountStmt = db.prepare( 'SELECT COUNT(*) AS n, COALESCE(SUM(dauer), 0) AS dauer FROM radio_queue WHERE guild_id = ?' ); const queueAddStmt = db.prepare(` INSERT INTO radio_queue (guild_id, video_id, url, titel, dauer, kanal, thumb, wunsch_von, sort) VALUES (@guild_id, @video_id, @url, @titel, @dauer, @kanal, @thumb, @wunsch_von, (SELECT COALESCE(MAX(sort), 0) + 1 FROM radio_queue WHERE guild_id = @guild_id)) `); const queueHeadStmt = db.prepare( 'SELECT * FROM radio_queue WHERE guild_id = ? ORDER BY sort, id LIMIT 1' ); const queueDeleteStmt = db.prepare('DELETE FROM radio_queue WHERE id = ?'); const queueClearStmt = db.prepare('DELETE FROM radio_queue WHERE guild_id = ?'); const queueVorStmt = db.prepare(` UPDATE radio_queue SET sort = (SELECT COALESCE(MIN(sort), 0) - 1 FROM radio_queue WHERE guild_id = @guild_id) WHERE id = @id AND guild_id = @guild_id `); const queueSortStmt = db.prepare( 'UPDATE radio_queue SET sort = @sort WHERE id = @id AND guild_id = @guild_id' ); export const queueList = (guildId) => queueListStmt.all(String(guildId)); export const queueGet = (id) => queueGetStmt.get(Number(id)) ?? null; export const queuePeek = (guildId) => queueHeadStmt.get(String(guildId)) ?? null; /** Wie viele und wie lange — für die Tafel, ohne die ganze Liste zu holen */ export const queueStand = (guildId) => queueCountStmt.get(String(guildId)); export const queueAdd = (guildId, e) => queueAddStmt.run({ guild_id: String(guildId), video_id: String(e.video_id ?? ''), url: String(e.url ?? ''), titel: String(e.titel ?? 'Ohne Titel').slice(0, 200), // Livestreams haben keine Dauer. NULL heisst genau das — eine 0 würde in // der Gesamtdauer mitgezählt und behauptete, der Titel sei sofort vorbei. dauer: Number.isFinite(Number(e.dauer)) && e.dauer !== null ? Number(e.dauer) : null, kanal: e.kanal ? String(e.kanal).slice(0, 120) : null, thumb: e.thumb ? String(e.thumb).slice(0, 500) : null, wunsch_von: e.wunsch_von ? String(e.wunsch_von).slice(0, 80) : null, }).lastInsertRowid; export const queueRemove = (id) => queueDeleteStmt.run(Number(id)).changes > 0; export const queueClear = (guildId) => queueClearStmt.run(String(guildId)).changes; export const queueVor = (guildId, id) => queueVorStmt.run({ guild_id: String(guildId), id: Number(id) }).changes > 0; /** * Den nächsten Titel nehmen — holen und entfernen in einem Zug. * * Als Transaktion, weil sonst zwei gleichzeitige Auslöser (der Titel endet * *und* jemand drückt „Überspringen") denselben Eintrag bekommen und einer * davon doppelt läuft. */ export const queueNext = db.transaction((guildId) => { const e = queueHeadStmt.get(String(guildId)); if (e) queueDeleteStmt.run(e.id); return e ?? null; }); /** * Neue Reihenfolge setzen. Nur Einträge dieses Servers, nur bekannte IDs. * * Der Server bestimmt die Zahlen, nicht der Browser: was von aussen kommt, * ist eine Reihenfolge von IDs und sonst nichts. */ export const queueReorder = db.transaction((guildId, ids) => { let n = 0; ids.forEach((id, i) => { n += queueSortStmt.run({ guild_id: String(guildId), id: Number(id), sort: i }).changes; }); return n; }); // ── Die zweite Tafel (YouTube) ──────────────────────────────────────────── // // Eigene Tabelle statt zweier Spalten in `radio_state`, und das ist kein // Geschmack: `radio_state` wird beim Stoppen gelöscht. Die YouTube-Tafel soll // das überleben — sie steht ja weiter im Kanal, und wer nach dem Stoppen // wieder etwas einreiht, erwartet, dass sich genau diese Nachricht aktualisiert // statt eine zweite danebenzustellen. db.exec(` CREATE TABLE IF NOT EXISTS radio_yt_panel ( guild_id TEXT PRIMARY KEY, channel_id TEXT NOT NULL, message_id TEXT NOT NULL ); `); const ytPanelStmt = db.prepare('SELECT * FROM radio_yt_panel WHERE guild_id = ?'); const ytPanelsStmt = db.prepare('SELECT * FROM radio_yt_panel'); const setYtPanelStmt = db.prepare(` INSERT INTO radio_yt_panel (guild_id, channel_id, message_id) VALUES (@guild_id, @channel_id, @message_id) ON CONFLICT(guild_id) DO UPDATE SET channel_id = excluded.channel_id, message_id = excluded.message_id `); const clearYtPanelStmt = db.prepare('DELETE FROM radio_yt_panel WHERE guild_id = ?'); export const ytPanel = (guildId) => ytPanelStmt.get(String(guildId)) ?? null; export const ytPanels = () => ytPanelsStmt.all(); export const setYtPanel = (guildId, channelId, messageId) => setYtPanelStmt.run({ guild_id: String(guildId), channel_id: String(channelId), message_id: String(messageId), }); export const clearYtPanel = (guildId) => clearYtPanelStmt.run(String(guildId)).changes > 0;