Add multi-guild Discord bot support with auto-setup

- Bot creates category and channels automatically when joining a server
- Channel structure: info, status, alerts, updates, diskussion, requests (forum)
- Add guild_settings database table for per-server configuration
- Add Discord bot invite button to Dashboard
- Add display settings API functions
- Add comprehensive Discord bot documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alexander Zielonka
2026-01-07 18:29:13 +01:00
parent ff3fa0752e
commit 4fcc111def
4 changed files with 770 additions and 0 deletions

197
docs/discord-bot.md Normal file
View File

@@ -0,0 +1,197 @@
# Discord Bot
Der GSM Discord Bot bietet Live-Status-Updates für alle Gameserver direkt in Discord. Der Bot kann auf mehreren Discord-Servern gleichzeitig laufen.
## Features
- **Live-Status**: Automatisch aktualisiertes Embed mit Server-Status, Spielerzahlen und Metriken
- **Alerts**: Benachrichtigungen wenn Server online/offline gehen oder Spieler joinen/leaven
- **Multi-Guild**: Kann auf beliebig vielen Discord-Servern eingesetzt werden
- **Auto-Setup**: Erstellt automatisch alle nötigen Channels beim Beitreten
## Bot einladen
### Invite-Link
```
https://discord.com/oauth2/authorize?client_id=1458251194806833306&permissions=34359831568&integration_type=0&scope=bot+applications.commands
```
Der Link ist auch im GSM Dashboard unter den Server-Cards verfügbar.
### Benötigte Permissions
| Permission | Verwendung |
|------------|------------|
| View Channels | Channels sehen |
| Manage Channels | Channels erstellen |
| Send Messages | Nachrichten senden |
| Manage Messages | Eigene Nachrichten bearbeiten |
| Embed Links | Rich Embeds für Status |
| Read Message History | Alte Nachrichten lesen |
| Create Public Threads | Forum-Threads erstellen |
## Automatisch erstellte Channel-Struktur
Wenn der Bot einem Server beitritt, erstellt er automatisch folgende Struktur:
```
🎮 Gameserver (Kategorie)
├── │info - Informationen zum GSM System
├── 📊│status - Live-Status aller Gameserver (Auto-Update)
├── 📢│alerts - Server-Events und Spieler-Benachrichtigungen
├── 📰│updates - Ankündigungen zu neuen Gameservern
├── 💬│diskussion - Diskussions-Channel (User können schreiben)
└── 💡│requests - Forum für Gameserver-Vorschläge
```
### Channel-Permissions
| Channel | @everyone | Bot |
|---------|-----------|-----|
| Kategorie | Lesen | Schreiben |
| info | Lesen | Schreiben |
| status | Lesen | Schreiben |
| alerts | Lesen | Schreiben |
| updates | Lesen | Schreiben |
| diskussion | Lesen + Schreiben | Schreiben |
| requests | Lesen + Threads erstellen | Schreiben |
## Datenbank
### guild_settings Tabelle
Speichert die Channel-IDs für jeden Discord-Server:
```sql
CREATE TABLE guild_settings (
guild_id TEXT PRIMARY KEY,
category_id TEXT,
info_channel_id TEXT,
status_channel_id TEXT,
status_message_id TEXT,
alerts_channel_id TEXT,
updates_channel_id TEXT,
discussion_channel_id TEXT,
requests_channel_id TEXT,
requests_info_thread_id TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
### DB-Funktionen
In `backend/db/init.js`:
```javascript
initGuildSettings() // Tabelle erstellen
getGuildSettings(guildId) // Settings für einen Server
getAllGuildSettings() // Alle Server-Settings
setGuildSettings(guildId, {}) // Settings speichern
deleteGuildSettings(guildId) // Settings löschen (bei Bot-Kick)
```
## Bot-Events
### guildCreate
Wird ausgelöst wenn der Bot einem neuen Server beitritt:
1. Erstellt Kategorie und alle Channels
2. Postet Info-Nachricht im `│info` Channel
3. Erstellt Info-Thread im `💡│requests` Forum
4. Speichert alle Channel-IDs in der Datenbank
5. Sendet erste Status-Nachricht
### guildDelete
Wird ausgelöst wenn der Bot von einem Server entfernt wird:
1. Löscht alle Settings aus der Datenbank
## Status-Updates
Der Bot aktualisiert die Status-Nachricht in allen registrierten Guilds alle 30 Sekunden:
```javascript
async function updateAllGuildStatus() {
const guilds = getAllGuildSettings();
for (const guild of guilds) {
await updateGuildStatus(guild);
}
}
```
Jeder Server bekommt ein eigenes Embed mit:
- Server-Name und Status (Online/Offline)
- Aktuelle Spielerzahl
- CPU und RAM Auslastung
- Verbindungsadresse
## Konfiguration
### Umgebungsvariablen (.env)
```env
DISCORD_CLIENT_ID=1458251194806833306
DISCORD_CLIENT_SECRET=xxx
DISCORD_BOT_TOKEN=xxx
DISCORD_GUILD_ID=729865854329815051 # Haupt-Server für Login
DISCORD_ADMIN_ROLE_ID=1024693717434650736
DISCORD_MOD_ROLE_ID=1024693170958766141
```
**Hinweis**: `DISCORD_GUILD_ID` wird nur für den Discord OAuth Login verwendet, nicht für den Bot selbst.
### Developer Portal Einstellungen
1. Gehe zu https://discord.com/developers/applications
2. Wähle die Bot-Application
3. **Bot** Tab:
- "Public Bot" aktivieren (damit andere einladen können)
- Privileged Gateway Intents:
- Server Members Intent: Optional
- Message Content Intent: Nicht benötigt
## Dateien
| Datei | Beschreibung |
|-------|--------------|
| `backend/services/discordBot.js` | Bot-Logik und Event-Handler |
| `backend/db/init.js` | Guild-Settings DB-Funktionen |
| `frontend/src/pages/Dashboard.jsx` | Invite-Button im Dashboard |
## Troubleshooting
### Bot erstellt keine Channels
- Prüfen ob Bot "Manage Channels" Permission hat
- Prüfen ob Bot-Rolle hoch genug in der Rollen-Hierarchie ist
### Status-Nachricht wird nicht aktualisiert
```bash
pm2 logs gameserver-backend --lines 50
```
Suche nach `[DiscordBot]` Log-Einträgen.
### Bot aus Datenbank entfernen
```bash
sqlite3 /opt/gameserver-monitor/backend/users.sqlite
DELETE FROM guild_settings WHERE guild_id = 'xxx';
```
## Login vs. Bot
| Feature | Login (OAuth) | Bot |
|---------|--------------|-----|
| Erfordert Mitgliedschaft | Haupt-Discord | Nein |
| Server-Steuerung | Ja (je nach Rolle) | Nein |
| Status sehen | Ja | Ja |
| Alerts erhalten | Nein | Ja |
| Verfügbar für | Haupt-Discord Mitglieder | Alle mit Bot |
Der Login zur Webapp erfordert Mitgliedschaft im Haupt-Discord-Server (DISCORD_GUILD_ID). Der Bot ist davon unabhängig und zeigt nur passive Status-Updates.

View File

@@ -0,0 +1,521 @@
import { Client, GatewayIntentBits, EmbedBuilder, ChannelType, PermissionFlagsBits } from 'discord.js';
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { getServerStatus } from './ssh.js';
import { getPlayers, getPlayerList } from './rcon.js';
import { initGuildSettings, getGuildSettings, getAllGuildSettings, setGuildSettings, deleteGuildSettings } from '../db/init.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
let client = null;
// State tracking for alerts (per guild)
const previousServerState = new Map();
const previousPlayerLists = new Map();
// Server display config
const serverDisplay = {
minecraft: { name: 'Minecraft ATM10', icon: '⛏️', color: 0x7B5E3C, address: 'minecraft.zeasy.dev' },
factorio: { name: 'Factorio', icon: '⚙️', color: 0xF97316, address: 'factorio.zeasy.dev' },
zomboid: { name: 'Project Zomboid', icon: '🧟', color: 0x4ADE80, address: 'pz.zeasy.dev:16261' },
vrising: { name: 'V Rising', icon: '🧛', color: 0xDC2626, address: 'vrising.zeasy.dev' },
palworld: { name: 'Palworld', icon: '🦎', color: 0x00D4AA, address: 'palworld.zeasy.dev:8211' }
};
function loadConfig() {
return JSON.parse(readFileSync(join(__dirname, '..', 'config.json'), 'utf-8'));
}
// ============================================
// Channel Setup Functions
// ============================================
async function setupGuildChannels(guild) {
console.log('[DiscordBot] Setting up channels for guild: ' + guild.name);
try {
// Create category
const category = await guild.channels.create({
name: '🎮 Gameserver',
type: ChannelType.GuildCategory,
permissionOverwrites: [
{
id: guild.id, // @everyone
deny: [PermissionFlagsBits.SendMessages],
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ReadMessageHistory]
},
{
id: client.user.id, // Bot
allow: [PermissionFlagsBits.SendMessages, PermissionFlagsBits.ManageMessages, PermissionFlagsBits.EmbedLinks]
}
]
});
// Create info channel
const infoChannel = await guild.channels.create({
name: '│info',
type: ChannelType.GuildText,
parent: category.id,
topic: 'Informationen zum Gameserver Management System'
});
// Create status channel
const statusChannel = await guild.channels.create({
name: '📊│status',
type: ChannelType.GuildText,
parent: category.id,
topic: 'Live-Status aller Gameserver'
});
// Create alerts channel
const alertsChannel = await guild.channels.create({
name: '📢│alerts',
type: ChannelType.GuildText,
parent: category.id,
topic: 'Benachrichtigungen über Server-Events und Spieler'
});
// Create updates channel
const updatesChannel = await guild.channels.create({
name: '📰│updates',
type: ChannelType.GuildText,
parent: category.id,
topic: 'Ankündigungen zu neuen Gameservern'
});
// Create discussion channel (users can write)
const discussionChannel = await guild.channels.create({
name: '💬│diskussion',
type: ChannelType.GuildText,
parent: category.id,
topic: 'Diskutiert hier über die Gameserver',
permissionOverwrites: [
{
id: guild.id,
allow: [PermissionFlagsBits.SendMessages, PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ReadMessageHistory]
}
]
});
// Create requests forum channel
const requestsChannel = await guild.channels.create({
name: '💡│requests',
type: ChannelType.GuildForum,
parent: category.id,
topic: 'Schlage neue Gameserver vor',
defaultAutoArchiveDuration: 10080, // 7 days
permissionOverwrites: [
{
id: guild.id,
allow: [PermissionFlagsBits.SendMessages, PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ReadMessageHistory, PermissionFlagsBits.CreatePublicThreads]
}
]
});
// Create info thread in requests forum
const infoThread = await requestsChannel.threads.create({
name: '📌 Wie funktioniert dieser Kanal?',
message: {
embeds: [new EmbedBuilder()
.setTitle('💡 Gameserver Vorschläge')
.setDescription(
'In diesem Kanal kannst du neue Gameserver vorschlagen!\n\n' +
'**So funktioniert\'s:**\n' +
'1. Erstelle einen neuen Beitrag mit dem Namen des Spiels\n' +
'2. Beschreibe warum dieser Server cool wäre\n' +
'3. Andere können deinen Vorschlag diskutieren und liken\n\n' +
'**Was wir berücksichtigen:**\n' +
'• Interesse der Community (Likes & Diskussion)\n' +
'• Technische Machbarkeit\n' +
'• Serverkosten\n\n' +
'Beliebte Vorschläge werden von uns geprüft!'
)
.setColor(0x5865F2)
.setFooter({ text: 'Zeasy Gameserver' })
]
}
});
// Pin the info thread
await infoThread.pin();
// Create info message
const infoEmbed = new EmbedBuilder()
.setTitle('🎮 Zeasy Gameserver Management')
.setDescription(
'Verwalte und überwache unsere Gameserver bequem über das Web-Interface.\n\n' +
'**Features:**\n' +
'• Server starten, stoppen & neustarten\n' +
'• Live Server-Status & Spielerlisten\n' +
'• Server-Konsole & RCON-Befehle\n' +
'• CPU, RAM & Uptime Metriken\n' +
'• Welten-Verwaltung (Factorio)\n' +
'• Config-Editor (Palworld, Zomboid)\n\n' +
'**Zugang:**\n' +
'Melde dich mit deinem Discord-Account an.'
)
.setColor(0x5865F2)
.addFields({
name: '🔗 Web-Interface',
value: '[server.zeasy.dev](https://server.zeasy.dev)',
inline: false
})
.setFooter({ text: 'Zeasy Software' })
.setTimestamp();
await infoChannel.send({ embeds: [infoEmbed] });
// Create initial status message
const statusMsg = await statusChannel.send({ embeds: [createLoadingEmbed()] });
// Save settings to database
const settings = {
category_id: category.id,
info_channel_id: infoChannel.id,
status_channel_id: statusChannel.id,
status_message_id: statusMsg.id,
alerts_channel_id: alertsChannel.id,
updates_channel_id: updatesChannel.id,
discussion_channel_id: discussionChannel.id,
requests_channel_id: requestsChannel.id,
requests_info_thread_id: infoThread.id
};
setGuildSettings(guild.id, settings);
console.log('[DiscordBot] Setup complete for guild: ' + guild.name);
// Send welcome message in alerts
const welcomeEmbed = new EmbedBuilder()
.setTitle('🎉 Gameserver Bot eingerichtet!')
.setDescription(
'Die Gameserver-Kanäle wurden erfolgreich erstellt.\n\n' +
'**Kanäle:**\n' +
'• <#' + infoChannel.id + '> - Allgemeine Infos\n' +
'• <#' + statusChannel.id + '> - Live Server-Status\n' +
'• <#' + alertsChannel.id + '> - Benachrichtigungen\n' +
'• <#' + updatesChannel.id + '> - Server-Ankündigungen\n' +
'• <#' + discussionChannel.id + '> - Diskussion\n' +
'• <#' + requestsChannel.id + '> - Server-Vorschläge'
)
.setColor(0x22C55E)
.setTimestamp();
await alertsChannel.send({ embeds: [welcomeEmbed] });
return settings;
} catch (err) {
console.error('[DiscordBot] Error setting up guild channels:', err);
throw err;
}
}
// ============================================
// Alert Functions
// ============================================
async function sendAlertToAllGuilds(embed) {
const allSettings = getAllGuildSettings();
for (const settings of allSettings) {
if (!settings.alerts_channel_id) continue;
try {
const channel = await client.channels.fetch(settings.alerts_channel_id);
if (channel) {
await channel.send({ embeds: [embed] });
}
} catch (err) {
console.error('[DiscordBot] Error sending alert to guild ' + settings.guild_id + ':', err.message);
}
}
}
async function checkAndSendAlerts(serverStatuses) {
for (const server of serverStatuses) {
const display = serverDisplay[server.id] || { name: server.name, icon: '🖥️', color: 0x6B7280 };
const prevState = previousServerState.get(server.id);
const prevPlayers = previousPlayerLists.get(server.id) || [];
// Check server status changes
if (prevState !== undefined && prevState !== server.status) {
let embed;
if (server.status === 'online' && prevState !== 'online') {
embed = new EmbedBuilder()
.setTitle(display.icon + ' Server gestartet')
.setDescription('**' + display.name + '** ist jetzt online')
.setColor(0x22C55E)
.setTimestamp();
} else if (server.status === 'offline' && prevState === 'online') {
embed = new EmbedBuilder()
.setTitle(display.icon + ' Server gestoppt')
.setDescription('**' + display.name + '** ist jetzt offline')
.setColor(0xEF4444)
.setTimestamp();
}
if (embed) {
await sendAlertToAllGuilds(embed);
}
}
// Check player changes (only if server is online)
if (server.status === 'online' && server.playerList) {
const currentPlayers = server.playerList;
for (const player of currentPlayers) {
if (!prevPlayers.includes(player)) {
const embed = new EmbedBuilder()
.setTitle('➡️ Spieler beigetreten')
.setDescription('**' + player + '** hat **' + display.name + '** betreten')
.setColor(0x22C55E)
.setTimestamp();
await sendAlertToAllGuilds(embed);
}
}
for (const player of prevPlayers) {
if (!currentPlayers.includes(player)) {
const embed = new EmbedBuilder()
.setTitle('⬅️ Spieler verlassen')
.setDescription('**' + player + '** hat **' + display.name + '** verlassen')
.setColor(0xF59E0B)
.setTimestamp();
await sendAlertToAllGuilds(embed);
}
}
previousPlayerLists.set(server.id, [...currentPlayers]);
} else {
previousPlayerLists.set(server.id, []);
}
previousServerState.set(server.id, server.status);
}
}
// ============================================
// Status Update Functions
// ============================================
function createLoadingEmbed() {
return new EmbedBuilder()
.setTitle('🎮 Gameserver Status')
.setDescription('Lade Server-Status...')
.setColor(0x6B7280)
.setTimestamp();
}
function createStatusEmbeds(serverStatuses) {
const embeds = [];
const onlineCount = serverStatuses.filter(s => s.running).length;
const totalPlayers = serverStatuses.reduce((sum, s) => sum + s.players, 0);
const headerEmbed = new EmbedBuilder()
.setTitle('🎮 Gameserver Status')
.setDescription('**' + onlineCount + '/' + serverStatuses.length + '** Server online • **' + totalPlayers + '** Spieler')
.setColor(onlineCount > 0 ? 0x22C55E : 0xEF4444)
.setTimestamp()
.setFooter({ text: 'Aktualisiert alle 60 Sekunden' });
embeds.push(headerEmbed);
for (const server of serverStatuses) {
const display = serverDisplay[server.id] || { name: server.name, icon: '🖥️', color: 0x6B7280, address: '' };
const serverEmbed = new EmbedBuilder()
.setTitle(display.icon + ' ' + display.name)
.setColor(server.running ? display.color : 0x4B5563);
if (server.running) {
let description = '✅ **Online**\n';
if (display.address) {
description += '```' + display.address + '```';
}
description += '👥 **Spieler:** ' + server.players;
if (server.maxPlayers) {
description += '/' + server.maxPlayers;
}
if (server.playerList && server.playerList.length > 0) {
const names = server.playerList.slice(0, 15).join(', ');
description += '\n' + names;
if (server.playerList.length > 15) {
description += ' *+' + (server.playerList.length - 15) + ' mehr*';
}
}
serverEmbed.setDescription(description);
} else {
serverEmbed.setDescription('❌ **Offline**');
}
embeds.push(serverEmbed);
}
return embeds;
}
async function fetchServerStatuses() {
const config = loadConfig();
return await Promise.all(config.servers.map(async (server) => {
try {
const status = await getServerStatus(server);
const running = status === 'online';
let players = { online: 0, max: null };
let playerList = { players: [] };
if (running && server.rconPassword) {
try {
players = await getPlayers(server);
playerList = await getPlayerList(server);
} catch (e) {
// RCON might fail
}
}
return {
id: server.id,
name: server.name,
type: server.type,
status: running ? 'online' : 'offline',
running,
players: players.online || 0,
maxPlayers: players.max,
playerList: playerList.players || []
};
} catch (err) {
return {
id: server.id,
name: server.name,
type: server.type,
status: 'unreachable',
running: false,
players: 0,
maxPlayers: null,
playerList: []
};
}
}));
}
async function updateAllStatusMessages(skipAlerts = false) {
if (!client) return;
try {
const serverStatuses = await fetchServerStatuses();
const embeds = createStatusEmbeds(serverStatuses);
// Send alerts
if (!skipAlerts) {
await checkAndSendAlerts(serverStatuses);
} else {
// Just populate initial state
for (const server of serverStatuses) {
previousServerState.set(server.id, server.status);
previousPlayerLists.set(server.id, server.playerList || []);
}
}
// Update status message in all guilds
const allSettings = getAllGuildSettings();
for (const settings of allSettings) {
if (!settings.status_channel_id || !settings.status_message_id) continue;
try {
const channel = await client.channels.fetch(settings.status_channel_id);
const message = await channel.messages.fetch(settings.status_message_id);
await message.edit({ embeds });
} catch (err) {
console.error('[DiscordBot] Error updating status for guild ' + settings.guild_id + ':', err.message);
}
}
} catch (err) {
console.error('[DiscordBot] Error updating status messages:', err.message);
}
}
// ============================================
// Public Functions
// ============================================
export async function sendUpdateToAllGuilds(embed) {
const allSettings = getAllGuildSettings();
for (const settings of allSettings) {
if (!settings.updates_channel_id) continue;
try {
const channel = await client.channels.fetch(settings.updates_channel_id);
if (channel) {
await channel.send({ embeds: [embed] });
}
} catch (err) {
console.error('[DiscordBot] Error sending update to guild ' + settings.guild_id + ':', err.message);
}
}
}
export async function initDiscordBot() {
const token = process.env.DISCORD_BOT_TOKEN;
if (!token) {
console.log('[DiscordBot] Missing DISCORD_BOT_TOKEN, bot disabled');
return;
}
// Initialize database table
initGuildSettings();
client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages]
});
// Bot joined a new server
client.on('guildCreate', async (guild) => {
console.log('[DiscordBot] Joined new guild: ' + guild.name + ' (' + guild.id + ')');
try {
await setupGuildChannels(guild);
} catch (err) {
console.error('[DiscordBot] Failed to setup guild:', err.message);
}
});
// Bot was removed from a server
client.on('guildDelete', (guild) => {
console.log('[DiscordBot] Left guild: ' + guild.name + ' (' + guild.id + ')');
deleteGuildSettings(guild.id);
});
client.once('ready', async () => {
console.log('[DiscordBot] Logged in as ' + client.user.tag);
// First run - populate state without alerts
await updateAllStatusMessages(true);
// Regular updates every 60 seconds
setInterval(() => updateAllStatusMessages(false), 60000);
});
client.login(token).catch(err => {
console.error('[DiscordBot] Failed to login:', err.message);
});
}
export function getDiscordClient() {
return client;
}
// Export setup function for manual setup via API
export { setupGuildChannels };

View File

@@ -249,3 +249,27 @@ export async function getActivityLog(token, limit = 100) {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}) })
} }
// Display Settings
export async function getAllDisplaySettings(token) {
return fetchAPI('/servers/display-settings', {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
}
export async function getDisplaySettings(token, serverId) {
return fetchAPI(`/servers/${serverId}/display-settings`, {
headers: { Authorization: `Bearer ${token}` },
})
}
export async function saveDisplaySettings(token, serverId, address, hint) {
return fetchAPI(`/servers/${serverId}/display-settings`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ address, hint }),
})
}
// Alias for backwards compatibility
export const setDisplaySettings = saveDisplaySettings

View File

@@ -230,6 +230,34 @@ export default function Dashboard({ onLogin, onLogout }) {
))} ))}
</div> </div>
)} )}
{/* Discord Bot Invite Section */}
<div className="mt-12 fade-in-up" style={{ animationDelay: '300ms' }}>
<div className="card p-6 flex flex-col sm:flex-row items-center justify-center gap-4 sm:gap-6">
<div className="flex items-center gap-4">
<svg className="w-10 h-10 text-[#5865F2]" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
<div>
<h3 className="text-white font-semibold">Discord Bot</h3>
<p className="text-sm text-neutral-400">
Willst du Server-Status Updates auch auf deinem Discord?
</p>
</div>
</div>
<a
href="https://discord.com/oauth2/authorize?client_id=1458251194806833306&permissions=34359831568&integration_type=0&scope=bot+applications.commands"
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary flex items-center gap-2 whitespace-nowrap"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
Bot einladen
</a>
</div>
</div>
</main> </main>
{/* Modals */} {/* Modals */}