strtolower(trim((string) plugin_setting(COMMUNITYBOT, 'discord_key', ''))), 'discord_app' => trim((string) plugin_setting(COMMUNITYBOT, 'discord_app', '')), 'discord_token' => $secret('discord_token'), 'telegram_token' => $secret('telegram_token'), 'telegram_secret' => $secret('telegram_secret'), 'department_id' => (int) plugin_setting(COMMUNITYBOT, 'department_id', '0'), 'priority_id' => (int) plugin_setting(COMMUNITYBOT, 'priority_id', '0'), 'limit' => max(1, min(50, (int) plugin_setting(COMMUNITYBOT, 'limit', '3'))), 'notify' => plugin_setting(COMMUNITYBOT, 'notify', '1') === '1', ]; } function communitybot_set_secret(string $key, string $value): void { plugin_set_setting(COMMUNITYBOT, $key, $value === '' ? '' : encrypt_secret($value)); } /** HTTP-Aufruf (für Tests austauschbar über $GLOBALS['communitybot_http']). Liefert ['status' => int, 'body' => string]. */ function communitybot_http(string $method, string $url, array $headers = [], ?string $body = null): array { if (isset($GLOBALS['communitybot_http']) && is_callable($GLOBALS['communitybot_http'])) { return ($GLOBALS['communitybot_http'])($method, $url, $headers, $body); } $ctx = stream_context_create(['http' => [ 'method' => $method, 'header' => implode("\r\n", $headers), 'content' => $body ?? '', 'timeout' => 8, 'ignore_errors' => true, ]]); $res = @file_get_contents($url, false, $ctx); $status = 0; foreach ($http_response_header ?? [] as $h) if (preg_match('#^HTTP/\S+\s+(\d+)#', $h, $m)) $status = (int) $m[1]; return ['status' => $status, 'body' => $res === false ? '' : (string) $res]; } function communitybot_json(string $method, string $url, array $headers, ?array $data): array { $r = communitybot_http($method, $url, array_merge(['Content-Type: application/json', 'User-Agent: DeskHub-CommunityBot (https://deskhub.example, 1.0)'], $headers), $data === null ? null : json_encode($data, JSON_UNESCAPED_UNICODE)); return ['status' => $r['status'], 'json' => json_decode($r['body'], true) ?: [], 'body' => $r['body']]; } function communitybot_clip(string $text, int $max): string { $text = trim(preg_replace('/\s+/u', ' ', strip_tags(html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8')))); return mb_strlen($text) > $max ? mb_substr($text, 0, $max - 1) . '…' : $text; } // ── Gemeinsame Befehle ───────────────────────────────────────────────────── /** Platzhalter-Adresse; Mails an ".invalid" werden vom Kern nie verschickt. */ function communitybot_email(string $platform, string $user): string { return $platform . '-' . preg_replace('/[^a-z0-9]/i', '', $user) . '@' . $platform . '.invalid'; } function communitybot_find_link(PDO $pdo, string $platform, string $user, string $number): ?array { $number = ltrim(trim($number), '#'); if ($number === '') return null; $s = $pdo->prepare('SELECT t.*, l.ext_chat FROM plugin_communitybot_links l JOIN tickets t ON t.id = l.ticket_id WHERE l.platform = ? AND l.ext_user = ? AND t.ticket_number = ? ORDER BY l.id DESC LIMIT 1'); $s->execute([$platform, $user, $number]); return $s->fetch() ?: null; } function communitybot_help(string $platform): string { $p = $platform === 'telegram' ? '/ticket Betreff | Nachricht' : '/ticket betreff:… nachricht:…'; return "So geht's:\n{$p} – neues Ticket anlegen\n/status Nummer – Stand eines Tickets\n/antwort Nummer Text – Ihrem Ticket etwas hinzufügen"; } function communitybot_cmd_ticket(PDO $pdo, string $platform, string $user, string $chat, string $name, string $subject, string $message): string { $cfg = communitybot_config(); $subject = trim($subject); $message = trim($message); if ($message === '' && $subject !== '') { $message = $subject; $subject = ''; } if ($message === '') return 'Bitte beschreiben Sie Ihr Anliegen: ' . ($platform === 'telegram' ? '/ticket Betreff | Nachricht' : '/ticket mit Betreff und Nachricht'); if ($subject === '') $subject = communitybot_clip($message, 60); $subject = mb_substr($subject, 0, 200); $message = mb_substr($message, 0, 4000); $c = $pdo->prepare('SELECT COUNT(*) FROM plugin_communitybot_links WHERE platform = ? AND ext_user = ? AND created_at >= ?'); $c->execute([$platform, $user, date('Y-m-d H:i:s', time() - 3600)]); if ((int) $c->fetchColumn() >= $cfg['limit']) return 'Sie haben in der letzten Stunde schon ' . $cfg['limit'] . ' Tickets angelegt. Bitte versuchen Sie es später noch einmal oder ergänzen Sie ein bestehendes Ticket mit /antwort.'; $label = $platform === 'discord' ? 'Discord' : 'Telegram'; $name = trim(preg_replace('/[\x00-\x1F]+/u', ' ', $name)); if ($name === '') $name = $label . '-Nutzer'; try { $r = public_ticket_create($pdo, [ 'name' => mb_substr($name, 0, 120), 'email' => communitybot_email($platform, $user), 'subject' => $subject, 'message' => $message, 'departmentId' => $cfg['department_id'] ?: null, 'priorityId' => $cfg['priority_id'] ?: null, ], '0.0.0.0', ['activity' => 'Ticket über ' . $label . ' erstellt', 'tag' => $label]); } catch (AdminModelException $e) { return 'Das Ticket konnte nicht angelegt werden: ' . $e->getMessage(); } catch (Throwable $e) { error_log('communitybot ticket: ' . $e->getMessage()); return 'Das Ticket konnte gerade nicht angelegt werden. Bitte versuchen Sie es später noch einmal.'; } $pdo->prepare('INSERT INTO plugin_communitybot_links (ticket_id, platform, ext_user, ext_chat, ext_name, created_at) VALUES (?,?,?,?,?,?)') ->execute([$r['id'], $platform, $user, $chat, mb_substr($name, 0, 120), db_now()]); $hint = $cfg['notify'] ? ' Wir melden uns hier, sobald es eine Antwort gibt.' : ''; return 'Ihr Ticket #' . $r['number'] . ' wurde angelegt.' . $hint . ' Stand abfragen mit /status ' . $r['number'] . '.'; } function communitybot_cmd_status(PDO $pdo, string $platform, string $user, string $number): string { if (trim($number) === '') { $s = $pdo->prepare('SELECT t.ticket_number, t.subject, t.status FROM plugin_communitybot_links l JOIN tickets t ON t.id = l.ticket_id WHERE l.platform = ? AND l.ext_user = ? ORDER BY l.id DESC LIMIT 5'); $s->execute([$platform, $user]); $rows = $s->fetchAll(); if (!$rows) return 'Sie haben über diesen Kanal noch kein Ticket angelegt.'; return "Ihre letzten Tickets:\n" . implode("\n", array_map(fn($t) => '#' . $t['ticket_number'] . ' – ' . status_label((string) $t['status'])[0] . ' – ' . communitybot_clip((string) $t['subject'], 50), $rows)); } $t = communitybot_find_link($pdo, $platform, $user, $number); if (!$t) return 'Dieses Ticket gibt es nicht oder es gehört nicht zu Ihnen.'; return 'Ticket #' . $t['ticket_number'] . ' „' . communitybot_clip((string) $t['subject'], 60) . '“: ' . status_label((string) $t['status'])[0] . ' (zuletzt geändert ' . format_dt((string) $t['updated_at']) . ').'; } function communitybot_cmd_reply(PDO $pdo, string $platform, string $user, string $number, string $text): string { $text = trim($text); if (trim($number) === '' || $text === '') return 'So geht es: /antwort Ticketnummer Ihre Nachricht'; $t = communitybot_find_link($pdo, $platform, $user, $number); if (!$t) return 'Dieses Ticket gibt es nicht oder es gehört nicht zu Ihnen.'; if ($t['status'] === 'closed') return 'Das Ticket #' . $t['ticket_number'] . ' ist geschlossen. Bitte legen Sie mit /ticket ein neues an.'; $now = db_now(); $pdo->prepare('INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)')->execute([$t['id'], 'customer', mb_substr($text, 0, 4000), $now]); $reopen = in_array($t['status'], ['answered', 'resolved'], true); $new = $reopen ? 'open' : $t['status']; $pdo->prepare('UPDATE tickets SET status = ?, updated_at = ? WHERE id = ?')->execute([$new, $now, $t['id']]); log_activity($pdo, (int) $t['id'], null, 'Kunde hat geantwortet (' . ($platform === 'discord' ? 'Discord' : 'Telegram') . ')'); if ($reopen) log_activity($pdo, (int) $t['id'], null, 'Ticket wieder geöffnet'); webhook_dispatch($pdo, 'ticket.replied', (int) $t['id'], ['author' => 'customer']); if ($new !== $t['status']) webhook_dispatch($pdo, 'ticket.status_changed', (int) $t['id'], ['previousStatus' => $t['status']]); return 'Ihre Nachricht wurde zu Ticket #' . $t['ticket_number'] . ' hinzugefügt.'; } // ── Discord ──────────────────────────────────────────────────────────────── function communitybot_discord_verify(array $req, string $publicKeyHex): bool { $sig = (string) ($req['headers']['x-signature-ed25519'] ?? ''); $ts = (string) ($req['headers']['x-signature-timestamp'] ?? ''); if ($publicKeyHex === '' || !ctype_xdigit($publicKeyHex) || strlen($publicKeyHex) !== 64 || !ctype_xdigit($sig) || strlen($sig) !== 128 || !ctype_digit($ts)) return false; if (abs(time() - (int) $ts) > 600) return false; if (!function_exists('sodium_crypto_sign_verify_detached')) return false; try { return sodium_crypto_sign_verify_detached(hex2bin($sig), $ts . $req['body'], hex2bin($publicKeyHex)); } catch (Throwable $e) { return false; } } function communitybot_discord(array $req): array { $cfg = communitybot_config(); if (!communitybot_discord_verify($req, $cfg['discord_key'])) return ['status' => 401, 'json' => ['error' => 'Ungültige Signatur.']]; $in = json_decode($req['body'], true); if (!is_array($in)) return ['status' => 400, 'json' => ['error' => 'Ungültige Anfrage.']]; if (($in['type'] ?? 0) === 1) return ['status' => 200, 'json' => ['type' => 1]]; if (($in['type'] ?? 0) !== 2) return ['status' => 200, 'json' => ['type' => 4, 'data' => ['content' => 'Das wird nicht unterstützt.', 'flags' => 64]]]; $user = $in['member']['user'] ?? $in['user'] ?? []; $uid = (string) ($user['id'] ?? ''); if (!preg_match('/^\d{5,25}$/', $uid)) return ['status' => 400, 'json' => ['error' => 'Kein Nutzer.']]; $name = (string) ($user['global_name'] ?? $user['username'] ?? ''); $opt = []; foreach ((array) ($in['data']['options'] ?? []) as $o) if (isset($o['name'])) $opt[(string) $o['name']] = (string) ($o['value'] ?? ''); $pdo = get_db(); $chat = (string) ($in['channel_id'] ?? ''); switch ((string) ($in['data']['name'] ?? '')) { case 'ticket': $text = communitybot_cmd_ticket($pdo, 'discord', $uid, $chat, $name, $opt['betreff'] ?? '', $opt['nachricht'] ?? ''); break; case 'status': $text = communitybot_cmd_status($pdo, 'discord', $uid, $opt['nummer'] ?? ''); break; case 'antwort': $text = communitybot_cmd_reply($pdo, 'discord', $uid, $opt['nummer'] ?? '', $opt['text'] ?? ''); break; default: $text = communitybot_help('discord'); } // flags 64: nur der Absender sieht die Antwort; keine Erwähnungen aus Nutzertext return ['status' => 200, 'json' => ['type' => 4, 'data' => ['content' => mb_substr($text, 0, 1900), 'flags' => 64, 'allowed_mentions' => ['parse' => []]]]]; } function communitybot_discord_commands(): array { $str = fn(string $n, string $d, bool $req, int $max = 1000) => ['type' => 3, 'name' => $n, 'description' => $d, 'required' => $req, 'max_length' => $max]; return [ ['name' => 'ticket', 'description' => 'Ein Support-Ticket anlegen', 'options' => [$str('betreff', 'Worum geht es?', true, 100), $str('nachricht', 'Bitte beschreiben Sie Ihr Anliegen', true, 1000)]], ['name' => 'status', 'description' => 'Stand eines Tickets abfragen', 'options' => [$str('nummer', 'Ticketnummer (leer lassen für Ihre letzten)', false, 40)]], ['name' => 'antwort', 'description' => 'Zu einem Ihrer Tickets etwas hinzufügen', 'options' => [$str('nummer', 'Ticketnummer', true, 40), $str('text', 'Ihre Nachricht', true, 1000)]], ]; } function communitybot_discord_register(): array { $cfg = communitybot_config(); if ($cfg['discord_app'] === '' || $cfg['discord_token'] === '') return ['ok' => false, 'message' => 'Bitte Anwendungs-ID und Bot-Token eintragen.']; $r = communitybot_json('PUT', 'https://discord.com/api/v10/applications/' . rawurlencode($cfg['discord_app']) . '/commands', ['Authorization: Bot ' . $cfg['discord_token']], communitybot_discord_commands()); if ($r['status'] >= 200 && $r['status'] < 300) return ['ok' => true, 'message' => 'Die Befehle /ticket, /status und /antwort sind bei Discord eingetragen (es kann bis zu einer Stunde dauern, bis sie überall erscheinen).']; return ['ok' => false, 'message' => 'Discord hat abgelehnt (Status ' . $r['status'] . '): ' . communitybot_clip((string) ($r['json']['message'] ?? $r['body']), 200)]; } function communitybot_discord_dm(string $userId, string $text): bool { $cfg = communitybot_config(); if ($cfg['discord_token'] === '') return false; $h = ['Authorization: Bot ' . $cfg['discord_token']]; $ch = communitybot_json('POST', 'https://discord.com/api/v10/users/@me/channels', $h, ['recipient_id' => $userId]); $id = (string) ($ch['json']['id'] ?? ''); if ($id === '') return false; $m = communitybot_json('POST', 'https://discord.com/api/v10/channels/' . rawurlencode($id) . '/messages', $h, ['content' => mb_substr($text, 0, 1900), 'allowed_mentions' => ['parse' => []]]); return $m['status'] >= 200 && $m['status'] < 300; } // ── Telegram ─────────────────────────────────────────────────────────────── function communitybot_telegram(array $req): array { $cfg = communitybot_config(); $given = (string) ($req['headers']['x-telegram-bot-api-secret-token'] ?? ''); if ($cfg['telegram_secret'] === '' || !hash_equals($cfg['telegram_secret'], $given)) return ['status' => 401, 'json' => ['error' => 'Ungültiges Token.']]; $in = json_decode($req['body'], true); $msg = is_array($in) ? ($in['message'] ?? null) : null; if (!is_array($msg) || !isset($msg['chat']['id'], $msg['from']['id']) || !is_string($msg['text'] ?? null)) return ['status' => 200, 'json' => new stdClass()]; if (!empty($msg['from']['is_bot'])) return ['status' => 200, 'json' => new stdClass()]; if (!preg_match('/^\/([a-z_]+)(?:@\w+)?(?:\s+(.*))?$/isu', trim($msg['text']), $m)) return ['status' => 200, 'json' => new stdClass()]; $uid = (string) $msg['from']['id']; $chat = (string) $msg['chat']['id']; $name = trim(((string) ($msg['from']['first_name'] ?? '')) . ' ' . ((string) ($msg['from']['last_name'] ?? ''))); if ($name === '') $name = (string) ($msg['from']['username'] ?? ''); $arg = trim((string) ($m[2] ?? '')); $pdo = get_db(); switch (strtolower($m[1])) { case 'ticket': $parts = array_map('trim', explode('|', $arg, 2)); $text = communitybot_cmd_ticket($pdo, 'telegram', $uid, $chat, $name, count($parts) === 2 ? $parts[0] : '', count($parts) === 2 ? $parts[1] : $parts[0]); break; case 'status': $text = communitybot_cmd_status($pdo, 'telegram', $uid, $arg); break; case 'antwort': $p = preg_split('/\s+/u', $arg, 2) ?: ['']; $text = communitybot_cmd_reply($pdo, 'telegram', $uid, $p[0] ?? '', $p[1] ?? ''); break; case 'start': case 'hilfe': case 'help': $text = "Hallo! Ich nehme Ihre Support-Anfragen entgegen.\n" . communitybot_help('telegram'); break; default: return ['status' => 200, 'json' => new stdClass()]; } // Antwort direkt im Webhook-Ergebnis: kein zusätzlicher Aufruf nötig $out = ['method' => 'sendMessage', 'chat_id' => $msg['chat']['id'], 'text' => mb_substr($text, 0, 3900)]; if (isset($msg['message_id'])) $out['reply_to_message_id'] = $msg['message_id']; return ['status' => 200, 'json' => $out]; } function communitybot_telegram_send(string $chatId, string $text): bool { $cfg = communitybot_config(); if ($cfg['telegram_token'] === '') return false; $r = communitybot_json('POST', 'https://api.telegram.org/bot' . $cfg['telegram_token'] . '/sendMessage', [], ['chat_id' => $chatId, 'text' => mb_substr($text, 0, 3900)]); return $r['status'] >= 200 && $r['status'] < 300; } function communitybot_telegram_register(): array { $cfg = communitybot_config(); if ($cfg['telegram_token'] === '') return ['ok' => false, 'message' => 'Bitte das Bot-Token von @BotFather eintragen.']; if ($cfg['telegram_secret'] === '') { $cfg['telegram_secret'] = bin2hex(random_bytes(24)); communitybot_set_secret('telegram_secret', $cfg['telegram_secret']); } $r = communitybot_json('POST', 'https://api.telegram.org/bot' . $cfg['telegram_token'] . '/setWebhook', [], [ 'url' => communitybot_url('telegram'), 'secret_token' => $cfg['telegram_secret'], 'allowed_updates' => ['message'], 'drop_pending_updates' => true, ]); if (!empty($r['json']['ok'])) { communitybot_json('POST', 'https://api.telegram.org/bot' . $cfg['telegram_token'] . '/setMyCommands', [], ['commands' => [ ['command' => 'ticket', 'description' => 'Ticket anlegen: /ticket Betreff | Nachricht'], ['command' => 'status', 'description' => 'Stand eines Tickets'], ['command' => 'antwort', 'description' => 'Zu einem Ticket antworten'], ['command' => 'hilfe', 'description' => 'Hilfe'], ]]); return ['ok' => true, 'message' => 'Der Telegram-Bot ist verbunden.']; } return ['ok' => false, 'message' => 'Telegram hat abgelehnt: ' . communitybot_clip((string) ($r['json']['description'] ?? $r['body']), 200)]; } function communitybot_url(string $endpoint): string { return rtrim(base_url(), '/') . '/api/public/v1/plugin_webhook.php?p=' . COMMUNITYBOT . '&e=' . $endpoint; } // ── Benachrichtigungen ───────────────────────────────────────────────────── function communitybot_send_to(array $link, string $text): bool { return $link['platform'] === 'discord' ? communitybot_discord_dm((string) $link['ext_user'], $text) : communitybot_telegram_send((string) $link['ext_chat'], $text); } function communitybot_links(PDO $pdo, int $ticketId): array { // pro Nutzer nur einmal, auch wenn mehrere Verknüpfungen bestehen $s = $pdo->prepare('SELECT platform, ext_user, MAX(ext_chat) AS ext_chat FROM plugin_communitybot_links WHERE ticket_id = ? GROUP BY platform, ext_user'); $s->execute([$ticketId]); return $s->fetchAll(); } function communitybot_notify_reply(PDO $pdo, int $ticketId): void { if (!communitybot_config()['notify']) return; $links = communitybot_links($pdo, $ticketId); if (!$links) return; $t = $pdo->prepare('SELECT ticket_number, is_spam FROM tickets WHERE id = ?'); $t->execute([$ticketId]); $ticket = $t->fetch(); if (!$ticket || !empty($ticket['is_spam'])) return; $m = $pdo->prepare("SELECT message FROM ticket_threads WHERE ticket_id = ? AND author_type = 'staff' AND is_internal_note = 0 ORDER BY id DESC LIMIT 1"); $m->execute([$ticketId]); $msg = (string) $m->fetchColumn(); if ($msg === '') return; $text = 'Neue Antwort zu Ticket #' . $ticket['ticket_number'] . ":\n\n" . communitybot_clip($msg, 1200) . "\n\nAntworten: /antwort " . $ticket['ticket_number'] . ' Ihre Nachricht'; foreach ($links as $l) { try { communitybot_send_to($l, $text); } catch (Throwable $e) { error_log('communitybot notify: ' . $e->getMessage()); } } } function communitybot_notify_status(PDO $pdo, int $ticketId): void { if (!communitybot_config()['notify']) return; $links = communitybot_links($pdo, $ticketId); if (!$links) return; $t = $pdo->prepare('SELECT ticket_number, status, is_spam FROM tickets WHERE id = ?'); $t->execute([$ticketId]); $ticket = $t->fetch(); if (!$ticket || !empty($ticket['is_spam']) || !in_array($ticket['status'], ['resolved', 'closed'], true)) return; $text = 'Ihr Ticket #' . $ticket['ticket_number'] . ' ist jetzt „' . status_label((string) $ticket['status'])[0] . '“. Wenn noch etwas offen ist, antworten Sie mit /antwort ' . $ticket['ticket_number'] . ' Ihre Nachricht.'; foreach ($links as $l) { try { communitybot_send_to($l, $text); } catch (Throwable $e) { error_log('communitybot notify: ' . $e->getMessage()); } } }