Moderation & Kontakt + Game-Server-Monitor
- Modmail: DMs an den Bot landen als Threads im privaten Staff-Kanal, Thread-Antworten gehen als DM zurück (Zustell-Feedback per Reaktion) - Willkommens-Embed für neue Member (GuildMemberAdd; Server-Members-Intent nötig) - Mod-Log: gelöschte/bearbeitete User-Nachrichten in privaten Log-Kanal - Server-Monitor: FiveM-kompatible Endpoints (/dynamic.json) alle 2 min, persistentes Status-Embed (edit in place) + Spielerzahl als Bot-Presence - Neue Intents: DirectMessages, GuildMembers; Partials.Channel - Setup-Seite: Sektionen // Moderation & Kontakt und // Game-Server Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,10 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St
|
|||||||
| 🧪 **Playtester-Programm** | `/playtester-setup` postet den Bewerbungs-Button; Rolle + Liste (Setup-Seite); Alpha-Keys später per DM über die API |
|
| 🧪 **Playtester-Programm** | `/playtester-setup` postet den Bewerbungs-Button; Rolle + Liste (Setup-Seite); Alpha-Keys später per DM über die API |
|
||||||
| ⭐ **Starboard** | Nachrichten mit genug ⭐-Reaktionen landen automatisch im Best-of-Kanal |
|
| ⭐ **Starboard** | Nachrichten mit genug ⭐-Reaktionen landen automatisch im Best-of-Kanal |
|
||||||
| 📸 **Screenshot-Galerie** | Bilder aus dem Screenshot-Kanal → öffentliche `/galerie` (lokal gespeichert); Historie via `/galerie-backfill` |
|
| 📸 **Screenshot-Galerie** | Bilder aus dem Screenshot-Kanal → öffentliche `/galerie` (lokal gespeichert); Historie via `/galerie-backfill` |
|
||||||
|
| 📬 **Modmail** | DM an den Bot → Thread im privaten Staff-Kanal; Antworten im Thread gehen als DM zurück |
|
||||||
|
| 👋 **Willkommens-Embed** | Begrüßung neuer Member im Brand-Look (braucht Server-Members-Intent) |
|
||||||
|
| 📋 **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 |
|
||||||
| 🏓 `/ping`, 🗄 `/devlog-backfill` | Lebenszeichen · Kanal-Historie nacharchivieren (Admin) |
|
| 🏓 `/ping`, 🗄 `/devlog-backfill` | Lebenszeichen · Kanal-Historie nacharchivieren (Admin) |
|
||||||
|
|
||||||
### Webinterface (`bot.d4rkst3r.de`)
|
### Webinterface (`bot.d4rkst3r.de`)
|
||||||
@@ -62,7 +66,8 @@ kein Redeploy nötig:
|
|||||||
1. **New Application** → Name vergeben
|
1. **New Application** → Name vergeben
|
||||||
2. **General Information** → Application ID = `DISCORD_CLIENT_ID`
|
2. **General Information** → Application ID = `DISCORD_CLIENT_ID`
|
||||||
3. **Bot** → Reset Token = `DISCORD_TOKEN` (wird nur einmal angezeigt!)
|
3. **Bot** → Reset Token = `DISCORD_TOKEN` (wird nur einmal angezeigt!)
|
||||||
und **Message Content Intent** aktivieren (Devlog-Listener)
|
und **Message Content Intent** + **Server Members Intent** aktivieren
|
||||||
|
(Devlog-Listener bzw. Willkommens-Embeds)
|
||||||
4. **OAuth2** → Client Secret = `DISCORD_CLIENT_SECRET`;
|
4. **OAuth2** → Client Secret = `DISCORD_CLIENT_SECRET`;
|
||||||
unter **Redirects** eintragen:
|
unter **Redirects** eintragen:
|
||||||
- `https://bot.d4rkst3r.de/auth/callback`
|
- `https://bot.d4rkst3r.de/auth/callback`
|
||||||
|
|||||||
@@ -357,6 +357,65 @@ export default function Settings({ me }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Moderation & Kontakt */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-title">// Moderation & Kontakt</h2>
|
||||||
|
<div className="field">
|
||||||
|
<label>Modmail-Kanal <span className="field-hint">PRIVATER Staff-Kanal — DMs an den Bot landen hier als Threads</span></label>
|
||||||
|
<select
|
||||||
|
value={form.modmail_channel_id ?? ''}
|
||||||
|
onChange={(e) => setForm({ ...form, modmail_channel_id: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">— deaktiviert —</option>
|
||||||
|
{channelOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Willkommens-Kanal <span className="field-hint">Begrüßungs-Embed für neue Member (braucht Server-Members-Intent im Dev-Portal!)</span></label>
|
||||||
|
<select
|
||||||
|
value={form.welcome_channel_id ?? ''}
|
||||||
|
onChange={(e) => setForm({ ...form, welcome_channel_id: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">— deaktiviert —</option>
|
||||||
|
{channelOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Mod-Log-Kanal <span className="field-hint">PRIVAT — gelöschte/bearbeitete Nachrichten</span></label>
|
||||||
|
<select
|
||||||
|
value={form.modlog_channel_id ?? ''}
|
||||||
|
onChange={(e) => setForm({ ...form, modlog_channel_id: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">— deaktiviert —</option>
|
||||||
|
{channelOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Game-Server */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-title">// Game-Server</h2>
|
||||||
|
<div className="field">
|
||||||
|
<label>Status-Kanal <span className="field-hint">persistentes Status-Embed, alle 2 min aktualisiert</span></label>
|
||||||
|
<select
|
||||||
|
value={form.status_channel_id ?? ''}
|
||||||
|
onChange={(e) => setForm({ ...form, status_channel_id: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">— deaktiviert —</option>
|
||||||
|
{channelOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Server <span className="field-hint">kommagetrennt Name=URL — FiveM-kompatibel (/dynamic.json)</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="z. B. FiveM=http://1.2.3.4:30120, EcoGame=http://…"
|
||||||
|
value={form.gameservers}
|
||||||
|
onChange={(e) => setForm({ ...form, gameservers: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Backups */}
|
{/* Backups */}
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h2 className="settings-title">// Backups</h2>
|
<h2 className="settings-title">// Backups</h2>
|
||||||
|
|||||||
+8
-2
@@ -4,6 +4,7 @@ import { config } from '../config.js';
|
|||||||
import { devlogChannelId, devlogPingRoleId, playtesterRoleId } from '../runtime-settings.js';
|
import { devlogChannelId, devlogPingRoleId, playtesterRoleId } from '../runtime-settings.js';
|
||||||
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 { addPlaytester, removePlaytester } from '../db.js';
|
import { addPlaytester, removePlaytester } from '../db.js';
|
||||||
import * as ping from './commands/ping.js';
|
import * as ping from './commands/ping.js';
|
||||||
import * as devlogBackfill from './commands/devlog-backfill.js';
|
import * as devlogBackfill from './commands/devlog-backfill.js';
|
||||||
@@ -24,9 +25,13 @@ export async function startBot() {
|
|||||||
GatewayIntentBits.MessageContent,
|
GatewayIntentBits.MessageContent,
|
||||||
// Für das Starboard (⭐-Reaktionen)
|
// Für das Starboard (⭐-Reaktionen)
|
||||||
GatewayIntentBits.GuildMessageReactions,
|
GatewayIntentBits.GuildMessageReactions,
|
||||||
|
// Für Modmail (DMs) — Willkommens-Embeds brauchen zusätzlich den
|
||||||
|
// privilegierten "Server Members Intent" im Developer Portal!
|
||||||
|
GatewayIntentBits.DirectMessages,
|
||||||
|
GatewayIntentBits.GuildMembers,
|
||||||
],
|
],
|
||||||
// Partials, damit Delete/Reaction-Events auch für ungecachte Nachrichten feuern
|
// Partials: Delete/Reaction-Events für ungecachte Nachrichten + DM-Kanäle
|
||||||
partials: [Partials.Message, Partials.Reaction],
|
partials: [Partials.Message, Partials.Reaction, Partials.Channel],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Commands in Collection ablegen für schnellen Zugriff im Interaction-Handler
|
// Commands in Collection ablegen für schnellen Zugriff im Interaction-Handler
|
||||||
@@ -145,6 +150,7 @@ export async function startBot() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
registerCommunityListeners(client);
|
registerCommunityListeners(client);
|
||||||
|
registerModTools(client);
|
||||||
|
|
||||||
await client.login(config.discordToken);
|
await client.login(config.discordToken);
|
||||||
return client;
|
return client;
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// Moderation & Kontakt: Modmail (DM ↔ Staff-Thread), Willkommens-Embed, Mod-Log
|
||||||
|
import { ChannelType, EmbedBuilder, Events } from 'discord.js';
|
||||||
|
import { saveModmail, modmailByUser, modmailByThread } from '../db.js';
|
||||||
|
import { modmailChannelId, welcomeChannelId, modlogChannelId, publicUrl } from '../runtime-settings.js';
|
||||||
|
|
||||||
|
const BRAND_YELLOW = 0xf5c518;
|
||||||
|
const BRAND_ORANGE = 0xff4d00;
|
||||||
|
|
||||||
|
/* ── Modmail ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** Staff-Thread für einen User finden oder anlegen */
|
||||||
|
async function ensureModmailThread(client, user) {
|
||||||
|
const channelId = modmailChannelId();
|
||||||
|
if (!channelId) return null;
|
||||||
|
const channel = await client.channels.fetch(channelId).catch(() => null);
|
||||||
|
if (!channel?.threads) return null;
|
||||||
|
|
||||||
|
const existing = modmailByUser(user.id);
|
||||||
|
if (existing) {
|
||||||
|
const thread = await client.channels.fetch(existing.thread_id).catch(() => null);
|
||||||
|
if (thread) {
|
||||||
|
if (thread.archived) await thread.setArchived(false).catch(() => {});
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const thread = await channel.threads.create({
|
||||||
|
name: `📬 ${user.username}`,
|
||||||
|
autoArchiveDuration: 10080, // 7 Tage
|
||||||
|
type: ChannelType.PublicThread,
|
||||||
|
});
|
||||||
|
saveModmail(user.id, thread.id, user.username);
|
||||||
|
await thread.send({
|
||||||
|
embeds: [
|
||||||
|
new EmbedBuilder()
|
||||||
|
.setColor(BRAND_YELLOW)
|
||||||
|
.setDescription(
|
||||||
|
`Neue Modmail-Konversation mit **${user.username}** (<@${user.id}>).\n` +
|
||||||
|
'Antworten in diesem Thread gehen als DM an den User.'
|
||||||
|
)
|
||||||
|
.setFooter({ text: 'D4RKST3R // MODMAIL' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DM vom User → in den Staff-Thread spiegeln */
|
||||||
|
async function handleIncomingDm(client, message) {
|
||||||
|
const thread = await ensureModmailThread(client, message.author);
|
||||||
|
if (!thread) return; // Feature aus → DM ignorieren
|
||||||
|
|
||||||
|
const files = [...message.attachments.values()].map((a) => a.url).slice(0, 5);
|
||||||
|
await thread.send({
|
||||||
|
content: `**${message.author.username}:** ${message.content || ''}${
|
||||||
|
files.length ? `\n${files.join('\n')}` : ''
|
||||||
|
}`.slice(0, 2000),
|
||||||
|
allowedMentions: { parse: [] },
|
||||||
|
});
|
||||||
|
await message.react('📬').catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Staff-Antwort im Thread → als DM an den User */
|
||||||
|
async function handleThreadReply(client, message) {
|
||||||
|
const row = modmailByThread(message.channelId);
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
const user = await client.users.fetch(row.user_id).catch(() => null);
|
||||||
|
if (!user) return;
|
||||||
|
try {
|
||||||
|
await user.send(
|
||||||
|
`**${message.member?.displayName ?? message.author.username}:** ${message.content}`.slice(0, 2000)
|
||||||
|
);
|
||||||
|
await message.react('✅').catch(() => {});
|
||||||
|
} catch {
|
||||||
|
await message.react('⚠️').catch(() => {});
|
||||||
|
await message.reply('DM nicht zustellbar (User blockt DMs).').catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Willkommens-Embed ─────────────────────────────── */
|
||||||
|
|
||||||
|
async function handleMemberAdd(member) {
|
||||||
|
const channelId = welcomeChannelId();
|
||||||
|
if (!channelId) return;
|
||||||
|
const channel = await member.client.channels.fetch(channelId).catch(() => null);
|
||||||
|
if (!channel?.isTextBased()) return;
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(BRAND_YELLOW)
|
||||||
|
.setTitle(`👋 Willkommen, ${member.displayName}!`)
|
||||||
|
.setThumbnail(member.user.displayAvatarURL({ size: 128 }))
|
||||||
|
.setDescription(
|
||||||
|
`Schön, dass du da bist — du bist Mitglied **#${member.guild.memberCount}**.\n\n` +
|
||||||
|
`📔 Devlogs & Roadmap: ${publicUrl()}\n` +
|
||||||
|
'🧪 Playtester werden? Schau nach dem Bewerbungs-Post!\n' +
|
||||||
|
'🐛 Bug gefunden? Einfach `/bug` benutzen.'
|
||||||
|
)
|
||||||
|
.setFooter({ text: 'D4RKST3R // COMMUNITY' })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
await channel.send({ content: `<@${member.id}>`, embeds: [embed] });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mod-Log ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
async function logToModlog(client, embed) {
|
||||||
|
const channelId = modlogChannelId();
|
||||||
|
if (!channelId) return;
|
||||||
|
const channel = await client.channels.fetch(channelId).catch(() => null);
|
||||||
|
if (!channel?.isTextBased()) return;
|
||||||
|
await channel.send({ embeds: [embed] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimmed(text) {
|
||||||
|
const t = (text ?? '').trim();
|
||||||
|
return t.length > 900 ? `${t.slice(0, 900)}…` : t || '*[kein Text / nicht im Cache]*';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Registrierung ─────────────────────────────────── */
|
||||||
|
|
||||||
|
export function registerModTools(client) {
|
||||||
|
// Modmail: eingehende DMs + Staff-Antworten in Threads
|
||||||
|
client.on(Events.MessageCreate, async (message) => {
|
||||||
|
try {
|
||||||
|
if (message.author?.bot) return;
|
||||||
|
if (message.channel?.type === ChannelType.DM) {
|
||||||
|
await handleIncomingDm(client, message);
|
||||||
|
} else if (
|
||||||
|
message.channel?.isThread?.() &&
|
||||||
|
message.channel.parentId === modmailChannelId()
|
||||||
|
) {
|
||||||
|
await handleThreadReply(client, message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[modmail] Fehler:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Willkommens-Embed
|
||||||
|
client.on(Events.GuildMemberAdd, async (member) => {
|
||||||
|
try {
|
||||||
|
await handleMemberAdd(member);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[welcome] Fehler:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mod-Log: gelöschte + bearbeitete Nachrichten (User-Nachrichten in Guild-Kanälen)
|
||||||
|
client.on(Events.MessageDelete, async (message) => {
|
||||||
|
try {
|
||||||
|
if (!message.guildId || message.author?.bot) return;
|
||||||
|
if (message.channelId === modlogChannelId()) return;
|
||||||
|
await logToModlog(client, new EmbedBuilder()
|
||||||
|
.setColor(BRAND_ORANGE)
|
||||||
|
.setTitle('🗑️ Nachricht gelöscht')
|
||||||
|
.addFields(
|
||||||
|
{ name: 'User', value: message.author ? `${message.author.username}` : 'unbekannt', inline: true },
|
||||||
|
{ name: 'Kanal', value: `<#${message.channelId}>`, inline: true },
|
||||||
|
{ name: 'Inhalt', value: trimmed(message.content) }
|
||||||
|
)
|
||||||
|
.setTimestamp());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[modlog] Fehler:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on(Events.MessageUpdate, async (oldMessage, newMessage) => {
|
||||||
|
try {
|
||||||
|
if (!newMessage.guildId || newMessage.author?.bot) return;
|
||||||
|
if (oldMessage.content === newMessage.content) return; // z. B. nur Embed-Update
|
||||||
|
await logToModlog(client, new EmbedBuilder()
|
||||||
|
.setColor(BRAND_YELLOW)
|
||||||
|
.setTitle('✏️ Nachricht bearbeitet')
|
||||||
|
.addFields(
|
||||||
|
{ name: 'User', value: newMessage.author?.username ?? 'unbekannt', inline: true },
|
||||||
|
{ name: 'Kanal', value: `<#${newMessage.channelId}>`, inline: true },
|
||||||
|
{ name: 'Vorher', value: trimmed(oldMessage.content) },
|
||||||
|
{ name: 'Nachher', value: trimmed(newMessage.content) }
|
||||||
|
)
|
||||||
|
.setTimestamp());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[modlog] Fehler:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Game-Server-Monitor: pollt FiveM-kompatible HTTP-Endpoints (/dynamic.json)
|
||||||
|
// und pflegt ein persistentes Status-Embed im Status-Kanal + Bot-Presence.
|
||||||
|
// Setting "gameservers": kommagetrennt "Name=http://ip:30120, Name2=…"
|
||||||
|
import { ActivityType, EmbedBuilder, Events } from 'discord.js';
|
||||||
|
import { getSetting, setSetting } from '../db.js';
|
||||||
|
|
||||||
|
const CHECK_INTERVAL_MS = 2 * 60 * 1000;
|
||||||
|
const TIMEOUT_MS = 8000;
|
||||||
|
const BRAND_YELLOW = 0xf5c518;
|
||||||
|
|
||||||
|
function serverList() {
|
||||||
|
return (getSetting('gameservers') ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((entry) => {
|
||||||
|
const [name, ...rest] = entry.split('=');
|
||||||
|
const url = rest.join('=').trim().replace(/\/$/, '');
|
||||||
|
return name?.trim() && /^https?:\/\//.test(url) ? { name: name.trim(), url } : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einen Server abfragen (FiveM-Format: clients, sv_maxclients, hostname) */
|
||||||
|
async function queryServer(server) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${server.url}/dynamic.json`, {
|
||||||
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||||
|
headers: { 'User-Agent': 'd4rkbot-monitor/1.0' },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return {
|
||||||
|
...server,
|
||||||
|
online: true,
|
||||||
|
players: Number(data.clients) || 0,
|
||||||
|
max: Number(data.sv_maxclients) || 0,
|
||||||
|
map: data.mapname ?? null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { ...server, online: false, players: 0, max: 0, map: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Status-Embed bauen */
|
||||||
|
function buildEmbed(results) {
|
||||||
|
const lines = results.map((r) =>
|
||||||
|
r.online
|
||||||
|
? `🟢 **${r.name}** — ${r.players}/${r.max} Spieler${r.map ? ` · ${r.map}` : ''}`
|
||||||
|
: `🔴 **${r.name}** — offline`
|
||||||
|
);
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setColor(BRAND_YELLOW)
|
||||||
|
.setTitle('🎮 Server-Status')
|
||||||
|
.setDescription(lines.join('\n'))
|
||||||
|
.setFooter({ text: 'D4RKST3R // STATUS • aktualisiert alle 2 min' })
|
||||||
|
.setTimestamp();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Poll-Durchlauf — exportiert für Tests und den Timer */
|
||||||
|
export async function monitorTick(client) {
|
||||||
|
const servers = serverList();
|
||||||
|
const channelId = getSetting('status_channel_id');
|
||||||
|
if (servers.length === 0) return;
|
||||||
|
|
||||||
|
const results = await Promise.all(servers.map(queryServer));
|
||||||
|
|
||||||
|
// Bot-Presence: Gesamtspielerzahl (nur wenn mindestens ein Server online)
|
||||||
|
const online = results.filter((r) => r.online);
|
||||||
|
const totalPlayers = online.reduce((sum, r) => sum + r.players, 0);
|
||||||
|
try {
|
||||||
|
client.user?.setActivity?.(
|
||||||
|
online.length > 0 ? `${totalPlayers} Spieler online` : 'Server offline',
|
||||||
|
{ type: ActivityType.Watching }
|
||||||
|
);
|
||||||
|
} catch { /* Presence ist nice-to-have */ }
|
||||||
|
|
||||||
|
// Persistentes Embed im Status-Kanal pflegen (einmal posten, danach editieren)
|
||||||
|
if (!channelId) return;
|
||||||
|
const channel = await client.channels.fetch(channelId).catch(() => null);
|
||||||
|
if (!channel?.isTextBased()) return;
|
||||||
|
|
||||||
|
const embed = buildEmbed(results);
|
||||||
|
const messageId = getSetting('status_message_id');
|
||||||
|
if (messageId) {
|
||||||
|
const existing = await channel.messages.fetch(messageId).catch(() => null);
|
||||||
|
if (existing) {
|
||||||
|
await existing.edit({ embeds: [embed] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const message = await channel.send({ embeds: [embed] });
|
||||||
|
setSetting('status_message_id', message.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startServerMonitor(client) {
|
||||||
|
setInterval(() => monitorTick(client).catch((e) => console.error('[monitor] Fehler:', e)), CHECK_INTERVAL_MS);
|
||||||
|
client.once(Events.ClientReady, () => monitorTick(client).catch(() => {}));
|
||||||
|
}
|
||||||
@@ -178,6 +178,25 @@ export const listGallery = (limit, offset) => ({
|
|||||||
total: countGalleryStmt.get().n,
|
total: countGalleryStmt.get().n,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Modmail: DM-Konversationen ↔ Staff-Threads
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS modmail (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
thread_id TEXT NOT NULL UNIQUE,
|
||||||
|
username TEXT,
|
||||||
|
opened_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const upsertModmail = db.prepare(`
|
||||||
|
INSERT INTO modmail (user_id, thread_id, username) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET thread_id = excluded.thread_id, username = excluded.username
|
||||||
|
`);
|
||||||
|
const modmailByUserStmt = db.prepare('SELECT * FROM modmail WHERE user_id = ?');
|
||||||
|
const modmailByThreadStmt = db.prepare('SELECT * FROM modmail WHERE thread_id = ?');
|
||||||
|
export const saveModmail = (userId, threadId, username) => upsertModmail.run(userId, threadId, username);
|
||||||
|
export const modmailByUser = (userId) => modmailByUserStmt.get(userId) ?? null;
|
||||||
|
export const modmailByThread = (threadId) => modmailByThreadStmt.get(threadId) ?? null;
|
||||||
|
|
||||||
// API-Keys für externe Skripte (/api/v1/*) — nur der SHA-256-Hash wird gespeichert
|
// API-Keys für externe Skripte (/api/v1/*) — nur der SHA-256-Hash wird gespeichert
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS api_keys (
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { startWebServer } from './web/server.js';
|
|||||||
import { scheduleWeeklyRecap } from './bot/weekly-recap.js';
|
import { scheduleWeeklyRecap } from './bot/weekly-recap.js';
|
||||||
import { startWatchdog } from './bot/watchdog.js';
|
import { startWatchdog } from './bot/watchdog.js';
|
||||||
import { scheduleBackups } from './backup.js';
|
import { scheduleBackups } from './backup.js';
|
||||||
|
import { startServerMonitor } from './bot/server-monitor.js';
|
||||||
|
|
||||||
process.on('unhandledRejection', (error) => {
|
process.on('unhandledRejection', (error) => {
|
||||||
console.error('[main] Unhandled Rejection:', error);
|
console.error('[main] Unhandled Rejection:', error);
|
||||||
@@ -15,6 +16,7 @@ try {
|
|||||||
scheduleWeeklyRecap(client);
|
scheduleWeeklyRecap(client);
|
||||||
startWatchdog(client);
|
startWatchdog(client);
|
||||||
scheduleBackups(client);
|
scheduleBackups(client);
|
||||||
|
startServerMonitor(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,21 @@ export function starboardThreshold() {
|
|||||||
return Number.isInteger(n) && n >= 1 ? n : 3;
|
return Number.isInteger(n) && n >= 1 ? n : 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Modmail-Staff-Kanal (privat!) — leer = Feature aus */
|
||||||
|
export function modmailChannelId() {
|
||||||
|
return getSetting('modmail_channel_id') || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Willkommens-Kanal — leer = Feature aus */
|
||||||
|
export function welcomeChannelId() {
|
||||||
|
return getSetting('welcome_channel_id') || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mod-Log-Kanal (privat!) — leer = Feature aus */
|
||||||
|
export function modlogChannelId() {
|
||||||
|
return getSetting('modlog_channel_id') || null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Screenshot-Kanal für die Galerie — leer = Feature aus */
|
/** Screenshot-Kanal für die Galerie — leer = Feature aus */
|
||||||
export function screenshotChannelId() {
|
export function screenshotChannelId() {
|
||||||
return getSetting('screenshot_channel_id') || null;
|
return getSetting('screenshot_channel_id') || null;
|
||||||
|
|||||||
+11
-2
@@ -203,6 +203,11 @@ ${rssItems}
|
|||||||
starboard_channel_id: getSetting('starboard_channel_id') ?? '',
|
starboard_channel_id: getSetting('starboard_channel_id') ?? '',
|
||||||
starboard_threshold: Number(getSetting('starboard_threshold')) || 3,
|
starboard_threshold: Number(getSetting('starboard_threshold')) || 3,
|
||||||
screenshot_channel_id: getSetting('screenshot_channel_id') ?? '',
|
screenshot_channel_id: getSetting('screenshot_channel_id') ?? '',
|
||||||
|
modmail_channel_id: getSetting('modmail_channel_id') ?? '',
|
||||||
|
welcome_channel_id: getSetting('welcome_channel_id') ?? '',
|
||||||
|
modlog_channel_id: getSetting('modlog_channel_id') ?? '',
|
||||||
|
status_channel_id: getSetting('status_channel_id') ?? '',
|
||||||
|
gameservers: getSetting('gameservers') ?? '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +236,11 @@ ${rssItems}
|
|||||||
const body = request.body ?? {};
|
const body = request.body ?? {};
|
||||||
|
|
||||||
// Kanäle: müssen existierende Textkanäle sein (optionale dürfen leer sein = aus)
|
// Kanäle: müssen existierende Textkanäle sein (optionale dürfen leer sein = aus)
|
||||||
const OPTIONAL_CHANNELS = ['release_channel_id', 'backup_channel_id', 'starboard_channel_id', 'screenshot_channel_id'];
|
const OPTIONAL_CHANNELS = [
|
||||||
|
'release_channel_id', 'backup_channel_id', 'starboard_channel_id',
|
||||||
|
'screenshot_channel_id', 'modmail_channel_id', 'welcome_channel_id',
|
||||||
|
'modlog_channel_id', 'status_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;
|
||||||
const value = String(body[key]);
|
const value = String(body[key]);
|
||||||
@@ -279,7 +288,7 @@ ${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');
|
||||||
}
|
}
|
||||||
for (const key of ['commit_branch_filter', 'ignored_repos', 'bug_report_repo', 'watchdog_urls', 'roadmap_repo']) {
|
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());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user