Carl-Bot-Paket: Moderation, Auto-/Sticky-Roles, Level-System, Tags, geplante Posts
- Moderation: /warn (DM + Mod-Log), /warns (Historie + löschen), /timeout (max 28d), /purge (bulk, 14-Tage-Limit sauber gemeldet); Mod-Log erweitert um Join/Leave und Nick-/Rollen-Änderungen - Auto-Rolle bei Join + Sticky-Roles (Rollen bei Leave gesichert, bei Rejoin wiederhergestellt; managed/@everyone ausgenommen) — Rollen-Tab - Level-System (opt-in): 15-25 XP/Nachricht mit 60s-Cooldown, MEE6-Formel, Level-Up-Announce, Rollen-Belohnungen (Level=RolleID), /rank mit Fortschritt, öffentliche Bestenliste /level; Community-Tab - Tags: /tag mit Autocomplete, Verwaltung im Composer-Tab (/api/tags) - Geplante Posts: einmalig/täglich/wöchentlich, Minuten-Scheduler, DST-sicher über Neuberechnung aus Zeitfeldern; Composer-Tab (/api/scheduled) - Autocomplete-Dispatch im InteractionCreate; alles smoke-getestet Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,11 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St
|
|||||||
| 📬 **Modmail** | DM an den Bot → Thread im privaten Staff-Kanal; Antworten im Thread gehen als DM zurück |
|
| 📬 **Modmail** | DM an den Bot → Thread im privaten Staff-Kanal; Antworten im Thread gehen als DM zurück |
|
||||||
| 🎫 **Tickets** | `/ticket-setup` postet den Button; Klick öffnet einen privaten Thread (ein offenes Ticket pro User, Schließen-Button) |
|
| 🎫 **Tickets** | `/ticket-setup` postet den Button; Klick öffnet einen privaten Thread (ein offenes Ticket pro User, Schließen-Button) |
|
||||||
| 🎭 **Rollen-Menüs** | Carl-Bot-Ersatz: Menüs im Webinterface bauen (Emoji, Label, Rolle, optional exklusiv), Bot postet Button-Embeds; Klick = Rolle nehmen/abgeben, Posts jederzeit editierbar |
|
| 🎭 **Rollen-Menüs** | Carl-Bot-Ersatz: Menüs im Webinterface bauen (Emoji, Label, Rolle, optional exklusiv), Bot postet Button-Embeds; Klick = Rolle nehmen/abgeben, Posts jederzeit editierbar |
|
||||||
|
| 🛡️ **Moderation** | `/warn` (DM + Historie), `/warns`, `/timeout`, `/purge` — alles im Mod-Log; dazu Join/Leave- und Nick-/Rollen-Änderungs-Logging |
|
||||||
|
| 🪪 **Auto- & Sticky-Roles** | Start-Rolle für neue Member; Rollen werden bei Leave gesichert und bei Rejoin wiederhergestellt |
|
||||||
|
| 📈 **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 |
|
||||||
|
| ⏰ **Geplante Posts** | Einmalig, täglich oder wöchentlich — im Composer-Tab geplant, vom Bot gepostet (bei Carl Premium) |
|
||||||
| 👋 **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 |
|
||||||
@@ -42,6 +47,7 @@ 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 |
|
||||||
| `/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) |
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Devlogs from './pages/Devlogs.jsx';
|
|||||||
import Changelog from './pages/Changelog.jsx';
|
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 Commits from './pages/Commits.jsx';
|
import Commits from './pages/Commits.jsx';
|
||||||
import Settings from './pages/Settings.jsx';
|
import Settings from './pages/Settings.jsx';
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ export default function App() {
|
|||||||
<NavLink to="/devlogs">Devlog</NavLink>
|
<NavLink to="/devlogs">Devlog</NavLink>
|
||||||
<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="/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>}
|
||||||
@@ -100,6 +102,7 @@ export default function App() {
|
|||||||
<Route path="/devlogs" element={<Devlogs me={me} />} />
|
<Route path="/devlogs" element={<Devlogs me={me} />} />
|
||||||
<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="/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,64 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { apiGet } from '../api.js';
|
||||||
|
|
||||||
|
/** Muss der Formel in src/bot/levels.js entsprechen */
|
||||||
|
function xpForLevel(level) {
|
||||||
|
let total = 0;
|
||||||
|
for (let n = 0; n < level; n++) total += 5 * n * n + 50 * n + 100;
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||||
|
|
||||||
|
export default function Level() {
|
||||||
|
const [rows, setRows] = useState(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiGet('/api/levels').then((d) => setRows(d.levels)).catch(() => setError(true));
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="page-header">
|
||||||
|
<div className="page-bg-text">LEVEL</div>
|
||||||
|
<div className="page-header-grid" aria-hidden="true" />
|
||||||
|
<div className="page-header-content">
|
||||||
|
<div className="page-tag">// Bestenliste</div>
|
||||||
|
<h1 className="page-title">Level</h1>
|
||||||
|
<p className="page-subtitle">
|
||||||
|
XP gibt's fürs Mitreden im Discord — hier stehen die Aktivsten. <code>/rank</code> zeigt deinen Platz.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="content">
|
||||||
|
{error && <p className="notice">Bestenliste konnte nicht geladen werden.</p>}
|
||||||
|
{!error && !rows && <p className="notice">Lade …</p>}
|
||||||
|
{rows?.length === 0 && <p className="notice">Noch keine XP vergeben — schreibt was im Discord! ✍️</p>}
|
||||||
|
|
||||||
|
{rows?.length > 0 && (
|
||||||
|
<div className="lvl-list">
|
||||||
|
{rows.map((r, i) => {
|
||||||
|
const base = xpForLevel(r.level);
|
||||||
|
const next = xpForLevel(r.level + 1);
|
||||||
|
const pct = Math.floor(((r.xp - base) / (next - base)) * 100);
|
||||||
|
return (
|
||||||
|
<div className="lvl-row" key={r.user_id}>
|
||||||
|
<span className="lvl-rank">{MEDALS[i] ?? `#${i + 1}`}</span>
|
||||||
|
<span className="lvl-name">{r.username ?? r.user_id}</span>
|
||||||
|
<div className="lvl-progress">
|
||||||
|
<div className="progress"><div className="progress-bar" style={{ width: `${pct}%` }} /></div>
|
||||||
|
</div>
|
||||||
|
<span className="lvl-level">Lv. {r.level}</span>
|
||||||
|
<span className="lvl-xp">{r.xp.toLocaleString('de-DE')} XP</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,6 +49,14 @@ export default function Settings({ me }) {
|
|||||||
const [menuDraft, setMenuDraft] = useState(emptyMenu);
|
const [menuDraft, setMenuDraft] = useState(emptyMenu);
|
||||||
// Composer
|
// Composer
|
||||||
const [composer, setComposer] = useState({ channel: '', title: '', text: '', messageId: '' });
|
const [composer, setComposer] = useState({ channel: '', title: '', text: '', messageId: '' });
|
||||||
|
// Tags + geplante Posts
|
||||||
|
const [tags, setTags] = useState([]);
|
||||||
|
const [tagDraft, setTagDraft] = useState({ name: '', content: '' });
|
||||||
|
const [scheduled, setScheduled] = useState([]);
|
||||||
|
const [schedDraft, setSchedDraft] = useState({
|
||||||
|
channel_id: '', embed_title: '', content: '', type: 'once',
|
||||||
|
date: '', time: '18:00', weekday: '5',
|
||||||
|
});
|
||||||
// API-Keys
|
// API-Keys
|
||||||
const [keys, setKeys] = useState([]);
|
const [keys, setKeys] = useState([]);
|
||||||
const [newKeyName, setNewKeyName] = useState('');
|
const [newKeyName, setNewKeyName] = useState('');
|
||||||
@@ -66,8 +74,51 @@ export default function Settings({ me }) {
|
|||||||
apiGet('/api/apikeys').then((d) => setKeys(d.keys)).catch(() => {});
|
apiGet('/api/apikeys').then((d) => setKeys(d.keys)).catch(() => {});
|
||||||
apiGet('/api/playtesters').then((d) => setPlaytesters(d.playtesters)).catch(() => {});
|
apiGet('/api/playtesters').then((d) => setPlaytesters(d.playtesters)).catch(() => {});
|
||||||
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/scheduled').then((d) => setScheduled(d.posts)).catch(() => {});
|
||||||
}, [me.admin]);
|
}, [me.admin]);
|
||||||
|
|
||||||
|
async function saveTagDraft() {
|
||||||
|
if (!tagDraft.name.trim() || !tagDraft.content.trim()) {
|
||||||
|
flash('✗ Name und Inhalt nötig');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiPut(`/api/tags/${encodeURIComponent(tagDraft.name.trim().toLowerCase())}`, { content: tagDraft.content });
|
||||||
|
setTags((await apiGet('/api/tags')).tags);
|
||||||
|
setTagDraft({ name: '', content: '' });
|
||||||
|
flash('✓ Tag gespeichert');
|
||||||
|
} catch {
|
||||||
|
flash('✗ Tag fehlgeschlagen (Name: a-z, 0-9, -, _)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTag(name) {
|
||||||
|
if (!window.confirm(`Tag „${name}" löschen?`)) return;
|
||||||
|
await apiDelete(`/api/tags/${encodeURIComponent(name)}`).catch(() => {});
|
||||||
|
setTags((await apiGet('/api/tags')).tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSchedule() {
|
||||||
|
if (!schedDraft.channel_id || !schedDraft.content.trim()) {
|
||||||
|
flash('✗ Kanal und Text nötig');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiPost('/api/scheduled', schedDraft);
|
||||||
|
setScheduled((await apiGet('/api/scheduled')).posts);
|
||||||
|
setSchedDraft({ ...schedDraft, content: '', embed_title: '' });
|
||||||
|
flash('✓ Post geplant');
|
||||||
|
} catch {
|
||||||
|
flash('✗ Planung fehlgeschlagen — Datum/Zeit prüfen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSchedule(id) {
|
||||||
|
await apiDelete(`/api/scheduled/${id}`).catch(() => {});
|
||||||
|
setScheduled((await apiGet('/api/scheduled')).posts);
|
||||||
|
}
|
||||||
|
|
||||||
function flash(msg) {
|
function flash(msg) {
|
||||||
setFeedback(msg);
|
setFeedback(msg);
|
||||||
setTimeout(() => setFeedback(null), 3000);
|
setTimeout(() => setFeedback(null), 3000);
|
||||||
@@ -375,11 +426,54 @@ export default function Settings({ me }) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-title">// Level-System</h2>
|
||||||
|
<div className="field">
|
||||||
|
<label className="toggle">
|
||||||
|
<input type="checkbox" checked={form.levels_enabled} onChange={(e) => setForm({ ...form, levels_enabled: e.target.checked })} />
|
||||||
|
<span>XP fürs Schreiben vergeben</span>
|
||||||
|
<span className="field-hint">15–25 XP pro Nachricht, 60s Cooldown; /rank + Bestenliste auf /level</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="toggle">
|
||||||
|
<input type="checkbox" checked={form.levels_announce} onChange={(e) => setForm({ ...form, levels_announce: e.target.checked })} />
|
||||||
|
<span>Level-Up im Kanal feiern</span>
|
||||||
|
<span className="field-hint">„🎉 Name ist jetzt Level 5!"</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Rollen-Belohnungen <span className="field-hint">Format: Level=Rollen-ID, kommagetrennt — z. B. 5=1234, 10=5678</span></label>
|
||||||
|
<input type="text" placeholder="5=RollenID, 10=RollenID" value={form.level_rewards} onChange={(e) => setForm({ ...form, level_rewards: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const tabRollen = (
|
const tabRollen = (
|
||||||
<>
|
<>
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-title">// Rollen bei Join</h2>
|
||||||
|
<div className="field">
|
||||||
|
<label>Auto-Rolle <span className="field-hint">bekommt jeder neue Member automatisch — leer = aus</span></label>
|
||||||
|
<select value={form.autorole_id ?? ''} onChange={(e) => setForm({ ...form, autorole_id: e.target.value })}>
|
||||||
|
<option value="">— deaktiviert —</option>
|
||||||
|
{roleOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="toggle">
|
||||||
|
<input type="checkbox" checked={form.sticky_roles_enabled} onChange={(e) => setForm({ ...form, sticky_roles_enabled: e.target.checked })} />
|
||||||
|
<span>Sticky-Roles</span>
|
||||||
|
<span className="field-hint">wer den Server verlässt und zurückkommt, bekommt seine Rollen wieder</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="settings-actions">
|
||||||
|
<button className="btn btn-save" onClick={save}>Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h2 className="settings-title">// Rollen-Menüs</h2>
|
<h2 className="settings-title">// Rollen-Menüs</h2>
|
||||||
<p className="section-intro">
|
<p className="section-intro">
|
||||||
@@ -497,6 +591,7 @@ export default function Settings({ me }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tabComposer = (
|
const tabComposer = (
|
||||||
|
<>
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h2 className="settings-title">// Post verfassen</h2>
|
<h2 className="settings-title">// Post verfassen</h2>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
@@ -524,6 +619,90 @@ export default function Settings({ me }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-title">// Geplante Posts</h2>
|
||||||
|
{scheduled.length > 0 && (
|
||||||
|
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||||||
|
{scheduled.map((p) => (
|
||||||
|
<div className="key-row" key={p.id}>
|
||||||
|
<span className="key-name">{channelName(p.channel_id)}</span>
|
||||||
|
<span className="key-scopes">
|
||||||
|
{{ once: 'einmalig', daily: 'täglich', weekly: 'wöchentlich' }[p.type]} {p.time}
|
||||||
|
{p.enabled ? '' : ' · erledigt'}
|
||||||
|
</span>
|
||||||
|
<span className="key-used" title={p.content}>{p.content.slice(0, 40)}…</span>
|
||||||
|
<button className="card-delete" title="Löschen" onClick={() => removeSchedule(p.id)}>✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="field">
|
||||||
|
<label>Neuer geplanter Post</label>
|
||||||
|
<div className="field-row">
|
||||||
|
<select value={schedDraft.channel_id} onChange={(e) => setSchedDraft({ ...schedDraft, channel_id: e.target.value })}>
|
||||||
|
<option value="" disabled>— Kanal wählen —</option>
|
||||||
|
{channelOptions}
|
||||||
|
</select>
|
||||||
|
<select style={{ flex: 'none', width: '9rem' }} value={schedDraft.type} onChange={(e) => setSchedDraft({ ...schedDraft, type: e.target.value })}>
|
||||||
|
<option value="once">einmalig</option>
|
||||||
|
<option value="daily">täglich</option>
|
||||||
|
<option value="weekly">wöchentlich</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<div className="field-row">
|
||||||
|
{schedDraft.type === 'once' && (
|
||||||
|
<input type="date" value={schedDraft.date} onChange={(e) => setSchedDraft({ ...schedDraft, date: e.target.value })} />
|
||||||
|
)}
|
||||||
|
{schedDraft.type === 'weekly' && (
|
||||||
|
<select value={schedDraft.weekday} onChange={(e) => setSchedDraft({ ...schedDraft, weekday: e.target.value })}>
|
||||||
|
{['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'].map((d, i) => (
|
||||||
|
<option key={i} value={String(i)}>{d}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<input type="time" value={schedDraft.time} onChange={(e) => setSchedDraft({ ...schedDraft, time: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<input type="text" placeholder="Embed-Titel (leer = normale Nachricht)" value={schedDraft.embed_title} onChange={(e) => setSchedDraft({ ...schedDraft, embed_title: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<textarea rows={4} placeholder="Text (Discord-Markdown)" value={schedDraft.content} onChange={(e) => setSchedDraft({ ...schedDraft, content: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<button className="btn btn-save" onClick={createSchedule}>Planen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-title">// Tags (/tag)</h2>
|
||||||
|
<p className="section-intro">
|
||||||
|
Text-Bausteine für wiederkehrende Antworten — jeder Member kann sie mit <code>/tag name</code> abrufen.
|
||||||
|
</p>
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||||||
|
{tags.map((t) => (
|
||||||
|
<div className="key-row" key={t.name}>
|
||||||
|
<span className="key-name">{t.name}</span>
|
||||||
|
<span className="key-used" title={t.content}>{t.content.slice(0, 50)}</span>
|
||||||
|
<button className="btn btn-mini" onClick={() => setTagDraft({ name: t.name, content: t.content })}>Bearbeiten</button>
|
||||||
|
<button className="card-delete" title="Tag löschen" onClick={() => removeTag(t.name)}>✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="field">
|
||||||
|
<div className="field-row">
|
||||||
|
<input type="text" style={{ flex: 'none', width: '12rem' }} placeholder="name (z. B. server-ip)" value={tagDraft.name} onChange={(e) => setTagDraft({ ...tagDraft, name: e.target.value })} />
|
||||||
|
<input type="text" placeholder="Inhalt des Tags" value={tagDraft.content} onChange={(e) => setTagDraft({ ...tagDraft, content: e.target.value })} />
|
||||||
|
<button className="btn" onClick={saveTagDraft}>Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const tabSystem = (
|
const tabSystem = (
|
||||||
|
|||||||
@@ -881,3 +881,42 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
|
|||||||
}
|
}
|
||||||
.toggle input { border-radius: 99px; }
|
.toggle input { border-radius: 99px; }
|
||||||
.toggle input::after { border-radius: 50%; }
|
.toggle input::after { border-radius: 50%; }
|
||||||
|
|
||||||
|
/* ── LEVEL-BESTENLISTE ─────────────────────────────── */
|
||||||
|
.lvl-list { border: 1px solid rgba(255, 255, 255, .06); background: var(--bg2); border-radius: 12px; }
|
||||||
|
.lvl-row {
|
||||||
|
display: flex; align-items: center; gap: 1rem;
|
||||||
|
padding: .65rem 1.1rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, .05);
|
||||||
|
}
|
||||||
|
.lvl-row:last-child { border-bottom: none; }
|
||||||
|
.lvl-row:first-child { border-radius: 12px 12px 0 0; }
|
||||||
|
.lvl-rank { font-family: var(--mono); font-size: .85rem; color: var(--muted); min-width: 2.6rem; }
|
||||||
|
.lvl-name { font-weight: 600; min-width: 10rem; }
|
||||||
|
.lvl-progress { flex: 1; }
|
||||||
|
.lvl-level {
|
||||||
|
font-family: var(--mono); font-size: .72rem; letter-spacing: .1em;
|
||||||
|
color: var(--neon); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.lvl-xp { font-family: var(--mono); font-size: .68rem; color: var(--muted2); white-space: nowrap; min-width: 6rem; text-align: right; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.lvl-name { min-width: 0; flex: 1; }
|
||||||
|
.lvl-progress { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Datums-/Zeit-Inputs wie Text-Inputs stylen */
|
||||||
|
.field input[type="date"], .field input[type="time"] {
|
||||||
|
background: rgba(0, 0, 0, .35);
|
||||||
|
border: 1px solid rgba(255, 255, 255, .09);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--body);
|
||||||
|
font-size: .95rem;
|
||||||
|
padding: .5rem .7rem;
|
||||||
|
outline: none;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
.field input[type="date"]:focus, .field input[type="time"]:focus {
|
||||||
|
border-color: var(--neon);
|
||||||
|
box-shadow: 0 0 0 3px rgba(245, 197, 24, .12);
|
||||||
|
}
|
||||||
|
|||||||
+21
-1
@@ -5,6 +5,7 @@ import { devlogChannelId, devlogPingRoleId, playtesterRoleId, ticketChannelId }
|
|||||||
import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
|
import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
|
||||||
import { registerCommunityListeners } from './community.js';
|
import { registerCommunityListeners } from './community.js';
|
||||||
import { registerModTools } from './mod-tools.js';
|
import { registerModTools } from './mod-tools.js';
|
||||||
|
import { registerLevels } from './levels.js';
|
||||||
import { handleRoleMenuButton } from './role-menus.js';
|
import { handleRoleMenuButton } from './role-menus.js';
|
||||||
import {
|
import {
|
||||||
addPlaytester, removePlaytester, isWish, bumpWish,
|
addPlaytester, removePlaytester, isWish, bumpWish,
|
||||||
@@ -20,9 +21,18 @@ import * as galerieBackfill from './commands/galerie-backfill.js';
|
|||||||
import * as wunsch from './commands/wunsch.js';
|
import * as wunsch from './commands/wunsch.js';
|
||||||
import * as giveaway from './commands/giveaway.js';
|
import * as giveaway from './commands/giveaway.js';
|
||||||
import * as ticketSetup from './commands/ticket-setup.js';
|
import * as ticketSetup from './commands/ticket-setup.js';
|
||||||
|
import * as warn from './commands/warn.js';
|
||||||
|
import * as warns from './commands/warns.js';
|
||||||
|
import * as timeout from './commands/timeout.js';
|
||||||
|
import * as purge from './commands/purge.js';
|
||||||
|
import * as rank from './commands/rank.js';
|
||||||
|
import * as tag from './commands/tag.js';
|
||||||
|
|
||||||
// Alle Commands hier eintragen — jedes Modul exportiert { data, execute }
|
// Alle Commands hier eintragen — jedes Modul exportiert { data, execute }
|
||||||
const commandModules = [ping, devlogBackfill, bug, playtesterSetup, galerieBackfill, wunsch, giveaway, ticketSetup];
|
const commandModules = [
|
||||||
|
ping, devlogBackfill, bug, playtesterSetup, galerieBackfill,
|
||||||
|
wunsch, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag,
|
||||||
|
];
|
||||||
|
|
||||||
export async function startBot() {
|
export async function startBot() {
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
@@ -92,6 +102,15 @@ export async function startBot() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
client.on(Events.InteractionCreate, async (interaction) => {
|
client.on(Events.InteractionCreate, async (interaction) => {
|
||||||
|
// Autocomplete (z. B. /tag)
|
||||||
|
if (interaction.isAutocomplete()) {
|
||||||
|
const command = client.commands.get(interaction.commandName);
|
||||||
|
await command?.autocomplete?.(interaction).catch((e) =>
|
||||||
|
console.error(`[bot] Autocomplete /${interaction.commandName}:`, e)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 🔔-Button unterm Devlog: Ping-Rolle selbst an-/abmelden
|
// 🔔-Button unterm Devlog: Ping-Rolle selbst an-/abmelden
|
||||||
if (interaction.isButton() && interaction.customId === 'devlog_ping_toggle') {
|
if (interaction.isButton() && interaction.customId === 'devlog_ping_toggle') {
|
||||||
const roleId = devlogPingRoleId();
|
const roleId = devlogPingRoleId();
|
||||||
@@ -276,6 +295,7 @@ export async function startBot() {
|
|||||||
|
|
||||||
registerCommunityListeners(client);
|
registerCommunityListeners(client);
|
||||||
registerModTools(client);
|
registerModTools(client);
|
||||||
|
registerLevels(client);
|
||||||
|
|
||||||
await client.login(config.discordToken);
|
await client.login(config.discordToken);
|
||||||
return client;
|
return client;
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// /purge — Nachrichten im aktuellen Kanal massenlöschen (max 100, jünger als 14 Tage)
|
||||||
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js';
|
||||||
|
import { modlogChannelId } from '../../runtime-settings.js';
|
||||||
|
|
||||||
|
export const data = new SlashCommandBuilder()
|
||||||
|
.setName('purge')
|
||||||
|
.setDescription('Löscht die letzten N Nachrichten in diesem Kanal')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
|
||||||
|
.addIntegerOption((o) =>
|
||||||
|
o.setName('anzahl').setDescription('Wie viele? (1–100)').setRequired(true).setMinValue(1).setMaxValue(100)
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function execute(interaction) {
|
||||||
|
const count = interaction.options.getInteger('anzahl');
|
||||||
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
|
let deleted;
|
||||||
|
try {
|
||||||
|
// true = Nachrichten >14 Tage still überspringen (Discord-API-Limit)
|
||||||
|
deleted = await interaction.channel.bulkDelete(count, true);
|
||||||
|
} catch {
|
||||||
|
await interaction.editReply('❌ Löschen fehlgeschlagen — Bot braucht „Nachrichten verwalten" hier.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logChannelId = modlogChannelId();
|
||||||
|
if (logChannelId && deleted.size > 0) {
|
||||||
|
const channel = await interaction.client.channels.fetch(logChannelId).catch(() => null);
|
||||||
|
await channel?.send({
|
||||||
|
embeds: [
|
||||||
|
new EmbedBuilder()
|
||||||
|
.setColor(0xff4d00)
|
||||||
|
.setTitle('🧹 Purge')
|
||||||
|
.setDescription(
|
||||||
|
`**${deleted.size}** Nachrichten in <#${interaction.channelId}> gelöscht von **${interaction.user.username}**`
|
||||||
|
)
|
||||||
|
.setTimestamp(),
|
||||||
|
],
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply(
|
||||||
|
`🧹 **${deleted.size}** Nachrichten gelöscht.${deleted.size < count ? ' (ältere als 14 Tage kann Discord nicht massenlöschen)' : ''}`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// /rank — eigenes Level, XP-Fortschritt und Platzierung anzeigen
|
||||||
|
import { SlashCommandBuilder, EmbedBuilder } from 'discord.js';
|
||||||
|
import { getLevelRow, rankOf } from '../../db.js';
|
||||||
|
import { xpForLevel } from '../levels.js';
|
||||||
|
import { levelsEnabled, publicUrl } from '../../runtime-settings.js';
|
||||||
|
|
||||||
|
export const data = new SlashCommandBuilder()
|
||||||
|
.setName('rank')
|
||||||
|
.setDescription('Dein Level, XP und Platz in der Bestenliste')
|
||||||
|
.addUserOption((o) => o.setName('member').setDescription('Optional: anderen Member anschauen'));
|
||||||
|
|
||||||
|
export async function execute(interaction) {
|
||||||
|
if (!levelsEnabled()) {
|
||||||
|
await interaction.reply({ content: '❌ Das Level-System ist nicht aktiviert.', ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const user = interaction.options.getUser('member') ?? interaction.user;
|
||||||
|
const row = getLevelRow(user.id);
|
||||||
|
if (!row) {
|
||||||
|
await interaction.reply({ content: `**${user.username}** hat noch keine XP — einfach mitschreiben! ✍️`, ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentBase = xpForLevel(row.level);
|
||||||
|
const nextAt = xpForLevel(row.level + 1);
|
||||||
|
const progress = row.xp - currentBase;
|
||||||
|
const needed = nextAt - currentBase;
|
||||||
|
const pct = Math.floor((progress / needed) * 100);
|
||||||
|
const barLen = 14;
|
||||||
|
const bar = '█'.repeat(Math.round((pct / 100) * barLen)).padEnd(barLen, '·');
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
embeds: [
|
||||||
|
new EmbedBuilder()
|
||||||
|
.setColor(0xf5c518)
|
||||||
|
.setAuthor({ name: user.username, iconURL: user.displayAvatarURL({ size: 64 }) })
|
||||||
|
.setTitle(`Level ${row.level} · Platz #${rankOf(user.id)}`)
|
||||||
|
.setDescription(`\`${bar}\` ${pct}%\n**${progress} / ${needed} XP** bis Level ${row.level + 1} · gesamt ${row.xp} XP`)
|
||||||
|
.setURL(`${publicUrl()}/level`)
|
||||||
|
.setFooter({ text: 'D4RKST3R // LEVEL' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// /tag — gespeicherte Text-Bausteine abrufen (verwaltet über die Setup-Seite)
|
||||||
|
import { SlashCommandBuilder, MessageFlags } from 'discord.js';
|
||||||
|
import { getTag, listTags } from '../../db.js';
|
||||||
|
|
||||||
|
export const data = new SlashCommandBuilder()
|
||||||
|
.setName('tag')
|
||||||
|
.setDescription('Gespeicherten Text-Baustein posten (z. B. /tag name:server-ip)')
|
||||||
|
.addStringOption((o) =>
|
||||||
|
o.setName('name').setDescription('Name des Tags').setRequired(true).setAutocomplete(true)
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function autocomplete(interaction) {
|
||||||
|
const input = interaction.options.getFocused().toLowerCase();
|
||||||
|
const choices = listTags()
|
||||||
|
.filter((t) => t.name.toLowerCase().includes(input))
|
||||||
|
.slice(0, 25)
|
||||||
|
.map((t) => ({ name: t.name, value: t.name }));
|
||||||
|
await interaction.respond(choices).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function execute(interaction) {
|
||||||
|
const name = interaction.options.getString('name').trim().toLowerCase();
|
||||||
|
const tag = getTag(name);
|
||||||
|
if (!tag) {
|
||||||
|
const available = listTags().map((t) => `\`${t.name}\``).slice(0, 25).join(', ');
|
||||||
|
await interaction.reply({
|
||||||
|
content: `❌ Tag \`${name}\` gibt es nicht.${available ? `\nVerfügbar: ${available}` : ''}`,
|
||||||
|
flags: MessageFlags.Ephemeral,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await interaction.reply({ content: tag.content.slice(0, 2000), allowedMentions: { parse: [] } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// /timeout — Member stummschalten (Discord-Timeout) mit Mod-Log
|
||||||
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js';
|
||||||
|
import { parseDuration } from './giveaway.js';
|
||||||
|
import { modlogChannelId } from '../../runtime-settings.js';
|
||||||
|
|
||||||
|
const MAX_TIMEOUT_MS = 28 * 86_400_000; // Discord-Limit
|
||||||
|
|
||||||
|
export const data = new SlashCommandBuilder()
|
||||||
|
.setName('timeout')
|
||||||
|
.setDescription('Member vorübergehend stummschalten')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers)
|
||||||
|
.addUserOption((o) => o.setName('member').setDescription('Wen?').setRequired(true))
|
||||||
|
.addStringOption((o) => o.setName('dauer').setDescription('z. B. 10m, 2h, 1d (max 28d)').setRequired(true))
|
||||||
|
.addStringOption((o) => o.setName('grund').setDescription('Grund').setMaxLength(400));
|
||||||
|
|
||||||
|
export async function execute(interaction) {
|
||||||
|
const duration = parseDuration(interaction.options.getString('dauer'));
|
||||||
|
if (!duration || duration > MAX_TIMEOUT_MS) {
|
||||||
|
await interaction.reply({ content: '❌ Dauer bitte als `10m`, `2h` oder `1d` (max 28d).', flags: MessageFlags.Ephemeral });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const user = interaction.options.getUser('member');
|
||||||
|
const reason = interaction.options.getString('grund') ?? 'Kein Grund angegeben';
|
||||||
|
|
||||||
|
const member = await interaction.guild.members.fetch(user.id).catch(() => null);
|
||||||
|
if (!member) {
|
||||||
|
await interaction.reply({ content: '❌ Member nicht gefunden.', flags: MessageFlags.Ephemeral });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await member.timeout(duration, reason);
|
||||||
|
} catch {
|
||||||
|
await interaction.reply({ content: '❌ Timeout fehlgeschlagen — Rechte/Rollen-Reihenfolge prüfen.', flags: MessageFlags.Ephemeral });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const until = Math.floor((Date.now() + duration) / 1000);
|
||||||
|
const logChannelId = modlogChannelId();
|
||||||
|
if (logChannelId) {
|
||||||
|
const channel = await interaction.client.channels.fetch(logChannelId).catch(() => null);
|
||||||
|
await channel?.send({
|
||||||
|
embeds: [
|
||||||
|
new EmbedBuilder()
|
||||||
|
.setColor(0xff4d00)
|
||||||
|
.setTitle('🔇 Timeout')
|
||||||
|
.addFields(
|
||||||
|
{ name: 'Member', value: `${user.username} (<@${user.id}>)`, inline: true },
|
||||||
|
{ name: 'Moderator', value: interaction.user.username, inline: true },
|
||||||
|
{ name: 'Bis', value: `<t:${until}:R>`, inline: true },
|
||||||
|
{ name: 'Grund', value: reason }
|
||||||
|
)
|
||||||
|
.setTimestamp(),
|
||||||
|
],
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: `🔇 **${user.username}** ist stumm bis <t:${until}:R>.`,
|
||||||
|
flags: MessageFlags.Ephemeral,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// /warn — Member verwarnen: DB-Eintrag, DM an den User, Mod-Log (Moderatoren)
|
||||||
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js';
|
||||||
|
import { addWarn, warnsOf } from '../../db.js';
|
||||||
|
import { modlogChannelId } from '../../runtime-settings.js';
|
||||||
|
|
||||||
|
export const data = new SlashCommandBuilder()
|
||||||
|
.setName('warn')
|
||||||
|
.setDescription('Member verwarnen (mit DM und Mod-Log)')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers)
|
||||||
|
.addUserOption((o) => o.setName('member').setDescription('Wen verwarnen?').setRequired(true))
|
||||||
|
.addStringOption((o) => o.setName('grund').setDescription('Grund der Verwarnung').setRequired(true).setMaxLength(500));
|
||||||
|
|
||||||
|
export async function execute(interaction) {
|
||||||
|
const user = interaction.options.getUser('member');
|
||||||
|
const reason = interaction.options.getString('grund');
|
||||||
|
if (user.bot) {
|
||||||
|
await interaction.reply({ content: '❌ Bots kann man nicht verwarnen.', flags: MessageFlags.Ephemeral });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addWarn(user.id, interaction.user.username, reason);
|
||||||
|
const count = warnsOf(user.id).length;
|
||||||
|
|
||||||
|
// DM — best effort, User kann DMs blocken
|
||||||
|
let dmOk = true;
|
||||||
|
await user
|
||||||
|
.send(`⚠️ Du wurdest auf **${interaction.guild.name}** verwarnt (Verwarnung #${count}):\n> ${reason}`)
|
||||||
|
.catch(() => { dmOk = false; });
|
||||||
|
|
||||||
|
// Mod-Log
|
||||||
|
const logChannelId = modlogChannelId();
|
||||||
|
if (logChannelId) {
|
||||||
|
const channel = await interaction.client.channels.fetch(logChannelId).catch(() => null);
|
||||||
|
await channel?.send({
|
||||||
|
embeds: [
|
||||||
|
new EmbedBuilder()
|
||||||
|
.setColor(0xff4d00)
|
||||||
|
.setTitle(`⚠️ Verwarnung #${count}`)
|
||||||
|
.addFields(
|
||||||
|
{ name: 'Member', value: `${user.username} (<@${user.id}>)`, inline: true },
|
||||||
|
{ name: 'Moderator', value: interaction.user.username, inline: true },
|
||||||
|
{ name: 'Grund', value: reason }
|
||||||
|
)
|
||||||
|
.setTimestamp(),
|
||||||
|
],
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: `⚠️ **${user.username}** verwarnt (#${count}).${dmOk ? '' : ' (DM nicht zustellbar)'}`,
|
||||||
|
flags: MessageFlags.Ephemeral,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// /warns — Verwarnungs-Historie eines Members einsehen / Eintrag löschen (Moderatoren)
|
||||||
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js';
|
||||||
|
import { warnsOf, deleteWarn } from '../../db.js';
|
||||||
|
|
||||||
|
export const data = new SlashCommandBuilder()
|
||||||
|
.setName('warns')
|
||||||
|
.setDescription('Verwarnungen eines Members anzeigen (oder eine löschen)')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers)
|
||||||
|
.addUserOption((o) => o.setName('member').setDescription('Wessen Verwarnungen?').setRequired(true))
|
||||||
|
.addIntegerOption((o) => o.setName('loeschen').setDescription('Optional: Verwarnungs-ID entfernen'));
|
||||||
|
|
||||||
|
export async function execute(interaction) {
|
||||||
|
const user = interaction.options.getUser('member');
|
||||||
|
const removeId = interaction.options.getInteger('loeschen');
|
||||||
|
|
||||||
|
if (removeId) {
|
||||||
|
const removed = deleteWarn(removeId);
|
||||||
|
await interaction.reply({
|
||||||
|
content: removed ? `🗑️ Verwarnung #${removeId} entfernt.` : `❌ Verwarnung #${removeId} nicht gefunden.`,
|
||||||
|
flags: MessageFlags.Ephemeral,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const warns = warnsOf(user.id);
|
||||||
|
if (warns.length === 0) {
|
||||||
|
await interaction.reply({ content: `✅ **${user.username}** hat keine Verwarnungen.`, flags: MessageFlags.Ephemeral });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = warns
|
||||||
|
.slice(-15)
|
||||||
|
.map((w) => `\`#${w.id}\` ${w.created_at.slice(0, 10)} — ${w.reason} *(${w.moderator ?? '—'})*`);
|
||||||
|
await interaction.reply({
|
||||||
|
embeds: [
|
||||||
|
new EmbedBuilder()
|
||||||
|
.setColor(0xf5c518)
|
||||||
|
.setTitle(`⚠️ Verwarnungen: ${user.username} (${warns.length})`)
|
||||||
|
.setDescription(lines.join('\n'))
|
||||||
|
.setFooter({ text: 'Löschen: /warns member:<user> loeschen:<ID>' }),
|
||||||
|
],
|
||||||
|
flags: MessageFlags.Ephemeral,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Level-System (Carl-/MEE6-Stil): XP pro Nachricht mit Cooldown, Level-Up-Announce,
|
||||||
|
// Rollen-Belohnungen; Bestenliste öffentlich auf der Webseite (/level).
|
||||||
|
import { Events } from 'discord.js';
|
||||||
|
import { getLevelRow, setLevelRow } from '../db.js';
|
||||||
|
import { levelsEnabled, levelsAnnounce, levelRewards } from '../runtime-settings.js';
|
||||||
|
|
||||||
|
const XP_COOLDOWN_MS = 60_000;
|
||||||
|
const XP_MIN = 15;
|
||||||
|
const XP_SPREAD = 11; // 15–25 XP pro gewerteter Nachricht
|
||||||
|
|
||||||
|
/** Kumulierte XP-Schwelle, ab der ein Level erreicht ist (MEE6-Formel pro Stufe) */
|
||||||
|
export function xpForLevel(level) {
|
||||||
|
let total = 0;
|
||||||
|
for (let n = 0; n < level; n++) total += 5 * n * n + 50 * n + 100;
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function levelFromXp(xp) {
|
||||||
|
let level = 0;
|
||||||
|
while (xp >= xpForLevel(level + 1)) level++;
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XP für eine Nachricht gutschreiben (Cooldown-gesteuert).
|
||||||
|
* @returns {{ xp: number, level: number, leveledUp: boolean } | null} null = Cooldown aktiv
|
||||||
|
*/
|
||||||
|
export function awardXp(userId, username, now = Date.now()) {
|
||||||
|
const row = getLevelRow(userId) ?? { user_id: userId, username, xp: 0, level: 0, last_xp_ms: 0 };
|
||||||
|
if (now - row.last_xp_ms < XP_COOLDOWN_MS) return null;
|
||||||
|
|
||||||
|
const xp = row.xp + XP_MIN + Math.floor(Math.random() * XP_SPREAD);
|
||||||
|
const level = levelFromXp(xp);
|
||||||
|
const leveledUp = level > row.level;
|
||||||
|
setLevelRow({ user_id: userId, username, xp, level, last_xp_ms: now });
|
||||||
|
return { xp, level, leveledUp };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beim Level-Up fällige Belohnungs-Rollen (alle Schwellen ≤ Level) vergeben */
|
||||||
|
async function grantRewards(member, level) {
|
||||||
|
for (const reward of levelRewards()) {
|
||||||
|
if (reward.level > level) break;
|
||||||
|
if (!member.roles.cache.has(reward.roleId)) {
|
||||||
|
await member.roles.add(reward.roleId).catch((e) =>
|
||||||
|
console.error(`[level] Belohnungs-Rolle ${reward.roleId} fehlgeschlagen: ${e.message}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerLevels(client) {
|
||||||
|
client.on(Events.MessageCreate, async (message) => {
|
||||||
|
try {
|
||||||
|
if (!levelsEnabled()) return;
|
||||||
|
if (!message.guildId || message.author?.bot || message.webhookId) return;
|
||||||
|
|
||||||
|
const result = awardXp(
|
||||||
|
message.author.id,
|
||||||
|
message.member?.displayName ?? message.author.username
|
||||||
|
);
|
||||||
|
if (!result?.leveledUp) return;
|
||||||
|
|
||||||
|
if (message.member) await grantRewards(message.member, result.level);
|
||||||
|
if (levelsAnnounce()) {
|
||||||
|
await message.channel
|
||||||
|
.send({
|
||||||
|
content: `🎉 **${message.member?.displayName ?? message.author.username}** ist jetzt **Level ${result.level}**!`,
|
||||||
|
allowedMentions: { parse: [] },
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[level] XP-Verarbeitung fehlgeschlagen:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
+79
-3
@@ -1,7 +1,10 @@
|
|||||||
// Moderation & Kontakt: Modmail (DM ↔ Staff-Thread), Willkommens-Embed, Mod-Log
|
// Moderation & Kontakt: Modmail (DM ↔ Staff-Thread), Willkommens-Embed, Mod-Log
|
||||||
import { ChannelType, EmbedBuilder, Events } from 'discord.js';
|
import { ChannelType, EmbedBuilder, Events } from 'discord.js';
|
||||||
import { saveModmail, modmailByUser, modmailByThread } from '../db.js';
|
import { saveModmail, modmailByUser, modmailByThread, saveStickyRoles, stickyRolesOf } from '../db.js';
|
||||||
import { modmailChannelId, welcomeChannelId, modlogChannelId, publicUrl } from '../runtime-settings.js';
|
import {
|
||||||
|
modmailChannelId, welcomeChannelId, modlogChannelId, publicUrl,
|
||||||
|
autoroleId, stickyRolesEnabled,
|
||||||
|
} from '../runtime-settings.js';
|
||||||
|
|
||||||
const BRAND_YELLOW = 0xf5c518;
|
const BRAND_YELLOW = 0xf5c518;
|
||||||
const BRAND_ORANGE = 0xff4d00;
|
const BRAND_ORANGE = 0xff4d00;
|
||||||
@@ -101,6 +104,26 @@ async function handleMemberAdd(member) {
|
|||||||
await channel.send({ content: `<@${member.id}>`, embeds: [embed] });
|
await channel.send({ content: `<@${member.id}>`, embeds: [embed] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Auto- & Sticky-Roles ──────────────────────────── */
|
||||||
|
|
||||||
|
/** Beim Join: gesicherte Rollen wiederherstellen + Auto-Rolle vergeben */
|
||||||
|
async function restoreRoles(member) {
|
||||||
|
const roleIds = new Set();
|
||||||
|
if (stickyRolesEnabled()) {
|
||||||
|
for (const id of stickyRolesOf(member.id)) roleIds.add(id);
|
||||||
|
}
|
||||||
|
const auto = autoroleId();
|
||||||
|
if (auto) roleIds.add(auto);
|
||||||
|
|
||||||
|
for (const id of roleIds) {
|
||||||
|
const role = member.guild.roles.cache.get(id);
|
||||||
|
if (!role || role.managed || role.id === member.guild.id) continue;
|
||||||
|
await member.roles.add(id).catch((e) =>
|
||||||
|
console.error(`[roles] Rolle ${id} bei Join fehlgeschlagen: ${e.message}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Mod-Log ───────────────────────────────────────── */
|
/* ── Mod-Log ───────────────────────────────────────── */
|
||||||
|
|
||||||
async function logToModlog(client, embed) {
|
async function logToModlog(client, embed) {
|
||||||
@@ -136,15 +159,68 @@ export function registerModTools(client) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Willkommens-Embed
|
// Willkommens-Embed + Auto-/Sticky-Roles + Join-Log
|
||||||
client.on(Events.GuildMemberAdd, async (member) => {
|
client.on(Events.GuildMemberAdd, async (member) => {
|
||||||
try {
|
try {
|
||||||
await handleMemberAdd(member);
|
await handleMemberAdd(member);
|
||||||
|
await restoreRoles(member);
|
||||||
|
await logToModlog(client, new EmbedBuilder()
|
||||||
|
.setColor(0x00ff88)
|
||||||
|
.setTitle('📥 Member beigetreten')
|
||||||
|
.setDescription(`**${member.user.username}** (<@${member.id}>) — Mitglied #${member.guild.memberCount}`)
|
||||||
|
.setTimestamp());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[welcome] Fehler:', error);
|
console.error('[welcome] Fehler:', error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Leave: Rollen für Sticky-Restore sichern + Log
|
||||||
|
client.on(Events.GuildMemberRemove, async (member) => {
|
||||||
|
try {
|
||||||
|
const roleIds = [...(member.roles?.cache?.values() ?? [])]
|
||||||
|
.filter((r) => r.id !== member.guild.id && !r.managed)
|
||||||
|
.map((r) => r.id);
|
||||||
|
if (roleIds.length > 0) saveStickyRoles(member.id, roleIds);
|
||||||
|
|
||||||
|
await logToModlog(client, new EmbedBuilder()
|
||||||
|
.setColor(BRAND_ORANGE)
|
||||||
|
.setTitle('📤 Member gegangen')
|
||||||
|
.setDescription(`**${member.user?.username ?? member.id}**${roleIds.length ? ` — Rollen gesichert (${roleIds.length})` : ''}`)
|
||||||
|
.setTimestamp());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[modlog] Leave-Handling fehlgeschlagen:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nick-/Rollen-Änderungen loggen
|
||||||
|
client.on(Events.GuildMemberUpdate, async (oldMember, newMember) => {
|
||||||
|
try {
|
||||||
|
const changes = [];
|
||||||
|
if (oldMember.nickname !== newMember.nickname) {
|
||||||
|
changes.push({
|
||||||
|
name: 'Nickname',
|
||||||
|
value: `\`${oldMember.nickname ?? oldMember.user?.username ?? '—'}\` → \`${newMember.nickname ?? newMember.user?.username ?? '—'}\``,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const oldIds = new Set(oldMember.roles?.cache?.keys?.() ?? []);
|
||||||
|
const newIds = new Set(newMember.roles?.cache?.keys?.() ?? []);
|
||||||
|
const added = [...newIds].filter((id) => !oldIds.has(id) && id !== newMember.guild.id);
|
||||||
|
const removed = [...oldIds].filter((id) => !newIds.has(id) && id !== newMember.guild.id);
|
||||||
|
if (added.length) changes.push({ name: 'Rollen +', value: added.map((id) => `<@&${id}>`).join(' ') });
|
||||||
|
if (removed.length) changes.push({ name: 'Rollen −', value: removed.map((id) => `<@&${id}>`).join(' ') });
|
||||||
|
if (changes.length === 0) return;
|
||||||
|
|
||||||
|
await logToModlog(client, new EmbedBuilder()
|
||||||
|
.setColor(BRAND_YELLOW)
|
||||||
|
.setTitle('👤 Member geändert')
|
||||||
|
.setDescription(`**${newMember.user?.username ?? newMember.id}**`)
|
||||||
|
.addFields(changes)
|
||||||
|
.setTimestamp());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[modlog] Member-Update fehlgeschlagen:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Mod-Log: gelöschte + bearbeitete Nachrichten (User-Nachrichten in Guild-Kanälen)
|
// Mod-Log: gelöschte + bearbeitete Nachrichten (User-Nachrichten in Guild-Kanälen)
|
||||||
client.on(Events.MessageDelete, async (message) => {
|
client.on(Events.MessageDelete, async (message) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -252,6 +252,113 @@ export function commitHeatmap() {
|
|||||||
.all();
|
.all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Moderation: Verwarnungen + Sticky-Roles
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS warns (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
moderator TEXT,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_warns_user ON warns (user_id);
|
||||||
|
CREATE TABLE IF NOT EXISTS sticky_roles (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
role_ids TEXT NOT NULL DEFAULT '[]',
|
||||||
|
saved_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const insertWarn = db.prepare('INSERT INTO warns (user_id, moderator, reason) VALUES (?, ?, ?)');
|
||||||
|
const warnsOfStmt = db.prepare('SELECT * FROM warns WHERE user_id = ? ORDER BY id');
|
||||||
|
const deleteWarnStmt = db.prepare('DELETE FROM warns WHERE id = ?');
|
||||||
|
export const addWarn = (userId, moderator, reason) => insertWarn.run(userId, moderator, reason).lastInsertRowid;
|
||||||
|
export const warnsOf = (userId) => warnsOfStmt.all(userId);
|
||||||
|
export const deleteWarn = (id) => deleteWarnStmt.run(id).changes > 0;
|
||||||
|
|
||||||
|
const upsertSticky = db.prepare(`
|
||||||
|
INSERT INTO sticky_roles (user_id, role_ids, saved_at) VALUES (?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET role_ids = excluded.role_ids, saved_at = excluded.saved_at
|
||||||
|
`);
|
||||||
|
const stickyOfStmt = db.prepare('SELECT role_ids FROM sticky_roles WHERE user_id = ?');
|
||||||
|
export const saveStickyRoles = (userId, roleIds) => upsertSticky.run(userId, JSON.stringify(roleIds));
|
||||||
|
export const stickyRolesOf = (userId) => JSON.parse(stickyOfStmt.get(userId)?.role_ids ?? '[]');
|
||||||
|
|
||||||
|
// Level-System: XP pro Nachricht (Cooldown), Bestenliste auf der Webseite
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS levels (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
username TEXT,
|
||||||
|
xp INTEGER NOT NULL DEFAULT 0,
|
||||||
|
level INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_xp_ms INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_levels_xp ON levels (xp DESC);
|
||||||
|
`);
|
||||||
|
const getLevelStmt = db.prepare('SELECT * FROM levels WHERE user_id = ?');
|
||||||
|
const upsertLevelStmt = db.prepare(`
|
||||||
|
INSERT INTO levels (user_id, username, xp, level, last_xp_ms) VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, xp = excluded.xp,
|
||||||
|
level = excluded.level, last_xp_ms = excluded.last_xp_ms
|
||||||
|
`);
|
||||||
|
const topLevelsStmt = db.prepare('SELECT user_id, username, xp, level FROM levels ORDER BY xp DESC LIMIT ?');
|
||||||
|
const rankOfStmt = db.prepare('SELECT count(*) + 1 AS rank FROM levels WHERE xp > (SELECT xp FROM levels WHERE user_id = ?)');
|
||||||
|
export const getLevelRow = (userId) => getLevelStmt.get(userId) ?? null;
|
||||||
|
export const setLevelRow = (r) => upsertLevelStmt.run(r.user_id, r.username, r.xp, r.level, r.last_xp_ms);
|
||||||
|
export const topLevels = (limit = 50) => topLevelsStmt.all(limit);
|
||||||
|
export const rankOf = (userId) => (getLevelStmt.get(userId) ? rankOfStmt.get(userId).rank : null);
|
||||||
|
|
||||||
|
// Tags (eigene Text-Befehle) + geplante Posts
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS scheduled_posts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
channel_id TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
embed_title TEXT,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
date TEXT,
|
||||||
|
time TEXT NOT NULL,
|
||||||
|
weekday INTEGER,
|
||||||
|
next_run_at TEXT NOT NULL,
|
||||||
|
last_run_at TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const upsertTag = db.prepare(`
|
||||||
|
INSERT INTO tags (name, content, updated_at) VALUES (?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(name) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at
|
||||||
|
`);
|
||||||
|
const getTagStmt = db.prepare('SELECT * FROM tags WHERE name = ?');
|
||||||
|
const listTagsStmt = db.prepare('SELECT name, content, updated_at FROM tags ORDER BY name');
|
||||||
|
const deleteTagStmt = db.prepare('DELETE FROM tags WHERE name = ?');
|
||||||
|
export const saveTag = (name, content) => upsertTag.run(name, content);
|
||||||
|
export const getTag = (name) => getTagStmt.get(name) ?? null;
|
||||||
|
export const listTags = () => listTagsStmt.all();
|
||||||
|
export const deleteTag = (name) => deleteTagStmt.run(name).changes > 0;
|
||||||
|
|
||||||
|
const insertScheduled = db.prepare(`
|
||||||
|
INSERT INTO scheduled_posts (channel_id, content, embed_title, type, date, time, weekday, next_run_at)
|
||||||
|
VALUES (@channel_id, @content, @embed_title, @type, @date, @time, @weekday, @next_run_at)
|
||||||
|
`);
|
||||||
|
const listScheduledStmt = db.prepare('SELECT * FROM scheduled_posts ORDER BY next_run_at');
|
||||||
|
const dueScheduledStmt = db.prepare(
|
||||||
|
`SELECT * FROM scheduled_posts WHERE enabled = 1 AND next_run_at <= datetime('now')`
|
||||||
|
);
|
||||||
|
const updateScheduledRunStmt = db.prepare(
|
||||||
|
`UPDATE scheduled_posts SET next_run_at = ?, last_run_at = datetime('now'), enabled = ? WHERE id = ?`
|
||||||
|
);
|
||||||
|
const deleteScheduledStmt = db.prepare('DELETE FROM scheduled_posts WHERE id = ?');
|
||||||
|
export const createScheduledPost = (p) => insertScheduled.run(p).lastInsertRowid;
|
||||||
|
export const listScheduledPosts = () => listScheduledStmt.all();
|
||||||
|
export const dueScheduledPosts = () => dueScheduledStmt.all();
|
||||||
|
export const markScheduledRun = (id, nextRunAt, stillEnabled) =>
|
||||||
|
updateScheduledRunStmt.run(nextRunAt, stillEnabled ? 1 : 0, id);
|
||||||
|
export const deleteScheduledPost = (id) => deleteScheduledStmt.run(id).changes > 0;
|
||||||
|
|
||||||
// Rollen-Menüs (Carl-Bot-Ersatz): selbst zuweisbare Rollen per Button-Post
|
// Rollen-Menüs (Carl-Bot-Ersatz): selbst zuweisbare Rollen per Button-Post
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS role_menus (
|
CREATE TABLE IF NOT EXISTS role_menus (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { startWatchdog } from './bot/watchdog.js';
|
|||||||
import { scheduleBackups } from './backup.js';
|
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';
|
||||||
|
|
||||||
process.on('unhandledRejection', (error) => {
|
process.on('unhandledRejection', (error) => {
|
||||||
console.error('[main] Unhandled Rejection:', error);
|
console.error('[main] Unhandled Rejection:', error);
|
||||||
@@ -19,6 +20,7 @@ try {
|
|||||||
scheduleBackups(client);
|
scheduleBackups(client);
|
||||||
startServerMonitor(client);
|
startServerMonitor(client);
|
||||||
startGiveaways(client);
|
startGiveaways(client);
|
||||||
|
startScheduledPosts(client);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[main] Start fehlgeschlagen:', error);
|
console.error('[main] Start fehlgeschlagen:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -47,6 +47,38 @@ export function starboardThreshold() {
|
|||||||
return Number.isInteger(n) && n >= 1 ? n : 3;
|
return Number.isInteger(n) && n >= 1 ? n : 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Auto-Rolle für neue Member — leer = aus */
|
||||||
|
export function autoroleId() {
|
||||||
|
return getSetting('autorole_id') || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sticky-Roles: Rollen bei Rejoin wiederherstellen (Default: aus) */
|
||||||
|
export function stickyRolesEnabled() {
|
||||||
|
return getSetting('sticky_roles_enabled') === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Level-System an/aus (Default: aus — bewusst opt-in) */
|
||||||
|
export function levelsEnabled() {
|
||||||
|
return getSetting('levels_enabled') === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Level-Up-Nachricht im Kanal (Default: an, wenn Levels an) */
|
||||||
|
export function levelsAnnounce() {
|
||||||
|
return getSetting('levels_announce') !== '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rollen-Belohnungen: "5=roleId, 10=roleId" → [{level, roleId}] aufsteigend */
|
||||||
|
export function levelRewards() {
|
||||||
|
return (getSetting('level_rewards') ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((entry) => {
|
||||||
|
const [lvl, roleId] = entry.split('=').map((s) => s.trim());
|
||||||
|
return Number(lvl) > 0 && roleId ? { level: Number(lvl), roleId } : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => a.level - b.level);
|
||||||
|
}
|
||||||
|
|
||||||
/** Kanal für Feature-Wünsche (/wunsch) — leer = Feature aus */
|
/** Kanal für Feature-Wünsche (/wunsch) — leer = Feature aus */
|
||||||
export function votingChannelId() {
|
export function votingChannelId() {
|
||||||
return getSetting('voting_channel_id') || null;
|
return getSetting('voting_channel_id') || null;
|
||||||
|
|||||||
+92
-1
@@ -5,8 +5,11 @@ import {
|
|||||||
getSetting, setSetting, createApiKey, listApiKeys, deleteApiKey,
|
getSetting, setSetting, createApiKey, listApiKeys, deleteApiKey,
|
||||||
listGallery, listPlaytesters, topWishes, commitHeatmap,
|
listGallery, listPlaytesters, topWishes, commitHeatmap,
|
||||||
createRoleMenu, updateRoleMenu, getRoleMenu, listRoleMenus, deleteRoleMenu,
|
createRoleMenu, updateRoleMenu, getRoleMenu, listRoleMenus, deleteRoleMenu,
|
||||||
|
topLevels, saveTag, listTags, deleteTag,
|
||||||
|
createScheduledPost, listScheduledPosts, deleteScheduledPost,
|
||||||
} from '../db.js';
|
} from '../db.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 { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { removeDevlog } from '../bot/devlog-archive.js';
|
import { removeDevlog } from '../bot/devlog-archive.js';
|
||||||
import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo } from '../runtime-settings.js';
|
import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo } from '../runtime-settings.js';
|
||||||
@@ -77,6 +80,9 @@ export function registerApiRoutes(app, client) {
|
|||||||
app.get('/api/wishes', async () => ({ wishes: topWishes(20) }));
|
app.get('/api/wishes', async () => ({ wishes: topWishes(20) }));
|
||||||
app.get('/api/heatmap', async () => ({ days: commitHeatmap() }));
|
app.get('/api/heatmap', async () => ({ days: commitHeatmap() }));
|
||||||
|
|
||||||
|
// Level-Bestenliste — öffentlich
|
||||||
|
app.get('/api/levels', async () => ({ levels: topLevels(50) }));
|
||||||
|
|
||||||
// Roadmap — öffentlich, aus Gitea-Milestones (5-Minuten-Cache gegen API-Hammering)
|
// 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 };
|
||||||
app.get('/api/roadmap', async (request, reply) => {
|
app.get('/api/roadmap', async (request, reply) => {
|
||||||
@@ -216,6 +222,11 @@ ${rssItems}
|
|||||||
voting_channel_id: getSetting('voting_channel_id') ?? '',
|
voting_channel_id: getSetting('voting_channel_id') ?? '',
|
||||||
ticket_channel_id: getSetting('ticket_channel_id') ?? '',
|
ticket_channel_id: getSetting('ticket_channel_id') ?? '',
|
||||||
gameservers: getSetting('gameservers') ?? '',
|
gameservers: getSetting('gameservers') ?? '',
|
||||||
|
autorole_id: getSetting('autorole_id') ?? '',
|
||||||
|
sticky_roles_enabled: getSetting('sticky_roles_enabled') === '1',
|
||||||
|
levels_enabled: getSetting('levels_enabled') === '1',
|
||||||
|
levels_announce: getSetting('levels_announce') !== '0',
|
||||||
|
level_rewards: getSetting('level_rewards') ?? '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +274,7 @@ ${rssItems}
|
|||||||
setSetting(key, value);
|
setSetting(key, value);
|
||||||
}
|
}
|
||||||
// Rollen: müssen existieren ('' = Feature aus)
|
// Rollen: müssen existieren ('' = Feature aus)
|
||||||
for (const key of ['devlog_ping_role_id', 'playtester_role_id']) {
|
for (const key of ['devlog_ping_role_id', 'playtester_role_id', 'autorole_id']) {
|
||||||
if (body[key] === undefined) continue;
|
if (body[key] === undefined) continue;
|
||||||
const value = String(body[key]);
|
const value = String(body[key]);
|
||||||
if (value !== '') {
|
if (value !== '') {
|
||||||
@@ -296,6 +307,18 @@ ${rssItems}
|
|||||||
if (body.backup_enabled !== undefined) {
|
if (body.backup_enabled !== undefined) {
|
||||||
setSetting('backup_enabled', body.backup_enabled ? '1' : '0');
|
setSetting('backup_enabled', body.backup_enabled ? '1' : '0');
|
||||||
}
|
}
|
||||||
|
if (body.sticky_roles_enabled !== undefined) {
|
||||||
|
setSetting('sticky_roles_enabled', body.sticky_roles_enabled ? '1' : '0');
|
||||||
|
}
|
||||||
|
if (body.levels_enabled !== undefined) {
|
||||||
|
setSetting('levels_enabled', body.levels_enabled ? '1' : '0');
|
||||||
|
}
|
||||||
|
if (body.levels_announce !== undefined) {
|
||||||
|
setSetting('levels_announce', body.levels_announce ? '1' : '0');
|
||||||
|
}
|
||||||
|
if (body.level_rewards !== undefined) {
|
||||||
|
setSetting('level_rewards', String(body.level_rewards).trim());
|
||||||
|
}
|
||||||
for (const key of ['commit_branch_filter', 'ignored_repos', 'bug_report_repo', 'watchdog_urls', 'roadmap_repo', 'gameservers']) {
|
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());
|
||||||
@@ -421,6 +444,74 @@ ${rssItems}
|
|||||||
return { deleted: true };
|
return { deleted: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Tags (Admin-Verwaltung; Abruf via /tag in Discord) ---
|
||||||
|
|
||||||
|
app.get('/api/tags', async (request, reply) => {
|
||||||
|
if (requireAdmin(request, reply)) return;
|
||||||
|
return { tags: listTags() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/tags/:name', async (request, reply) => {
|
||||||
|
if (requireAdmin(request, reply)) return;
|
||||||
|
const name = String(request.params.name).trim().toLowerCase();
|
||||||
|
const content = String(request.body?.content ?? '').trim();
|
||||||
|
if (!/^[a-z0-9äöüß_-]{1,40}$/.test(name)) {
|
||||||
|
return reply.code(400).send({ error: 'Name: nur Kleinbuchstaben, Zahlen, - und _ (max 40)' });
|
||||||
|
}
|
||||||
|
if (!content) return reply.code(400).send({ error: 'content nötig' });
|
||||||
|
saveTag(name, content.slice(0, 2000));
|
||||||
|
return { ok: true, name };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/tags/:name', async (request, reply) => {
|
||||||
|
if (requireAdmin(request, reply)) return;
|
||||||
|
return { deleted: deleteTag(String(request.params.name).trim().toLowerCase()) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Geplante Posts (Admin) ---
|
||||||
|
|
||||||
|
app.get('/api/scheduled', async (request, reply) => {
|
||||||
|
if (requireAdmin(request, reply)) return;
|
||||||
|
return { posts: listScheduledPosts() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/scheduled', async (request, reply) => {
|
||||||
|
if (requireAdmin(request, reply)) return;
|
||||||
|
const body = request.body ?? {};
|
||||||
|
const channelId = String(body.channel_id ?? '');
|
||||||
|
const content = String(body.content ?? '').trim();
|
||||||
|
if (!client.channels.cache.get(channelId)?.isTextBased?.()) {
|
||||||
|
return reply.code(400).send({ error: 'Kanal nicht gefunden' });
|
||||||
|
}
|
||||||
|
if (!content) return reply.code(400).send({ error: 'content nötig' });
|
||||||
|
|
||||||
|
let nextRunAt;
|
||||||
|
try {
|
||||||
|
nextRunAt = computeNextRun({
|
||||||
|
type: body.type, date: body.date, time: body.time, weekday: body.weekday,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code(400).send({ error: error.message });
|
||||||
|
}
|
||||||
|
const id = createScheduledPost({
|
||||||
|
channel_id: channelId,
|
||||||
|
content: content.slice(0, 4000),
|
||||||
|
embed_title: String(body.embed_title ?? '').trim() || null,
|
||||||
|
type: body.type,
|
||||||
|
date: body.date ?? null,
|
||||||
|
time: body.time,
|
||||||
|
weekday: body.weekday ?? null,
|
||||||
|
next_run_at: nextRunAt,
|
||||||
|
});
|
||||||
|
request.log.info(`Geplanter Post ${id} (${body.type}) → ${nextRunAt} UTC`);
|
||||||
|
return { ok: true, id, next_run_at: nextRunAt };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/scheduled/:id', async (request, reply) => {
|
||||||
|
if (requireAdmin(request, reply)) return;
|
||||||
|
return { deleted: deleteScheduledPost(Number(request.params.id)) };
|
||||||
|
});
|
||||||
|
|
||||||
// Composer (Admin): Nachricht/Embed als Bot senden oder eigene Posts bearbeiten
|
// Composer (Admin): Nachricht/Embed als Bot senden oder eigene Posts bearbeiten
|
||||||
app.post('/api/compose', async (request, reply) => {
|
app.post('/api/compose', async (request, reply) => {
|
||||||
if (requireAdmin(request, reply)) return;
|
if (requireAdmin(request, reply)) return;
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Geplante Posts (Carl-Bot-Premium-Feature 😄): einmalig, täglich oder wöchentlich —
|
||||||
|
// verwaltet über den Composer-Tab, ausgeführt von einem Minuten-Scheduler.
|
||||||
|
// Zeiten sind lokale Serverzeit (TZ im Compose = Europe/Berlin), gespeichert als UTC.
|
||||||
|
import { EmbedBuilder } from 'discord.js';
|
||||||
|
import { dueScheduledPosts, markScheduledRun } from '../db.js';
|
||||||
|
|
||||||
|
const CHECK_INTERVAL_MS = 60 * 1000;
|
||||||
|
|
||||||
|
const toUtcString = (d) => d.toISOString().slice(0, 19).replace('T', ' ');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nächsten Ausführungszeitpunkt berechnen (lokale TZ des Prozesses).
|
||||||
|
* @param {{ type: 'once'|'daily'|'weekly', date?: string, time: string, weekday?: number }} p
|
||||||
|
* @param {Date} after — erst nach diesem Zeitpunkt (Default: jetzt)
|
||||||
|
* @returns {string} UTC "YYYY-MM-DD HH:MM:SS"
|
||||||
|
*/
|
||||||
|
export function computeNextRun(p, after = new Date()) {
|
||||||
|
const [h, m] = String(p.time).split(':').map(Number);
|
||||||
|
if (!Number.isInteger(h) || !Number.isInteger(m)) throw new Error('Uhrzeit ungültig (HH:MM)');
|
||||||
|
|
||||||
|
if (p.type === 'once') {
|
||||||
|
const d = new Date(`${p.date}T${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00`);
|
||||||
|
if (Number.isNaN(d.getTime())) throw new Error('Datum ungültig');
|
||||||
|
if (d <= after) throw new Error('Zeitpunkt liegt in der Vergangenheit');
|
||||||
|
return toUtcString(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = new Date(after);
|
||||||
|
d.setHours(h, m, 0, 0);
|
||||||
|
if (p.type === 'daily') {
|
||||||
|
if (d <= after) d.setDate(d.getDate() + 1);
|
||||||
|
} else if (p.type === 'weekly') {
|
||||||
|
const target = Number(p.weekday); // 0 = Sonntag … 6 = Samstag
|
||||||
|
if (!Number.isInteger(target) || target < 0 || target > 6) throw new Error('Wochentag ungültig');
|
||||||
|
let diff = (target - d.getDay() + 7) % 7;
|
||||||
|
if (diff === 0 && d <= after) diff = 7;
|
||||||
|
d.setDate(d.getDate() + diff);
|
||||||
|
} else {
|
||||||
|
throw new Error('Typ ungültig');
|
||||||
|
}
|
||||||
|
return toUtcString(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nachricht bauen: mit Embed-Titel → gebrandetes Embed, sonst Plaintext */
|
||||||
|
export function buildScheduledPayload(post) {
|
||||||
|
if (post.embed_title?.trim()) {
|
||||||
|
return {
|
||||||
|
embeds: [
|
||||||
|
new EmbedBuilder()
|
||||||
|
.setColor(0xf5c518)
|
||||||
|
.setTitle(post.embed_title.slice(0, 200))
|
||||||
|
.setDescription(post.content.slice(0, 4000))
|
||||||
|
.setFooter({ text: 'D4RKST3R' })
|
||||||
|
.setTimestamp(),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { content: post.content.slice(0, 2000) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fällige Posts senden — exportiert für Tests und den Timer */
|
||||||
|
export async function scheduledPostsTick(client) {
|
||||||
|
for (const post of dueScheduledPosts()) {
|
||||||
|
try {
|
||||||
|
const channel = await client.channels.fetch(post.channel_id).catch(() => null);
|
||||||
|
if (channel?.isTextBased()) {
|
||||||
|
await channel.send(buildScheduledPayload(post));
|
||||||
|
console.log(`[scheduled] Post ${post.id} → #${channel.name ?? post.channel_id}`);
|
||||||
|
} else {
|
||||||
|
console.warn(`[scheduled] Post ${post.id}: Kanal ${post.channel_id} nicht erreichbar`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[scheduled] Post ${post.id} fehlgeschlagen:`, error);
|
||||||
|
}
|
||||||
|
// Einmalige Posts deaktivieren, wiederkehrende neu terminieren
|
||||||
|
if (post.type === 'once') {
|
||||||
|
markScheduledRun(post.id, post.next_run_at, false);
|
||||||
|
} else {
|
||||||
|
markScheduledRun(post.id, computeNextRun(post), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startScheduledPosts(client) {
|
||||||
|
setInterval(() => scheduledPostsTick(client).catch((e) => console.error('[scheduled]', e)), CHECK_INTERVAL_MS);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user