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:
@@ -33,7 +33,15 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St
|
|||||||
| 📈 **Level-System** | XP pro Nachricht (60s-Cooldown, MEE6-Formel), `/rank`, Level-Up-Announce, Rollen-Belohnungen, öffentliche Bestenliste auf `/level` |
|
| 📈 **Level-System** | XP pro Nachricht (60s-Cooldown, MEE6-Formel), `/rank`, Level-Up-Announce, Rollen-Belohnungen, öffentliche Bestenliste auf `/level` |
|
||||||
| 🏷️ **Tags** | `/tag <name>` (mit Autocomplete) postet gespeicherte Text-Bausteine — verwaltet im Composer-Tab |
|
| 🏷️ **Tags** | `/tag <name>` (mit Autocomplete) postet gespeicherte Text-Bausteine — verwaltet im Composer-Tab |
|
||||||
| ⏰ **Geplante Posts** | Einmalig, täglich oder wöchentlich — im Composer-Tab geplant, vom Bot gepostet (bei Carl Premium) |
|
| ⏰ **Geplante Posts** | Einmalig, täglich oder wöchentlich — im Composer-Tab geplant, vom Bot gepostet (bei Carl Premium) |
|
||||||
| 🎨 **Branding** | Brand-Tab: Name + Embed-Farben für alle Bot-Posts, Bot-Status (Presence), Avatar-/Banner-Upload, Guild-ID & Gitea-Token ohne Env-Redeploy |
|
| 🎨 **Branding** | Brand-Tab mit Bot-Identity-Karte (Avatar-Vorschau, Name, Status online/idle/dnd, Aktivität), Embed-Farben, Banner-Upload, Guild-ID & Gitea-Token ohne Env-Redeploy |
|
||||||
|
| 🎟️ **Alpha-Keys** | Key-Pool im Community-Tab; Ein-Klick-Verteilung an alle Playtester per DM |
|
||||||
|
| 📋 **Bewerbungen** | Formulare (bis 5 Fragen) als Discord-Modal, Review mit ✅/❌ im Staff-Kanal, Rolle + DM bei Annahme — z. B. FiveM-Whitelist |
|
||||||
|
| 📅 **Events** | Neue Discord-Events werden angekündigt + öffentliche `/events`-Seite |
|
||||||
|
| 💬 **Triggers** | Auto-Antworten auf Schlüsselwörter (30s-Cooldown), verwaltet im Composer-Tab |
|
||||||
|
| ⏰ **/remind** | Erinnerungen per DM (`/remind dauer:2h text:…`) |
|
||||||
|
| 📣 **Twitch/YouTube** | 🔴-Live- und ▶️-Neues-Video-Announcements (YouTube ohne Key via RSS, Twitch mit App-Credentials) |
|
||||||
|
| 🔊 **Temp-Voice** | „Join to Create": Hub-Kanal betreten → eigener Voice-Kanal, löscht sich wenn leer |
|
||||||
|
| 📊 **Server-Stats** | Nachrichten/Joins/Leaves pro Tag → Aktivitäts-Chart auf der Level-Seite |
|
||||||
| 👋 **Willkommens-Embed** | Begrüßung neuer Member im Brand-Look (braucht Server-Members-Intent) |
|
| 👋 **Willkommens-Embed** | Begrüßung neuer Member im Brand-Look (braucht Server-Members-Intent) |
|
||||||
| 📋 **Mod-Log** | Gelöschte/bearbeitete Nachrichten in einen privaten Log-Kanal |
|
| 📋 **Mod-Log** | Gelöschte/bearbeitete Nachrichten in einen privaten Log-Kanal |
|
||||||
| 🎮 **Server-Monitor** | FiveM-kompatible Game-Server (/dynamic.json): Live-Status-Embed + Spielerzahl in der Bot-Presence |
|
| 🎮 **Server-Monitor** | FiveM-kompatible Game-Server (/dynamic.json): Live-Status-Embed + Spielerzahl in der Bot-Presence |
|
||||||
@@ -48,7 +56,8 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St
|
|||||||
| `/devlogs` | öffentlich | Devlog-Archiv: Timeline, Bildergalerien, Projekt-Chips, **Volltextsuche** |
|
| `/devlogs` | öffentlich | Devlog-Archiv: Timeline, Bildergalerien, Projekt-Chips, **Volltextsuche** |
|
||||||
| `/roadmap` | öffentlich | Meilensteine aus Gitea mit Fortschrittsbalken |
|
| `/roadmap` | öffentlich | Meilensteine aus Gitea mit Fortschrittsbalken |
|
||||||
| `/galerie` | öffentlich | Community-Screenshots aus dem Discord |
|
| `/galerie` | öffentlich | Community-Screenshots aus dem Discord |
|
||||||
| `/level` | öffentlich | XP-Bestenliste mit Fortschrittsbalken |
|
| `/level` | öffentlich | XP-Bestenliste + Server-Aktivitäts-Chart |
|
||||||
|
| `/events` | öffentlich | Discord-Events (Playtests, Streams …) |
|
||||||
| `/changelog` | öffentlich | Alle Releases mit Notes, Tag- und Pre-Release-Chips |
|
| `/changelog` | öffentlich | Alle Releases mit Notes, Tag- und Pre-Release-Chips |
|
||||||
| `/feed.xml` | öffentlich | RSS-Feed der Devlogs |
|
| `/feed.xml` | öffentlich | RSS-Feed der Devlogs |
|
||||||
| `/commits` | nur Admin | Archivierte Commits aller Repos (SHA → Gitea-Link) |
|
| `/commits` | nur Admin | Archivierte Commits aller Repos (SHA → Gitea-Link) |
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Changelog from './pages/Changelog.jsx';
|
|||||||
import Roadmap from './pages/Roadmap.jsx';
|
import Roadmap from './pages/Roadmap.jsx';
|
||||||
import Galerie from './pages/Galerie.jsx';
|
import Galerie from './pages/Galerie.jsx';
|
||||||
import Level from './pages/Level.jsx';
|
import Level from './pages/Level.jsx';
|
||||||
|
import Events from './pages/Events.jsx';
|
||||||
import Commits from './pages/Commits.jsx';
|
import Commits from './pages/Commits.jsx';
|
||||||
import Settings from './pages/Settings.jsx';
|
import Settings from './pages/Settings.jsx';
|
||||||
|
|
||||||
@@ -76,6 +77,7 @@ export default function App() {
|
|||||||
<NavLink to="/roadmap">Roadmap</NavLink>
|
<NavLink to="/roadmap">Roadmap</NavLink>
|
||||||
<NavLink to="/galerie">Galerie</NavLink>
|
<NavLink to="/galerie">Galerie</NavLink>
|
||||||
<NavLink to="/level">Level</NavLink>
|
<NavLink to="/level">Level</NavLink>
|
||||||
|
<NavLink to="/events">Events</NavLink>
|
||||||
<NavLink to="/changelog">Changelog</NavLink>
|
<NavLink to="/changelog">Changelog</NavLink>
|
||||||
{me.admin && <NavLink to="/commits">Commits</NavLink>}
|
{me.admin && <NavLink to="/commits">Commits</NavLink>}
|
||||||
{me.admin && <NavLink to="/settings">Setup</NavLink>}
|
{me.admin && <NavLink to="/settings">Setup</NavLink>}
|
||||||
@@ -103,6 +105,7 @@ export default function App() {
|
|||||||
<Route path="/roadmap" element={<Roadmap />} />
|
<Route path="/roadmap" element={<Roadmap />} />
|
||||||
<Route path="/galerie" element={<Galerie />} />
|
<Route path="/galerie" element={<Galerie />} />
|
||||||
<Route path="/level" element={<Level />} />
|
<Route path="/level" element={<Level />} />
|
||||||
|
<Route path="/events" element={<Events />} />
|
||||||
<Route path="/changelog" element={<Changelog />} />
|
<Route path="/changelog" element={<Changelog />} />
|
||||||
<Route path="/commits" element={<Commits me={me} />} />
|
<Route path="/commits" element={<Commits me={me} />} />
|
||||||
<Route path="/settings" element={<Settings me={me} />} />
|
<Route path="/settings" element={<Settings me={me} />} />
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { apiGet } from '../api.js';
|
||||||
|
import { Markdown } from '../markdown.jsx';
|
||||||
|
|
||||||
|
const dateFmt = new Intl.DateTimeFormat('de-DE', {
|
||||||
|
weekday: 'long', day: '2-digit', month: 'long', hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function Events() {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiGet('/api/events').then(setData).catch(() => setError(true));
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="page-header">
|
||||||
|
<div className="page-bg-text">EVENTS</div>
|
||||||
|
<div className="page-header-grid" aria-hidden="true" />
|
||||||
|
<div className="page-header-content">
|
||||||
|
<div className="page-tag">// Termine</div>
|
||||||
|
<h1 className="page-title">Events</h1>
|
||||||
|
<p className="page-subtitle">Playtests, Streams & Community-Abende — direkt aus dem Discord.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="content">
|
||||||
|
{error && <p className="notice">Events konnten nicht geladen werden.</p>}
|
||||||
|
{!error && !data && <p className="notice">Lade …</p>}
|
||||||
|
{data?.events.length === 0 && <p className="notice">Gerade keine geplanten Events — schau bald wieder rein!</p>}
|
||||||
|
|
||||||
|
{data?.events.map((e) => (
|
||||||
|
<article className="card event-card" key={e.url}>
|
||||||
|
{e.cover && <a href={e.url} target="_blank" rel="noreferrer"><img className="event-cover" src={e.cover} alt="" /></a>}
|
||||||
|
<div className="card-meta">
|
||||||
|
<span className="card-date">{e.start ? dateFmt.format(new Date(e.start)) : 'Termin folgt'}</span>
|
||||||
|
{e.interested != null && <span className="card-rel">⭐ {e.interested} interessiert</span>}
|
||||||
|
</div>
|
||||||
|
<h3 className="release-name">{e.name}</h3>
|
||||||
|
{e.description && (
|
||||||
|
<div className="card-body">
|
||||||
|
<Markdown text={e.description} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="card-footer">
|
||||||
|
<a href={e.url} target="_blank" rel="noreferrer">Im Discord ansehen →</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,12 +10,41 @@ function xpForLevel(level) {
|
|||||||
|
|
||||||
const MEDALS = ['🥇', '🥈', '🥉'];
|
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||||
|
|
||||||
|
/** Nachrichten/Tag der letzten 30 Tage als Balken */
|
||||||
|
function ActivityChart({ stats }) {
|
||||||
|
const byDay = new Map(stats.days.map((d) => [d.day, d]));
|
||||||
|
const days = [];
|
||||||
|
for (let i = 29; i >= 0; i--) {
|
||||||
|
const key = new Date(Date.now() - i * 86400000).toISOString().slice(0, 10);
|
||||||
|
days.push({ key, ...(byDay.get(key) ?? { messages: 0, joins: 0, leaves: 0 }) });
|
||||||
|
}
|
||||||
|
const max = Math.max(1, ...days.map((d) => d.messages));
|
||||||
|
const total = days.reduce((s, d) => s + d.messages, 0);
|
||||||
|
return (
|
||||||
|
<div className="roadmap-group" style={{ marginTop: '2.5rem' }}>
|
||||||
|
<h2 className="settings-title">// Server-Aktivität — {total.toLocaleString('de-DE')} Nachrichten in 30 Tagen · {stats.members} Member</h2>
|
||||||
|
<div className="act-chart">
|
||||||
|
{days.map((d) => (
|
||||||
|
<span
|
||||||
|
key={d.key}
|
||||||
|
className="act-bar"
|
||||||
|
style={{ height: `${Math.max(4, (d.messages / max) * 100)}%` }}
|
||||||
|
title={`${d.key}: ${d.messages} Nachrichten · +${d.joins}/−${d.leaves} Member`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Level() {
|
export default function Level() {
|
||||||
const [rows, setRows] = useState(null);
|
const [rows, setRows] = useState(null);
|
||||||
|
const [stats, setStats] = useState(null);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiGet('/api/levels').then((d) => setRows(d.levels)).catch(() => setError(true));
|
apiGet('/api/levels').then((d) => setRows(d.levels)).catch(() => setError(true));
|
||||||
|
apiGet('/api/serverstats').then(setStats).catch(() => {});
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -58,6 +87,8 @@ export default function Level() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{stats && <ActivityChart stats={stats} />}
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+322
-35
@@ -15,6 +15,7 @@ const TABS = [
|
|||||||
{ id: 'community', icon: '🙌', label: 'Community' },
|
{ id: 'community', icon: '🙌', label: 'Community' },
|
||||||
{ id: 'rollen', icon: '🎭', label: 'Rollen' },
|
{ id: 'rollen', icon: '🎭', label: 'Rollen' },
|
||||||
{ id: 'support', icon: '📬', label: 'Support' },
|
{ id: 'support', icon: '📬', label: 'Support' },
|
||||||
|
{ id: 'bewerbungen', icon: '📋', label: 'Bewerbungen' },
|
||||||
{ id: 'composer', icon: '📝', label: 'Composer' },
|
{ id: 'composer', icon: '📝', label: 'Composer' },
|
||||||
{ id: 'system', icon: '⚙️', label: 'System' },
|
{ id: 'system', icon: '⚙️', label: 'System' },
|
||||||
{ id: 'api', icon: '🔑', label: 'API' },
|
{ id: 'api', icon: '🔑', label: 'API' },
|
||||||
@@ -52,6 +53,21 @@ export default function Settings({ me }) {
|
|||||||
const [composer, setComposer] = useState({ channel: '', title: '', text: '', messageId: '' });
|
const [composer, setComposer] = useState({ channel: '', title: '', text: '', messageId: '' });
|
||||||
// Branding
|
// Branding
|
||||||
const [giteaToken, setGiteaToken] = useState('');
|
const [giteaToken, setGiteaToken] = useState('');
|
||||||
|
const [botName, setBotName] = useState('');
|
||||||
|
const [twitchCreds, setTwitchCreds] = useState({ id: '', secret: '' });
|
||||||
|
// Alpha-Keys
|
||||||
|
const [alphaKeys, setAlphaKeys] = useState(null);
|
||||||
|
const [keyUpload, setKeyUpload] = useState('');
|
||||||
|
// Bewerbungs-Formulare
|
||||||
|
const emptyForm = {
|
||||||
|
id: null, title: '', description: '', review_channel_id: '',
|
||||||
|
approve_role_id: '', post_channel_id: '', questions: [''],
|
||||||
|
};
|
||||||
|
const [appForms, setAppForms] = useState([]);
|
||||||
|
const [formDraft, setFormDraft] = useState(emptyForm);
|
||||||
|
// Triggers
|
||||||
|
const [triggers, setTriggers] = useState([]);
|
||||||
|
const [triggerDraft, setTriggerDraft] = useState({ keyword: '', reply: '' });
|
||||||
// Tags + geplante Posts
|
// Tags + geplante Posts
|
||||||
const [tags, setTags] = useState([]);
|
const [tags, setTags] = useState([]);
|
||||||
const [tagDraft, setTagDraft] = useState({ name: '', content: '' });
|
const [tagDraft, setTagDraft] = useState({ name: '', content: '' });
|
||||||
@@ -79,8 +95,85 @@ export default function Settings({ me }) {
|
|||||||
apiGet('/api/rolemenus').then((d) => setMenus(d.menus)).catch(() => {});
|
apiGet('/api/rolemenus').then((d) => setMenus(d.menus)).catch(() => {});
|
||||||
apiGet('/api/tags').then((d) => setTags(d.tags)).catch(() => {});
|
apiGet('/api/tags').then((d) => setTags(d.tags)).catch(() => {});
|
||||||
apiGet('/api/scheduled').then((d) => setScheduled(d.posts)).catch(() => {});
|
apiGet('/api/scheduled').then((d) => setScheduled(d.posts)).catch(() => {});
|
||||||
|
apiGet('/api/alphakeys').then(setAlphaKeys).catch(() => {});
|
||||||
|
apiGet('/api/appforms').then((d) => setAppForms(d.forms)).catch(() => {});
|
||||||
|
apiGet('/api/triggers').then((d) => setTriggers(d.triggers)).catch(() => {});
|
||||||
}, [me.admin]);
|
}, [me.admin]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.status?.botName) setBotName(data.status.botName);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
async function uploadAlphaKeys() {
|
||||||
|
if (!keyUpload.trim()) return;
|
||||||
|
try {
|
||||||
|
const res = await apiPost('/api/alphakeys', { keys: keyUpload });
|
||||||
|
setAlphaKeys(res);
|
||||||
|
setKeyUpload('');
|
||||||
|
flash(`✓ ${res.added} Keys hinzugefügt`);
|
||||||
|
} catch {
|
||||||
|
flash('✗ Upload fehlgeschlagen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function distributeKeys() {
|
||||||
|
if (!window.confirm(`Alpha-Keys jetzt per DM an ${alphaKeys?.playtestersWithout?.length ?? 0} Playtester verteilen?`)) return;
|
||||||
|
try {
|
||||||
|
const res = await apiPost('/api/alphakeys/distribute');
|
||||||
|
setAlphaKeys(res);
|
||||||
|
flash(`✓ ${res.sent} Keys verschickt${res.failed.length ? ` — ${res.failed.length}× DM blockiert` : ''}`);
|
||||||
|
} catch {
|
||||||
|
flash('✗ Verteilung fehlgeschlagen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveForm(publish = false) {
|
||||||
|
const questions = formDraft.questions.map((q) => q.trim()).filter(Boolean);
|
||||||
|
if (!formDraft.title.trim() || questions.length === 0) {
|
||||||
|
flash('✗ Titel und mindestens eine Frage nötig');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const body = { ...formDraft, questions };
|
||||||
|
const res = formDraft.id
|
||||||
|
? await apiPut(`/api/appforms/${formDraft.id}`, body)
|
||||||
|
: await apiPost('/api/appforms', body);
|
||||||
|
if (publish) await apiPost(`/api/appforms/${res.form.id}/publish`);
|
||||||
|
setAppForms((await apiGet('/api/appforms')).forms);
|
||||||
|
setFormDraft(emptyForm);
|
||||||
|
flash(publish ? '✓ Formular gepostet' : '✓ Gespeichert');
|
||||||
|
} catch {
|
||||||
|
flash('✗ Fehlgeschlagen — Kanäle/Fragen prüfen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeForm(id) {
|
||||||
|
if (!window.confirm('Formular löschen? Der Discord-Post wird mit entfernt.')) return;
|
||||||
|
await apiDelete(`/api/appforms/${id}`).catch(() => {});
|
||||||
|
setAppForms((await apiGet('/api/appforms')).forms);
|
||||||
|
if (formDraft.id === id) setFormDraft(emptyForm);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTriggerDraft() {
|
||||||
|
if (triggerDraft.keyword.trim().length < 3 || !triggerDraft.reply.trim()) {
|
||||||
|
flash('✗ Keyword (min 3 Zeichen) und Antwort nötig');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiPut('/api/triggers', triggerDraft);
|
||||||
|
setTriggers((await apiGet('/api/triggers')).triggers);
|
||||||
|
setTriggerDraft({ keyword: '', reply: '' });
|
||||||
|
flash('✓ Trigger gespeichert');
|
||||||
|
} catch {
|
||||||
|
flash('✗ Trigger fehlgeschlagen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTrigger(keyword) {
|
||||||
|
await apiDelete(`/api/triggers/${encodeURIComponent(keyword)}`).catch(() => {});
|
||||||
|
setTriggers((await apiGet('/api/triggers')).triggers);
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadImage(target, file) {
|
async function uploadImage(target, file) {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@@ -97,14 +190,17 @@ export default function Settings({ me }) {
|
|||||||
|
|
||||||
async function saveBrand() {
|
async function saveBrand() {
|
||||||
try {
|
try {
|
||||||
const body = { ...form };
|
const body = { ...form, bot_name: botName };
|
||||||
if (giteaToken.trim()) body.gitea_api_token = giteaToken.trim();
|
if (giteaToken.trim()) body.gitea_api_token = giteaToken.trim();
|
||||||
|
if (twitchCreds.id.trim()) body.twitch_client_id = twitchCreds.id.trim();
|
||||||
|
if (twitchCreds.secret.trim()) body.twitch_client_secret = twitchCreds.secret.trim();
|
||||||
const res = await apiPut('/api/settings', body);
|
const res = await apiPut('/api/settings', body);
|
||||||
setForm(res.settings);
|
setForm(res.settings);
|
||||||
setGiteaToken('');
|
setGiteaToken('');
|
||||||
|
setTwitchCreds({ id: '', secret: '' });
|
||||||
flash('✓ Gespeichert & angewendet');
|
flash('✓ Gespeichert & angewendet');
|
||||||
} catch {
|
} catch {
|
||||||
flash('✗ Speichern fehlgeschlagen (Farben als #rrggbb?)');
|
flash('✗ Speichern fehlgeschlagen (Farben/Name prüfen)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,8 +252,12 @@ export default function Settings({ me }) {
|
|||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
try {
|
try {
|
||||||
const res = await apiPut('/api/settings', form);
|
const body = { ...form };
|
||||||
|
if (twitchCreds.id.trim()) body.twitch_client_id = twitchCreds.id.trim();
|
||||||
|
if (twitchCreds.secret.trim()) body.twitch_client_secret = twitchCreds.secret.trim();
|
||||||
|
const res = await apiPut('/api/settings', body);
|
||||||
setForm(res.settings);
|
setForm(res.settings);
|
||||||
|
setTwitchCreds({ id: '', secret: '' });
|
||||||
flash('✓ Gespeichert');
|
flash('✓ Gespeichert');
|
||||||
} catch {
|
} catch {
|
||||||
flash('✗ Speichern fehlgeschlagen');
|
flash('✗ Speichern fehlgeschlagen');
|
||||||
@@ -323,10 +423,56 @@ export default function Settings({ me }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const statusDot = { online: '#23a55a', idle: '#f0b232', dnd: '#f23f43' }[form.bot_presence_status] ?? '#23a55a';
|
||||||
const tabBrand = (
|
const tabBrand = (
|
||||||
<>
|
<>
|
||||||
|
<div className="settings-section bot-card">
|
||||||
|
<div className="bot-card-left">
|
||||||
|
<div className="bot-avatar-wrap">
|
||||||
|
{data.status.botAvatar
|
||||||
|
? <img className="bot-avatar" src={data.status.botAvatar} alt="Bot-Avatar" />
|
||||||
|
: <div className="bot-avatar bot-avatar-empty">🤖</div>}
|
||||||
|
<span className="bot-dot" style={{ background: statusDot }} />
|
||||||
|
</div>
|
||||||
|
<label className="btn btn-upload">
|
||||||
|
⬆ Bild ändern
|
||||||
|
<input type="file" accept="image/*" hidden onChange={(e) => uploadImage('avatar', e.target.files?.[0])} />
|
||||||
|
</label>
|
||||||
|
<label className="btn btn-upload btn-ghost">
|
||||||
|
🖼 Banner
|
||||||
|
<input type="file" accept="image/*" hidden onChange={(e) => uploadImage('banner', e.target.files?.[0])} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="bot-card-right">
|
||||||
|
<div className="field">
|
||||||
|
<label>Bot-Name <span className="field-hint">Discord erlaubt ~2 Umbenennungen pro Stunde</span></label>
|
||||||
|
<input type="text" maxLength={32} value={botName} onChange={(e) => setBotName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Bot-Status</label>
|
||||||
|
<select value={form.bot_presence_status} onChange={(e) => setForm({ ...form, bot_presence_status: e.target.value })}>
|
||||||
|
<option value="online">🟢 Online</option>
|
||||||
|
<option value="idle">🌙 Abwesend</option>
|
||||||
|
<option value="dnd">⛔ Bitte nicht stören</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Aktivität <span className="field-hint">leer = Server-Monitor darf die Spielerzahl anzeigen</span></label>
|
||||||
|
<div className="field-row">
|
||||||
|
<select style={{ flex: 'none', width: '9rem' }} value={form.bot_status_type} onChange={(e) => setForm({ ...form, bot_status_type: e.target.value })}>
|
||||||
|
<option value="custom">Status</option>
|
||||||
|
<option value="playing">Spielt</option>
|
||||||
|
<option value="watching">Schaut</option>
|
||||||
|
<option value="listening">Hört</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" placeholder="z. B. ⛏️ EcoGame" value={form.bot_status_text} onChange={(e) => setForm({ ...form, bot_status_text: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h2 className="settings-title">// Identität</h2>
|
<h2 className="settings-title">// Embeds</h2>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Brand-Name <span className="field-hint">erscheint in allen Embed-Footern („NAME // DEVLOG")</span></label>
|
<label>Brand-Name <span className="field-hint">erscheint in allen Embed-Footern („NAME // DEVLOG")</span></label>
|
||||||
<input type="text" placeholder="D4RKST3R" value={form.brand_name} onChange={(e) => setForm({ ...form, brand_name: e.target.value })} />
|
<input type="text" placeholder="D4RKST3R" value={form.brand_name} onChange={(e) => setForm({ ...form, brand_name: e.target.value })} />
|
||||||
@@ -341,37 +487,6 @@ export default function Settings({ me }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="settings-section">
|
|
||||||
<h2 className="settings-title">// Bot-Status</h2>
|
|
||||||
<div className="field">
|
|
||||||
<label>Presence <span className="field-hint">leer = Server-Monitor darf die Spielerzahl anzeigen</span></label>
|
|
||||||
<div className="field-row">
|
|
||||||
<select style={{ flex: 'none', width: '9rem' }} value={form.bot_status_type} onChange={(e) => setForm({ ...form, bot_status_type: e.target.value })}>
|
|
||||||
<option value="custom">Status</option>
|
|
||||||
<option value="playing">Spielt</option>
|
|
||||||
<option value="watching">Schaut</option>
|
|
||||||
<option value="listening">Hört</option>
|
|
||||||
</select>
|
|
||||||
<input type="text" placeholder="z. B. ⛏️ baut an EcoGame" value={form.bot_status_text} onChange={(e) => setForm({ ...form, bot_status_text: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="settings-section">
|
|
||||||
<h2 className="settings-title">// Avatar & Banner</h2>
|
|
||||||
<p className="section-intro">
|
|
||||||
Wird direkt beim Bot-Account gesetzt. Discord erlaubt nur ~2 Änderungen pro 10 Minuten.
|
|
||||||
</p>
|
|
||||||
<div className="field">
|
|
||||||
<label>Bot-Avatar <span className="field-hint">PNG/JPG/GIF, quadratisch empfohlen</span></label>
|
|
||||||
<input type="file" accept="image/*" onChange={(e) => uploadImage('avatar', e.target.files?.[0])} />
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<label>Bot-Banner <span className="field-hint">breites Bild fürs Bot-Profil</span></label>
|
|
||||||
<input type="file" accept="image/*" onChange={(e) => uploadImage('banner', e.target.files?.[0])} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h2 className="settings-title">// Aus der Env verlagert</h2>
|
<h2 className="settings-title">// Aus der Env verlagert</h2>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
@@ -498,6 +613,39 @@ export default function Settings({ me }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-title">// Alpha-Keys</h2>
|
||||||
|
<div className="stats" style={{ marginBottom: '1.2rem' }}>
|
||||||
|
<div className="stat"><span className="stat-value">{alphaKeys?.free ?? '—'}</span><span className="stat-label">Frei</span></div>
|
||||||
|
<div className="stat"><span className="stat-value">{alphaKeys?.assigned?.length ?? '—'}</span><span className="stat-label">Vergeben</span></div>
|
||||||
|
<div className="stat"><span className="stat-value">{alphaKeys?.playtestersWithout?.length ?? '—'}</span><span className="stat-label">Warten</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Keys hinzufügen <span className="field-hint">einer pro Zeile</span></label>
|
||||||
|
<textarea rows={3} placeholder={'KEY-AAAA-0001\nKEY-AAAA-0002'} value={keyUpload} onChange={(e) => setKeyUpload(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="field-row">
|
||||||
|
<button className="btn" onClick={uploadAlphaKeys}>Hinzufügen</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-save"
|
||||||
|
onClick={distributeKeys}
|
||||||
|
disabled={!alphaKeys || alphaKeys.free === 0 || alphaKeys.playtestersWithout.length === 0}
|
||||||
|
>
|
||||||
|
🎟️ An {alphaKeys?.playtestersWithout?.length ?? 0} Playtester verteilen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{alphaKeys?.assigned?.length > 0 && (
|
||||||
|
<div className="key-list" style={{ marginTop: '1rem' }}>
|
||||||
|
{alphaKeys.assigned.map((k) => (
|
||||||
|
<div className="key-row" key={k.key}>
|
||||||
|
<span className="key-name">{k.key}</span>
|
||||||
|
<span className="key-used">→ {k.assigned_to} · {k.assigned_at?.slice(0, 10)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h2 className="settings-title">// Starboard & Galerie</h2>
|
<h2 className="settings-title">// Starboard & Galerie</h2>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
@@ -700,6 +848,80 @@ export default function Settings({ me }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const tabBewerbungen = (
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-title">// Bewerbungs-Formulare</h2>
|
||||||
|
<p className="section-intro">
|
||||||
|
Button-Post → Discord-Formular (bis 5 Fragen) → Review mit ✅/❌ im Staff-Kanal.
|
||||||
|
Annahme vergibt optional eine Rolle — z. B. für FiveM-Whitelist oder Team-Bewerbungen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{appForms.length > 0 && (
|
||||||
|
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||||||
|
{appForms.map((f) => (
|
||||||
|
<div className="key-row" key={f.id}>
|
||||||
|
<span className="key-name">{f.title}</span>
|
||||||
|
<span className="key-scopes">{f.questions.length} Fragen{f.message_id ? ' · gepostet' : ' · Entwurf'}</span>
|
||||||
|
<button className="btn btn-mini" onClick={() => setFormDraft({
|
||||||
|
id: f.id, title: f.title, description: f.description,
|
||||||
|
review_channel_id: f.review_channel_id ?? '', approve_role_id: f.approve_role_id ?? '',
|
||||||
|
post_channel_id: f.post_channel_id ?? '',
|
||||||
|
questions: f.questions.length ? f.questions : [''],
|
||||||
|
})}>Bearbeiten</button>
|
||||||
|
<button className="card-delete" title="Formular + Post löschen" onClick={() => removeForm(f.id)}>✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>{formDraft.id ? `Formular #${formDraft.id} bearbeiten` : 'Neues Formular'}</label>
|
||||||
|
<div className="field-row">
|
||||||
|
<input type="text" placeholder="Titel, z. B. 🎮 FiveM-Whitelist" value={formDraft.title} onChange={(e) => setFormDraft({ ...formDraft, title: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<input type="text" placeholder="Beschreibung überm Button (optional)" value={formDraft.description} onChange={(e) => setFormDraft({ ...formDraft, description: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<div className="field-row">
|
||||||
|
<select value={formDraft.post_channel_id} onChange={(e) => setFormDraft({ ...formDraft, post_channel_id: e.target.value })}>
|
||||||
|
<option value="">— Post-Kanal (öffentlich) —</option>
|
||||||
|
{channelOptions}
|
||||||
|
</select>
|
||||||
|
<select value={formDraft.review_channel_id} onChange={(e) => setFormDraft({ ...formDraft, review_channel_id: e.target.value })}>
|
||||||
|
<option value="">— Review-Kanal (Staff) —</option>
|
||||||
|
{channelOptions}
|
||||||
|
</select>
|
||||||
|
<select value={formDraft.approve_role_id} onChange={(e) => setFormDraft({ ...formDraft, approve_role_id: e.target.value })}>
|
||||||
|
<option value="">— Rolle bei Annahme —</option>
|
||||||
|
{roleOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Fragen <span className="field-hint">max 5 (Discord-Modal-Limit)</span></label>
|
||||||
|
{formDraft.questions.map((q, i) => (
|
||||||
|
<div className="rr-row" key={i}>
|
||||||
|
<input type="text" placeholder={`Frage ${i + 1}`} maxLength={45} value={q} onChange={(e) => setFormDraft({
|
||||||
|
...formDraft,
|
||||||
|
questions: formDraft.questions.map((x, j) => (j === i ? e.target.value : x)),
|
||||||
|
})} />
|
||||||
|
<button className="card-delete" title="Frage entfernen" disabled={formDraft.questions.length === 1}
|
||||||
|
onClick={() => setFormDraft({ ...formDraft, questions: formDraft.questions.filter((_, j) => j !== i) })}>✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="field-row" style={{ marginTop: '.6rem' }}>
|
||||||
|
<button className="btn" disabled={formDraft.questions.length >= 5}
|
||||||
|
onClick={() => setFormDraft({ ...formDraft, questions: [...formDraft.questions, ''] })}>+ Frage</button>
|
||||||
|
<button className="btn" onClick={() => saveForm(false)}>Speichern</button>
|
||||||
|
<button className="btn btn-save" onClick={() => saveForm(true)}>Speichern + posten</button>
|
||||||
|
{formDraft.id && <button className="btn btn-ghost" onClick={() => setFormDraft(emptyForm)}>Abbrechen</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const tabComposer = (
|
const tabComposer = (
|
||||||
<>
|
<>
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
@@ -787,6 +1009,32 @@ export default function Settings({ me }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-title">// Triggers</h2>
|
||||||
|
<p className="section-intro">
|
||||||
|
Auto-Antworten: Enthält eine Nachricht das Keyword, antwortet der Bot (30s-Cooldown pro Kanal).
|
||||||
|
</p>
|
||||||
|
{triggers.length > 0 && (
|
||||||
|
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||||||
|
{triggers.map((t) => (
|
||||||
|
<div className="key-row" key={t.keyword}>
|
||||||
|
<span className="key-name">{t.keyword}</span>
|
||||||
|
<span className="key-used" title={t.reply}>{t.reply.slice(0, 50)}</span>
|
||||||
|
<button className="btn btn-mini" onClick={() => setTriggerDraft({ keyword: t.keyword, reply: t.reply })}>Bearbeiten</button>
|
||||||
|
<button className="card-delete" title="Trigger löschen" onClick={() => removeTrigger(t.keyword)}>✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="field">
|
||||||
|
<div className="field-row">
|
||||||
|
<input type="text" style={{ flex: 'none', width: '12rem' }} placeholder="Keyword (min 3 Zeichen)" value={triggerDraft.keyword} onChange={(e) => setTriggerDraft({ ...triggerDraft, keyword: e.target.value })} />
|
||||||
|
<input type="text" placeholder="Antwort des Bots" value={triggerDraft.reply} onChange={(e) => setTriggerDraft({ ...triggerDraft, reply: e.target.value })} />
|
||||||
|
<button className="btn" onClick={saveTriggerDraft}>Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h2 className="settings-title">// Tags (/tag)</h2>
|
<h2 className="settings-title">// Tags (/tag)</h2>
|
||||||
<p className="section-intro">
|
<p className="section-intro">
|
||||||
@@ -852,6 +1100,44 @@ export default function Settings({ me }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-title">// Events, Social & Voice</h2>
|
||||||
|
<div className="field">
|
||||||
|
<label>Event-Ankündigungs-Kanal <span className="field-hint">neue Discord-Events werden hier angekündigt + Events-Seite</span></label>
|
||||||
|
<select value={form.events_announce_channel_id ?? ''} onChange={(e) => setForm({ ...form, events_announce_channel_id: e.target.value })}>
|
||||||
|
<option value="">— deaktiviert —</option>
|
||||||
|
{channelOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Social-Kanal <span className="field-hint">🔴 Twitch-Live + ▶️ neue YouTube-Videos</span></label>
|
||||||
|
<select value={form.social_announce_channel_id ?? ''} onChange={(e) => setForm({ ...form, social_announce_channel_id: e.target.value })}>
|
||||||
|
<option value="">— deaktiviert —</option>
|
||||||
|
{channelOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<div className="field-row">
|
||||||
|
<input type="text" placeholder="YouTube-Kanal-ID (UC…)" value={form.youtube_channel_id} onChange={(e) => setForm({ ...form, youtube_channel_id: e.target.value })} />
|
||||||
|
<input type="text" placeholder="Twitch-Login (z. B. d4rkst3r)" value={form.twitch_channel} onChange={(e) => setForm({ ...form, twitch_channel: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Twitch-App-Credentials <span className="field-hint">{form.twitch_creds_set ? '✓ gesetzt' : 'dev.twitch.tv → App registrieren'} — write-only</span></label>
|
||||||
|
<div className="field-row">
|
||||||
|
<input type="password" placeholder="Client-ID" value={twitchCreds.id} onChange={(e) => setTwitchCreds({ ...twitchCreds, id: e.target.value })} />
|
||||||
|
<input type="password" placeholder="Client-Secret" value={twitchCreds.secret} onChange={(e) => setTwitchCreds({ ...twitchCreds, secret: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Temp-Voice-Hub <span className="field-hint">„Join to Create": Beitritt erzeugt eigenen Voice-Kanal (Bot braucht Kanäle verwalten + Member verschieben)</span></label>
|
||||||
|
<select value={form.tempvoice_channel_id ?? ''} onChange={(e) => setForm({ ...form, tempvoice_channel_id: e.target.value })}>
|
||||||
|
<option value="">— deaktiviert —</option>
|
||||||
|
{channelOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h2 className="settings-title">// Backups & Watchdog</h2>
|
<h2 className="settings-title">// Backups & Watchdog</h2>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
@@ -937,6 +1223,7 @@ export default function Settings({ me }) {
|
|||||||
community: tabCommunity,
|
community: tabCommunity,
|
||||||
rollen: tabRollen,
|
rollen: tabRollen,
|
||||||
support: tabSupport,
|
support: tabSupport,
|
||||||
|
bewerbungen: tabBewerbungen,
|
||||||
composer: tabComposer,
|
composer: tabComposer,
|
||||||
system: tabSystem,
|
system: tabSystem,
|
||||||
api: tabApi,
|
api: tabApi,
|
||||||
|
|||||||
@@ -955,3 +955,57 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
|
|||||||
|
|
||||||
/* Button-Farb-Auswahl im Rollen-Menü-Builder */
|
/* Button-Farb-Auswahl im Rollen-Menü-Builder */
|
||||||
.btn-style-pick { width: 7.5rem !important; flex: none !important; }
|
.btn-style-pick { width: 7.5rem !important; flex: none !important; }
|
||||||
|
|
||||||
|
/* ── BOT-IDENTITY-KARTE (MEE6-Style) ───────────────── */
|
||||||
|
.bot-card { display: flex; gap: 2rem; align-items: flex-start; }
|
||||||
|
.bot-card-left {
|
||||||
|
display: flex; flex-direction: column; gap: .6rem;
|
||||||
|
align-items: center; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.bot-avatar-wrap { position: relative; }
|
||||||
|
.bot-avatar {
|
||||||
|
width: 108px; height: 108px; border-radius: 50%;
|
||||||
|
border: 3px solid rgba(255, 255, 255, .1);
|
||||||
|
object-fit: cover; display: block;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
.bot-avatar-empty {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
.bot-dot {
|
||||||
|
position: absolute; right: 4px; bottom: 4px;
|
||||||
|
width: 24px; height: 24px; border-radius: 50%;
|
||||||
|
border: 4px solid var(--bg2);
|
||||||
|
}
|
||||||
|
.btn-upload { justify-content: center; width: 100%; cursor: pointer; }
|
||||||
|
.bot-card-right { flex: 1; min-width: 0; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.bot-card { flex-direction: column; align-items: stretch; }
|
||||||
|
.bot-card-left { flex-direction: row; justify-content: flex-start; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── EVENTS ────────────────────────────────────────── */
|
||||||
|
.event-card { margin-bottom: 1.5rem; }
|
||||||
|
.event-cover {
|
||||||
|
width: 100%; max-height: 220px; object-fit: cover;
|
||||||
|
border-radius: 8px; margin-bottom: .9rem; display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── AKTIVITÄTS-CHART ──────────────────────────────── */
|
||||||
|
.act-chart {
|
||||||
|
display: flex; align-items: flex-end; gap: 3px;
|
||||||
|
height: 120px;
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid rgba(255, 255, 255, .06);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: .8rem;
|
||||||
|
}
|
||||||
|
.act-bar {
|
||||||
|
flex: 1;
|
||||||
|
background: linear-gradient(180deg, var(--neon), rgba(245, 197, 24, .35));
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
|
min-height: 4px;
|
||||||
|
transition: opacity .15s;
|
||||||
|
}
|
||||||
|
.act-bar:hover { opacity: .7; }
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
// Bewerbungs-Formulare: Button-Post → Discord-Modal (bis 5 Fragen) → Review-Embed
|
||||||
|
// mit ✅/❌ im Staff-Kanal; Annahme vergibt optional eine Rolle + DM.
|
||||||
|
import {
|
||||||
|
ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, MessageFlags,
|
||||||
|
ModalBuilder, TextInputBuilder, TextInputStyle,
|
||||||
|
} from 'discord.js';
|
||||||
|
import {
|
||||||
|
getAppForm, setAppFormMessage,
|
||||||
|
createApplication, getApplication, openApplicationOf, setApplicationStatus,
|
||||||
|
} from '../db.js';
|
||||||
|
import { brandColor, brandColor2, brandFooter } from '../runtime-settings.js';
|
||||||
|
|
||||||
|
export const MAX_QUESTIONS = 5; // Discord-Modal-Limit
|
||||||
|
|
||||||
|
/** Bewerbungs-Post (Embed + Button) senden/aktualisieren */
|
||||||
|
export async function publishAppForm(client, formId) {
|
||||||
|
const form = getAppForm(formId);
|
||||||
|
if (!form) throw new Error('Formular nicht gefunden');
|
||||||
|
if (!form.post_channel_id) throw new Error('Kein Post-Kanal gewählt');
|
||||||
|
if (!form.review_channel_id) throw new Error('Kein Review-Kanal gewählt');
|
||||||
|
if (JSON.parse(form.questions || '[]').length === 0) throw new Error('Formular hat keine Fragen');
|
||||||
|
|
||||||
|
const channel = await client.channels.fetch(form.post_channel_id);
|
||||||
|
if (!channel?.isTextBased()) throw new Error('Post-Kanal nicht gefunden');
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
embeds: [
|
||||||
|
new EmbedBuilder()
|
||||||
|
.setColor(brandColor())
|
||||||
|
.setTitle(`📋 ${form.title}`)
|
||||||
|
.setDescription(form.description?.trim() || 'Klick auf den Button und füll das Formular aus.')
|
||||||
|
.setFooter({ text: brandFooter('BEWERBUNG') }),
|
||||||
|
],
|
||||||
|
components: [
|
||||||
|
new ActionRowBuilder().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`apply:${form.id}`)
|
||||||
|
.setStyle(ButtonStyle.Primary)
|
||||||
|
.setLabel('Jetzt bewerben')
|
||||||
|
.setEmoji('📋')
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (form.message_id) {
|
||||||
|
const existing = await channel.messages.fetch(form.message_id).catch(() => null);
|
||||||
|
if (existing) {
|
||||||
|
await existing.edit(payload);
|
||||||
|
return form.message_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const message = await channel.send(payload);
|
||||||
|
setAppFormMessage(form.id, message.id);
|
||||||
|
return message.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unpublishAppForm(client, form) {
|
||||||
|
if (!form.post_channel_id || !form.message_id) return;
|
||||||
|
const channel = await client.channels.fetch(form.post_channel_id).catch(() => null);
|
||||||
|
const message = await channel?.messages?.fetch(form.message_id).catch(() => null);
|
||||||
|
await message?.delete().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Button „Jetzt bewerben" → Modal öffnen */
|
||||||
|
export async function handleApplyButton(interaction) {
|
||||||
|
const formId = Number(interaction.customId.split(':')[1]);
|
||||||
|
const form = getAppForm(formId);
|
||||||
|
if (!form) {
|
||||||
|
await interaction.reply({ content: '❌ Dieses Formular ist nicht mehr aktiv.', flags: MessageFlags.Ephemeral });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (openApplicationOf(formId, interaction.user.id)) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: '❕ Du hast hier schon eine offene Bewerbung — das Team meldet sich!',
|
||||||
|
flags: MessageFlags.Ephemeral,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = new ModalBuilder()
|
||||||
|
.setCustomId(`applymodal:${form.id}`)
|
||||||
|
.setTitle(form.title.slice(0, 45));
|
||||||
|
JSON.parse(form.questions).slice(0, MAX_QUESTIONS).forEach((q, i) => {
|
||||||
|
modal.addComponents(
|
||||||
|
new ActionRowBuilder().addComponents(
|
||||||
|
new TextInputBuilder()
|
||||||
|
.setCustomId(`q${i}`)
|
||||||
|
.setLabel(String(q).slice(0, 45))
|
||||||
|
.setStyle(TextInputStyle.Paragraph)
|
||||||
|
.setMaxLength(600)
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await interaction.showModal(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Modal abgeschickt → speichern + Review-Embed in den Staff-Kanal */
|
||||||
|
export async function handleApplyModal(interaction) {
|
||||||
|
const formId = Number(interaction.customId.split(':')[1]);
|
||||||
|
const form = getAppForm(formId);
|
||||||
|
if (!form) {
|
||||||
|
await interaction.reply({ content: '❌ Formular nicht mehr aktiv.', flags: MessageFlags.Ephemeral });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const questions = JSON.parse(form.questions);
|
||||||
|
const answers = questions.map((q, i) => ({
|
||||||
|
q,
|
||||||
|
a: interaction.fields.getTextInputValue(`q${i}`),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const appId = createApplication(
|
||||||
|
form.id, interaction.user.id,
|
||||||
|
interaction.member?.displayName ?? interaction.user.username,
|
||||||
|
answers
|
||||||
|
);
|
||||||
|
|
||||||
|
const review = await interaction.client.channels.fetch(form.review_channel_id).catch(() => null);
|
||||||
|
if (review?.isTextBased()) {
|
||||||
|
await review.send({
|
||||||
|
embeds: [
|
||||||
|
new EmbedBuilder()
|
||||||
|
.setColor(brandColor())
|
||||||
|
.setAuthor({
|
||||||
|
name: `${interaction.user.username} (${interaction.user.id})`,
|
||||||
|
iconURL: interaction.user.displayAvatarURL({ size: 64 }),
|
||||||
|
})
|
||||||
|
.setTitle(`📋 Bewerbung #${appId} — ${form.title}`)
|
||||||
|
.addFields(answers.map(({ q, a }) => ({
|
||||||
|
name: String(q).slice(0, 250),
|
||||||
|
value: String(a).slice(0, 1000) || '—',
|
||||||
|
})))
|
||||||
|
.setFooter({ text: brandFooter('BEWERBUNG') })
|
||||||
|
.setTimestamp(),
|
||||||
|
],
|
||||||
|
components: [
|
||||||
|
new ActionRowBuilder().addComponents(
|
||||||
|
new ButtonBuilder().setCustomId(`appdec:${appId}:approve`).setStyle(ButtonStyle.Success).setLabel('Annehmen').setEmoji('✅'),
|
||||||
|
new ButtonBuilder().setCustomId(`appdec:${appId}:deny`).setStyle(ButtonStyle.Danger).setLabel('Ablehnen').setEmoji('❌')
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: '✅ Bewerbung eingereicht — danke! Du bekommst eine DM, sobald sie geprüft wurde.',
|
||||||
|
flags: MessageFlags.Ephemeral,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ✅/❌ im Review-Kanal → Status, Rolle, DM, Embed abschließen */
|
||||||
|
export async function handleReviewButton(interaction) {
|
||||||
|
const [, appIdRaw, decision] = interaction.customId.split(':');
|
||||||
|
const application = getApplication(Number(appIdRaw));
|
||||||
|
if (!application || application.status !== 'open') {
|
||||||
|
await interaction.reply({ content: '❌ Bewerbung nicht (mehr) offen.', flags: MessageFlags.Ephemeral });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const form = getAppForm(application.form_id);
|
||||||
|
const approved = decision === 'approve';
|
||||||
|
setApplicationStatus(application.id, approved ? 'approved' : 'denied');
|
||||||
|
|
||||||
|
// Rolle bei Annahme
|
||||||
|
if (approved && form?.approve_role_id) {
|
||||||
|
const member = await interaction.guild.members.fetch(application.user_id).catch(() => null);
|
||||||
|
await member?.roles?.add(form.approve_role_id).catch((e) =>
|
||||||
|
console.error(`[bewerbung] Rolle fehlgeschlagen: ${e.message}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// DM an den Bewerber
|
||||||
|
const user = await interaction.client.users.fetch(application.user_id).catch(() => null);
|
||||||
|
await user?.send(
|
||||||
|
approved
|
||||||
|
? `✅ Deine Bewerbung **„${form?.title ?? ''}"** wurde angenommen — willkommen! 🎉`
|
||||||
|
: `❌ Deine Bewerbung **„${form?.title ?? ''}"** wurde leider abgelehnt.`
|
||||||
|
).catch(() => {});
|
||||||
|
|
||||||
|
// Review-Embed abschließen
|
||||||
|
const original = EmbedBuilder.from(interaction.message.embeds[0])
|
||||||
|
.setColor(approved ? brandColor() : brandColor2())
|
||||||
|
.setTitle(`${approved ? '✅ Angenommen' : '❌ Abgelehnt'} — ${interaction.message.embeds[0].title?.replace('📋 ', '') ?? ''}`)
|
||||||
|
.setFooter({ text: `${brandFooter('BEWERBUNG')} • ${approved ? 'angenommen' : 'abgelehnt'} von ${interaction.member?.displayName ?? interaction.user.username}` });
|
||||||
|
await interaction.update({ embeds: [original], components: [] });
|
||||||
|
}
|
||||||
+30
-1
@@ -7,6 +7,8 @@ import { registerCommunityListeners } from './community.js';
|
|||||||
import { registerModTools } from './mod-tools.js';
|
import { registerModTools } from './mod-tools.js';
|
||||||
import { registerLevels } from './levels.js';
|
import { registerLevels } from './levels.js';
|
||||||
import { registerPresence } from './presence.js';
|
import { registerPresence } from './presence.js';
|
||||||
|
import { registerExtras } from './extras.js';
|
||||||
|
import { handleApplyButton, handleApplyModal, handleReviewButton } from './app-forms.js';
|
||||||
import { handleRoleMenuButton } from './role-menus.js';
|
import { handleRoleMenuButton } from './role-menus.js';
|
||||||
import {
|
import {
|
||||||
addPlaytester, removePlaytester, isWish, bumpWish,
|
addPlaytester, removePlaytester, isWish, bumpWish,
|
||||||
@@ -28,11 +30,12 @@ import * as timeout from './commands/timeout.js';
|
|||||||
import * as purge from './commands/purge.js';
|
import * as purge from './commands/purge.js';
|
||||||
import * as rank from './commands/rank.js';
|
import * as rank from './commands/rank.js';
|
||||||
import * as tag from './commands/tag.js';
|
import * as tag from './commands/tag.js';
|
||||||
|
import * as remind from './commands/remind.js';
|
||||||
|
|
||||||
// Alle Commands hier eintragen — jedes Modul exportiert { data, execute }
|
// Alle Commands hier eintragen — jedes Modul exportiert { data, execute }
|
||||||
const commandModules = [
|
const commandModules = [
|
||||||
ping, devlogBackfill, bug, playtesterSetup, galerieBackfill,
|
ping, devlogBackfill, bug, playtesterSetup, galerieBackfill,
|
||||||
wunsch, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag,
|
wunsch, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind,
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function startBot() {
|
export async function startBot() {
|
||||||
@@ -49,6 +52,9 @@ export async function startBot() {
|
|||||||
// privilegierten "Server Members Intent" im Developer Portal!
|
// privilegierten "Server Members Intent" im Developer Portal!
|
||||||
GatewayIntentBits.DirectMessages,
|
GatewayIntentBits.DirectMessages,
|
||||||
GatewayIntentBits.GuildMembers,
|
GatewayIntentBits.GuildMembers,
|
||||||
|
// Temp-Voice (Join to Create) + Event-Ankündigungen
|
||||||
|
GatewayIntentBits.GuildVoiceStates,
|
||||||
|
GatewayIntentBits.GuildScheduledEvents,
|
||||||
],
|
],
|
||||||
// Partials: Delete/Reaction-Events für ungecachte Nachrichten + DM-Kanäle
|
// Partials: Delete/Reaction-Events für ungecachte Nachrichten + DM-Kanäle
|
||||||
partials: [Partials.Message, Partials.Reaction, Partials.Channel],
|
partials: [Partials.Message, Partials.Reaction, Partials.Channel],
|
||||||
@@ -144,6 +150,28 @@ export async function startBot() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bewerbungen: Button → Modal → Review-Entscheidung
|
||||||
|
try {
|
||||||
|
if (interaction.isButton() && interaction.customId.startsWith('apply:')) {
|
||||||
|
await handleApplyButton(interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (interaction.isModalSubmit() && interaction.customId.startsWith('applymodal:')) {
|
||||||
|
await handleApplyModal(interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (interaction.isButton() && interaction.customId.startsWith('appdec:')) {
|
||||||
|
await handleReviewButton(interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[bewerbung] Fehler:', error);
|
||||||
|
if (!interaction.replied && !interaction.deferred) {
|
||||||
|
await interaction.reply({ content: '❌ Da ist etwas schiefgelaufen.', flags: MessageFlags.Ephemeral }).catch(() => {});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Rollen-Menü-Buttons: rolemenu:<menuId>:<roleId>
|
// Rollen-Menü-Buttons: rolemenu:<menuId>:<roleId>
|
||||||
if (interaction.isButton() && interaction.customId.startsWith('rolemenu:')) {
|
if (interaction.isButton() && interaction.customId.startsWith('rolemenu:')) {
|
||||||
try {
|
try {
|
||||||
@@ -298,6 +326,7 @@ export async function startBot() {
|
|||||||
registerModTools(client);
|
registerModTools(client);
|
||||||
registerLevels(client);
|
registerLevels(client);
|
||||||
registerPresence(client);
|
registerPresence(client);
|
||||||
|
registerExtras(client);
|
||||||
|
|
||||||
await client.login(config.discordToken);
|
await client.login(config.discordToken);
|
||||||
return client;
|
return client;
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// /remind — Erinnerung per DM (z. B. /remind dauer:2h text:Server neustarten)
|
||||||
|
import { SlashCommandBuilder, MessageFlags } from 'discord.js';
|
||||||
|
import { parseDuration } from './giveaway.js';
|
||||||
|
import { createReminder } from '../../db.js';
|
||||||
|
|
||||||
|
export const data = new SlashCommandBuilder()
|
||||||
|
.setName('remind')
|
||||||
|
.setDescription('Erinnert dich per DM')
|
||||||
|
.addStringOption((o) => o.setName('dauer').setDescription('z. B. 30m, 2h, 1d').setRequired(true))
|
||||||
|
.addStringOption((o) => o.setName('text').setDescription('Woran erinnern?').setRequired(true).setMaxLength(500));
|
||||||
|
|
||||||
|
export async function execute(interaction) {
|
||||||
|
const duration = parseDuration(interaction.options.getString('dauer'));
|
||||||
|
if (!duration) {
|
||||||
|
await interaction.reply({ content: '❌ Dauer bitte als `30m`, `2h` oder `1d`.', flags: MessageFlags.Ephemeral });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = interaction.options.getString('text');
|
||||||
|
const remindAt = new Date(Date.now() + duration).toISOString().slice(0, 19).replace('T', ' ');
|
||||||
|
createReminder(interaction.user.id, text, remindAt);
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: `⏰ Alles klar — ich melde mich <t:${Math.floor((Date.now() + duration) / 1000)}:R> per DM.`,
|
||||||
|
flags: MessageFlags.Ephemeral,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// Kleinkram-Paket: Triggers (Auto-Antworten), Reminder-Scheduler, Temp-Voice
|
||||||
|
// (Join to Create), Event-Ankündigungen und Aktivitäts-Zählung.
|
||||||
|
import { ChannelType, EmbedBuilder, Events } from 'discord.js';
|
||||||
|
import {
|
||||||
|
listTriggers, dueReminders, markReminderSent, countActivity,
|
||||||
|
trackTempVoice, untrackTempVoice, isTempVoice, listTempVoice,
|
||||||
|
} from '../db.js';
|
||||||
|
import {
|
||||||
|
tempVoiceChannelId, eventsAnnounceChannelId, brandColor, brandFooter,
|
||||||
|
} from '../runtime-settings.js';
|
||||||
|
|
||||||
|
/* ── Triggers ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
const triggerCooldown = new Map(); // `${channelId}:${keyword}` → ts
|
||||||
|
const TRIGGER_COOLDOWN_MS = 30_000;
|
||||||
|
|
||||||
|
async function handleTriggers(message) {
|
||||||
|
const content = message.content?.toLowerCase();
|
||||||
|
if (!content) return;
|
||||||
|
for (const t of listTriggers()) {
|
||||||
|
if (!content.includes(t.keyword.toLowerCase())) continue;
|
||||||
|
const key = `${message.channelId}:${t.keyword}`;
|
||||||
|
if (Date.now() - (triggerCooldown.get(key) ?? 0) < TRIGGER_COOLDOWN_MS) continue;
|
||||||
|
triggerCooldown.set(key, Date.now());
|
||||||
|
await message.reply({ content: t.reply.slice(0, 2000), allowedMentions: { parse: [] } }).catch(() => {});
|
||||||
|
break; // max. ein Trigger pro Nachricht
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Temp-Voice (Join to Create) ───────────────────── */
|
||||||
|
|
||||||
|
async function handleVoiceState(oldState, newState) {
|
||||||
|
const hubId = tempVoiceChannelId();
|
||||||
|
|
||||||
|
// Join in den Hub → eigenen Kanal erstellen und rüberschieben
|
||||||
|
if (hubId && newState.channelId === hubId && newState.member) {
|
||||||
|
const hub = newState.channel;
|
||||||
|
const channel = await newState.guild.channels.create({
|
||||||
|
name: `🔊 ${newState.member.displayName}`.slice(0, 90),
|
||||||
|
type: ChannelType.GuildVoice,
|
||||||
|
parent: hub.parentId ?? undefined,
|
||||||
|
});
|
||||||
|
trackTempVoice(channel.id);
|
||||||
|
await newState.member.voice.setChannel(channel).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Temp-Kanal leer → löschen
|
||||||
|
if (oldState.channelId && isTempVoice(oldState.channelId)) {
|
||||||
|
const channel = oldState.channel ?? (await oldState.guild.channels.fetch(oldState.channelId).catch(() => null));
|
||||||
|
if (channel && channel.members.size === 0) {
|
||||||
|
untrackTempVoice(channel.id);
|
||||||
|
await channel.delete().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beim Start: verwaiste leere Temp-Kanäle aufräumen */
|
||||||
|
async function cleanupTempVoice(client) {
|
||||||
|
for (const id of listTempVoice()) {
|
||||||
|
const channel = await client.channels.fetch(id).catch(() => null);
|
||||||
|
if (!channel) {
|
||||||
|
untrackTempVoice(id);
|
||||||
|
} else if (channel.members.size === 0) {
|
||||||
|
untrackTempVoice(id);
|
||||||
|
await channel.delete().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reminder ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
export async function remindersTick(client) {
|
||||||
|
for (const r of dueReminders()) {
|
||||||
|
markReminderSent(r.id);
|
||||||
|
const user = await client.users.fetch(r.user_id).catch(() => null);
|
||||||
|
await user?.send(`⏰ **Erinnerung:** ${r.text}`).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Event-Ankündigung ─────────────────────────────── */
|
||||||
|
|
||||||
|
async function announceEvent(event) {
|
||||||
|
const channelId = eventsAnnounceChannelId();
|
||||||
|
if (!channelId) return;
|
||||||
|
const channel = await event.client.channels.fetch(channelId).catch(() => null);
|
||||||
|
if (!channel?.isTextBased()) return;
|
||||||
|
|
||||||
|
const start = event.scheduledStartTimestamp
|
||||||
|
? `<t:${Math.floor(event.scheduledStartTimestamp / 1000)}:F> (<t:${Math.floor(event.scheduledStartTimestamp / 1000)}:R>)`
|
||||||
|
: '—';
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(brandColor())
|
||||||
|
.setTitle(`📅 Neues Event: ${event.name}`)
|
||||||
|
.setDescription(
|
||||||
|
`${event.description?.slice(0, 1500) ?? ''}\n\n🕒 ${start}\n[→ Zum Event](${event.url})`
|
||||||
|
)
|
||||||
|
.setFooter({ text: brandFooter('EVENT') });
|
||||||
|
if (event.coverImageURL?.()) embed.setImage(event.coverImageURL({ size: 1024 }));
|
||||||
|
await channel.send({ embeds: [embed] }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Registrierung ─────────────────────────────────── */
|
||||||
|
|
||||||
|
export function registerExtras(client) {
|
||||||
|
client.on(Events.MessageCreate, async (message) => {
|
||||||
|
if (!message.guildId || message.author?.bot) return;
|
||||||
|
try {
|
||||||
|
countActivity('message');
|
||||||
|
await handleTriggers(message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[extras] Message-Handling:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
client.on(Events.GuildMemberAdd, () => { try { countActivity('join'); } catch {} });
|
||||||
|
client.on(Events.GuildMemberRemove, () => { try { countActivity('leave'); } catch {} });
|
||||||
|
|
||||||
|
client.on(Events.VoiceStateUpdate, (oldState, newState) =>
|
||||||
|
handleVoiceState(oldState, newState).catch((e) => console.error('[tempvoice]', e))
|
||||||
|
);
|
||||||
|
client.on(Events.GuildScheduledEventCreate, (event) =>
|
||||||
|
announceEvent(event).catch((e) => console.error('[events]', e))
|
||||||
|
);
|
||||||
|
client.once(Events.ClientReady, () => cleanupTempVoice(client).catch(() => {}));
|
||||||
|
|
||||||
|
setInterval(() => remindersTick(client).catch((e) => console.error('[remind]', e)), 60_000);
|
||||||
|
}
|
||||||
+9
-8
@@ -1,7 +1,7 @@
|
|||||||
// Bot-Status (Presence) aus den Branding-Settings — hat Vorrang vor dem
|
// Bot-Status (Presence) aus den Branding-Settings — hat Vorrang vor dem
|
||||||
// Server-Monitor (der die Presence nur nutzt, wenn hier nichts gesetzt ist).
|
// Server-Monitor (der die Presence nur nutzt, wenn hier kein Text gesetzt ist).
|
||||||
import { ActivityType, Events } from 'discord.js';
|
import { ActivityType, Events } from 'discord.js';
|
||||||
import { botStatusText, botStatusType } from '../runtime-settings.js';
|
import { botStatusText, botStatusType, botPresenceStatus } from '../runtime-settings.js';
|
||||||
|
|
||||||
const TYPES = {
|
const TYPES = {
|
||||||
playing: ActivityType.Playing,
|
playing: ActivityType.Playing,
|
||||||
@@ -10,15 +10,16 @@ const TYPES = {
|
|||||||
custom: ActivityType.Custom,
|
custom: ActivityType.Custom,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Presence aus den Settings anwenden (leerer Text = Presence löschen) */
|
/** Presence aus den Settings anwenden (Status immer, Aktivität nur wenn Text gesetzt) */
|
||||||
export function applyBotStatus(client) {
|
export function applyBotStatus(client) {
|
||||||
const text = botStatusText();
|
const text = botStatusText();
|
||||||
try {
|
try {
|
||||||
if (!text) {
|
client.user?.setPresence?.({
|
||||||
client.user?.setPresence?.({ activities: [] });
|
status: botPresenceStatus(),
|
||||||
return;
|
activities: text
|
||||||
}
|
? [{ name: text, type: TYPES[botStatusType()] ?? ActivityType.Custom }]
|
||||||
client.user?.setActivity?.(text, { type: TYPES[botStatusType()] ?? ActivityType.Custom });
|
: [],
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[presence] Status setzen fehlgeschlagen:', error.message);
|
console.error('[presence] Status setzen fehlgeschlagen:', error.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Twitch-/YouTube-Benachrichtigungen: pollt alle 5 min und postet in den
|
||||||
|
// Social-Kanal. YouTube läuft ohne Key (RSS), Twitch braucht App-Credentials
|
||||||
|
// (dev.twitch.tv → App registrieren → Client-ID + Secret im System-Tab).
|
||||||
|
import { EmbedBuilder } from 'discord.js';
|
||||||
|
import { getSetting, setSetting } from '../db.js';
|
||||||
|
import {
|
||||||
|
socialAnnounceChannelId, youtubeChannelId,
|
||||||
|
twitchChannel, twitchClientId, twitchClientSecret,
|
||||||
|
brandColor, brandColor2, brandFooter,
|
||||||
|
} from '../runtime-settings.js';
|
||||||
|
|
||||||
|
const CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
let twitchToken = null; // { token, expiresAt }
|
||||||
|
|
||||||
|
async function announce(client, embed) {
|
||||||
|
const channelId = socialAnnounceChannelId();
|
||||||
|
if (!channelId) return;
|
||||||
|
const channel = await client.channels.fetch(channelId).catch(() => null);
|
||||||
|
if (channel?.isTextBased()) await channel.send({ embeds: [embed] }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── YouTube (RSS, ohne API-Key) ───────────────────── */
|
||||||
|
|
||||||
|
async function checkYouTube(client) {
|
||||||
|
const channelId = youtubeChannelId();
|
||||||
|
if (!channelId) return;
|
||||||
|
const res = await fetch(`https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`, {
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
|
const xml = await res.text();
|
||||||
|
const videoId = xml.match(/<yt:videoId>([^<]+)<\/yt:videoId>/)?.[1];
|
||||||
|
const title = xml.match(/<entry>[\s\S]*?<title>([^<]+)<\/title>/)?.[1];
|
||||||
|
if (!videoId) return;
|
||||||
|
|
||||||
|
const last = getSetting('yt_last_video');
|
||||||
|
if (last === videoId) return;
|
||||||
|
setSetting('yt_last_video', videoId);
|
||||||
|
if (last === null) return; // Erststart: nur merken, nicht alte Videos posten
|
||||||
|
|
||||||
|
await announce(client, new EmbedBuilder()
|
||||||
|
.setColor(brandColor2())
|
||||||
|
.setTitle(`▶️ Neues Video: ${title ?? 'YouTube'}`)
|
||||||
|
.setURL(`https://www.youtube.com/watch?v=${videoId}`)
|
||||||
|
.setImage(`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`)
|
||||||
|
.setFooter({ text: brandFooter('YOUTUBE') }));
|
||||||
|
console.log(`[social] Neues YouTube-Video angekündigt (${videoId})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Twitch (Helix, Client-Credentials) ────────────── */
|
||||||
|
|
||||||
|
async function getTwitchToken() {
|
||||||
|
if (twitchToken && Date.now() < twitchToken.expiresAt) return twitchToken.token;
|
||||||
|
const res = await fetch('https://id.twitch.tv/oauth2/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: twitchClientId(),
|
||||||
|
client_secret: twitchClientSecret(),
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Twitch-Token ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
twitchToken = { token: data.access_token, expiresAt: Date.now() + (data.expires_in - 60) * 1000 };
|
||||||
|
return twitchToken.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkTwitch(client) {
|
||||||
|
const login = twitchChannel();
|
||||||
|
if (!login || !twitchClientId() || !twitchClientSecret()) return;
|
||||||
|
|
||||||
|
const token = await getTwitchToken();
|
||||||
|
const res = await fetch(`https://api.twitch.tv/helix/streams?user_login=${encodeURIComponent(login)}`, {
|
||||||
|
headers: { 'Client-ID': twitchClientId(), Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
|
const stream = (await res.json()).data?.[0] ?? null;
|
||||||
|
|
||||||
|
const wasLive = getSetting('twitch_live') === '1';
|
||||||
|
if (stream && !wasLive) {
|
||||||
|
setSetting('twitch_live', '1');
|
||||||
|
await announce(client, new EmbedBuilder()
|
||||||
|
.setColor(0x9146ff)
|
||||||
|
.setTitle(`🔴 ${login} ist LIVE: ${stream.title ?? ''}`.slice(0, 250))
|
||||||
|
.setURL(`https://twitch.tv/${login}`)
|
||||||
|
.setDescription(stream.game_name ? `Spielt **${stream.game_name}**` : null)
|
||||||
|
.setImage(stream.thumbnail_url?.replace('{width}', '1280').replace('{height}', '720') ?? null)
|
||||||
|
.setFooter({ text: brandFooter('TWITCH') }));
|
||||||
|
console.log('[social] Twitch-Live angekündigt');
|
||||||
|
} else if (!stream && wasLive) {
|
||||||
|
setSetting('twitch_live', '0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startSocialNotify(client) {
|
||||||
|
const tick = async () => {
|
||||||
|
try { await checkYouTube(client); } catch (e) { console.error('[social] YouTube:', e.message); }
|
||||||
|
try { await checkTwitch(client); } catch (e) { console.error('[social] Twitch:', e.message); }
|
||||||
|
};
|
||||||
|
setInterval(tick, CHECK_INTERVAL_MS);
|
||||||
|
}
|
||||||
@@ -252,6 +252,158 @@ export function commitHeatmap() {
|
|||||||
.all();
|
.all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alpha-Keys: Pool + Zuweisung an Playtester
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS alpha_keys (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
assigned_to TEXT,
|
||||||
|
assigned_at TEXT
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const insertAlphaKey = db.prepare('INSERT OR IGNORE INTO alpha_keys (key) VALUES (?)');
|
||||||
|
const freeKeyCountStmt = db.prepare('SELECT count(*) AS n FROM alpha_keys WHERE assigned_to IS NULL');
|
||||||
|
const assignedKeysStmt = db.prepare(
|
||||||
|
`SELECT key, assigned_to, assigned_at FROM alpha_keys WHERE assigned_to IS NOT NULL ORDER BY assigned_at`
|
||||||
|
);
|
||||||
|
const hasKeyForUserStmt = db.prepare('SELECT key FROM alpha_keys WHERE assigned_to = ?');
|
||||||
|
const reserveKeyStmt = db.prepare(`
|
||||||
|
UPDATE alpha_keys SET assigned_to = ?, assigned_at = datetime('now')
|
||||||
|
WHERE key = (SELECT key FROM alpha_keys WHERE assigned_to IS NULL LIMIT 1)
|
||||||
|
RETURNING key
|
||||||
|
`);
|
||||||
|
const unreserveKeyStmt = db.prepare('UPDATE alpha_keys SET assigned_to = NULL, assigned_at = NULL WHERE key = ?');
|
||||||
|
export const addAlphaKeys = db.transaction((keys) => {
|
||||||
|
let added = 0;
|
||||||
|
for (const k of keys) added += insertAlphaKey.run(k).changes;
|
||||||
|
return added;
|
||||||
|
});
|
||||||
|
export const freeAlphaKeyCount = () => freeKeyCountStmt.get().n;
|
||||||
|
export const assignedAlphaKeys = () => assignedKeysStmt.all();
|
||||||
|
export const alphaKeyOf = (userId) => hasKeyForUserStmt.get(userId)?.key ?? null;
|
||||||
|
export const reserveAlphaKey = (userId) => reserveKeyStmt.get(userId)?.key ?? null;
|
||||||
|
export const unreserveAlphaKey = (key) => unreserveKeyStmt.run(key);
|
||||||
|
|
||||||
|
// Bewerbungs-Formulare (Modals) + eingereichte Bewerbungen
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS app_forms (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
review_channel_id TEXT,
|
||||||
|
approve_role_id TEXT,
|
||||||
|
post_channel_id TEXT,
|
||||||
|
message_id TEXT,
|
||||||
|
questions TEXT NOT NULL DEFAULT '[]'
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS applications (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
form_id INTEGER NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
username TEXT,
|
||||||
|
answers TEXT NOT NULL DEFAULT '[]',
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const insertAppForm = db.prepare(`
|
||||||
|
INSERT INTO app_forms (title, description, review_channel_id, approve_role_id, post_channel_id, questions)
|
||||||
|
VALUES (@title, @description, @review_channel_id, @approve_role_id, @post_channel_id, @questions)
|
||||||
|
`);
|
||||||
|
const updateAppFormStmt = db.prepare(`
|
||||||
|
UPDATE app_forms SET title = @title, description = @description, review_channel_id = @review_channel_id,
|
||||||
|
approve_role_id = @approve_role_id, post_channel_id = @post_channel_id, questions = @questions
|
||||||
|
WHERE id = @id
|
||||||
|
`);
|
||||||
|
const setAppFormMessageStmt = db.prepare('UPDATE app_forms SET message_id = ? WHERE id = ?');
|
||||||
|
const getAppFormStmt = db.prepare('SELECT * FROM app_forms WHERE id = ?');
|
||||||
|
const listAppFormsStmt = db.prepare('SELECT * FROM app_forms ORDER BY id');
|
||||||
|
const deleteAppFormStmt = db.prepare('DELETE FROM app_forms WHERE id = ?');
|
||||||
|
export const createAppForm = (f) => insertAppForm.run(f).lastInsertRowid;
|
||||||
|
export const updateAppForm = (f) => updateAppFormStmt.run(f).changes > 0;
|
||||||
|
export const setAppFormMessage = (id, messageId) => setAppFormMessageStmt.run(messageId, id);
|
||||||
|
export const getAppForm = (id) => getAppFormStmt.get(id) ?? null;
|
||||||
|
export const listAppForms = () => listAppFormsStmt.all();
|
||||||
|
export const deleteAppForm = (id) => deleteAppFormStmt.run(id).changes > 0;
|
||||||
|
|
||||||
|
const insertApplication = db.prepare(`
|
||||||
|
INSERT INTO applications (form_id, user_id, username, answers) VALUES (?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
const getApplicationStmt = db.prepare('SELECT * FROM applications WHERE id = ?');
|
||||||
|
const openApplicationStmt = db.prepare(
|
||||||
|
`SELECT * FROM applications WHERE form_id = ? AND user_id = ? AND status = 'open'`
|
||||||
|
);
|
||||||
|
const setApplicationStatusStmt = db.prepare('UPDATE applications SET status = ? WHERE id = ?');
|
||||||
|
export const createApplication = (formId, userId, username, answers) =>
|
||||||
|
insertApplication.run(formId, userId, username, JSON.stringify(answers)).lastInsertRowid;
|
||||||
|
export const getApplication = (id) => getApplicationStmt.get(id) ?? null;
|
||||||
|
export const openApplicationOf = (formId, userId) => openApplicationStmt.get(formId, userId) ?? null;
|
||||||
|
export const setApplicationStatus = (id, status) => setApplicationStatusStmt.run(status, id);
|
||||||
|
|
||||||
|
// Triggers (Auto-Antworten), Reminders, Server-Aktivität, Temp-Voice
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS triggers (
|
||||||
|
keyword TEXT PRIMARY KEY,
|
||||||
|
reply TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS reminders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
remind_at TEXT NOT NULL,
|
||||||
|
sent INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS activity_daily (
|
||||||
|
day TEXT PRIMARY KEY,
|
||||||
|
messages INTEGER NOT NULL DEFAULT 0,
|
||||||
|
joins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
leaves INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS temp_voice (
|
||||||
|
channel_id TEXT PRIMARY KEY
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const upsertTrigger = db.prepare(`
|
||||||
|
INSERT INTO triggers (keyword, reply) VALUES (?, ?)
|
||||||
|
ON CONFLICT(keyword) DO UPDATE SET reply = excluded.reply
|
||||||
|
`);
|
||||||
|
const listTriggersStmt = db.prepare('SELECT keyword, reply FROM triggers ORDER BY keyword');
|
||||||
|
const deleteTriggerStmt = db.prepare('DELETE FROM triggers WHERE keyword = ?');
|
||||||
|
export const saveTrigger = (keyword, reply) => upsertTrigger.run(keyword, reply);
|
||||||
|
export const listTriggers = () => listTriggersStmt.all();
|
||||||
|
export const deleteTrigger = (keyword) => deleteTriggerStmt.run(keyword).changes > 0;
|
||||||
|
|
||||||
|
const insertReminder = db.prepare('INSERT INTO reminders (user_id, text, remind_at) VALUES (?, ?, ?)');
|
||||||
|
const dueRemindersStmt = db.prepare(
|
||||||
|
`SELECT * FROM reminders WHERE sent = 0 AND remind_at <= datetime('now')`
|
||||||
|
);
|
||||||
|
const markReminderSentStmt = db.prepare('UPDATE reminders SET sent = 1 WHERE id = ?');
|
||||||
|
export const createReminder = (userId, text, remindAt) => insertReminder.run(userId, text, remindAt).lastInsertRowid;
|
||||||
|
export const dueReminders = () => dueRemindersStmt.all();
|
||||||
|
export const markReminderSent = (id) => markReminderSentStmt.run(id);
|
||||||
|
|
||||||
|
const bumpActivityStmt = (col) => db.prepare(`
|
||||||
|
INSERT INTO activity_daily (day, ${col}) VALUES (date('now'), 1)
|
||||||
|
ON CONFLICT(day) DO UPDATE SET ${col} = ${col} + 1
|
||||||
|
`);
|
||||||
|
const bumpMessages = bumpActivityStmt('messages');
|
||||||
|
const bumpJoins = bumpActivityStmt('joins');
|
||||||
|
const bumpLeaves = bumpActivityStmt('leaves');
|
||||||
|
export const countActivity = (kind) =>
|
||||||
|
(kind === 'join' ? bumpJoins : kind === 'leave' ? bumpLeaves : bumpMessages).run();
|
||||||
|
const activityRangeStmt = db.prepare(
|
||||||
|
`SELECT day, messages, joins, leaves FROM activity_daily WHERE day >= date('now', ?) ORDER BY day`
|
||||||
|
);
|
||||||
|
export const activityRange = (days = 30) => activityRangeStmt.all(`-${days} days`);
|
||||||
|
|
||||||
|
const insertTempVoice = db.prepare('INSERT OR IGNORE INTO temp_voice (channel_id) VALUES (?)');
|
||||||
|
const deleteTempVoiceStmt = db.prepare('DELETE FROM temp_voice WHERE channel_id = ?');
|
||||||
|
const listTempVoiceStmt = db.prepare('SELECT channel_id FROM temp_voice');
|
||||||
|
export const trackTempVoice = (id) => insertTempVoice.run(id);
|
||||||
|
export const untrackTempVoice = (id) => deleteTempVoiceStmt.run(id);
|
||||||
|
export const listTempVoice = () => listTempVoiceStmt.all().map((r) => r.channel_id);
|
||||||
|
const isTempVoiceStmt = db.prepare('SELECT 1 FROM temp_voice WHERE channel_id = ?');
|
||||||
|
export const isTempVoice = (id) => Boolean(isTempVoiceStmt.get(id));
|
||||||
|
|
||||||
// Moderation: Verwarnungen + Sticky-Roles
|
// Moderation: Verwarnungen + Sticky-Roles
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS warns (
|
CREATE TABLE IF NOT EXISTS warns (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { scheduleBackups } from './backup.js';
|
|||||||
import { startServerMonitor } from './bot/server-monitor.js';
|
import { startServerMonitor } from './bot/server-monitor.js';
|
||||||
import { startGiveaways } from './bot/giveaways.js';
|
import { startGiveaways } from './bot/giveaways.js';
|
||||||
import { startScheduledPosts } from './web/scheduled-posts.js';
|
import { startScheduledPosts } from './web/scheduled-posts.js';
|
||||||
|
import { startSocialNotify } from './bot/social-notify.js';
|
||||||
|
|
||||||
process.on('unhandledRejection', (error) => {
|
process.on('unhandledRejection', (error) => {
|
||||||
console.error('[main] Unhandled Rejection:', error);
|
console.error('[main] Unhandled Rejection:', error);
|
||||||
@@ -21,6 +22,7 @@ try {
|
|||||||
startServerMonitor(client);
|
startServerMonitor(client);
|
||||||
startGiveaways(client);
|
startGiveaways(client);
|
||||||
startScheduledPosts(client);
|
startScheduledPosts(client);
|
||||||
|
startSocialNotify(client);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[main] Start fehlgeschlagen:', error);
|
console.error('[main] Start fehlgeschlagen:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -93,6 +93,42 @@ export function botStatusType() {
|
|||||||
return getSetting('bot_status_type') || 'custom';
|
return getSetting('bot_status_type') || 'custom';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bot-Presence-Status: online | idle | dnd */
|
||||||
|
export function botPresenceStatus() {
|
||||||
|
return getSetting('bot_presence_status') || 'online';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ankündigungs-Kanal für neue Discord-Events — leer = aus */
|
||||||
|
export function eventsAnnounceChannelId() {
|
||||||
|
return getSetting('events_announce_channel_id') || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Announce-Kanal für Twitch/YouTube — leer = aus */
|
||||||
|
export function socialAnnounceChannelId() {
|
||||||
|
return getSetting('social_announce_channel_id') || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** YouTube-Kanal-ID (UC…) für neue-Video-Benachrichtigungen */
|
||||||
|
export function youtubeChannelId() {
|
||||||
|
return getSetting('youtube_channel_id') || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Twitch-Login-Name + App-Credentials (Client-Credentials-Flow) */
|
||||||
|
export function twitchChannel() {
|
||||||
|
return getSetting('twitch_channel') || null;
|
||||||
|
}
|
||||||
|
export function twitchClientId() {
|
||||||
|
return getSetting('twitch_client_id') || null;
|
||||||
|
}
|
||||||
|
export function twitchClientSecret() {
|
||||||
|
return getSetting('twitch_client_secret') || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Join to Create"-Voice-Kanal — leer = Temp-Voice aus */
|
||||||
|
export function tempVoiceChannelId() {
|
||||||
|
return getSetting('tempvoice_channel_id') || null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Auto-Rolle für neue Member — leer = aus */
|
/** Auto-Rolle für neue Member — leer = aus */
|
||||||
export function autoroleId() {
|
export function autoroleId() {
|
||||||
return getSetting('autorole_id') || null;
|
return getSetting('autorole_id') || null;
|
||||||
|
|||||||
+223
-1
@@ -7,7 +7,11 @@ import {
|
|||||||
createRoleMenu, updateRoleMenu, getRoleMenu, listRoleMenus, deleteRoleMenu,
|
createRoleMenu, updateRoleMenu, getRoleMenu, listRoleMenus, deleteRoleMenu,
|
||||||
topLevels, saveTag, listTags, deleteTag,
|
topLevels, saveTag, listTags, deleteTag,
|
||||||
createScheduledPost, listScheduledPosts, deleteScheduledPost,
|
createScheduledPost, listScheduledPosts, deleteScheduledPost,
|
||||||
|
addAlphaKeys, freeAlphaKeyCount, assignedAlphaKeys, alphaKeyOf, reserveAlphaKey, unreserveAlphaKey,
|
||||||
|
createAppForm, updateAppForm, getAppForm, listAppForms, deleteAppForm,
|
||||||
|
saveTrigger, listTriggers, deleteTrigger, activityRange,
|
||||||
} from '../db.js';
|
} from '../db.js';
|
||||||
|
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
|
||||||
import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-menus.js';
|
import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-menus.js';
|
||||||
import { computeNextRun } from './scheduled-posts.js';
|
import { computeNextRun } from './scheduled-posts.js';
|
||||||
import { config } from '../config.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/wishes', async () => ({ wishes: topWishes(20) }));
|
||||||
app.get('/api/heatmap', async () => ({ days: commitHeatmap() }));
|
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/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)
|
// Roadmap — öffentlich, aus Gitea-Milestones (5-Minuten-Cache gegen API-Hammering)
|
||||||
let roadmapCache = { at: 0, repo: null, data: null };
|
let roadmapCache = { at: 0, repo: null, data: null };
|
||||||
@@ -234,6 +266,13 @@ ${rssItems}
|
|||||||
levels_enabled: getSetting('levels_enabled') === '1',
|
levels_enabled: getSetting('levels_enabled') === '1',
|
||||||
levels_announce: getSetting('levels_announce') !== '0',
|
levels_announce: getSetting('levels_announce') !== '0',
|
||||||
level_rewards: getSetting('level_rewards') ?? '',
|
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(),
|
settings: currentSettings(),
|
||||||
status: {
|
status: {
|
||||||
botTag: client.user?.tag ?? null,
|
botTag: client.user?.tag ?? null,
|
||||||
|
botName: client.user?.username ?? null,
|
||||||
|
botAvatar: client.user?.displayAvatarURL?.({ size: 128 }) ?? null,
|
||||||
uptimeSeconds: Math.floor(process.uptime()),
|
uptimeSeconds: Math.floor(process.uptime()),
|
||||||
guilds: client.guilds.cache.size,
|
guilds: client.guilds.cache.size,
|
||||||
giteaTokenConfigured: Boolean(giteaApiToken()),
|
giteaTokenConfigured: Boolean(giteaApiToken()),
|
||||||
@@ -266,6 +307,7 @@ ${rssItems}
|
|||||||
'release_channel_id', 'backup_channel_id', 'starboard_channel_id',
|
'release_channel_id', 'backup_channel_id', 'starboard_channel_id',
|
||||||
'screenshot_channel_id', 'modmail_channel_id', 'welcome_channel_id',
|
'screenshot_channel_id', 'modmail_channel_id', 'welcome_channel_id',
|
||||||
'modlog_channel_id', 'status_channel_id', 'voting_channel_id', 'ticket_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]) {
|
for (const key of ['commit_channel_id', 'devlog_channel_id', ...OPTIONAL_CHANNELS]) {
|
||||||
if (body[key] === undefined) continue;
|
if (body[key] === undefined) continue;
|
||||||
@@ -351,6 +393,22 @@ ${rssItems}
|
|||||||
setSetting('bot_status_text', String(body.bot_status_text).trim().slice(0, 120));
|
setSetting('bot_status_text', String(body.bot_status_text).trim().slice(0, 120));
|
||||||
statusChanged = true;
|
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) {
|
if (statusChanged) {
|
||||||
const { applyBotStatus } = await import('../bot/presence.js');
|
const { applyBotStatus } = await import('../bot/presence.js');
|
||||||
applyBotStatus(client);
|
applyBotStatus(client);
|
||||||
@@ -362,6 +420,15 @@ ${rssItems}
|
|||||||
if (body.gitea_api_token !== undefined && String(body.gitea_api_token).trim() !== '') {
|
if (body.gitea_api_token !== undefined && String(body.gitea_api_token).trim() !== '') {
|
||||||
setSetting('gitea_api_token', 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']) {
|
for (const key of ['commit_branch_filter', 'ignored_repos', 'bug_report_repo', 'watchdog_urls', 'roadmap_repo', 'gameservers']) {
|
||||||
if (body[key] !== undefined) {
|
if (body[key] !== undefined) {
|
||||||
setSetting(key, String(body[key]).trim());
|
setSetting(key, String(body[key]).trim());
|
||||||
@@ -488,6 +555,161 @@ ${rssItems}
|
|||||||
return { deleted: true };
|
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) ---
|
// --- Tags (Admin-Verwaltung; Abruf via /tag in Discord) ---
|
||||||
|
|
||||||
app.get('/api/tags', async (request, reply) => {
|
app.get('/api/tags', async (request, reply) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user