open($zipPath, ZipArchive::CREATE); foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) { $zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1))); } $zip->close(); $r = plugin_install_zip($pdo, $zipPath); $check($r['ok'] && $r['slug'] === 'community-bot', 'Plugin lässt sich installieren: ' . json_encode($r)); $act = plugin_set_active($pdo, 'community-bot', true, ['api.public', 'hooks.tickets', 'ui.menu', 'db.migrations']); $check($act['ok'], 'Plugin lässt sich aktivieren: ' . json_encode($act)); $state = &plugin_state(); $state['booted'] = false; plugins_boot(); $wh = plugin_state()['webhooks']['community-bot'] ?? []; $check(isset($wh['discord'], $wh['telegram']), 'beide Webhook-Adressen sind registriert'); // ── Discord ──────────────────────────────────────────────────────────────── $kp = sodium_crypto_sign_keypair(); $sk = sodium_crypto_sign_secretkey($kp); plugin_set_setting('community-bot', 'discord_key', bin2hex(sodium_crypto_sign_publickey($kp))); communitybot_set_secret('discord_token', 'bot-token-xyz'); plugin_set_setting('community-bot', 'discord_app', '123456789012'); $sent = []; $GLOBALS['communitybot_http'] = function (string $method, string $url, array $headers, ?string $body) use (&$sent): array { $sent[] = compact('method', 'url', 'headers', 'body'); if (str_contains($url, '/users/@me/channels')) return ['status' => 200, 'body' => '{"id":"dm-1"}']; return ['status' => 200, 'body' => '{"ok":true,"id":"m1"}']; }; $dc = function (array $payload, ?string $ts = null, bool $tamper = false) use ($wh, $sk): array { $body = json_encode($payload); $ts ??= (string) time(); $sig = bin2hex(sodium_crypto_sign_detached($ts . $body, $sk)); return $wh['discord'](['method' => 'POST', 'headers' => ['x-signature-ed25519' => $sig, 'x-signature-timestamp' => $ts], 'body' => $tamper ? $body . ' ' : $body, 'query' => [], 'ip' => '1.2.3.4']); }; $check($dc(['type' => 1])['json'] === ['type' => 1], 'PING wird mit PONG beantwortet'); $check($dc(['type' => 1], null, true)['status'] === 401, 'veränderter Inhalt wird abgelehnt (401)'); $check($dc(['type' => 1], (string) (time() - 3600))['status'] === 401, 'alter Zeitstempel wird abgelehnt'); $bad = $wh['discord'](['method' => 'POST', 'headers' => [], 'body' => '{"type":1}', 'query' => [], 'ip' => '1.2.3.4']); $check($bad['status'] === 401, 'ohne Signatur: 401'); $other = sodium_crypto_sign_secretkey(sodium_crypto_sign_keypair()); $ts = (string) time(); $forged = $wh['discord'](['method' => 'POST', 'headers' => ['x-signature-ed25519' => bin2hex(sodium_crypto_sign_detached($ts . '{"type":1}', $other)), 'x-signature-timestamp' => $ts], 'body' => '{"type":1}', 'query' => [], 'ip' => '1.2.3.4']); $check($forged['status'] === 401, 'Signatur mit fremdem Schlüssel: 401'); $cmd = fn(string $name, array $opts, string $uid = '111111111111') => ['type' => 2, 'channel_id' => '999', 'member' => ['user' => ['id' => $uid, 'username' => 'kim', 'global_name' => 'Kim Test']], 'data' => ['name' => $name, 'options' => array_map(fn($k, $v) => ['name' => $k, 'value' => $v], array_keys($opts), $opts)]]; $res = $dc($cmd('ticket', ['betreff' => 'Login geht nicht', 'nachricht' => 'Ich komme nicht mehr in mein Konto'])); $content = $res['json']['data']['content'] ?? ''; $check($res['status'] === 200 && ($res['json']['data']['flags'] ?? 0) === 64 && preg_match('/#(\S+) wurde angelegt/', $content, $m) === 1, '/ticket legt ein Ticket an und nennt die Nummer: ' . $content); $number = $m[1] ?? ''; $t = $pdo->prepare('SELECT * FROM tickets WHERE ticket_number = ?'); $t->execute([$number]); $ticket = $t->fetch(); $check($ticket && $ticket['subject'] === 'Login geht nicht' && $ticket['requester_email'] === 'discord-111111111111@discord.invalid' && $ticket['source'] === 'api', 'Ticket hat Betreff, Platzhalter-Adresse und Quelle'); $check($ticket && $ticket['requester_name'] === 'Kim Test', 'Name aus Discord übernommen'); $tid = (int) ($ticket['id'] ?? 0); $tagged = (int) $pdo->query("SELECT COUNT(*) FROM ticket_tags tt JOIN tags g ON g.id = tt.tag_id WHERE tt.ticket_id = {$tid} AND g.name = 'Discord'")->fetchColumn(); $check($tagged === 1, 'Ticket trägt den Tag „Discord“'); $check((int) $pdo->query("SELECT COUNT(*) FROM plugin_communitybot_links WHERE ticket_id = {$tid} AND platform = 'discord' AND ext_user = '111111111111'")->fetchColumn() === 1, 'Verknüpfung gespeichert'); $check(($res['json']['data']['allowed_mentions']['parse'] ?? null) === [], 'keine Erwähnungen in der Antwort erlaubt'); $st = $dc($cmd('status', ['nummer' => $number]))['json']['data']['content'] ?? ''; $check(str_contains($st, $number) && str_contains($st, 'Offen'), '/status zeigt den Stand: ' . $st); $foreign = $dc($cmd('status', ['nummer' => $number], '222222222222'))['json']['data']['content'] ?? ''; $check(str_contains($foreign, 'gehört nicht zu Ihnen') && !str_contains($foreign, 'Offen'), 'fremdes Ticket ist nicht abfragbar'); $foreignReply = $dc($cmd('antwort', ['nummer' => $number, 'text' => 'Ich mische mich ein'], '222222222222'))['json']['data']['content'] ?? ''; $check(str_contains($foreignReply, 'gehört nicht zu Ihnen') && (int) $pdo->query("SELECT COUNT(*) FROM ticket_threads WHERE ticket_id = {$tid}")->fetchColumn() === 1, 'fremde Antwort wird nicht gespeichert'); $list = $dc($cmd('status', []))['json']['data']['content'] ?? ''; $check(str_contains($list, $number), '/status ohne Nummer listet die eigenen Tickets'); $rep = $dc($cmd('antwort', ['nummer' => $number, 'text' => 'Noch eine Info']))['json']['data']['content'] ?? ''; $check(str_contains($rep, 'hinzugefügt') && (int) $pdo->query("SELECT COUNT(*) FROM ticket_threads WHERE ticket_id = {$tid} AND author_type = 'customer'")->fetchColumn() === 2, '/antwort ergänzt das Ticket'); // Ratenbegrenzung (Standard 3 pro Stunde): zwei weitere gehen, das vierte nicht $ok2 = $dc($cmd('ticket', ['betreff' => 'Zwei', 'nachricht' => 'Zweites Anliegen']))['json']['data']['content'] ?? ''; $ok3 = $dc($cmd('ticket', ['betreff' => 'Drei', 'nachricht' => 'Drittes Anliegen']))['json']['data']['content'] ?? ''; $no4 = $dc($cmd('ticket', ['betreff' => 'Vier', 'nachricht' => 'Viertes Anliegen']))['json']['data']['content'] ?? ''; $check(str_contains($ok2, 'angelegt') && str_contains($ok3, 'angelegt') && str_contains($no4, 'schon 3 Tickets'), 'Ratenbegrenzung greift beim vierten Ticket: ' . $no4); $other1 = $dc($cmd('ticket', ['betreff' => 'Ich', 'nachricht' => 'Anderer Nutzer'], '333333333333'))['json']['data']['content'] ?? ''; $check(str_contains($other1, 'angelegt'), 'andere Nutzer sind nicht betroffen'); // Benachrichtigung bei Mitarbeiter-Antwort $staffId = (int) $pdo->query('SELECT id FROM staff ORDER BY id LIMIT 1')->fetchColumn(); $pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?, 'staff', ?, ?, 0, ?)")->execute([$tid, $staffId, '
Bitte setzen Sie das Passwort zurück.
', db_now()]); $sent = []; webhook_dispatch($pdo, 'ticket.replied', $tid, ['author' => 'staff', 'internal' => false]); $dm = array_values(array_filter($sent, fn($s) => str_contains($s['url'], '/channels/dm-1/messages'))); $check(count($dm) === 1 && str_contains((string) $dm[0]['body'], 'Passwort zurück') && !str_contains((string) $dm[0]['body'], '') && in_array('Authorization: Bot bot-token-xyz', $dm[0]['headers'], true), 'Antwort kommt als Direktnachricht (ohne HTML, mit Bot-Token)'); $sent = []; webhook_dispatch($pdo, 'ticket.replied', $tid, ['author' => 'staff', 'internal' => true]); webhook_dispatch($pdo, 'ticket.replied', $tid, ['author' => 'customer']); $check(!$sent, 'interne Notizen und Kunden-Antworten lösen nichts aus'); $pdo->prepare("UPDATE tickets SET status = 'resolved' WHERE id = ?")->execute([$tid]); webhook_dispatch($pdo, 'ticket.status_changed', $tid, ['previousStatus' => 'open']); $check(count(array_filter($sent, fn($s) => str_contains((string) $s['body'], 'Gelöst'))) === 1, 'bei „Gelöst“ kommt eine Mitteilung'); $sent = []; plugin_set_setting('community-bot', 'notify', '0'); webhook_dispatch($pdo, 'ticket.status_changed', $tid, ['previousStatus' => 'open']); $check(!$sent, 'ohne Benachrichtigung wird nichts gesendet'); plugin_set_setting('community-bot', 'notify', '1'); // Discord-Befehle registrieren $sent = []; $reg = communitybot_discord_register(); $check($reg['ok'] && $sent[0]['method'] === 'PUT' && str_ends_with($sent[0]['url'], '/applications/123456789012/commands') && count(json_decode((string) $sent[0]['body'], true)) === 3, 'Befehle werden bei Discord eingetragen'); // ── Telegram ─────────────────────────────────────────────────────────────── communitybot_set_secret('telegram_token', '123:ABC'); communitybot_set_secret('telegram_secret', 'geheim-1234'); $good = ['x-telegram-bot-api-secret-token' => 'geheim-1234']; $tg = fn(string $text, array $headers = ['x-telegram-bot-api-secret-token' => 'geheim-1234'], int $uid = 777) => $wh['telegram'](['method' => 'POST', 'headers' => $headers, 'body' => json_encode(['update_id' => 1, 'message' => ['message_id' => 5, 'text' => $text, 'chat' => ['id' => $uid, 'type' => 'private'], 'from' => ['id' => $uid, 'first_name' => 'Tom', 'last_name' => 'Tester']]]), 'query' => [], 'ip' => '1.2.3.4']); $check($tg('/ticket x', [])['status'] === 401, 'Telegram ohne Token: 401'); $check($tg('/ticket x', ['x-telegram-bot-api-secret-token' => 'falsch'])['status'] === 401, 'Telegram mit falschem Token: 401'); $res = $tg('/ticket Drucker kaputt | Der Drucker im Büro druckt nicht mehr'); $check(($res['json']['method'] ?? '') === 'sendMessage' && (int) $res['json']['chat_id'] === 777 && preg_match('/#(\S+) wurde angelegt/', (string) ($res['json']['text'] ?? ''), $m2) === 1, 'Telegram: /ticket antwortet mit der Nummer: ' . json_encode($res)); $num2 = $m2[1] ?? ''; $s = $pdo->prepare('SELECT * FROM tickets WHERE ticket_number = ?'); $s->execute([$num2]); $t2 = $s->fetch(); $check($t2 && $t2['subject'] === 'Drucker kaputt' && $t2['requester_name'] === 'Tom Tester' && $t2['requester_email'] === 'telegram-777@telegram.invalid', 'Telegram-Ticket: Betreff vor dem Strich, Name, Adresse'); $noSub = $tg('/ticket Mein Monitor flackert seit heute', $good, 778); $check(str_contains((string) $noSub['json']['text'], 'angelegt'), 'Telegram: ohne Strich wird der Betreff aus dem Text gebildet'); $check(str_contains((string) $tg('/status ' . $num2)['json']['text'], 'Offen'), 'Telegram: /status'); $check(str_contains((string) $tg('/status ' . $num2, $good, 999)['json']['text'], 'gehört nicht zu Ihnen'), 'Telegram: fremdes Ticket nicht abfragbar'); $check(str_contains((string) $tg('/antwort ' . $num2 . ' Danke, jetzt geht es')['json']['text'], 'hinzugefügt'), 'Telegram: /antwort'); $check(str_contains((string) $tg('/hilfe@MeinBot')['json']['text'], '/ticket'), 'Telegram: /hilfe mit Bot-Namen'); $check($tg('Hallo ohne Befehl')['json'] instanceof stdClass, 'Telegram: normaler Text wird ignoriert'); $id2 = (int) $t2['id']; $pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?, 'staff', ?, 'Wir kümmern uns darum.', 0, ?)")->execute([$id2, $staffId, db_now()]); $sent = []; webhook_dispatch($pdo, 'ticket.replied', $id2, ['author' => 'staff', 'internal' => false]); $send = array_values(array_filter($sent, fn($x) => str_contains($x['url'], 'api.telegram.org/bot123:ABC/sendMessage'))); $check(count($send) === 1 && (string) json_decode((string) $send[0]['body'], true)['chat_id'] === '777', 'Telegram: Antwort wird an den Chat geschickt'); $sent = []; $treg = communitybot_telegram_register(); $check($treg['ok'] && str_contains((string) $sent[0]['body'], 'secret_token') && str_contains((string) $sent[0]['body'], 'plugin_webhook.php?p=community-bot'), 'Telegram-Webhook wird mit Token und Adresse gesetzt'); // Gesperrte Absenderadresse $pdo->prepare('INSERT INTO banlist (match_type, value, created_at) VALUES (?,?,?)')->execute(['email', 'telegram-555@telegram.invalid', db_now()]); $ban = $tg('/ticket Sperre | Test', $good, 555); $check(str_contains((string) $ban['json']['text'], 'nicht angelegt'), 'gesperrte Absender bekommen kein Ticket: ' . ($ban['json']['text'] ?? '')); unset($GLOBALS['communitybot_http']); foreach (scandir($pluginDir) ?: [] as $entry) { if ($entry === '.' || $entry === '..') continue; is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry); } @rmdir($pluginDir); @unlink($zipPath); if ($failures) { fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); exit(1); } echo 'Community-Bot plugin checks passed.' . "\n";