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:
+92
-1
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user