Files
deskhub/DeskHub-Webseite/tests/plugin_community_bot.php
T
2026-10-24 20:14:43 +00:00

183 lines
14 KiB
PHP

<?php
declare(strict_types=1);
/**
* Prüft das Plugin "Discord- und Telegram-Bot": Signaturprüfung (Ed25519), Befehle /ticket /status /antwort, Ratenbegrenzung,
* Benachrichtigung bei Antworten und Lösung (mit ersetztem HTTP-Aufruf), Telegram-Geheimtoken und Schutz fremder Tickets.
*/
if (!function_exists('sodium_crypto_sign_keypair')) { echo "sodium fehlt, Test übersprungen\n"; exit(0); }
$pluginDir = sys_get_temp_dir() . '/deskhub-cb-' . bin2hex(random_bytes(4));
mkdir($pluginDir, 0755, true);
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void {
if (!$ok) $failures[] = $what;
};
$pdo = get_db();
require_once __DIR__ . '/fixtures/paths.php';
$src = plugin_source_dir_or_skip('community-bot', 'plugin_community_bot');
$zipPath = sys_get_temp_dir() . '/cb-' . bin2hex(random_bytes(3)) . '.zip';
$zip = new ZipArchive();
$zip->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, '<p>Bitte setzen Sie das <b>Passwort</b> zurück.</p>', 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'], '<b>') && 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";