Files
deskhub/Plugins/plugins/community-bot/lib.php
T
2026-10-24 20:15:15 +00:00

355 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// Community-Bot: Discord- und Telegram-Anbindung. Alle Funktionen beginnen mit communitybot_.
const COMMUNITYBOT = 'community-bot';
function communitybot_config(): array
{
$secret = static function (string $key): string {
$v = (string) plugin_setting(COMMUNITYBOT, $key, '');
if ($v === '') return '';
try { return decrypt_secret($v); } catch (Throwable $e) { return ''; }
};
return [
'discord_key' => 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()); }
}
}