Files
d4rkbot/src/db.js
T
D4rkst3r af16bc1b18 YouTube als zweite Tonquelle: yt-dlp, Warteschlange, Abbild
Das Fundament, noch ohne Anbindung ans Radio.

yt-dlp holt selbst und schiebt rohe Bytes durch ein Rohr an ffmpeg. Der
bequemere Weg waere `-g` und die fertige Adresse -- der ist eine Falle:
googlevideo-Adressen laufen ab und haengen an der IP, die sie geholt hat.
Das schlaegt nach zehn Minuten zu und sieht dann aus wie ein Netzproblem.

Beim Stroemen nach stdout ist die Vorgabe von yt-dlp *mit* Bild; ohne ein
ausdrueckliches `-f bestaudio/best` laedt ein Tonstrom das ganze Video.
Nachgelesen im README der Fassung 2026.08.19.

stderr wird aufgehoben statt weggeworfen. Genau hier entsteht sonst der
Fehler, den niemand deuten kann: YouTube antwortet "Sign in to confirm",
yt-dlp bricht ab, der Bot sitzt still im Kanal. Die letzte ERROR-Zeile
geht ins Panel.

Gemessen am 28.08.2026 gegen echtes YouTube (yt-dlp 2026.08.19, von hier
aus, NICHT auf dem Zielhost): Einzelvideo, Suche und flache Playlist
liefern die erwarteten Felder, eine kaputte Video-ID meldet "Video
unavailable" und wird als Fehler durchgereicht statt als leere Liste.

Die Warteschlange steht in der Datenbank, nicht nur im Speicher -- ein
Deploy soll die Musik unterbrechen, nicht die Wuensche von fuenf Leuten
wegwerfen. Die YouTube-Tafel bekommt eine eigene Tabelle, weil
`radio_state` beim Stoppen geloescht wird und die Tafel das ueberleben
soll.

Nebenbei: der ffmpeg-Kommentar im Dockerfile beschrieb noch die alte
Ogg/Opus-Kette. Seit der Lautstaerkeregelung laeuft dort PCM.
2026-08-28 13:26:05 +02:00

2110 lines
99 KiB
JavaScript

// 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<string, number>} 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;