Files
d4rkbot/src/db.js
T
D4rkst3randClaude Opus 4.8 4d1b0a956d Ticket-System, Web-Composer und Nachrichten-Bearbeitung
- Tickets: /ticket-setup postet 🎫-Button; Klick erstellt privaten Thread
  (ein offenes Ticket pro User), Schließen-Button sperrt + archiviert
- Composer auf der Setup-Seite: Nachricht/Embed als Bot in beliebigen Kanal
  senden oder per Message-ID bearbeiten (nur eigene Bot-Posts)
- API v1: PATCH /api/v1/message — Skripte können ihre Bot-Posts aktualisieren
- Setting ticket_channel_id; README ergänzt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:43:27 +02:00

506 lines
20 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'
);
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();
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 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);
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();
}
// 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);
}
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 };
}
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 };
}
/** Statistiken fürs Status-Panel der Settings-Seite */
export function archiveStats() {
return {
devlogs: countDevlogsStmt.get().n,
commits: countCommitsStmt.get().n,
dbSizeBytes: statSync(dbFile).size,
};
}