Files
d4rkbot/src/db.js
T
D4rkst3randClaude Opus 5 e32152b5ac
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Eigener Herzschlag: der Bot kann jetzt auch ueber sich selbst Auskunft geben
Der Waechter prueft alles ausser sich selbst — ist der Bot weg, prueft
niemand mehr, und hinterher sieht der Verlauf aus, als waere nie etwas
gewesen. Im Dashboard stand deshalb nur "seit 12d 4h", also wie lange es
diesmal gutgegangen ist. Was davor war, wusste niemand.

Jetzt schreibt der Bot alle zwei Minuten eine Zeile: laeuft der Prozess, und
steht die Verbindung zu Discord? Ein Bot, der laeuft aber nicht verbunden
ist, ist fuer alle draussen genauso weg — deshalb zaehlt beides.

Der Takt ist absichtlich nicht einstellbar. Ein spaeter geaenderter Abstand
wuerde den alten Verlauf falsch aussehen lassen: wie viele Proben eine
Stunde haette haben muessen, liesse sich rueckwirkend nicht mehr sagen.

Ausgewertet wird vor allem die Abwesenheit. Fuer einen fremden Dienst heisst
eine fehlende Stunde "niemand hat gemessen" und wird grau; beim Bot selbst
ist genau das die Aussage — fehlende Stunden werden rot. Grau bleibt nur,
was vor der allerersten Aufzeichnung liegt. Die angebrochene erste und die
laufende letzte Stunde rechnen anteilig, sonst waeren sie dauerhaft gelb.

Nebenbei mitgenommen:

- Statusseite und die frei angelegten Seiten stehen jetzt in der Fusszeile
  beider Seiten — dort sucht man Rechtliches. Die Navigationsleiste zeigt
  weiterhin nur, was "im Menü" gesetzt hat; wer die Nutzungsbedingungen dort
  raus nimmt, hat sie trotzdem noch in der Fusszeile.
- Die Config-Spalte hatte keine Obergrenze. min-width:0 allein reicht nicht,
  wenn ein Kind eine grosse Mindestbreite mitbringt — mit dem Balken wuchs
  sie auf 724 statt 335 px, und weil der Body waagerecht abschneidet, war
  der Ueberstand nicht scrollbar, sondern weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 07:10:53 +02:00

1353 lines
61 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');
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 wishes (
message_id TEXT PRIMARY KEY,
author TEXT,
idea TEXT NOT NULL,
score INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
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)
);
`);
const insertWish = db.prepare('INSERT INTO wishes (message_id, author, idea) VALUES (?, ?, ?)');
const wishScoreStmt = db.prepare('UPDATE wishes SET score = MAX(0, score + ?) WHERE message_id = ?');
const hasWishStmt = db.prepare('SELECT 1 FROM wishes WHERE message_id = ?');
const topWishesStmt = db.prepare(
'SELECT message_id, idea, author, score, created_at FROM wishes ORDER BY score DESC, created_at DESC LIMIT ?'
);
export const saveWish = (messageId, author, idea) => insertWish.run(messageId, author, idea);
export const isWish = (messageId) => Boolean(hasWishStmt.get(messageId));
export const bumpWish = (messageId, delta) => wishScoreStmt.run(delta, messageId);
export const topWishes = (limit = 20) => topWishesStmt.all(limit);
// Web-Votes auf Wünsche (Discord-👍 laufen separat über Reactions)
db.exec(`
CREATE TABLE IF NOT EXISTS wish_votes (
message_id TEXT NOT NULL,
user_id TEXT NOT NULL,
PRIMARY KEY (message_id, user_id)
);
`);
const hasWishVoteStmt = db.prepare('SELECT 1 FROM wish_votes WHERE message_id = ? AND user_id = ?');
const insertWishVote = db.prepare('INSERT OR IGNORE INTO wish_votes (message_id, user_id) VALUES (?, ?)');
const deleteWishVote = db.prepare('DELETE FROM wish_votes WHERE message_id = ? AND user_id = ?');
const myWishVotesStmt = db.prepare('SELECT message_id FROM wish_votes WHERE user_id = ?');
/** Web-Vote togglen; passt den Score mit an. @returns {boolean} true = jetzt gevotet */
export function toggleWishVote(messageId, userId) {
if (hasWishVoteStmt.get(messageId, userId)) {
deleteWishVote.run(messageId, userId);
wishScoreStmt.run(-1, messageId);
return false;
}
insertWishVote.run(messageId, userId);
wishScoreStmt.run(1, messageId);
return true;
}
export const myWishVotes = (userId) => myWishVotesStmt.all(userId).map((r) => r.message_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 = ?');
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);
// 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'))
);
`);
const insertAppForm = db.prepare(`
INSERT INTO app_forms (title, description, review_channel_id, approve_role_id, post_channel_id, questions)
VALUES (@title, @description, @review_channel_id, @approve_role_id, @post_channel_id, @questions)
`);
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
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 = ?');
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 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 ''`);
}
const insertGameserver = db.prepare(`
INSERT INTO gameservers (name, type, query_url, address, host, port, connect_url, image_url)
VALUES (@name, @type, @query_url, @address, @host, @port, @connect_url, @image_url)
`);
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
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 = ?');
export const createGameserver = (s) => insertGameserver.run(s).lastInsertRowid;
export const updateGameserver = (s) => updateGameserverStmt.run(s).changes > 0;
export const setGameserverMessage = (id, messageId) => setGameserverMessageStmt.run(messageId, id);
export const listGameservers = () => listGameserversStmt.all();
export const getGameserver = (id) => getGameserverStmt.get(id) ?? null;
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')`);
// 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
`);
export const recordPlayerSample = (serverId, online, players) =>
insertPlayerSample.run(serverId, online ? 1 : 0, players);
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;
// 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'))
);
`);
const insertTicket = db.prepare('INSERT INTO tickets (thread_id, user_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'`
);
export const saveTicket = (threadId, userId) => insertTicket.run(threadId, userId);
export const getTicket = (threadId) => getTicketStmt.get(threadId) ?? null;
export const closeTicket = (threadId) => closeTicketStmt.run(threadId);
export const openTicketOf = (userId) => openTicketByUserStmt.get(userId) ?? null;
// 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,
};
}