Carl-Bot-Paket: Moderation, Auto-/Sticky-Roles, Level-System, Tags, geplante Posts

- Moderation: /warn (DM + Mod-Log), /warns (Historie + löschen), /timeout (max 28d),
  /purge (bulk, 14-Tage-Limit sauber gemeldet); Mod-Log erweitert um Join/Leave
  und Nick-/Rollen-Änderungen
- Auto-Rolle bei Join + Sticky-Roles (Rollen bei Leave gesichert, bei Rejoin
  wiederhergestellt; managed/@everyone ausgenommen) — Rollen-Tab
- Level-System (opt-in): 15-25 XP/Nachricht mit 60s-Cooldown, MEE6-Formel,
  Level-Up-Announce, Rollen-Belohnungen (Level=RolleID), /rank mit Fortschritt,
  öffentliche Bestenliste /level; Community-Tab
- Tags: /tag mit Autocomplete, Verwaltung im Composer-Tab (/api/tags)
- Geplante Posts: einmalig/täglich/wöchentlich, Minuten-Scheduler, DST-sicher
  über Neuberechnung aus Zeitfeldern; Composer-Tab (/api/scheduled)
- Autocomplete-Dispatch im InteractionCreate; alles smoke-getestet

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 00:30:23 +02:00
co-authored by Claude Fable 5
parent a27a594a07
commit db9c7d07c9
19 changed files with 1066 additions and 5 deletions
+179
View File
@@ -49,6 +49,14 @@ export default function Settings({ me }) {
const [menuDraft, setMenuDraft] = useState(emptyMenu);
// Composer
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
const [keys, setKeys] = 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/playtesters').then((d) => setPlaytesters(d.playtesters)).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]);
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) {
setFeedback(msg);
setTimeout(() => setFeedback(null), 3000);
@@ -375,11 +426,54 @@ export default function Settings({ me }) {
</select>
</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">1525 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 = (
<>
<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">
<h2 className="settings-title">// Rollen-Menüs</h2>
<p className="section-intro">
@@ -497,6 +591,7 @@ export default function Settings({ me }) {
);
const tabComposer = (
<>
<div className="settings-section">
<h2 className="settings-title">// Post verfassen</h2>
<div className="field">
@@ -524,6 +619,90 @@ export default function Settings({ me }) {
</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 = (