Community-Endgame: Alpha-Keys, Bewerbungen, Events, Stats, Triggers, /remind, Social, Temp-Voice + MEE6-Bot-Karte
- Alpha-Keys: Pool im Community-Tab, Ein-Klick-Verteilung per DM an alle Playtester (Key bleibt frei, wenn die DM geblockt wird) - Bewerbungs-Formulare: Builder im neuen Bewerbungen-Tab (bis 5 Fragen), Button → Discord-Modal → Review-Embed mit ✅/❌, Rolle + DM bei Entscheidung - Events: GuildScheduledEventCreate → Announce-Embed; öffentliche /events-Seite aus den Discord-Events (5-min-Cache) - Server-Stats: activity_daily (Nachrichten/Joins/Leaves) → Balken-Chart auf /level - Triggers (Auto-Antworten, 30s-Cooldown), /remind (DM-Scheduler), Twitch-Live (Helix, App-Creds write-only) + YouTube-RSS-Announcements, Temp-Voice (Join to Create, Cleanup bei Leerstand + Start) - Brand-Tab: MEE6-Style Bot-Identity-Karte (Avatar-Vorschau mit Status-Dot, Bot-Name via setUsername, Presence online/idle/dnd, Aktivität) - Neue Intents: GuildVoiceStates, GuildScheduledEvents; alles smoke-getestet Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+223
-1
@@ -7,7 +7,11 @@ import {
|
||||
createRoleMenu, updateRoleMenu, getRoleMenu, listRoleMenus, deleteRoleMenu,
|
||||
topLevels, saveTag, listTags, deleteTag,
|
||||
createScheduledPost, listScheduledPosts, deleteScheduledPost,
|
||||
addAlphaKeys, freeAlphaKeyCount, assignedAlphaKeys, alphaKeyOf, reserveAlphaKey, unreserveAlphaKey,
|
||||
createAppForm, updateAppForm, getAppForm, listAppForms, deleteAppForm,
|
||||
saveTrigger, listTriggers, deleteTrigger, activityRange,
|
||||
} from '../db.js';
|
||||
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
|
||||
import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-menus.js';
|
||||
import { computeNextRun } from './scheduled-posts.js';
|
||||
import { config } from '../config.js';
|
||||
@@ -80,8 +84,36 @@ export function registerApiRoutes(app, client) {
|
||||
app.get('/api/wishes', async () => ({ wishes: topWishes(20) }));
|
||||
app.get('/api/heatmap', async () => ({ days: commitHeatmap() }));
|
||||
|
||||
// Level-Bestenliste — öffentlich
|
||||
// Level-Bestenliste + Server-Aktivität — öffentlich
|
||||
app.get('/api/levels', async () => ({ levels: topLevels(50) }));
|
||||
app.get('/api/serverstats', async () => ({
|
||||
days: activityRange(30),
|
||||
members: [...client.guilds.cache.values()].reduce((sum, g) => sum + (g.memberCount ?? 0), 0),
|
||||
}));
|
||||
|
||||
// Discord-Events — öffentlich (5-min-Cache)
|
||||
let eventsCache = { at: 0, data: null };
|
||||
app.get('/api/events', async () => {
|
||||
if (eventsCache.data && Date.now() - eventsCache.at < 5 * 60_000) return eventsCache.data;
|
||||
const events = [];
|
||||
for (const guild of client.guilds.cache.values()) {
|
||||
const fetched = await guild.scheduledEvents?.fetch?.().catch(() => null);
|
||||
for (const e of fetched?.values?.() ?? []) {
|
||||
events.push({
|
||||
name: e.name,
|
||||
description: e.description ?? '',
|
||||
start: e.scheduledStartAt?.toISOString() ?? null,
|
||||
end: e.scheduledEndAt?.toISOString() ?? null,
|
||||
url: e.url,
|
||||
cover: e.coverImageURL?.({ size: 1024 }) ?? null,
|
||||
interested: e.userCount ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
events.sort((a, b) => (a.start ?? '').localeCompare(b.start ?? ''));
|
||||
eventsCache = { at: Date.now(), data: { events } };
|
||||
return eventsCache.data;
|
||||
});
|
||||
|
||||
// Roadmap — öffentlich, aus Gitea-Milestones (5-Minuten-Cache gegen API-Hammering)
|
||||
let roadmapCache = { at: 0, repo: null, data: null };
|
||||
@@ -234,6 +266,13 @@ ${rssItems}
|
||||
levels_enabled: getSetting('levels_enabled') === '1',
|
||||
levels_announce: getSetting('levels_announce') !== '0',
|
||||
level_rewards: getSetting('level_rewards') ?? '',
|
||||
bot_presence_status: getSetting('bot_presence_status') ?? 'online',
|
||||
events_announce_channel_id: getSetting('events_announce_channel_id') ?? '',
|
||||
social_announce_channel_id: getSetting('social_announce_channel_id') ?? '',
|
||||
youtube_channel_id: getSetting('youtube_channel_id') ?? '',
|
||||
twitch_channel: getSetting('twitch_channel') ?? '',
|
||||
twitch_creds_set: Boolean(getSetting('twitch_client_id') && getSetting('twitch_client_secret')),
|
||||
tempvoice_channel_id: getSetting('tempvoice_channel_id') ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,6 +286,8 @@ ${rssItems}
|
||||
settings: currentSettings(),
|
||||
status: {
|
||||
botTag: client.user?.tag ?? null,
|
||||
botName: client.user?.username ?? null,
|
||||
botAvatar: client.user?.displayAvatarURL?.({ size: 128 }) ?? null,
|
||||
uptimeSeconds: Math.floor(process.uptime()),
|
||||
guilds: client.guilds.cache.size,
|
||||
giteaTokenConfigured: Boolean(giteaApiToken()),
|
||||
@@ -266,6 +307,7 @@ ${rssItems}
|
||||
'release_channel_id', 'backup_channel_id', 'starboard_channel_id',
|
||||
'screenshot_channel_id', 'modmail_channel_id', 'welcome_channel_id',
|
||||
'modlog_channel_id', 'status_channel_id', 'voting_channel_id', 'ticket_channel_id',
|
||||
'events_announce_channel_id', 'social_announce_channel_id',
|
||||
];
|
||||
for (const key of ['commit_channel_id', 'devlog_channel_id', ...OPTIONAL_CHANNELS]) {
|
||||
if (body[key] === undefined) continue;
|
||||
@@ -351,6 +393,22 @@ ${rssItems}
|
||||
setSetting('bot_status_text', String(body.bot_status_text).trim().slice(0, 120));
|
||||
statusChanged = true;
|
||||
}
|
||||
if (body.bot_presence_status !== undefined) {
|
||||
if (!['online', 'idle', 'dnd'].includes(body.bot_presence_status)) {
|
||||
return reply.code(400).send({ error: 'bot_presence_status ungültig' });
|
||||
}
|
||||
setSetting('bot_presence_status', body.bot_presence_status);
|
||||
statusChanged = true;
|
||||
}
|
||||
// Bot-Name (Discord rate-limitet Umbenennen stark)
|
||||
if (body.bot_name !== undefined && String(body.bot_name).trim() &&
|
||||
String(body.bot_name).trim() !== client.user?.username) {
|
||||
try {
|
||||
await client.user.setUsername(String(body.bot_name).trim().slice(0, 32));
|
||||
} catch (error) {
|
||||
return reply.code(502).send({ error: `Bot-Name: Discord lehnt ab (${error.message?.slice(0, 80)})` });
|
||||
}
|
||||
}
|
||||
if (statusChanged) {
|
||||
const { applyBotStatus } = await import('../bot/presence.js');
|
||||
applyBotStatus(client);
|
||||
@@ -362,6 +420,15 @@ ${rssItems}
|
||||
if (body.gitea_api_token !== undefined && String(body.gitea_api_token).trim() !== '') {
|
||||
setSetting('gitea_api_token', String(body.gitea_api_token).trim());
|
||||
}
|
||||
// Social + Temp-Voice
|
||||
for (const key of ['youtube_channel_id', 'twitch_channel', 'tempvoice_channel_id']) {
|
||||
if (body[key] !== undefined) setSetting(key, String(body[key]).trim());
|
||||
}
|
||||
for (const key of ['twitch_client_id', 'twitch_client_secret']) {
|
||||
if (body[key] !== undefined && String(body[key]).trim() !== '') {
|
||||
setSetting(key, String(body[key]).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());
|
||||
@@ -488,6 +555,161 @@ ${rssItems}
|
||||
return { deleted: true };
|
||||
});
|
||||
|
||||
// --- Alpha-Keys (Admin) ---
|
||||
|
||||
const keyStatus = () => ({
|
||||
free: freeAlphaKeyCount(),
|
||||
assigned: assignedAlphaKeys(),
|
||||
playtestersWithout: listPlaytesters().filter((p) => !alphaKeyOf(p.user_id)),
|
||||
});
|
||||
|
||||
app.get('/api/alphakeys', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
return keyStatus();
|
||||
});
|
||||
|
||||
app.post('/api/alphakeys', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
const keys = String(request.body?.keys ?? '')
|
||||
.split(/\r?\n/)
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean);
|
||||
if (keys.length === 0) return reply.code(400).send({ error: 'keys: eine pro Zeile' });
|
||||
const added = addAlphaKeys(keys);
|
||||
request.log.info(`${added} Alpha-Keys hinzugefügt`);
|
||||
return { ok: true, added, ...keyStatus() };
|
||||
});
|
||||
|
||||
// Verteilen: jeder Playtester ohne Key bekommt einen per DM (Key bleibt frei bei DM-Fehler)
|
||||
app.post('/api/alphakeys/distribute', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
let sent = 0;
|
||||
const failed = [];
|
||||
for (const p of keyStatus().playtestersWithout) {
|
||||
const key = reserveAlphaKey(p.user_id);
|
||||
if (!key) break; // Pool leer
|
||||
try {
|
||||
const user = await client.users.fetch(p.user_id);
|
||||
await user.send(
|
||||
`🎟️ **Dein EcoGame-Alpha-Key:**\n\`\`\`\n${key}\n\`\`\`\nViel Spaß beim Testen — Feedback gern per \`/bug\` oder \`/wunsch\`! 💛`
|
||||
);
|
||||
sent++;
|
||||
} catch {
|
||||
unreserveAlphaKey(key);
|
||||
failed.push(p.username ?? p.user_id);
|
||||
}
|
||||
}
|
||||
request.log.info(`Alpha-Keys verteilt: ${sent} gesendet, ${failed.length} fehlgeschlagen`);
|
||||
return { ok: true, sent, failed, ...keyStatus() };
|
||||
});
|
||||
|
||||
// --- Bewerbungs-Formulare (Admin) ---
|
||||
|
||||
const formToJson = (f) => ({ ...f, questions: JSON.parse(f.questions || '[]') });
|
||||
|
||||
function parseFormBody(request, reply) {
|
||||
const body = request.body ?? {};
|
||||
const title = String(body.title ?? '').trim();
|
||||
if (!title) {
|
||||
reply.code(400).send({ error: 'title nötig' });
|
||||
return null;
|
||||
}
|
||||
const questions = (Array.isArray(body.questions) ? body.questions : [])
|
||||
.map((q) => String(q).trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, MAX_QUESTIONS);
|
||||
for (const key of ['review_channel_id', 'post_channel_id']) {
|
||||
const v = String(body[key] ?? '');
|
||||
if (v && !client.channels.cache.get(v)?.isTextBased?.()) {
|
||||
reply.code(400).send({ error: `${key}: Kanal nicht gefunden` });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const roleId = String(body.approve_role_id ?? '');
|
||||
if (roleId && ![...client.guilds.cache.values()].some((g) => g.roles?.cache?.has?.(roleId))) {
|
||||
reply.code(400).send({ error: 'approve_role_id: Rolle nicht gefunden' });
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
title: title.slice(0, 100),
|
||||
description: String(body.description ?? '').trim().slice(0, 1000),
|
||||
review_channel_id: String(body.review_channel_id ?? '') || null,
|
||||
approve_role_id: roleId || null,
|
||||
post_channel_id: String(body.post_channel_id ?? '') || null,
|
||||
questions: JSON.stringify(questions),
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/api/appforms', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
return { forms: listAppForms().map(formToJson) };
|
||||
});
|
||||
|
||||
app.post('/api/appforms', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
const form = parseFormBody(request, reply);
|
||||
if (!form) return;
|
||||
const id = createAppForm(form);
|
||||
return { ok: true, form: formToJson(getAppForm(id)) };
|
||||
});
|
||||
|
||||
app.put('/api/appforms/:id', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
const id = Number(request.params.id);
|
||||
if (!getAppForm(id)) return reply.code(404).send({ error: 'Formular nicht gefunden' });
|
||||
const form = parseFormBody(request, reply);
|
||||
if (!form) return;
|
||||
updateAppForm({ ...form, id });
|
||||
if (getAppForm(id).message_id) {
|
||||
await publishAppForm(client, id).catch((e) =>
|
||||
request.log.warn(`Formular ${id}: Discord-Update fehlgeschlagen: ${e.message}`)
|
||||
);
|
||||
}
|
||||
return { ok: true, form: formToJson(getAppForm(id)) };
|
||||
});
|
||||
|
||||
app.post('/api/appforms/:id/publish', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
try {
|
||||
const messageId = await publishAppForm(client, Number(request.params.id));
|
||||
return { ok: true, message_id: messageId };
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/appforms/:id', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
const form = getAppForm(Number(request.params.id));
|
||||
if (!form) return { deleted: false };
|
||||
await unpublishAppForm(client, form);
|
||||
deleteAppForm(form.id);
|
||||
return { deleted: true };
|
||||
});
|
||||
|
||||
// --- Triggers (Admin) ---
|
||||
|
||||
app.get('/api/triggers', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
return { triggers: listTriggers() };
|
||||
});
|
||||
|
||||
app.put('/api/triggers', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
const keyword = String(request.body?.keyword ?? '').trim().toLowerCase();
|
||||
const replyText = String(request.body?.reply ?? '').trim();
|
||||
if (keyword.length < 3 || !replyText) {
|
||||
return reply.code(400).send({ error: 'keyword (min 3 Zeichen) und reply nötig' });
|
||||
}
|
||||
saveTrigger(keyword.slice(0, 60), replyText.slice(0, 2000));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.delete('/api/triggers/:keyword', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
return { deleted: deleteTrigger(String(request.params.keyword).toLowerCase()) };
|
||||
});
|
||||
|
||||
// --- Tags (Admin-Verwaltung; Abruf via /tag in Discord) ---
|
||||
|
||||
app.get('/api/tags', async (request, reply) => {
|
||||
|
||||
Reference in New Issue
Block a user