Carl-Bot-Paket: Moderation, Auto-/Sticky-Roles, Level-System, Tags, geplante Posts

- Moderation: /warn (DM + Mod-Log), /warns (Historie + löschen), /timeout (max 28d),
  /purge (bulk, 14-Tage-Limit sauber gemeldet); Mod-Log erweitert um Join/Leave
  und Nick-/Rollen-Änderungen
- Auto-Rolle bei Join + Sticky-Roles (Rollen bei Leave gesichert, bei Rejoin
  wiederhergestellt; managed/@everyone ausgenommen) — Rollen-Tab
- Level-System (opt-in): 15-25 XP/Nachricht mit 60s-Cooldown, MEE6-Formel,
  Level-Up-Announce, Rollen-Belohnungen (Level=RolleID), /rank mit Fortschritt,
  öffentliche Bestenliste /level; Community-Tab
- Tags: /tag mit Autocomplete, Verwaltung im Composer-Tab (/api/tags)
- Geplante Posts: einmalig/täglich/wöchentlich, Minuten-Scheduler, DST-sicher
  über Neuberechnung aus Zeitfeldern; Composer-Tab (/api/scheduled)
- Autocomplete-Dispatch im InteractionCreate; alles smoke-getestet

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 00:30:23 +02:00
co-authored by Claude Fable 5
parent a27a594a07
commit db9c7d07c9
19 changed files with 1066 additions and 5 deletions
+92 -1
View File
@@ -5,8 +5,11 @@ import {
getSetting, setSetting, createApiKey, listApiKeys, deleteApiKey,
listGallery, listPlaytesters, topWishes, commitHeatmap,
createRoleMenu, updateRoleMenu, getRoleMenu, listRoleMenus, deleteRoleMenu,
topLevels, saveTag, listTags, deleteTag,
createScheduledPost, listScheduledPosts, deleteScheduledPost,
} from '../db.js';
import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-menus.js';
import { computeNextRun } from './scheduled-posts.js';
import { config } from '../config.js';
import { removeDevlog } from '../bot/devlog-archive.js';
import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo } from '../runtime-settings.js';
@@ -77,6 +80,9 @@ export function registerApiRoutes(app, client) {
app.get('/api/wishes', async () => ({ wishes: topWishes(20) }));
app.get('/api/heatmap', async () => ({ days: commitHeatmap() }));
// Level-Bestenliste — öffentlich
app.get('/api/levels', async () => ({ levels: topLevels(50) }));
// Roadmap — öffentlich, aus Gitea-Milestones (5-Minuten-Cache gegen API-Hammering)
let roadmapCache = { at: 0, repo: null, data: null };
app.get('/api/roadmap', async (request, reply) => {
@@ -216,6 +222,11 @@ ${rssItems}
voting_channel_id: getSetting('voting_channel_id') ?? '',
ticket_channel_id: getSetting('ticket_channel_id') ?? '',
gameservers: getSetting('gameservers') ?? '',
autorole_id: getSetting('autorole_id') ?? '',
sticky_roles_enabled: getSetting('sticky_roles_enabled') === '1',
levels_enabled: getSetting('levels_enabled') === '1',
levels_announce: getSetting('levels_announce') !== '0',
level_rewards: getSetting('level_rewards') ?? '',
};
}
@@ -263,7 +274,7 @@ ${rssItems}
setSetting(key, value);
}
// Rollen: müssen existieren ('' = Feature aus)
for (const key of ['devlog_ping_role_id', 'playtester_role_id']) {
for (const key of ['devlog_ping_role_id', 'playtester_role_id', 'autorole_id']) {
if (body[key] === undefined) continue;
const value = String(body[key]);
if (value !== '') {
@@ -296,6 +307,18 @@ ${rssItems}
if (body.backup_enabled !== undefined) {
setSetting('backup_enabled', body.backup_enabled ? '1' : '0');
}
if (body.sticky_roles_enabled !== undefined) {
setSetting('sticky_roles_enabled', body.sticky_roles_enabled ? '1' : '0');
}
if (body.levels_enabled !== undefined) {
setSetting('levels_enabled', body.levels_enabled ? '1' : '0');
}
if (body.levels_announce !== undefined) {
setSetting('levels_announce', body.levels_announce ? '1' : '0');
}
if (body.level_rewards !== undefined) {
setSetting('level_rewards', String(body.level_rewards).trim());
}
for (const key of ['commit_branch_filter', 'ignored_repos', 'bug_report_repo', 'watchdog_urls', 'roadmap_repo', 'gameservers']) {
if (body[key] !== undefined) {
setSetting(key, String(body[key]).trim());
@@ -421,6 +444,74 @@ ${rssItems}
return { deleted: true };
});
// --- Tags (Admin-Verwaltung; Abruf via /tag in Discord) ---
app.get('/api/tags', async (request, reply) => {
if (requireAdmin(request, reply)) return;
return { tags: listTags() };
});
app.put('/api/tags/:name', async (request, reply) => {
if (requireAdmin(request, reply)) return;
const name = String(request.params.name).trim().toLowerCase();
const content = String(request.body?.content ?? '').trim();
if (!/^[a-z0-9äöüß_-]{1,40}$/.test(name)) {
return reply.code(400).send({ error: 'Name: nur Kleinbuchstaben, Zahlen, - und _ (max 40)' });
}
if (!content) return reply.code(400).send({ error: 'content nötig' });
saveTag(name, content.slice(0, 2000));
return { ok: true, name };
});
app.delete('/api/tags/:name', async (request, reply) => {
if (requireAdmin(request, reply)) return;
return { deleted: deleteTag(String(request.params.name).trim().toLowerCase()) };
});
// --- Geplante Posts (Admin) ---
app.get('/api/scheduled', async (request, reply) => {
if (requireAdmin(request, reply)) return;
return { posts: listScheduledPosts() };
});
app.post('/api/scheduled', async (request, reply) => {
if (requireAdmin(request, reply)) return;
const body = request.body ?? {};
const channelId = String(body.channel_id ?? '');
const content = String(body.content ?? '').trim();
if (!client.channels.cache.get(channelId)?.isTextBased?.()) {
return reply.code(400).send({ error: 'Kanal nicht gefunden' });
}
if (!content) return reply.code(400).send({ error: 'content nötig' });
let nextRunAt;
try {
nextRunAt = computeNextRun({
type: body.type, date: body.date, time: body.time, weekday: body.weekday,
});
} catch (error) {
return reply.code(400).send({ error: error.message });
}
const id = createScheduledPost({
channel_id: channelId,
content: content.slice(0, 4000),
embed_title: String(body.embed_title ?? '').trim() || null,
type: body.type,
date: body.date ?? null,
time: body.time,
weekday: body.weekday ?? null,
next_run_at: nextRunAt,
});
request.log.info(`Geplanter Post ${id} (${body.type}) → ${nextRunAt} UTC`);
return { ok: true, id, next_run_at: nextRunAt };
});
app.delete('/api/scheduled/:id', async (request, reply) => {
if (requireAdmin(request, reply)) return;
return { deleted: deleteScheduledPost(Number(request.params.id)) };
});
// Composer (Admin): Nachricht/Embed als Bot senden oder eigene Posts bearbeiten
app.post('/api/compose', async (request, reply) => {
if (requireAdmin(request, reply)) return;
+86
View File
@@ -0,0 +1,86 @@
// Geplante Posts (Carl-Bot-Premium-Feature 😄): einmalig, täglich oder wöchentlich —
// verwaltet über den Composer-Tab, ausgeführt von einem Minuten-Scheduler.
// Zeiten sind lokale Serverzeit (TZ im Compose = Europe/Berlin), gespeichert als UTC.
import { EmbedBuilder } from 'discord.js';
import { dueScheduledPosts, markScheduledRun } from '../db.js';
const CHECK_INTERVAL_MS = 60 * 1000;
const toUtcString = (d) => d.toISOString().slice(0, 19).replace('T', ' ');
/**
* Nächsten Ausführungszeitpunkt berechnen (lokale TZ des Prozesses).
* @param {{ type: 'once'|'daily'|'weekly', date?: string, time: string, weekday?: number }} p
* @param {Date} after — erst nach diesem Zeitpunkt (Default: jetzt)
* @returns {string} UTC "YYYY-MM-DD HH:MM:SS"
*/
export function computeNextRun(p, after = new Date()) {
const [h, m] = String(p.time).split(':').map(Number);
if (!Number.isInteger(h) || !Number.isInteger(m)) throw new Error('Uhrzeit ungültig (HH:MM)');
if (p.type === 'once') {
const d = new Date(`${p.date}T${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00`);
if (Number.isNaN(d.getTime())) throw new Error('Datum ungültig');
if (d <= after) throw new Error('Zeitpunkt liegt in der Vergangenheit');
return toUtcString(d);
}
const d = new Date(after);
d.setHours(h, m, 0, 0);
if (p.type === 'daily') {
if (d <= after) d.setDate(d.getDate() + 1);
} else if (p.type === 'weekly') {
const target = Number(p.weekday); // 0 = Sonntag … 6 = Samstag
if (!Number.isInteger(target) || target < 0 || target > 6) throw new Error('Wochentag ungültig');
let diff = (target - d.getDay() + 7) % 7;
if (diff === 0 && d <= after) diff = 7;
d.setDate(d.getDate() + diff);
} else {
throw new Error('Typ ungültig');
}
return toUtcString(d);
}
/** Nachricht bauen: mit Embed-Titel → gebrandetes Embed, sonst Plaintext */
export function buildScheduledPayload(post) {
if (post.embed_title?.trim()) {
return {
embeds: [
new EmbedBuilder()
.setColor(0xf5c518)
.setTitle(post.embed_title.slice(0, 200))
.setDescription(post.content.slice(0, 4000))
.setFooter({ text: 'D4RKST3R' })
.setTimestamp(),
],
};
}
return { content: post.content.slice(0, 2000) };
}
/** Fällige Posts senden — exportiert für Tests und den Timer */
export async function scheduledPostsTick(client) {
for (const post of dueScheduledPosts()) {
try {
const channel = await client.channels.fetch(post.channel_id).catch(() => null);
if (channel?.isTextBased()) {
await channel.send(buildScheduledPayload(post));
console.log(`[scheduled] Post ${post.id} → #${channel.name ?? post.channel_id}`);
} else {
console.warn(`[scheduled] Post ${post.id}: Kanal ${post.channel_id} nicht erreichbar`);
}
} catch (error) {
console.error(`[scheduled] Post ${post.id} fehlgeschlagen:`, error);
}
// Einmalige Posts deaktivieren, wiederkehrende neu terminieren
if (post.type === 'once') {
markScheduledRun(post.id, post.next_run_at, false);
} else {
markScheduledRun(post.id, computeNextRun(post), true);
}
}
}
export function startScheduledPosts(client) {
setInterval(() => scheduledPostsTick(client).catch((e) => console.error('[scheduled]', e)), CHECK_INTERVAL_MS);
}