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;