diff --git a/Plugins/plugins/community-bot-1.0.1.zip b/Plugins/plugins/community-bot-1.0.1.zip new file mode 100644 index 00000000..db87a212 Binary files /dev/null and b/Plugins/plugins/community-bot-1.0.1.zip differ diff --git a/Plugins/plugins/community-bot/lib.php b/Plugins/plugins/community-bot/lib.php new file mode 100644 index 00000000..e3e18ee2 --- /dev/null +++ b/Plugins/plugins/community-bot/lib.php @@ -0,0 +1,354 @@ + 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()); } + } +} diff --git a/Plugins/plugins/community-bot/migrations/001_verknuepfung.mysql.sql b/Plugins/plugins/community-bot/migrations/001_verknuepfung.mysql.sql new file mode 100644 index 00000000..75b31e12 --- /dev/null +++ b/Plugins/plugins/community-bot/migrations/001_verknuepfung.mysql.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS plugin_communitybot_links ( + id INT AUTO_INCREMENT PRIMARY KEY, + ticket_id INT NOT NULL, + platform VARCHAR(20) NOT NULL, + ext_user VARCHAR(64) NOT NULL, + ext_chat VARCHAR(64) NOT NULL DEFAULT '', + ext_name VARCHAR(120) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + KEY idx_plugin_communitybot_ticket (ticket_id), + KEY idx_plugin_communitybot_user (platform, ext_user, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 diff --git a/Plugins/plugins/community-bot/migrations/001_verknuepfung.sqlite.sql b/Plugins/plugins/community-bot/migrations/001_verknuepfung.sqlite.sql new file mode 100644 index 00000000..8574cadb --- /dev/null +++ b/Plugins/plugins/community-bot/migrations/001_verknuepfung.sqlite.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS plugin_communitybot_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ticket_id INTEGER NOT NULL, + platform TEXT NOT NULL, + ext_user TEXT NOT NULL, + ext_chat TEXT NOT NULL DEFAULT '', + ext_name TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_plugin_communitybot_ticket ON plugin_communitybot_links (ticket_id); +CREATE INDEX IF NOT EXISTS idx_plugin_communitybot_user ON plugin_communitybot_links (platform, ext_user, created_at) diff --git a/Plugins/plugins/community-bot/pages/community-bot.php b/Plugins/plugins/community-bot/pages/community-bot.php new file mode 100644 index 00000000..35b6043a --- /dev/null +++ b/Plugins/plugins/community-bot/pages/community-bot.php @@ -0,0 +1,123 @@ +Discord und Telegram
Diese Seite ist nur für Administratoren zugänglich.
'; return; } +$message = null; +$error = null; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verify_csrf(); + if (!$isAdmin) { + $error = 'Einstellungen ändern darf nur ein Administrator.'; + } else { + $action = $_POST['action'] ?? ''; + if ($action === 'save') { + $key = strtolower(trim((string) ($_POST['discord_key'] ?? ''))); + if ($key !== '' && !preg_match('/^[0-9a-f]{64}$/', $key)) { + $error = 'Der öffentliche Schlüssel von Discord besteht aus 64 Zeichen (0–9, a–f).'; + } else { + plugin_set_setting(COMMUNITYBOT, 'discord_key', $key); + plugin_set_setting(COMMUNITYBOT, 'discord_app', preg_replace('/[^0-9]/', '', (string) ($_POST['discord_app'] ?? ''))); + // Geheimnisse: leeres Feld lässt den bisherigen Wert stehen + foreach (['discord_token', 'telegram_token'] as $k) { + $v = trim((string) ($_POST[$k] ?? '')); + if ($v !== '') communitybot_set_secret($k, $v); + } + if (!empty($_POST['clear_discord'])) communitybot_set_secret('discord_token', ''); + if (!empty($_POST['clear_telegram'])) { communitybot_set_secret('telegram_token', ''); communitybot_set_secret('telegram_secret', ''); } + plugin_set_setting(COMMUNITYBOT, 'department_id', (string) (int) ($_POST['department_id'] ?? 0)); + plugin_set_setting(COMMUNITYBOT, 'priority_id', (string) (int) ($_POST['priority_id'] ?? 0)); + plugin_set_setting(COMMUNITYBOT, 'limit', (string) max(1, min(50, (int) ($_POST['limit'] ?? 3)))); + plugin_set_setting(COMMUNITYBOT, 'notify', !empty($_POST['notify']) ? '1' : '0'); + $message = 'Einstellungen gespeichert.'; + } + } elseif ($action === 'discord_register') { + $r = communitybot_discord_register(); + if ($r['ok']) $message = $r['message']; else $error = $r['message']; + } elseif ($action === 'telegram_register') { + $r = communitybot_telegram_register(); + if ($r['ok']) $message = $r['message']; else $error = $r['message']; + } + } +} + +$cfg = communitybot_config(); +$depts = $pdo->query('SELECT id, name FROM departments ORDER BY name')->fetchAll(); +$prios = $pdo->query('SELECT id, name FROM priorities ORDER BY sort_order')->fetchAll(); +$recent = $pdo->query('SELECT l.*, t.ticket_number, t.subject, t.status FROM plugin_communitybot_links l JOIN tickets t ON t.id = l.ticket_id ORDER BY l.id DESC LIMIT 30')->fetchAll(); +$week = (int) $pdo->query("SELECT COUNT(*) FROM plugin_communitybot_links WHERE created_at >= '" . date('Y-m-d H:i:s', time() - 7 * 86400) . "'")->fetchColumn(); +?> +

Discord und Telegram

+
+
+ +
+

Zustand

+

Tickets in den letzten 7 Tagen über Discord oder Telegram angelegt.

+

+ Discord: Schlüssel eingetragen' : 'Schlüssel fehlt') ?> + · Benachrichtigung per Direktnachricht: Bot-Token fehlt' ?>
+ Telegram: Token eingetragen' : 'Token fehlt' ?> +
Auf diesem Server fehlt die PHP-Erweiterung „sodium“, Discord-Anfragen können nicht geprüft werden. +

+

Adressen für die Einrichtung (nur aus dem Internet erreichbar, mit https):
+ Discord „Interactions Endpoint URL“:
+ Telegram wird mit dem Knopf unten automatisch verbunden.

+
+ + +
+ + +

Einstellungen

+

Discord

+

Im Discord Developer Portal eine Anwendung anlegen. „General Information“ liefert die Anwendungs-ID und den öffentlichen Schlüssel (Public Key), „Bot“ das Token. Als „Interactions Endpoint URL“ die Adresse oben eintragen.

+ + + + + + +

Telegram

+

Bei @BotFather mit /newbot einen Bot anlegen und das Token hier eintragen.

+ + + + +

Neue Tickets

+
+
+
+
+
+ +
+
+ +
+

Verbinden

+

Erst speichern, dann verbinden.

+
+
+
+ + +
+

Zuletzt angelegt

+

Noch keine Tickets über Discord oder Telegram.

+ + + + + + + + + + + + + +
TicketKanalAbsenderStatusAngelegt
#
+ +
diff --git a/Plugins/plugins/community-bot/plugin.json b/Plugins/plugins/community-bot/plugin.json new file mode 100644 index 00000000..0e693871 --- /dev/null +++ b/Plugins/plugins/community-bot/plugin.json @@ -0,0 +1,9 @@ +{ + "slug": "community-bot", + "name": "Discord- und Telegram-Bot", + "version": "1.0.1", + "description": "Mitglieder legen per /ticket im Discord oder in Telegram ein Ticket an, bekommen die Ticketnummer zurück und werden bei Antworten und bei Lösung benachrichtigt. Der Server nimmt die Anfragen direkt entgegen, ein eigener Bot-Server ist nicht nötig. Weitere Befehle: /status und /antwort.", + "author": "DeskHub", + "requires": "1.0.30", + "permissions": ["api.public", "hooks.tickets", "ui.menu", "db.migrations"] +} diff --git a/Plugins/plugins/community-bot/plugin.php b/Plugins/plugins/community-bot/plugin.php new file mode 100644 index 00000000..004bb4d3 --- /dev/null +++ b/Plugins/plugins/community-bot/plugin.php @@ -0,0 +1,19 @@ + communitybot_discord($req)); +plugin_register_webhook('telegram', fn(array $req) => communitybot_telegram($req)); + +// Mitarbeiter-Antwort (nicht intern) → Mitteilung an den Absender +plugin_add_hook('ticket.replied', function (array $payload) { + if (($payload['author'] ?? '') === 'staff' && empty($payload['internal']) && !empty($payload['ticketId'])) { + communitybot_notify_reply(get_db(), (int) $payload['ticketId']); + } +}); +plugin_add_hook('ticket.status_changed', function (array $payload) { + if (!empty($payload['ticketId'])) communitybot_notify_status(get_db(), (int) $payload['ticketId']); +}); + +// Im Adminportal, nur für Administratoren (die Seite prüft das zusätzlich selbst) +plugin_register_menu('Discord/Telegram', 'community-bot', null, 'admin'); diff --git a/Plugins/plugins/datenschutz-1.2.0.zip b/Plugins/plugins/datenschutz-1.2.0.zip new file mode 100644 index 00000000..9066233d Binary files /dev/null and b/Plugins/plugins/datenschutz-1.2.0.zip differ diff --git a/Plugins/plugins/datenschutz/lib.php b/Plugins/plugins/datenschutz/lib.php new file mode 100644 index 00000000..9aec7eed --- /dev/null +++ b/Plugins/plugins/datenschutz/lib.php @@ -0,0 +1,280 @@ + Beschriftung). */ +/** + * Selbstheilung: Fehlt nach einem Update eine Datenbank-Änderung des Plugins (ältere DeskHub-Dateien spielen sie nicht von selbst ein), + * wird sie hier nachgeholt, statt dass das Plugin mit Datenbankfehlern abgeschaltet wird. + */ +function datenschutz_ensure_schema(bool $force = false): void +{ + static $done = false; + if ($done && !$force) return; + $done = true; + try { + get_db()->query('SELECT ticket_id FROM plugin_datenschutz_done LIMIT 1'); + } catch (Throwable $e) { + try { + plugin_run_migrations(get_db(), DATENSCHUTZ); + } catch (Throwable $e2) { + error_log('datenschutz schema: ' . $e2->getMessage()); + } + } +} + +function datenschutz_field_labels(): array +{ + return [ + 'name' => 'Name', + 'email' => 'E-Mail-Adresse', + 'ip' => 'IP-Adresse', + 'collaborators' => 'Mitleser (Adressen in Kopie)', + 'fieldvalues' => 'Zusatzfelder (vom Kunden ausgefüllt)', + 'tokens' => 'Zugangslinks zum Ticket', + 'rating' => 'Kommentar der Bewertung', + 'messages' => 'Nachrichten des Kunden (Text)', + 'attachments' => 'Anhänge des Kunden (Dateien)', + ]; +} + +/** Auswahl-Vorlagen für die Einstellungen. @return array}> */ +function datenschutz_presets(): array +{ + return [ + 'standard' => ['Stammdaten (Name, E-Mail, IP, Mitleser, Zusatzfelder, Links)', ['name', 'email', 'ip', 'collaborators', 'fieldvalues', 'tokens', 'rating']], + 'full' => ['Komplett (zusätzlich Nachrichten und Anhänge)', array_keys(datenschutz_field_labels())], + 'name' => ['Nur der Name', ['name']], + 'email' => ['Nur die E-Mail-Adresse', ['email']], + 'name_email' => ['Name und E-Mail-Adresse', ['name', 'email']], + ]; +} + +function datenschutz_units(): array +{ + return ['days' => 'Tage', 'weeks' => 'Wochen', 'months' => 'Monate', 'years' => 'Jahre']; +} + +/** Zeitpunkt, vor dem Tickets als „fällig“ gelten. */ +function datenschutz_cutoff(array $cfg): string +{ + return date('Y-m-d H:i:s', (int) strtotime('-' . (int) $cfg['amount'] . ' ' . $cfg['unit'])); +} + +/** Adresse eines Reiters der Seite. */ +function datenschutz_tab_url(string $tab, array $extra = []): string +{ + $q = ['p' => DATENSCHUTZ, 'page' => 'datenschutz', 'tab' => $tab] + array_filter($extra, fn($v) => $v !== '' && $v !== null && $v !== 1); + return '/staff/plugin.php?' . http_build_query($q); +} + +/** Reiterleiste. @param array $tabs Schlüssel => Beschriftung */ +function datenschutz_tabs(string $active, array $tabs): string +{ + $html = '
'; + foreach ($tabs as $key => $label) { + $on = $key === $active; + $html .= '' + . htmlspecialchars($label) . ''; + } + return $html . '
'; +} + +/** „30 Tage“, „1 Monat“ usw. für Texte. */ +function datenschutz_frist_label(array $cfg): string +{ + $one = ['days' => 'Tag', 'weeks' => 'Woche', 'months' => 'Monat', 'years' => 'Jahr']; + $many = datenschutz_units(); + return (int) $cfg['amount'] . ' ' . ((int) $cfg['amount'] === 1 ? $one[$cfg['unit']] : $many[$cfg['unit']]); +} + +function datenschutz_config(): array +{ + $labels = datenschutz_field_labels(); + $raw = plugin_setting(DATENSCHUTZ, 'fields', null); + $fields = $raw === null ? datenschutz_presets()['standard'][1] : array_values(array_intersect(array_keys($labels), array_filter(explode(',', (string) $raw)))); + $unit = (string) plugin_setting(DATENSCHUTZ, 'unit', 'days'); + $unit = isset(datenschutz_units()[$unit]) ? $unit : 'days'; + $max = ['days' => 3650, 'weeks' => 520, 'months' => 120, 'years' => 10][$unit]; + return [ + 'enabled' => plugin_setting(DATENSCHUTZ, 'enabled', '0') === '1', + 'amount' => max(1, min($max, (int) plugin_setting(DATENSCHUTZ, 'amount', '30'))), + 'unit' => $unit, + 'basis' => plugin_setting(DATENSCHUTZ, 'basis', 'closed') === 'created' ? 'created' : 'closed', + 'include_open' => plugin_setting(DATENSCHUTZ, 'include_open', '0') === '1', + 'fields' => $fields, + ]; +} +/** Nicht rückrechenbare Ersatzadresse (Zufallswert, keine Ableitung aus der echten Adresse). */ +function datenschutz_anon_email(): string +{ + return 'anon_' . bin2hex(random_bytes(10)) . '@deleted.local'; +} + +/** Adresse für das Protokoll: nur Anfangsbuchstaben, damit das Protokoll selbst keine Kundendaten enthält. */ +function datenschutz_mask(string $email): string +{ + [$local, $domain] = array_pad(explode('@', $email, 2), 2, ''); + $dom = $domain !== '' ? mb_substr($domain, 0, 1) . '***' . (str_contains($domain, '.') ? substr($domain, strrpos($domain, '.')) : '') : ''; + return mb_substr($local, 0, 1) . '***' . ($dom !== '' ? '@' . $dom : ''); +} + +function datenschutz_log(PDO $pdo, string $kind, int $tickets, int $accounts, string $note, ?int $staffId = null): void +{ + $pdo->prepare('INSERT INTO plugin_datenschutz_log (kind, tickets, accounts, note, staff_id, created_at) VALUES (?,?,?,?,?,?)') + ->execute([$kind, $tickets, $accounts, mb_substr($note, 0, 250), $staffId, db_now()]); +} + +/** WHERE-Teil für Tickets ohne Registrierung, die anonymisiert werden sollen. @return array{0:string,1:array} */ +function datenschutz_candidate_sql(array $cfg): array +{ + $cut = datenschutz_cutoff($cfg); + $closed = "status IN ('closed','resolved')"; + $notDone = "requester_email NOT LIKE 'anon!_%' ESCAPE '!' AND id NOT IN (SELECT ticket_id FROM plugin_datenschutz_done)"; + if ($cfg['basis'] === 'closed') { + $sql = "(({$closed} AND COALESCE(closed_at, resolved_at, updated_at) < ?)" . ($cfg['include_open'] ? " OR (status NOT IN ('closed','resolved') AND updated_at < ?)" : '') . ')'; + $params = $cfg['include_open'] ? [$cut, $cut] : [$cut]; + } else { + $sql = "(({$closed} AND created_at < ?)" . ($cfg['include_open'] ? " OR (status NOT IN ('closed','resolved') AND created_at < ? AND updated_at < ?)" : '') . ')'; + $params = $cfg['include_open'] ? [$cut, $cut, $cut] : [$cut]; + } + return ["user_id IS NULL AND {$notDone} AND {$sql}", $params]; +} + +function datenschutz_candidate_count(PDO $pdo): int +{ + [$where, $params] = datenschutz_candidate_sql(datenschutz_config()); + $s = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE {$where}"); + $s->execute($params); + return (int) $s->fetchColumn(); +} + +/** Entfernt die gewählten Kundendaten eines Tickets unwiderruflich. */ +function datenschutz_anonymize_ticket(PDO $pdo, int $ticketId, array $cfg): void +{ + $f = $cfg['fields']; + $sets = []; + $params = []; + if (in_array('name', $f, true)) $sets[] = "requester_name = 'Anonymisiert'"; + if (in_array('email', $f, true)) { $sets[] = 'requester_email = ?'; $params[] = datenschutz_anon_email(); } + if (in_array('ip', $f, true)) $sets[] = "requester_ip = '0.0.0.0'"; + if ($sets) $pdo->prepare('UPDATE tickets SET ' . implode(', ', $sets) . ' WHERE id = ?')->execute(array_merge($params, [$ticketId])); + if (in_array('collaborators', $f, true)) $pdo->prepare('DELETE FROM ticket_collaborators WHERE ticket_id = ?')->execute([$ticketId]); + if (in_array('tokens', $f, true)) $pdo->prepare('DELETE FROM ticket_access_tokens WHERE ticket_id = ?')->execute([$ticketId]); + if (in_array('fieldvalues', $f, true)) $pdo->prepare('UPDATE ticket_field_values SET value = NULL WHERE ticket_id = ?')->execute([$ticketId]); + if (in_array('rating', $f, true)) $pdo->prepare('UPDATE ticket_ratings SET comment = NULL, user_id = NULL WHERE ticket_id = ?')->execute([$ticketId]); + if (in_array('email', $f, true) || in_array('name', $f, true)) { + try { $pdo->prepare('DELETE FROM plugin_communitybot_links WHERE ticket_id = ?')->execute([$ticketId]); } catch (Throwable $e) { /* Plugin nicht installiert */ } + } + if (in_array('messages', $f, true)) { + $pdo->prepare("UPDATE ticket_threads SET message = ? WHERE ticket_id = ? AND author_type = 'customer'")->execute([DATENSCHUTZ_TEXT_REMOVED, $ticketId]); + } + if (in_array('attachments', $f, true)) { + $dir = realpath(rtrim((string) (config()['uploads']['path'] ?? dirname(__DIR__, 2) . '/uploads'), '/\\')); + $q = $pdo->prepare("SELECT a.id, a.stored_name FROM ticket_attachments a JOIN ticket_threads t ON t.id = a.thread_id WHERE t.ticket_id = ? AND t.author_type = 'customer'"); + $q->execute([$ticketId]); + foreach ($q->fetchAll() as $a) { + if ($dir && !in_array($a['stored_name'], ['gesperrt', 'geloescht'], true)) { + foreach ([$dir . '/' . basename((string) $a['stored_name']), $dir . '/quarantaene/' . basename((string) $a['stored_name'])] as $file) if (is_file($file)) @unlink($file); + } + $pdo->prepare("UPDATE ticket_attachments SET original_name = '[Anhang aus Datenschutzgründen gelöscht]', stored_name = 'geloescht' WHERE id = ?")->execute([$a['id']]); + } + } + $has = $pdo->prepare('SELECT COUNT(*) FROM plugin_datenschutz_done WHERE ticket_id = ?'); + $has->execute([$ticketId]); + if (!(int) $has->fetchColumn()) $pdo->prepare('INSERT INTO plugin_datenschutz_done (ticket_id, done_at) VALUES (?,?)')->execute([$ticketId, db_now()]); + $labels = datenschutz_field_labels(); + log_activity($pdo, $ticketId, null, 'Kundendaten aus Datenschutzgründen anonymisiert (' . implode(', ', array_map(fn($k) => $labels[$k], $f)) . ')'); +} + +/** Anonymisiert fällige Tickets ohne Registrierung. @return int Zahl der Tickets */ +function datenschutz_run(PDO $pdo, int $limit = 200, string $kind = 'auto', ?int $staffId = null): int +{ + $cfg = datenschutz_config(); + if (!$cfg['enabled'] || !$cfg['fields']) return 0; + [$where, $params] = datenschutz_candidate_sql($cfg); + $s = $pdo->prepare("SELECT id FROM tickets WHERE {$where} ORDER BY id LIMIT " . max(1, $limit)); + $s->execute($params); + $n = 0; + foreach ($s->fetchAll(PDO::FETCH_COLUMN) as $id) { + try { + datenschutz_anonymize_ticket($pdo, (int) $id, $cfg); + $n++; + } catch (Throwable $e) { + error_log('datenschutz ticket ' . $id . ': ' . $e->getMessage()); + } + } + plugin_set_setting(DATENSCHUTZ, 'last_run', db_now()); + if ($n > 0) datenschutz_log($pdo, $kind, $n, 0, 'Tickets ohne Registrierung nach ' . datenschutz_frist_label($cfg) . ' anonymisiert (' . count($cfg['fields']) . ' Datenarten)', $staffId); + return $n; +} + +/** Hintergrundlauf: höchstens einmal pro Stunde. */ +function datenschutz_tick(PDO $pdo): int +{ + if (!datenschutz_config()['enabled']) return 0; + if (time() - (int) plugin_setting(DATENSCHUTZ, 'last_tick', '0') < 3600) return 0; + plugin_set_setting(DATENSCHUTZ, 'last_tick', (string) time()); + return datenschutz_run($pdo, 200, 'auto'); +} +/** Registrierten Kunden suchen. @return array{user:array,tickets:int,open:int,already:bool}|null */ +function datenschutz_find_customer(PDO $pdo, string $email): ?array +{ + $email = strtolower(trim($email)); + if ($email === '') return null; + $s = $pdo->prepare('SELECT id, name, email, organization_id FROM users WHERE LOWER(email) = ?'); + $s->execute([$email]); + $u = $s->fetch(); + if (!$u) return null; + $c = $pdo->prepare("SELECT COUNT(*), SUM(CASE WHEN status NOT IN ('closed','resolved') THEN 1 ELSE 0 END) FROM tickets WHERE user_id = ? OR LOWER(requester_email) = ?"); + $c->execute([(int) $u['id'], $email]); + [$total, $open] = array_map('intval', array_values($c->fetch(PDO::FETCH_NUM))); + return ['user' => $u, 'tickets' => $total, 'open' => $open, 'already' => str_starts_with((string) $u['email'], 'anon_')]; +} + +/** Löscht die Daten eines registrierten Kunden auf dessen Antrag unwiderruflich. @return array{ok:bool,error?:string,tickets?:int} */ +function datenschutz_delete_customer(PDO $pdo, array $staff, int $userId, bool $closeOpen): array +{ + if (($staff['role'] ?? '') !== 'admin') return ['ok' => false, 'error' => 'Das darf nur ein Administrator.']; + $s = $pdo->prepare('SELECT * FROM users WHERE id = ?'); + $s->execute([$userId]); + $u = $s->fetch(); + if (!$u) return ['ok' => false, 'error' => 'Der Kunde wurde nicht gefunden.']; + if (str_starts_with((string) $u['email'], 'anon_')) return ['ok' => false, 'error' => 'Dieser Kunde ist bereits anonymisiert.']; + $email = strtolower((string) $u['email']); + $t = $pdo->prepare('SELECT id, status FROM tickets WHERE user_id = ? OR LOWER(requester_email) = ?'); + $t->execute([$userId, $email]); + $tickets = $t->fetchAll(); + $open = array_filter($tickets, fn($x) => !in_array($x['status'], ['closed', 'resolved'], true)); + if ($open && !$closeOpen) return ['ok' => false, 'error' => 'Der Kunde hat noch ' . count($open) . ' offene Tickets. Bitte die Option „Offene Tickets schließen“ wählen oder die Tickets zuerst bearbeiten.']; + $cfg = datenschutz_config(); + // Bei einer Löschung auf Antrag werden Nachrichten und Anhänge des Kunden immer mit entfernt: er hat die Löschung seiner Daten verlangt + $cfg['fields'] = array_keys(datenschutz_field_labels()); + $now = db_now(); + foreach ($tickets as $row) { + if (in_array($row['id'], array_column($open, 'id'), true)) { + $pdo->prepare("UPDATE tickets SET status = 'closed', closed_at = ?, updated_at = ? WHERE id = ?")->execute([$now, $now, $row['id']]); + log_activity($pdo, (int) $row['id'], null, 'Ticket auf Antrag des Kunden auf Löschung geschlossen'); + } + datenschutz_anonymize_ticket($pdo, (int) $row['id'], $cfg); + $pdo->prepare('UPDATE tickets SET user_id = NULL WHERE id = ?')->execute([$row['id']]); + } + $pdo->prepare('UPDATE ticket_threads SET user_id = NULL WHERE user_id = ?')->execute([$userId]); + $pdo->prepare('DELETE FROM customer_notes WHERE user_id = ?')->execute([$userId]); + try { $pdo->prepare('DELETE FROM password_resets WHERE LOWER(email) = ?')->execute([$email]); } catch (Throwable $e) {} + $pdo->prepare("UPDATE users SET name = 'Anonymisiert', email = ?, phone = NULL, organization_id = NULL, password_hash = ? WHERE id = ?") + ->execute([datenschutz_anon_email(), password_hash(bin2hex(random_bytes(24)), PASSWORD_DEFAULT), $userId]); + datenschutz_log($pdo, 'request', count($tickets), 1, 'Löschung auf Antrag: ' . datenschutz_mask($email), (int) $staff['id']); + return ['ok' => true, 'tickets' => count($tickets)]; +} + +plugin_add_hook('plugin.uninstall.' . DATENSCHUTZ, function () { + try { + get_db()->exec('DROP TABLE IF EXISTS plugin_datenschutz_log'); + get_db()->exec('DROP TABLE IF EXISTS plugin_datenschutz_done'); + } catch (Throwable $e) { + } +}); diff --git a/Plugins/plugins/datenschutz/migrations/001_protokoll.mysql.sql b/Plugins/plugins/datenschutz/migrations/001_protokoll.mysql.sql new file mode 100644 index 00000000..e5c7d032 --- /dev/null +++ b/Plugins/plugins/datenschutz/migrations/001_protokoll.mysql.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS plugin_datenschutz_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + kind VARCHAR(12) NOT NULL, + tickets INT NOT NULL DEFAULT 0, + accounts INT NOT NULL DEFAULT 0, + note VARCHAR(250) NOT NULL DEFAULT '', + staff_id INT NULL, + created_at DATETIME NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 diff --git a/Plugins/plugins/datenschutz/migrations/001_protokoll.sqlite.sql b/Plugins/plugins/datenschutz/migrations/001_protokoll.sqlite.sql new file mode 100644 index 00000000..5741a2d7 --- /dev/null +++ b/Plugins/plugins/datenschutz/migrations/001_protokoll.sqlite.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS plugin_datenschutz_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + tickets INTEGER NOT NULL DEFAULT 0, + accounts INTEGER NOT NULL DEFAULT 0, + note TEXT NOT NULL DEFAULT '', + staff_id INTEGER NULL, + created_at TEXT NOT NULL +) diff --git a/Plugins/plugins/datenschutz/migrations/002_erledigt.mysql.sql b/Plugins/plugins/datenschutz/migrations/002_erledigt.mysql.sql new file mode 100644 index 00000000..4a5ce026 --- /dev/null +++ b/Plugins/plugins/datenschutz/migrations/002_erledigt.mysql.sql @@ -0,0 +1,4 @@ +CREATE TABLE IF NOT EXISTS plugin_datenschutz_done ( + ticket_id INT PRIMARY KEY, + done_at DATETIME NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 diff --git a/Plugins/plugins/datenschutz/migrations/002_erledigt.sqlite.sql b/Plugins/plugins/datenschutz/migrations/002_erledigt.sqlite.sql new file mode 100644 index 00000000..21f2bbfb --- /dev/null +++ b/Plugins/plugins/datenschutz/migrations/002_erledigt.sqlite.sql @@ -0,0 +1,4 @@ +CREATE TABLE IF NOT EXISTS plugin_datenschutz_done ( + ticket_id INTEGER PRIMARY KEY, + done_at TEXT NOT NULL +) diff --git a/Plugins/plugins/datenschutz/pages/datenschutz.php b/Plugins/plugins/datenschutz/pages/datenschutz.php new file mode 100644 index 00000000..a141ac93 --- /dev/null +++ b/Plugins/plugins/datenschutz/pages/datenschutz.php @@ -0,0 +1,179 @@ +Datenschutz
Diese Seite ist nur für Administratoren zugänglich.
'; return; } +$message = null; +$error = null; +$found = null; +$searched = ''; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verify_csrf(); + $action = $_POST['action'] ?? ''; + if ($action === 'settings') { + $unit = isset(datenschutz_units()[$_POST['unit'] ?? '']) ? $_POST['unit'] : 'days'; + $picked = array_values(array_intersect(array_keys(datenschutz_field_labels()), (array) ($_POST['fields'] ?? []))); + if (!$picked) { + $error = 'Bitte mindestens eine Datenart zum Anonymisieren wählen.'; + } else { + plugin_set_setting(DATENSCHUTZ, 'amount', (string) max(1, (int) ($_POST['amount'] ?? 30))); + plugin_set_setting(DATENSCHUTZ, 'unit', $unit); + plugin_set_setting(DATENSCHUTZ, 'basis', ($_POST['basis'] ?? '') === 'created' ? 'created' : 'closed'); + plugin_set_setting(DATENSCHUTZ, 'include_open', !empty($_POST['include_open']) ? '1' : '0'); + plugin_set_setting(DATENSCHUTZ, 'fields', implode(',', $picked)); + $message = 'Einstellungen gespeichert.'; + } + } elseif ($action === 'enable') { + if (empty($_POST['confirm'])) $error = 'Bitte bestätigen, dass Sie die unwiderrufliche Anonymisierung einschalten wollen.'; + else { plugin_set_setting(DATENSCHUTZ, 'enabled', '1'); datenschutz_log($pdo, 'manual', 0, 0, 'Automatische Anonymisierung eingeschaltet', (int) $staff['id']); $message = 'Die automatische Anonymisierung ist eingeschaltet. Sie läuft ab jetzt im Hintergrund.'; } + } elseif ($action === 'disable') { + plugin_set_setting(DATENSCHUTZ, 'enabled', '0'); + datenschutz_log($pdo, 'manual', 0, 0, 'Automatische Anonymisierung ausgeschaltet', (int) $staff['id']); + $message = 'Die automatische Anonymisierung ist ausgeschaltet.'; + } elseif ($action === 'run') { + $n = datenschutz_run($pdo, 500, 'manual', (int) $staff['id']); + $message = $n . ' Ticket(s) anonymisiert.'; + } elseif ($action === 'core_off') { + set_setting('retention_enabled', '0'); + audit_log($staff['id'], 'Automatische Datenlöschung des Kerns ausgeschaltet (Plugin Datenschutz übernimmt)'); + $message = 'Die automatische Datenlöschung des Kerns ist ausgeschaltet.'; + } elseif ($action === 'find') { + $searched = trim((string) ($_POST['email'] ?? '')); + $found = datenschutz_find_customer($pdo, $searched); + if (!$found) $error = 'Unter dieser Adresse gibt es keinen registrierten Kunden.'; + } elseif ($action === 'delete_customer') { + if (empty($_POST['confirm'])) $error = 'Bitte die Löschung bestätigen.'; + else { + $r = datenschutz_delete_customer($pdo, $staff, (int) ($_POST['user_id'] ?? 0), !empty($_POST['close_open'])); + if ($r['ok']) $message = 'Die Daten des Kunden wurden unwiderruflich gelöscht (' . $r['tickets'] . ' Ticket(s) anonymisiert, Konto anonymisiert, Nachrichten und Anhänge entfernt).'; + else { $error = $r['error']; $searched = trim((string) ($_POST['email'] ?? '')); $found = datenschutz_find_customer($pdo, $searched); } + } + } +} + +$cfg = datenschutz_config(); +$count = datenschutz_candidate_count($pdo); +$tabs = ['uebersicht' => 'Übersicht', 'einstellungen' => 'Einstellungen', 'antrag' => 'Löschung auf Antrag', 'protokoll' => 'Protokoll']; +$tab = (string) ($_GET['tab'] ?? 'uebersicht'); +if (!isset($tabs[$tab])) $tab = 'uebersicht'; +// Nach einer Suche bzw. Löschung bleibt man im Reiter „Löschung auf Antrag“ +if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array($_POST['action'] ?? '', ['find', 'delete_customer'], true)) $tab = 'antrag'; +$log = []; +$logTotal = 0; +$perPage = 50; +$seite = 1; +if ($tab === 'protokoll') { + $logTotal = (int) $pdo->query('SELECT COUNT(*) FROM plugin_datenschutz_log')->fetchColumn(); + $pages = max(1, (int) ceil($logTotal / $perPage)); + $seite = min($pages, max(1, (int) ($_GET['seite'] ?? 1))); + $log = $pdo->query('SELECT l.*, s.name AS staff_name FROM plugin_datenschutz_log l LEFT JOIN staff s ON s.id = l.staff_id ORDER BY l.id DESC LIMIT ' . $perPage . ' OFFSET ' . (($seite - 1) * $perPage))->fetchAll(); +} +$coreOn = get_setting('retention_enabled', '0') === '1'; +$kinds = ['auto' => 'Automatisch', 'manual' => 'Von Hand', 'request' => 'Löschung auf Antrag']; +?> +

Datenschutz

+
+
+ + + + +
+ Die automatische Datenlöschung von DeskHub (Einstellungen → Datenschutz) ist eingeschaltet. Sie anonymisiert nach ihrer Frist auch registrierte Kunden. Das widerspricht der Regel „Daten registrierter Kunden bleiben bis zur Löschung auf Antrag“. Schalten Sie sie aus, dieses Plugin übernimmt die Aufgabe. +
+
+ + + +
+

Regeln

+
    +
  • Tickets ohne Registrierung: Nach werden unwiderruflich entfernt: datenschutz_field_labels()[$k], $cfg['fields']))) ?>. Eine Wiederherstellung ist nicht möglich.
  • +
  • Registrierte Kunden: Die Daten bleiben gespeichert, bis der Kunde die Löschung beantragt. Sie führen die Löschung unten aus.
  • +
+

+ ✓ Automatische Anonymisierung ist eingeschaltet. Zurzeit fällig: Ticket(s). Letzter Lauf: . + Die automatische Anonymisierung ist noch ausgeschaltet. Beim Einschalten würden sofort vorhandene Ticket(s) anonymisiert. +

+ +
+
+ +
+ +
+ +
+ + + + +
+ + +

Einstellungen (nur Administratoren)

+ +
+ + + +
+ + + +
+ [$plabel, $pfields]): ?> +
+ $flabel): ?> + + +

Nachrichten können Namen, Adressen oder Telefonnummern im Text enthalten; Antworten der Mitarbeiter bleiben immer erhalten. Die Auswahl gilt für Tickets, die ab jetzt anonymisiert werden. Bereits bearbeitete Tickets werden nicht noch einmal angefasst.

+ +
+
+ + + +
+

Löschung auf Antrag eines registrierten Kunden

+

Der Kunde hat die Löschung seiner Daten verlangt. Das Konto wird anonymisiert und kann sich nicht mehr anmelden. Seine Tickets verlieren Name, Adresse, Nachrichten und Anhänge. Das ist nicht rückgängig zu machen.

+
+ +
+ +

()
Ticket(s), davon offen.

+ + +
+
+

Dieser Kunde ist bereits anonymisiert.

+
+ + + +
+

Protokoll

+

Noch keine Einträge. Das Protokoll enthält keine Kundendaten, bei Löschanträgen nur eine verkürzte Adresse.

+ + + + +
WannArtTicketsHinweisVon
+ 1): ?> +

+ 1): ?>← Neuere · + Seite von + · Ältere → +

+ + +
+ diff --git a/Plugins/plugins/datenschutz/plugin.json b/Plugins/plugins/datenschutz/plugin.json new file mode 100644 index 00000000..ef42f0d0 --- /dev/null +++ b/Plugins/plugins/datenschutz/plugin.json @@ -0,0 +1,9 @@ +{ + "slug": "datenschutz", + "name": "Datenschutz (Anonymisierung und Löschung)", + "version": "1.2.0", + "description": "Kundendaten aus Tickets ohne Registrierung werden nach einer frei wählbaren Frist (Tage, Wochen, Monate, Jahre; Standard 30 Tage) unwiderruflich anonymisiert. Sie wählen selbst, was: nur Name, nur E-Mail-Adresse, beides oder komplett inklusive IP, Mitleser, Zusatzfelder, Nachrichten und Anhänge. Daten registrierter Kunden bleiben, bis der Kunde die Löschung beantragt; der Administrator führt sie mit einem Klick aus. Alle Einstellungen nur für Administratoren.", + "author": "DeskHub", + "requires": "1.0.0", + "permissions": ["hooks.cron", "ui.menu", "db.migrations"] +} diff --git a/Plugins/plugins/datenschutz/plugin.php b/Plugins/plugins/datenschutz/plugin.php new file mode 100644 index 00000000..494ce7b3 --- /dev/null +++ b/Plugins/plugins/datenschutz/plugin.php @@ -0,0 +1,11 @@ + diff --git a/Plugins/plugins/lang-de/plugin.json b/Plugins/plugins/lang-de/plugin.json new file mode 100644 index 00000000..5a5fced4 --- /dev/null +++ b/Plugins/plugins/lang-de/plugin.json @@ -0,0 +1,17 @@ +{ + "slug": "lang-de", + "name": "Deutsch (Sprachpaket)", + "version": "1.0.0", + "description": "Deutsch ist die Quellsprache von DeskHub und immer eingebaut. Dieses Paket macht Deutsch im Sprachwechsler sichtbar und dient als Vorlage für eigene Sprachpakete (plugin.json, plugin.php, flag.svg, strings.json).", + "author": "DeskHub", + "entry": "plugin.php", + "permissions": [], + "language": { + "code": "de", + "name": "Deutsch", + "flag": "flag.svg", + "strings": "strings.json", + "htmlLang": "de" + }, + "requires": "1.0.35" +} \ No newline at end of file diff --git a/Plugins/plugins/lang-de/plugin.php b/Plugins/plugins/lang-de/plugin.php new file mode 100644 index 00000000..1e6d704f --- /dev/null +++ b/Plugins/plugins/lang-de/plugin.php @@ -0,0 +1,2 @@ + diff --git a/Plugins/plugins/lang-en/plugin.json b/Plugins/plugins/lang-en/plugin.json new file mode 100644 index 00000000..414c45d4 --- /dev/null +++ b/Plugins/plugins/lang-en/plugin.json @@ -0,0 +1,17 @@ +{ + "slug": "lang-en", + "name": "English (Sprachpaket)", + "version": "1.0.1", + "description": "Englische Oberfläche für Kundenportal und Mitarbeiterbereich. Nach der Aktivierung erscheint die britische Flagge zum Umschalten. Nicht übersetzte Texte bleiben deutsch.", + "author": "DeskHub", + "entry": "plugin.php", + "permissions": [], + "language": { + "code": "en", + "name": "English", + "flag": "flag.svg", + "strings": "strings.json", + "htmlLang": "en" + }, + "requires": "1.0.35" +} \ No newline at end of file diff --git a/Plugins/plugins/lang-en/plugin.php b/Plugins/plugins/lang-en/plugin.php new file mode 100644 index 00000000..64b90daf --- /dev/null +++ b/Plugins/plugins/lang-en/plugin.php @@ -0,0 +1,2 @@ + */ +function rundmail_presets(): array +{ + return [ + 'wartung' => ['Geplante Wartung', "Guten Tag {name},\n\nwir führen am [Datum] von [Uhrzeit] bis [Uhrzeit] eine Wartung durch. In dieser Zeit ist unser Dienst möglicherweise nicht erreichbar.\n\nIhr offenes Ticket bleibt davon unberührt, wir melden uns danach bei Ihnen.\n\nVielen Dank für Ihr Verständnis."], + 'stoerung' => ['Aktuelle Störung', "Guten Tag {name},\n\nzurzeit gibt es eine Störung: [Beschreibung]. Wir arbeiten an der Behebung und halten Sie auf dem Laufenden.\n\nIhr offenes Ticket ist dadurch möglicherweise ebenfalls betroffen. Sie müssen nichts weiter tun.\n\nVielen Dank für Ihre Geduld."], + 'behoben' => ['Störung behoben', "Guten Tag {name},\n\ndie Störung ist behoben. Alles läuft wieder wie gewohnt.\n\nSollte bei Ihnen noch etwas nicht funktionieren, antworten Sie einfach auf Ihr Ticket.\n\nVielen Dank für Ihre Geduld."], + ]; +} + +/** Gültige Adresse eines echten Kunden (keine Platzhalter, keine anonymisierten oder gesperrten Adressen). */ +function rundmail_usable_email(string $email): bool +{ + $email = strtolower(trim($email)); + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) return false; + if (str_ends_with($email, '.invalid') || str_ends_with($email, '@deleted.local') || str_starts_with($email, 'anon_')) return false; + if (function_exists('is_banned') && is_banned($email)) return false; + return true; +} + +/** Abteilungen, an deren Kunden der Mitarbeiter schreiben darf (null = alle). */ +function rundmail_allowed_departments(array $staff): ?array +{ + $ids = staff_department_ids($staff); + return $ids === null ? null : array_map('intval', $ids); +} + +/** Auswahl für das Formular. @return list */ +function rundmail_groups(PDO $pdo, array $staff): array +{ + $allowed = rundmail_allowed_departments($staff); + $out = [['key' => 'open', 'label' => 'Alle Kunden mit offenen Tickets' . ($allowed === null ? '' : ' (meiner Abteilungen)')]]; + foreach ($pdo->query('SELECT id, name FROM departments ORDER BY name')->fetchAll() as $d) { + if ($allowed !== null && !in_array((int) $d['id'], $allowed, true)) continue; + $out[] = ['key' => 'department:' . $d['id'], 'label' => 'Kunden mit offenen Tickets in Abteilung „' . $d['name'] . '“']; + } + foreach ($pdo->query('SELECT id, name FROM organizations ORDER BY name')->fetchAll() as $o) { + $out[] = ['key' => 'organization:' . $o['id'], 'label' => 'Organisation „' . $o['name'] . '“' . ($allowed === null ? ' (alle Mitglieder)' : ' (Mitglieder mit offenem Ticket)')]; + } + return $out; +} + +/** @return array{type:string,id:?int,label:string}|null */ +function rundmail_parse_group(PDO $pdo, string $key): ?array +{ + if ($key === 'open') return ['type' => 'open', 'id' => null, 'label' => 'Kunden mit offenen Tickets']; + if (!preg_match('/^(department|organization):(\d+)$/', $key, $m)) return null; + $table = $m[1] === 'department' ? 'departments' : 'organizations'; + $s = $pdo->prepare("SELECT name FROM {$table} WHERE id = ?"); + $s->execute([(int) $m[2]]); + $name = $s->fetchColumn(); + if ($name === false) return null; + return ['type' => $m[1], 'id' => (int) $m[2], 'label' => ($m[1] === 'department' ? 'Abteilung ' : 'Organisation ') . $name]; +} + +/** Empfänger der Gruppe, einmal je Adresse. @return list */ +function rundmail_recipients(PDO $pdo, array $staff, string $type, ?int $id): array +{ + $allowed = rundmail_allowed_departments($staff); + if ($type === 'department' && $allowed !== null && !in_array((int) $id, $allowed, true)) return []; + $open = "t.status NOT IN ('resolved','closed') AND t.is_spam = 0 AND t.merged_into IS NULL"; + $params = []; + $deptSql = ''; + if ($type === 'department') { $deptSql = ' AND t.department_id = ?'; $params[] = (int) $id; } + elseif ($allowed !== null) { + if (!$allowed) return []; + $deptSql = ' AND t.department_id IN (' . implode(',', array_fill(0, count($allowed), '?')) . ')'; + $params = array_merge($params, $allowed); + } + if ($type === 'organization') { + if ($allowed === null) { + $stmt = $pdo->prepare('SELECT email, name FROM users WHERE organization_id = ? ORDER BY id'); + $stmt->execute([(int) $id]); + } else { + $stmt = $pdo->prepare("SELECT DISTINCT u.email, u.name FROM users u JOIN tickets t ON t.user_id = u.id WHERE u.organization_id = ? AND {$open}{$deptSql} ORDER BY u.id"); + $stmt->execute(array_merge([(int) $id], $params)); + } + } else { + $stmt = $pdo->prepare("SELECT t.requester_email AS email, t.requester_name AS name FROM tickets t WHERE {$open}{$deptSql} ORDER BY t.id"); + $stmt->execute($params); + } + $seen = []; + $out = []; + foreach ($stmt->fetchAll() as $r) { + $mail = strtolower(trim((string) $r['email'])); + if (isset($seen[$mail]) || !rundmail_usable_email($mail)) continue; + $seen[$mail] = true; + $out[] = ['email' => $mail, 'name' => trim((string) $r['name'])]; + } + return $out; +} + +/** Betreff und Mail-Text für einen Empfänger. @return array{0:string,1:string} */ +function rundmail_render(array $msg, string $name): array +{ + $name = $name !== '' ? $name : 'Kunde'; + $text = str_replace('{name}', $name, (string) $msg['body']); + $html = '
' . nl2br(e($text)) . '
' + . '

Sie erhalten diese Nachricht, weil Sie bei uns ein offenes Ticket haben bzw. zur genannten Kundengruppe gehören. Bitte antworten Sie bei Fragen auf Ihr Ticket.

'; + if (function_exists('email_wrap')) $html = email_wrap($html); + return [(string) $msg['subject'], $html]; +} + +/** Versand einer einzelnen Mail (für Tests austauschbar über $GLOBALS['rundmail_send']). */ +function rundmail_send(string $to, string $subject, string $html): bool +{ + if (isset($GLOBALS['rundmail_send']) && is_callable($GLOBALS['rundmail_send'])) return (bool) ($GLOBALS['rundmail_send'])($to, $subject, $html); + return (bool) send_mail($to, $subject, $html); +} + +/** Prüft Betreff und Text. */ +function rundmail_validate(string $subject, string $body): ?string +{ + if (trim($subject) === '' || mb_strlen($subject) > 150) return 'Bitte einen Betreff angeben (höchstens 150 Zeichen).'; + if (trim($body) === '' || mb_strlen($body) > 5000) return 'Bitte einen Text angeben (höchstens 5000 Zeichen).'; + if (preg_match('/[\r\n]/', $subject)) return 'Der Betreff darf nur eine Zeile sein.'; + return null; +} + +/** Legt die Nachricht an und startet den Versand. @return array{ok:bool,error?:string,id?:int,total?:int} */ +function rundmail_create(PDO $pdo, array $staff, string $groupKey, string $subject, string $body): array +{ + if ($err = rundmail_validate($subject, $body)) return ['ok' => false, 'error' => $err]; + $group = rundmail_parse_group($pdo, $groupKey); + if (!$group) return ['ok' => false, 'error' => 'Bitte eine Empfängergruppe wählen.']; + $isAdmin = ($staff['role'] ?? '') === 'admin'; + $recent = $pdo->prepare('SELECT COUNT(*) FROM plugin_rundmail_messages WHERE sender_id = ? AND created_at >= ?'); + $recent->execute([(int) $staff['id'], date('Y-m-d H:i:s', time() - 3600)]); + if ((int) $recent->fetchColumn() >= ($isAdmin ? 20 : 5)) return ['ok' => false, 'error' => 'Sie haben in der letzten Stunde schon viele Rundmails verschickt. Bitte später noch einmal.']; + $rcpt = rundmail_recipients($pdo, $staff, $group['type'], $group['id']); + if (!$rcpt) return ['ok' => false, 'error' => 'In dieser Gruppe gibt es keine Empfänger (oder Sie dürfen ihr nicht schreiben).']; + $max = $isAdmin ? 2000 : 200; + if (count($rcpt) > $max) return ['ok' => false, 'error' => 'Zu viele Empfänger (' . count($rcpt) . '). Erlaubt sind höchstens ' . $max . '.' . ($isAdmin ? '' : ' Größere Verteiler darf nur ein Administrator senden.')]; + $pdo->prepare("INSERT INTO plugin_rundmail_messages (subject, body, group_type, group_id, group_label, sender_id, sender_name, status, total, created_at) VALUES (?,?,?,?,?,?,?,'sending',?,?)") + ->execute([trim($subject), trim($body), $group['type'], $group['id'], mb_substr($group['label'], 0, 190), (int) $staff['id'], mb_substr((string) $staff['name'], 0, 120), count($rcpt), db_now()]); + $id = (int) $pdo->lastInsertId(); + $ins = $pdo->prepare('INSERT INTO plugin_rundmail_queue (message_id, email, name) VALUES (?,?,?)'); + foreach ($rcpt as $r) $ins->execute([$id, $r['email'], mb_substr($r['name'], 0, 120)]); + rundmail_process($pdo, $id, RUNDMAIL_BATCH); + return ['ok' => true, 'id' => $id, 'total' => count($rcpt)]; +} + +/** Verschickt die nächsten Mails (einer Nachricht oder der ältesten offenen). @return int Zahl der bearbeiteten Empfänger */ +function rundmail_process(PDO $pdo, ?int $messageId = null, int $batch = RUNDMAIL_BATCH): int +{ + if ($messageId === null) { + $messageId = (int) $pdo->query("SELECT id FROM plugin_rundmail_messages WHERE status = 'sending' ORDER BY id LIMIT 1")->fetchColumn(); + if (!$messageId) return 0; + } + $m = $pdo->prepare("SELECT * FROM plugin_rundmail_messages WHERE id = ? AND status = 'sending'"); + $m->execute([$messageId]); + $msg = $m->fetch(); + if (!$msg) return 0; + $q = $pdo->prepare("SELECT * FROM plugin_rundmail_queue WHERE message_id = ? AND status = 'pending' ORDER BY id LIMIT " . max(1, $batch)); + $q->execute([$messageId]); + $n = 0; + foreach ($q->fetchAll() as $row) { + [$subject, $html] = rundmail_render($msg, (string) $row['name']); + try { + $ok = rundmail_send((string) $row['email'], $subject, $html); + $err = $ok ? null : 'Versand fehlgeschlagen (E-Mail-Versand eingerichtet?)'; + } catch (Throwable $e) { + $ok = false; + $err = mb_substr($e->getMessage(), 0, 240); + } + $pdo->prepare('UPDATE plugin_rundmail_queue SET status = ?, error = ?, sent_at = ? WHERE id = ?')->execute([$ok ? 'sent' : 'failed', $err, db_now(), $row['id']]); + $n++; + } + $c = $pdo->prepare("SELECT SUM(CASE WHEN status = 'sent' THEN 1 ELSE 0 END), SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) FROM plugin_rundmail_queue WHERE message_id = ?"); + $c->execute([$messageId]); + [$sent, $failed, $pending] = array_map('intval', array_values($c->fetch(PDO::FETCH_NUM))); + if ($pending === 0) $pdo->prepare("UPDATE plugin_rundmail_messages SET sent = ?, failed = ?, status = 'done', finished_at = ? WHERE id = ?")->execute([$sent, $failed, db_now(), $messageId]); + else $pdo->prepare('UPDATE plugin_rundmail_messages SET sent = ?, failed = ? WHERE id = ?')->execute([$sent, $failed, $messageId]); + return $n; +} + +/** Hintergrundlauf: nächste Mails der laufenden Nachrichten versenden. */ +function rundmail_tick(PDO $pdo): int +{ + return rundmail_process($pdo, null, RUNDMAIL_BATCH); +} + +/** Bricht den Versand ab (noch nicht gesendete Mails entfallen). Verfasser oder Administrator. */ +function rundmail_cancel(PDO $pdo, array $staff, int $id): bool +{ + $s = $pdo->prepare("SELECT sender_id FROM plugin_rundmail_messages WHERE id = ? AND status = 'sending'"); + $s->execute([$id]); + $owner = $s->fetchColumn(); + if ($owner === false || (($staff['role'] ?? '') !== 'admin' && (int) $owner !== (int) $staff['id'])) return false; + $pdo->prepare("UPDATE plugin_rundmail_queue SET status = 'cancelled' WHERE message_id = ? AND status = 'pending'")->execute([$id]); + $c = $pdo->prepare("SELECT SUM(CASE WHEN status = 'sent' THEN 1 ELSE 0 END), SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) FROM plugin_rundmail_queue WHERE message_id = ?"); + $c->execute([$id]); + [$sent, $failed] = array_map('intval', array_values($c->fetch(PDO::FETCH_NUM))); + $pdo->prepare("UPDATE plugin_rundmail_messages SET status = 'cancelled', sent = ?, failed = ?, finished_at = ? WHERE id = ?")->execute([$sent, $failed, db_now(), $id]); + return true; +} + +/** Probemail an den angemeldeten Mitarbeiter. */ +function rundmail_test(array $staff, string $subject, string $body): array +{ + if ($err = rundmail_validate($subject, $body)) return ['ok' => false, 'error' => $err]; + [$s, $html] = rundmail_render(['subject' => '[TEST] ' . trim($subject), 'body' => trim($body)], (string) $staff['name']); + $ok = rundmail_send((string) $staff['email'], $s, $html); + return $ok ? ['ok' => true] : ['ok' => false, 'error' => 'Die Probemail konnte nicht gesendet werden (E-Mail-Versand eingerichtet?).']; +} + +/** Verlauf: Administratoren sehen alle, Mitarbeiter ihre eigenen. */ +function rundmail_history(PDO $pdo, array $staff, int $limit = 40): array +{ + if (($staff['role'] ?? '') === 'admin') return $pdo->query('SELECT * FROM plugin_rundmail_messages ORDER BY id DESC LIMIT ' . max(1, $limit))->fetchAll(); + $s = $pdo->prepare('SELECT * FROM plugin_rundmail_messages WHERE sender_id = ? ORDER BY id DESC LIMIT ' . max(1, $limit)); + $s->execute([(int) $staff['id']]); + return $s->fetchAll(); +} diff --git a/Plugins/plugins/rundmail/migrations/001_rundmail.mysql.sql b/Plugins/plugins/rundmail/migrations/001_rundmail.mysql.sql new file mode 100644 index 00000000..48b93915 --- /dev/null +++ b/Plugins/plugins/rundmail/migrations/001_rundmail.mysql.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS plugin_rundmail_messages ( + id INT AUTO_INCREMENT PRIMARY KEY, + subject VARCHAR(200) NOT NULL, + body TEXT NOT NULL, + group_type VARCHAR(20) NOT NULL, + group_id INT NULL, + group_label VARCHAR(190) NOT NULL DEFAULT '', + sender_id INT NULL, + sender_name VARCHAR(120) NOT NULL DEFAULT '', + status VARCHAR(12) NOT NULL DEFAULT 'sending', + total INT NOT NULL DEFAULT 0, + sent INT NOT NULL DEFAULT 0, + failed INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + finished_at DATETIME NULL, + KEY idx_plugin_rundmail_msg_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS plugin_rundmail_queue ( + id INT AUTO_INCREMENT PRIMARY KEY, + message_id INT NOT NULL, + email VARCHAR(190) NOT NULL, + name VARCHAR(120) NOT NULL DEFAULT '', + status VARCHAR(10) NOT NULL DEFAULT 'pending', + error VARCHAR(250) NULL, + sent_at DATETIME NULL, + KEY idx_plugin_rundmail_queue (message_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 diff --git a/Plugins/plugins/rundmail/migrations/001_rundmail.sqlite.sql b/Plugins/plugins/rundmail/migrations/001_rundmail.sqlite.sql new file mode 100644 index 00000000..220163b9 --- /dev/null +++ b/Plugins/plugins/rundmail/migrations/001_rundmail.sqlite.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS plugin_rundmail_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subject TEXT NOT NULL, + body TEXT NOT NULL, + group_type TEXT NOT NULL, + group_id INTEGER NULL, + group_label TEXT NOT NULL DEFAULT '', + sender_id INTEGER NULL, + sender_name TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'sending', + total INTEGER NOT NULL DEFAULT 0, + sent INTEGER NOT NULL DEFAULT 0, + failed INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + finished_at TEXT NULL +); +CREATE INDEX IF NOT EXISTS idx_plugin_rundmail_msg_status ON plugin_rundmail_messages (status); +CREATE TABLE IF NOT EXISTS plugin_rundmail_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL, + email TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + error TEXT NULL, + sent_at TEXT NULL +); +CREATE INDEX IF NOT EXISTS idx_plugin_rundmail_queue ON plugin_rundmail_queue (message_id, status) diff --git a/Plugins/plugins/rundmail/pages/rundmail.php b/Plugins/plugins/rundmail/pages/rundmail.php new file mode 100644 index 00000000..1f9ae23c --- /dev/null +++ b/Plugins/plugins/rundmail/pages/rundmail.php @@ -0,0 +1,110 @@ + (string) ($_POST['group'] ?? ''), 'subject' => (string) ($_POST['subject'] ?? ''), 'body' => (string) ($_POST['body'] ?? '')]; +$preview = null; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verify_csrf(); + $action = $_POST['action'] ?? ''; + if ($action === 'preview') { + $g = rundmail_parse_group($pdo, $form['group']); + $err = rundmail_validate($form['subject'], $form['body']); + if (!$g) $error = 'Bitte eine Empfängergruppe wählen.'; + elseif ($err) $error = $err; + else { + $rcpt = rundmail_recipients($pdo, $staff, $g['type'], $g['id']); + if (!$rcpt) $error = 'In dieser Gruppe gibt es keine Empfänger (oder Sie dürfen ihr nicht schreiben).'; + else $preview = ['group' => $g, 'rcpt' => $rcpt]; + } + } elseif ($action === 'send') { + if (empty($_POST['confirm'])) { $error = 'Bitte bestätigen, dass Text und Empfänger geprüft sind.'; } + else { + $r = rundmail_create($pdo, $staff, $form['group'], $form['subject'], $form['body']); + if ($r['ok']) { + $message = 'Die Nachricht geht an ' . $r['total'] . ' Empfänger. Der Versand läuft im Hintergrund, den Stand sehen Sie unten.'; + $form = ['group' => '', 'subject' => '', 'body' => '']; + } else $error = $r['error']; + } + } elseif ($action === 'test') { + $r = rundmail_test($staff, $form['subject'], $form['body']); + if ($r['ok']) $message = 'Die Probemail ist an ' . $staff['email'] . ' unterwegs.'; else $error = $r['error']; + } elseif ($action === 'cancel') { + if (rundmail_cancel($pdo, $staff, (int) ($_POST['id'] ?? 0))) $message = 'Der Versand wurde abgebrochen.'; else $error = 'Dieser Versand lässt sich nicht abbrechen.'; + } +} +// Offene Versände weiterschieben, damit der Stand beim Ansehen nicht stehen bleibt +if ($_SERVER['REQUEST_METHOD'] !== 'POST') rundmail_tick($pdo); + +$groups = rundmail_groups($pdo, $staff); +$history = rundmail_history($pdo, $staff); +$presets = rundmail_presets(); +$statusLabels = ['sending' => 'Läuft', 'done' => 'Fertig', 'cancelled' => 'Abgebrochen']; +?> +

Störungsmitteilung und Rundmail

+
+
+ +
+ +

Neue Nachricht

+

Jede Mail geht einzeln an den Kunden (keine sichtbare Empfängerliste). Gedacht für Kunden mit offenen Tickets, etwa bei einer Wartung oder Störung. Mit {name} wird der Name eingesetzt.

+ + + + + + + + + +
+ + +
+
+ + +
+ + + + + +

Vor dem Senden prüfen

+

Empfänger:

+

Zum Beispiel: $r['email'], array_slice($preview['rcpt'], 0, 5)))) ?> 5 ? ' …' : '' ?>

+


+ +
+
+ + +
+

+

Noch nichts gesendet.

+ + + + + + + + + + + + + + +
WannBetreffEmpfängerVonStand
von gesendet' . (int) $h['failed'] . ' fehlgeschlagen' : '' ?>
+ +
diff --git a/Plugins/plugins/rundmail/plugin.json b/Plugins/plugins/rundmail/plugin.json new file mode 100644 index 00000000..8fb680e1 --- /dev/null +++ b/Plugins/plugins/rundmail/plugin.json @@ -0,0 +1,9 @@ +{ + "slug": "rundmail", + "name": "Störungsmitteilung und Rundmail", + "version": "1.0.0", + "description": "Eine Nachricht an viele Kunden gleichzeitig, zum Beispiel bei einer Wartung oder einem Ausfall. Empfänger sind Kunden mit offenen Tickets, die einer Abteilung oder die einer Organisation. Jede Mail geht einzeln raus, der Versand läuft im Hintergrund und wird protokolliert. Mitarbeiter dürfen nur an Kunden ihrer eigenen Abteilungen schreiben.", + "author": "DeskHub", + "requires": "1.0.0", + "permissions": ["hooks.cron", "ui.menu", "db.migrations"] +} diff --git a/Plugins/plugins/rundmail/plugin.php b/Plugins/plugins/rundmail/plugin.php new file mode 100644 index 00000000..eb7fc489 --- /dev/null +++ b/Plugins/plugins/rundmail/plugin.php @@ -0,0 +1,10 @@ +prepare('SELECT department_id FROM staff_department WHERE staff_id = ?'); + $s->execute([(int) $staff['id']]); + foreach ($s->fetchAll(PDO::FETCH_COLUMN) as $d) $ids[] = (int) $d; + return array_values(array_unique($ids)); +} + +/** SQL-Bedingung und Parameter: Notizen, die dieser Mitarbeiter sehen darf (für alle oder für eine seiner Abteilungen). */ +function schicht_visible_sql(PDO $pdo, array $staff, string $alias = 'n'): array +{ + $depts = schicht_departments($pdo, $staff); + if ($depts === null) return ['1=1', []]; + if (!$depts) return ["{$alias}.department_id IS NULL", []]; + return ["({$alias}.department_id IS NULL OR {$alias}.department_id IN (" . implode(',', array_fill(0, count($depts), '?')) . '))', $depts]; +} + +/** Notiz anlegen. @return array{ok:bool,error?:string,id?:int} */ +function schicht_create(PDO $pdo, array $staff, string $message, ?int $departmentId, bool $important): array +{ + $message = trim($message); + if ($message === '') return ['ok' => false, 'error' => 'Bitte eine Notiz schreiben.']; + if (mb_strlen($message) > 3000) return ['ok' => false, 'error' => 'Die Notiz ist zu lang (höchstens 3000 Zeichen).']; + if ($departmentId !== null) { + $own = schicht_departments($pdo, $staff); + if ($own !== null && !in_array($departmentId, $own, true)) return ['ok' => false, 'error' => 'Sie können Notizen nur für Ihre eigenen Abteilungen schreiben.']; + $d = $pdo->prepare('SELECT COUNT(*) FROM departments WHERE id = ?'); + $d->execute([$departmentId]); + if (!(int) $d->fetchColumn()) return ['ok' => false, 'error' => 'Die Abteilung gibt es nicht.']; + } + $pdo->prepare('INSERT INTO plugin_schichtuebergabe_notes (staff_id, department_id, message, is_important, created_at) VALUES (?,?,?,?,?)') + ->execute([(int) $staff['id'], $departmentId, $message, $important ? 1 : 0, db_now()]); + $id = (int) $pdo->lastInsertId(); + // Der Verfasser hat sie natürlich gelesen + $pdo->prepare('INSERT INTO plugin_schichtuebergabe_reads (note_id, staff_id, read_at) VALUES (?,?,?)')->execute([$id, (int) $staff['id'], db_now()]); + return ['ok' => true, 'id' => $id]; +} + +/** Ungelesene Notizen für einen Mitarbeiter (innerhalb der Aufbewahrungsfrist). */ +function schicht_unread(PDO $pdo, array $staff, bool $onlyImportant = false): array +{ + [$cond, $params] = schicht_visible_sql($pdo, $staff); + $sql = "SELECT n.*, s.name AS author FROM plugin_schichtuebergabe_notes n LEFT JOIN staff s ON s.id = n.staff_id + WHERE {$cond} AND n.created_at >= ? AND NOT EXISTS (SELECT 1 FROM plugin_schichtuebergabe_reads r WHERE r.note_id = n.id AND r.staff_id = ?)" + . ($onlyImportant ? ' AND n.is_important = 1' : '') . ' ORDER BY n.id DESC LIMIT 50'; + $stmt = $pdo->prepare($sql); + $stmt->execute(array_merge($params, [date('Y-m-d H:i:s', time() - schicht_keep_days() * 86400), (int) $staff['id']])); + return $stmt->fetchAll(); +} + +function schicht_unread_count(PDO $pdo, array $staff): int +{ + return count(schicht_unread($pdo, $staff)); +} + +/** Alle sichtbaren Notizen (neueste zuerst) mit Leseinfo. */ +function schicht_list(PDO $pdo, array $staff, int $limit = 60): array +{ + [$cond, $params] = schicht_visible_sql($pdo, $staff); + $stmt = $pdo->prepare("SELECT n.*, s.name AS author, d.name AS department, + (SELECT COUNT(*) FROM plugin_schichtuebergabe_reads r WHERE r.note_id = n.id AND r.staff_id = ?) AS is_read + FROM plugin_schichtuebergabe_notes n LEFT JOIN staff s ON s.id = n.staff_id LEFT JOIN departments d ON d.id = n.department_id + WHERE {$cond} AND n.created_at >= ? ORDER BY n.id DESC LIMIT " . max(1, $limit)); + $stmt->execute(array_merge([(int) $staff['id']], $params, [date('Y-m-d H:i:s', time() - schicht_keep_days() * 86400)])); + return $stmt->fetchAll(); +} + +/** Markiert die Notizen als gelesen. */ +function schicht_mark_read(PDO $pdo, array $staff, array $noteIds): void +{ + foreach ($noteIds as $id) { + $id = (int) $id; + $has = $pdo->prepare('SELECT COUNT(*) FROM plugin_schichtuebergabe_reads WHERE note_id = ? AND staff_id = ?'); + $has->execute([$id, (int) $staff['id']]); + if ((int) $has->fetchColumn()) continue; + $pdo->prepare('INSERT INTO plugin_schichtuebergabe_reads (note_id, staff_id, read_at) VALUES (?,?,?)')->execute([$id, (int) $staff['id'], db_now()]); + } +} + +/** Wer hat die Notiz gelesen? @return list */ +function schicht_readers(PDO $pdo, int $noteId): array +{ + $s = $pdo->prepare('SELECT s.name FROM plugin_schichtuebergabe_reads r JOIN staff s ON s.id = r.staff_id WHERE r.note_id = ? ORDER BY r.read_at'); + $s->execute([$noteId]); + return array_map('strval', $s->fetchAll(PDO::FETCH_COLUMN)); +} + +/** Löschen darf der Verfasser und jeder Administrator. */ +function schicht_delete(PDO $pdo, array $staff, int $id): bool +{ + $s = $pdo->prepare('SELECT staff_id FROM plugin_schichtuebergabe_notes WHERE id = ?'); + $s->execute([$id]); + $owner = $s->fetchColumn(); + if ($owner === false) return false; + if (($staff['role'] ?? '') !== 'admin' && (int) $owner !== (int) $staff['id']) return false; + $pdo->prepare('DELETE FROM plugin_schichtuebergabe_reads WHERE note_id = ?')->execute([$id]); + $pdo->prepare('DELETE FROM plugin_schichtuebergabe_notes WHERE id = ?')->execute([$id]); + return true; +} + +/** Aufräumen: Notizen nach Ablauf der Frist löschen (höchstens einmal pro Stunde). */ +function schicht_cleanup(PDO $pdo): int +{ + if (time() - (int) plugin_setting(SCHICHT, 'last_cleanup', '0') < 3600) return 0; + plugin_set_setting(SCHICHT, 'last_cleanup', (string) time()); + $cut = date('Y-m-d H:i:s', time() - schicht_keep_days() * 86400); + $pdo->prepare('DELETE FROM plugin_schichtuebergabe_reads WHERE note_id IN (SELECT id FROM plugin_schichtuebergabe_notes WHERE created_at < ?)')->execute([$cut]); + $del = $pdo->prepare('DELETE FROM plugin_schichtuebergabe_notes WHERE created_at < ?'); + $del->execute([$cut]); + return $del->rowCount(); +} + +/** Beschriftung des Menüpunkts mit Zähler. Ohne angemeldeten Mitarbeiter oder bei Fehlern der einfache Name. */ +function schicht_menu_label(): string +{ + try { + $staff = function_exists('current_staff') ? current_staff() : null; + if ($staff) { + $n = schicht_unread_count(get_db(), $staff); + if ($n > 0) return 'Schichtübergabe (' . $n . ' neu)'; + } + } catch (Throwable $e) { + // Der Menüpunkt darf nie wegen eines Fehlers fehlen + } + return 'Schichtübergabe'; +} + +/** Wichtige Notizen für die Seitenleiste: ungelesene und die eigenen der letzten 12 Stunden (so sieht der Verfasser, dass sie ankommt). */ +function schicht_sidebar_notes(PDO $pdo, array $staff): array +{ + $byId = []; + foreach (schicht_unread($pdo, $staff, true) as $n) $byId[(int) $n['id']] = $n + ['own' => (int) $n['staff_id'] === (int) $staff['id']]; + $own = $pdo->prepare("SELECT n.*, s.name AS author FROM plugin_schichtuebergabe_notes n LEFT JOIN staff s ON s.id = n.staff_id WHERE n.staff_id = ? AND n.is_important = 1 AND n.created_at >= ? ORDER BY n.id DESC LIMIT 5"); + $own->execute([(int) $staff['id'], date('Y-m-d H:i:s', time() - 12 * 3600)]); + foreach ($own->fetchAll() as $n) $byId[(int) $n['id']] = $n + ['own' => true]; + krsort($byId); + return array_values($byId); +} + +/** HTML für die Seitenleiste eines Tickets: wichtige ungelesene Notizen und die eigenen wichtigen der letzten Stunden. */ +function schicht_sidebar_html(PDO $pdo, ?array $staff): string +{ + if (!$staff) return ''; + $notes = schicht_sidebar_notes($pdo, $staff); + if (!$notes) return ''; + $html = '

Wichtige Schichtübergabe

'; + foreach (array_slice($notes, 0, 3) as $n) { + $who = !empty($n['own']) ? 'Ihre Notiz' : (string) $n['author']; + $readers = !empty($n['own']) ? count(array_filter(schicht_readers($pdo, (int) $n['id']), fn($r) => $r !== (string) ($staff['name'] ?? ''))) : null; + $html .= '

' . e($who) . ' ' . e(format_dt((string) $n['created_at'])) . ($readers !== null ? ' · von ' . $readers . ' gelesen' : '') . '
' . nl2br(e(mb_strimwidth((string) $n['message'], 0, 300, '…'))) . '

'; + } + $html .= 'Alle Notizen öffnen
'; + return $html; +} + +/** Ausgeblendete eigene Notizen (je Mitarbeiter, nur die Bestätigung auf der Übersicht). @return list */ +function schicht_hidden_own(array $staff): array +{ + $raw = (string) plugin_setting(SCHICHT, 'hidden_own_' . (int) $staff['id'], ''); + return $raw === '' ? [] : array_map('intval', explode(',', $raw)); +} + +function schicht_hide_own(array $staff, int $noteId): void +{ + $ids = array_slice(array_values(array_unique(array_merge(schicht_hidden_own($staff), [$noteId]))), -50); + plugin_set_setting(SCHICHT, 'hidden_own_' . (int) $staff['id'], implode(',', $ids)); +} + +/** Eigene Notizen der letzten 12 Stunden (für die Bestätigung auf der Übersicht), mit den Namen der Leser. */ +function schicht_own_recent(PDO $pdo, array $staff): array +{ + $s = $pdo->prepare('SELECT n.*, d.name AS department FROM plugin_schichtuebergabe_notes n LEFT JOIN departments d ON d.id = n.department_id WHERE n.staff_id = ? AND n.created_at >= ? ORDER BY n.id DESC LIMIT 5'); + $s->execute([(int) $staff['id'], date('Y-m-d H:i:s', time() - 12 * 3600)]); + $hidden = schicht_hidden_own($staff); + $rows = array_values(array_filter($s->fetchAll(), fn($r) => !in_array((int) $r['id'], $hidden, true))); + foreach ($rows as &$r) $r['readers'] = array_values(array_filter(schicht_readers($pdo, (int) $r['id']), fn($n) => $n !== (string) ($staff['name'] ?? ''))); + return $rows; +} + +/** + * Deutlicher Hinweis oben auf den Seiten des Mitarbeiterbereichs (also schon direkt nach der Anmeldung): + * - solange es ungelesene Notizen anderer gibt, auf jeder Seite (rot bei wichtigen); auf der Seite der Schichtübergabe selbst entfällt er; + * - die eigenen Notizen der letzten 12 Stunden als ruhige Bestätigung auf der Übersicht, mit den Namen derer, die sie gelesen haben. + */ +function schicht_banner_html(PDO $pdo, ?array $staff, string $activePage = ''): string +{ + if (!$staff || $activePage === 'plugin_' . SCHICHT) return ''; + $html = ''; + $notes = schicht_unread($pdo, $staff); + if ($notes) { + usort($notes, fn($a, $b) => [(int) $b['is_important'], (int) $b['id']] <=> [(int) $a['is_important'], (int) $a['id']]); + $important = count(array_filter($notes, fn($n) => (int) $n['is_important'] === 1)); + $color = $important ? '#dc2626' : '#0f6f6a'; + $bg = $important ? '#fef2f2' : '#ecfdf5'; + $count = count($notes); + $html .= '
' + . '
' + . '' . ($important ? '⚠ ' : '') . 'Schichtübergabe: ' . $count . ($count === 1 ? ' neue Notiz' : ' neue Notizen') . ($important ? ' (' . $important . ' wichtig)' : '') . '' + . '
Öffnen' + . '
' . csrf_field() . '
'; + foreach (array_slice($notes, 0, 3) as $n) { + $html .= '
' . e((string) $n['author']) . '' + . ' ' . e(format_dt((string) $n['created_at'])) . ((int) $n['is_important'] ? ' · wichtig' : '') . '
' + . nl2br(e(mb_strimwidth((string) $n['message'], 0, 400, '…'))) . '
'; + } + if ($count > 3) $html .= '
… und ' . ($count - 3) . ' weitere
'; + $html .= '
'; + } + // Eigene Notizen: nur auf der Übersicht, damit der Verfasser sieht, dass seine Notiz da ist und wer sie schon gelesen hat + if (in_array($activePage, ['reports', 'dashboard'], true)) { + foreach (schicht_own_recent($pdo, $staff) as $n) { + $readers = $n['readers']; + $html .= '
' + . '
' + . 'Ihre Notiz an die nächste Schicht' . ((int) $n['is_important'] ? ' (wichtig)' : '') . ' · ' . ($n['department'] ? 'Abteilung ' . e((string) $n['department']) : 'alle Mitarbeiter') . '' + . '
Alle Notizen' + . '
' . csrf_field() . '
' + . '
' . nl2br(e(mb_strimwidth((string) $n['message'], 0, 400, '…'))) . '
' + . '
' . e(format_dt((string) $n['created_at'])) . ' · ' . ($readers ? 'Gelesen von: ' . e(implode(', ', $readers)) : 'noch von niemandem gelesen') . '
'; + } + } + return $html; +} diff --git a/Plugins/plugins/schichtuebergabe/migrations/001_notizen.mysql.sql b/Plugins/plugins/schichtuebergabe/migrations/001_notizen.mysql.sql new file mode 100644 index 00000000..ad86189d --- /dev/null +++ b/Plugins/plugins/schichtuebergabe/migrations/001_notizen.mysql.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS plugin_schichtuebergabe_notes ( + id INT AUTO_INCREMENT PRIMARY KEY, + staff_id INT NOT NULL, + department_id INT NULL, + message TEXT NOT NULL, + is_important TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + KEY idx_plugin_schichtuebergabe_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS plugin_schichtuebergabe_reads ( + note_id INT NOT NULL, + staff_id INT NOT NULL, + read_at DATETIME NOT NULL, + PRIMARY KEY (note_id, staff_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 diff --git a/Plugins/plugins/schichtuebergabe/migrations/001_notizen.sqlite.sql b/Plugins/plugins/schichtuebergabe/migrations/001_notizen.sqlite.sql new file mode 100644 index 00000000..30bbde42 --- /dev/null +++ b/Plugins/plugins/schichtuebergabe/migrations/001_notizen.sqlite.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS plugin_schichtuebergabe_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + staff_id INTEGER NOT NULL, + department_id INTEGER NULL, + message TEXT NOT NULL, + is_important INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_plugin_schichtuebergabe_created ON plugin_schichtuebergabe_notes (created_at); +CREATE TABLE IF NOT EXISTS plugin_schichtuebergabe_reads ( + note_id INTEGER NOT NULL, + staff_id INTEGER NOT NULL, + read_at TEXT NOT NULL, + PRIMARY KEY (note_id, staff_id) +) diff --git a/Plugins/plugins/schichtuebergabe/pages/schichtuebergabe.php b/Plugins/plugins/schichtuebergabe/pages/schichtuebergabe.php new file mode 100644 index 00000000..ec6ff088 --- /dev/null +++ b/Plugins/plugins/schichtuebergabe/pages/schichtuebergabe.php @@ -0,0 +1,104 @@ + (int) $n['id'], schicht_unread($pdo, $staff))); + $message = 'Alle Notizen sind als gelesen markiert. Die Hinweise sind ausgeblendet.'; + } elseif ($action === 'settings') { + if (!$isAdmin) $error = 'Einstellungen ändern darf nur ein Administrator.'; + else { + plugin_set_setting(SCHICHT, 'keep_days', (string) max(1, min(365, (int) ($_POST['keep_days'] ?? 30)))); + $message = 'Einstellungen gespeichert.'; + } + } +} + +// Notizen gelten erst als gelesen, wenn der Mitarbeiter sie bestätigt („Gelesen“); erst dann verschwinden die Hinweise +$notes = schicht_list($pdo, $staff); +$newIds = array_map(fn($n) => (int) $n['id'], array_filter($notes, fn($n) => !(int) $n['is_read'])); + +$own = schicht_departments($pdo, $staff); +$depts = $pdo->query('SELECT id, name FROM departments ORDER BY name')->fetchAll(); +if ($own !== null) $depts = array_values(array_filter($depts, fn($d) => in_array((int) $d['id'], $own, true))); +?> +

Schichtübergabe

+
+
+ +
+ + +

Notiz für die nächste Schicht

+ +
+
+
+ +
+
+
+ +
+
+

Notizen der letzten Tage

+
+
+

Noch keine Notizen.

+ +
+
+
+ + · · + neu + von Ihnen + wichtig +
+
+ +
+ + +
+ +
+
+

+ +
Gelesen von:
+ +
+ +
+ + +
+ + +

Einstellungen (nur Administratoren)

+ + +
+
+ diff --git a/Plugins/plugins/schichtuebergabe/plugin.json b/Plugins/plugins/schichtuebergabe/plugin.json new file mode 100644 index 00000000..2138fbbe --- /dev/null +++ b/Plugins/plugins/schichtuebergabe/plugin.json @@ -0,0 +1,9 @@ +{ + "slug": "schichtuebergabe", + "name": "Schichtübergabe", + "version": "1.1.3", + "description": "Am Ende der Schicht schreibt ein Mitarbeiter eine kurze Notiz, die der nächste sieht: als deutlicher Hinweis oben auf jeder Seite (schon direkt nach der Anmeldung), im Menü als „neu“-Zähler und, wenn sie wichtig ist, in der Seitenleiste jedes Tickets. Die Notiz gilt für alle oder nur für eine Abteilung, der Verfasser sieht, wer sie gelesen hat. Alte Notizen werden automatisch gelöscht (Frist nur für Administratoren einstellbar).", + "author": "DeskHub", + "requires": "1.0.0", + "permissions": ["hooks.cron", "ui.menu", "ui.sidebar", "db.migrations"] +} diff --git a/Plugins/plugins/schichtuebergabe/plugin.php b/Plugins/plugins/schichtuebergabe/plugin.php new file mode 100644 index 00000000..b224905f --- /dev/null +++ b/Plugins/plugins/schichtuebergabe/plugin.php @@ -0,0 +1,17 @@ + 25, 'konto gesperrt' => 25, 'konto wird gesperrt' => 30, 'verify your account' => 30, 'confirm your identity' => 25, + 'unusual activity' => 25, 'ungewöhnliche aktivität' => 25, 'ungewoehnliche aktivitaet' => 25, 'membership is on hold' => 35, 'mitgliedschaft ist pausiert' => 35, + 'update your payment' => 25, 'zahlungsinformationen aktualisieren' => 25, 'zahlungsdaten aktualisieren' => 25, 'invoice update' => 20, + "we're processing" => 15, 'customer refund' => 20, 'klicken sie hier' => 10, 'click here' => 10, 'sofort handeln' => 20, 'within 24 hours' => 15, + 'innerhalb von 24 stunden' => 15, 'passwort läuft ab' => 25, 'password expires' => 25, 'gewonnen' => 15, 'lottery' => 30, 'lotterie' => 30, + 'bitcoin' => 20, 'krypto' => 12, 'crypto' => 15, 'viagra' => 40, 'casino' => 25, 'seo services' => 30, 'backlinks' => 30, 'guest post' => 25, + 'make money' => 25, 'erbschaft' => 30, 'inheritance' => 30, 'western union' => 30, 'urgent action' => 20, 'account suspended' => 30, + 'your account has been' => 20, 'dringende maßnahme' => 20, 'verifizieren sie' => 15, + ]; +} + +function spamfilter_default_brands(): array +{ + return ['netflix', 'paypal', 'amazon', 'meta', 'facebook', 'instagram', 'microsoft', 'apple', 'google', 'dhl', 'hermes', 'ups', 'fedex', 'sparkasse', 'volksbank', + 'postbank', 'commerzbank', 'ebay', 'telekom', 'vodafone', 'docusign', 'quickbooks', 'adobe', 'dropbox', 'whatsapp', 'spotify', 'steam', 'binance']; +} + +function spamfilter_shorteners(): array +{ + return ['bit.ly', 'tinyurl.com', 't.co', 'goo.gl', 'ow.ly', 'is.gd', 'cutt.ly', 'rebrand.ly', 'shorturl.at', 'rb.gy', 'tiny.cc']; +} + +function spamfilter_disposable(): array +{ + return ['mailinator.com', 'guerrillamail.com', '10minutemail.com', 'yopmail.com', 'trashmail.com', 'tempmail.com', 'temp-mail.org', 'sharklasers.com', 'getnada.com', 'maildrop.cc']; +} + +/** Zeilen einer Liste (eine pro Zeile, # beginnt einen Kommentar). */ +function spamfilter_lines(string $text): array +{ + $out = []; + foreach (preg_split('/\R/', $text) ?: [] as $l) { + $l = trim(preg_replace('/\s+#.*$/', '', trim($l)) ?? ''); + if ($l !== '' && $l[0] !== '#') $out[] = mb_strtolower($l); + } + return $out; +} + +function spamfilter_config(): array +{ + $custom = []; + foreach (spamfilter_lines((string) plugin_setting(SPAMFILTER, 'words', '')) as $line) { + [$w, $p] = array_pad(explode('=', $line, 2), 2, '20'); + if (trim($w) !== '') $custom[trim($w)] = max(1, min(100, (int) $p)); + } + return [ + 'enabled' => plugin_setting(SPAMFILTER, 'enabled', '1') === '1', + 'spam_at' => max(20, min(200, (int) plugin_setting(SPAMFILTER, 'spam_at', '70'))), + 'suspect_at' => max(10, min(150, (int) plugin_setting(SPAMFILTER, 'suspect_at', '35'))), + 'auto' => plugin_setting(SPAMFILTER, 'auto', '1') === '1', + 'ban' => plugin_setting(SPAMFILTER, 'ban', '0') === '1', + 'allow' => spamfilter_lines((string) plugin_setting(SPAMFILTER, 'allow', '')), + 'block' => spamfilter_lines((string) plugin_setting(SPAMFILTER, 'block', '')), + 'words' => $custom + spamfilter_default_words(), + 'brands' => array_values(array_unique(array_merge(spamfilter_default_brands(), spamfilter_lines((string) plugin_setting(SPAMFILTER, 'brands', ''))))), + ]; +} + +function spamfilter_domain(string $email): string +{ + return mb_strtolower(substr(strrchr($email, '@') ?: '@', 1)); +} + +/** Passt die Adresse auf einen Listeneintrag (ganze Adresse oder Domain)? */ +function spamfilter_listed(string $email, array $list): bool +{ + $email = mb_strtolower(trim($email)); + $domain = spamfilter_domain($email); + foreach ($list as $entry) { + $entry = ltrim($entry, '@'); + if ($entry === $email || ($domain !== '' && ($entry === $domain || str_ends_with($domain, '.' . $entry)))) return true; + } + return false; +} + +/** + * Bewertet eine Nachricht. $m: name, email, subject, message, user_id (optional), ticket_id (optional, zum Ausschließen des eigenen Tickets). + * + * @return array{score:int,reasons:string[],allowed:bool} + */ +function spamfilter_score(PDO $pdo, array $m, ?array $cfg = null): array +{ + $cfg ??= spamfilter_config(); + $name = trim((string) ($m['name'] ?? '')); + $email = mb_strtolower(trim((string) ($m['email'] ?? ''))); + $subject = trim((string) ($m['subject'] ?? '')); + $message = trim((string) ($m['message'] ?? '')); + $domain = spamfilter_domain($email); + $text = mb_strtolower($subject . "\n" . strip_tags($message)); + $score = 0; + $reasons = []; + $add = function (int $points, string $why) use (&$score, &$reasons): void { $score += $points; $reasons[] = ($points >= 0 ? '+' : '') . $points . ' ' . $why; }; + + if (spamfilter_listed($email, $cfg['allow'])) return ['score' => 0, 'reasons' => ['Absender steht auf der Freigabeliste'], 'allowed' => true]; + if (spamfilter_listed($email, $cfg['block'])) $add(100, 'Absender steht auf der Sperrliste'); + if ($domain !== '' && in_array($domain, spamfilter_disposable(), true)) $add(30, 'Wegwerf-Adresse (' . $domain . ')'); + + // Wörter und Wendungen (jede höchstens einmal) + $wordPoints = 0; + $hits = []; + foreach ($cfg['words'] as $word => $points) { + if ($word !== '' && str_contains($text, $word)) { $wordPoints += $points; $hits[] = $word; } + } + if ($hits) $add(min(70, $wordPoints), 'verdächtige Wörter: ' . implode(', ', array_slice($hits, 0, 5)) . (count($hits) > 5 ? ' …' : '')); + + // Links + $links = preg_match_all('~https?://|www\.~i', $message); + if ($links > 8) $add(30, $links . ' Links in der Nachricht'); + elseif ($links > 3) $add(15, $links . ' Links in der Nachricht'); + foreach (spamfilter_shorteners() as $s) if (stripos($message, $s . '/') !== false) { $add(25, 'Link-Verkürzer (' . $s . ')'); break; } + if ($links >= 1 && mb_strlen($message) < 25) $add(15, 'sehr kurze Nachricht mit Link'); + + // Marken-Nachahmung: Absendername nennt eine Marke, die Adresse gehört nicht dazu + foreach ($cfg['brands'] as $brand) { + if ($brand === '' || !preg_match('/(^|[^a-z0-9])' . preg_quote($brand, '/') . '([^a-z0-9]|$)/i', $name)) continue; + $domainWord = preg_replace('/\.[a-z.]+$/', '', $domain) ?? $domain; + if (!str_contains($domainWord, $brand)) { $add(45, 'Absender „' . $name . '“ nennt die Marke ' . $brand . ', die Adresse gehört aber zu ' . ($domain ?: 'unbekannt')); break; } + } + + // Betreff + $letters = preg_replace('/[^\p{L}]/u', '', $subject) ?? ''; + if (mb_strlen($letters) >= 10) { + $upper = preg_replace('/[^\p{Lu}]/u', '', $letters) ?? ''; + if (mb_strlen($upper) / mb_strlen($letters) > 0.7) $add(10, 'Betreff fast nur in Großbuchstaben'); + } + if ($subject === '' || preg_match('/^\(?kein betreff\)?$/i', $subject)) $add(5, 'kein Betreff'); + + // Wiederholungen (Kampagnen): gleiche Nachricht an viele Tickets + $self = (int) ($m['ticket_id'] ?? 0); + $since24 = date('Y-m-d H:i:s', time() - 86400); + $since1h = date('Y-m-d H:i:s', time() - 3600); + $dup = static function (string $sql, array $params) use ($pdo): int { $s = $pdo->prepare($sql); $s->execute($params); return (int) $s->fetchColumn(); }; + if ($subject !== '') { + $n = $dup('SELECT COUNT(DISTINCT requester_email) FROM tickets WHERE id <> ? AND LOWER(subject) = ? AND created_at >= ? AND requester_email <> ?', [$self, mb_strtolower($subject), $since24, $email]); + if ($n >= 2) $add(30, 'derselbe Betreff von ' . ($n + 1) . ' verschiedenen Absendern innerhalb von 24 Stunden'); + } + if ($email !== '') { + $n = $dup('SELECT COUNT(*) FROM tickets WHERE id <> ? AND requester_email = ? AND created_at >= ?', [$self, $email, $since1h]); + if ($n >= 3) $add(25, 'derselbe Absender hat in der letzten Stunde ' . ($n + 1) . ' Tickets angelegt'); + } + $body200 = mb_substr(preg_replace('/\s+/', ' ', mb_strtolower(strip_tags($message))) ?? '', 0, 120); + if (mb_strlen($body200) >= 30) { + $n = $dup("SELECT COUNT(DISTINCT t.requester_email) FROM ticket_threads th JOIN tickets t ON t.id = th.ticket_id WHERE th.author_type = 'customer' AND t.id <> ? AND t.created_at >= ? AND LOWER(th.message) LIKE ? AND t.requester_email <> ?", [$self, $since24, '%' . preg_replace('/[%_\\\\]/', '', mb_substr($body200, 0, 60)) . '%', $email]); + if ($n >= 2) $add(35, 'fast gleicher Text von ' . ($n + 1) . ' verschiedenen Absendern'); + } + + // Vertrauen: bekannte Absender und angemeldete Kunden + if ($email !== '') { + $good = $dup("SELECT COUNT(*) FROM tickets WHERE id <> ? AND requester_email = ? AND is_spam = 0 AND status IN ('resolved','closed') AND created_at < ?", [$self, $email, $since24]); + if ($good > 0) $add(-40, 'bekannter Absender (' . $good . ' frühere gelöste Tickets)'); + } + if (!empty($m['user_id'])) $add(-30, 'angemeldeter Kunde'); + + return ['score' => max(0, $score), 'reasons' => $reasons, 'allowed' => false]; +} + +function spamfilter_log(PDO $pdo, array $t, int $score, string $decision, array $reasons): void +{ + $pdo->prepare('INSERT INTO plugin_spamfilter_log (ticket_id, ticket_number, sender, subject, score, decision, reasons, created_at) VALUES (?,?,?,?,?,?,?,?)') + ->execute([(int) $t['id'], (string) $t['ticket_number'], mb_substr((string) $t['requester_email'], 0, 255), mb_substr((string) $t['subject'], 0, 255), $score, $decision, json_encode($reasons, JSON_UNESCAPED_UNICODE), db_now()]); +} + +/** Bewertet ein neues Ticket und handelt: ablegen (Spam), markieren (Verdacht) oder nichts. @return string spam, suspect, ok oder skipped */ +function spamfilter_handle_ticket(PDO $pdo, int $ticketId): string +{ + $cfg = spamfilter_config(); + if (!$cfg['enabled']) return 'skipped'; + $st = $pdo->prepare('SELECT * FROM tickets WHERE id = ?'); + $st->execute([$ticketId]); + $t = $st->fetch(); + // nur Tickets von außen; Mitarbeiter, Live-Chat und bereits als Spam markierte Tickets bleiben unberührt + if (!$t || !empty($t['is_spam']) || !in_array((string) $t['source'], ['web', 'email', 'api', 'phone'], true)) return 'skipped'; + $mt = $pdo->prepare("SELECT message FROM ticket_threads WHERE ticket_id = ? AND author_type = 'customer' ORDER BY id LIMIT 1"); + $mt->execute([$ticketId]); + $res = spamfilter_score($pdo, ['name' => $t['requester_name'], 'email' => $t['requester_email'], 'subject' => $t['subject'], 'message' => (string) $mt->fetchColumn(), 'user_id' => $t['user_id'] ?? null, 'ticket_id' => $ticketId], $cfg); + $decision = 'ok'; + if ($res['score'] >= $cfg['spam_at']) $decision = 'spam'; + elseif ($res['score'] >= $cfg['suspect_at']) $decision = 'suspect'; + if ($decision === 'spam' && !$cfg['auto']) $decision = 'suspect'; + + if ($decision === 'spam') { + spamfilter_mark_spam($pdo, $t, 'Vom Spamfilter als Spam abgelegt (' . $res['score'] . ' Punkte)'); + if ($cfg['ban'] && filter_var($t['requester_email'], FILTER_VALIDATE_EMAIL)) spamfilter_ban($pdo, (string) $t['requester_email'], 'Spamfilter (' . $res['score'] . ' Punkte)'); + } elseif ($decision === 'suspect') { + apply_tag_to_ticket($pdo, $ticketId, 'Spam-Verdacht'); + log_activity($pdo, $ticketId, null, 'Spamfilter: Verdacht (' . $res['score'] . ' Punkte): ' . implode('; ', array_slice($res['reasons'], 0, 3))); + } + spamfilter_log($pdo, $t, $res['score'], $decision, $res['reasons']); + return $decision; +} + +/** Legt ein Ticket als Spam ab (bei E-Mail-Tickets wird die Mail zusätzlich im Postfach in den Spam-Ordner verschoben). */ +function spamfilter_mark_spam(PDO $pdo, array $ticket, string $note): void +{ + $id = (int) $ticket['id']; + if (($ticket['source'] ?? '') === 'email' && function_exists('set_ticket_spam')) { + try { + $r = set_ticket_spam($pdo, $ticket, 0, false); + if (empty($r['error'])) { log_activity($pdo, $id, null, $note); return; } + } catch (Throwable $e) { + // dann wie bei den anderen Tickets direkt markieren + } + } + $now = db_now(); + $pdo->prepare("UPDATE tickets SET is_spam = 1, status = 'closed', closed_at = COALESCE(closed_at, ?), updated_at = ?, locked_by = NULL, locked_at = NULL WHERE id = ?")->execute([$now, $now, $id]); + apply_tag_to_ticket($pdo, $id, 'Spam'); + log_activity($pdo, $id, null, $note); +} + +function spamfilter_ban(PDO $pdo, string $email, string $reason): void +{ + $email = mb_strtolower(trim($email)); + $c = $pdo->prepare("SELECT COUNT(*) FROM banlist WHERE match_type = 'email' AND value = ?"); + $c->execute([$email]); + if ((int) $c->fetchColumn() === 0) $pdo->prepare("INSERT INTO banlist (match_type, value, reason, created_by, created_at) VALUES ('email', ?, ?, NULL, ?)")->execute([$email, mb_substr($reason, 0, 250), db_now()]); +} + +/** Korrektur: „Kein Spam“ stellt das Ticket wieder her und kann den Absender freigeben. */ +function spamfilter_not_spam(PDO $pdo, int $ticketId, int $staffId, bool $allowSender): bool +{ + $st = $pdo->prepare('SELECT * FROM tickets WHERE id = ?'); + $st->execute([$ticketId]); + $t = $st->fetch(); + if (!$t) return false; + $pdo->prepare("UPDATE tickets SET is_spam = 0, status = 'open', closed_at = NULL, updated_at = ? WHERE id = ?")->execute([db_now(), $ticketId]); + $pdo->prepare('DELETE FROM ticket_tags WHERE ticket_id = ? AND tag_id IN (SELECT id FROM tags WHERE name IN (?, ?))')->execute([$ticketId, 'Spam', 'Spam-Verdacht']); + log_activity($pdo, $ticketId, $staffId ?: null, 'Spamfilter: als „Kein Spam“ korrigiert'); + $pdo->prepare("UPDATE plugin_spamfilter_log SET decision = 'manual_ok' WHERE ticket_id = ?")->execute([$ticketId]); + if ($allowSender && filter_var($t['requester_email'], FILTER_VALIDATE_EMAIL)) { + $allow = spamfilter_lines((string) plugin_setting(SPAMFILTER, 'allow', '')); + $mail = mb_strtolower($t['requester_email']); + if (!in_array($mail, $allow, true)) plugin_set_setting(SPAMFILTER, 'allow', trim(implode("\n", array_merge($allow, [$mail])))); + } + return true; +} + +/** Korrektur: „Doch Spam“ legt ein Ticket ab, das nur im Verdacht stand. */ +function spamfilter_confirm_spam(PDO $pdo, int $ticketId, int $staffId, bool $banSender): bool +{ + $st = $pdo->prepare('SELECT * FROM tickets WHERE id = ?'); + $st->execute([$ticketId]); + $t = $st->fetch(); + if (!$t || !empty($t['is_spam'])) return false; + spamfilter_mark_spam($pdo, $t, 'Spamfilter: von Hand als Spam abgelegt'); + $pdo->prepare('DELETE FROM ticket_tags WHERE ticket_id = ? AND tag_id IN (SELECT id FROM tags WHERE name = ?)')->execute([$ticketId, 'Spam-Verdacht']); + $pdo->prepare("UPDATE plugin_spamfilter_log SET decision = 'manual_spam' WHERE ticket_id = ?")->execute([$ticketId]); + if ($banSender && filter_var($t['requester_email'], FILTER_VALIDATE_EMAIL)) spamfilter_ban($pdo, (string) $t['requester_email'], 'Spamfilter (von Hand)'); + return true; +} + +/** Bewertung in der Seitenleiste der Ticketansicht (fertiges, maskiertes HTML). */ +function spamfilter_sidebar_html(PDO $pdo, int $ticketId): string +{ + if ($ticketId <= 0) return ''; + try { + $s = $pdo->prepare('SELECT * FROM plugin_spamfilter_log WHERE ticket_id = ? ORDER BY id DESC LIMIT 1'); + $s->execute([$ticketId]); + $row = $s->fetch(); + } catch (Throwable $e) { + return ''; + } + if (!$row) return ''; + $labels = ['ok' => ['unauffällig', '#15803d'], 'suspect' => ['Verdacht', '#b45309'], 'spam' => ['als Spam abgelegt', '#b91c1c'], 'manual_ok' => ['von Hand: kein Spam', '#15803d'], 'manual_spam' => ['von Hand: Spam', '#b91c1c']]; + [$label, $color] = $labels[$row['decision']] ?? [$row['decision'], '#475569']; + $reasons = json_decode((string) $row['reasons'], true) ?: []; + $html = '
Spamfilter
' + . '
' . e($label) . ' · ' . (int) $row['score'] . ' Punkte
'; + if ($reasons) { + $html .= '
    '; + foreach (array_slice($reasons, 0, 6) as $r) $html .= '
  • ' . e((string) $r) . '
  • '; + $html .= '
'; + } + return $html . '
'; +} + +/** Kennzahlen der letzten 30 Tage. @return array */ +function spamfilter_stats(PDO $pdo): array +{ + $out = ['ok' => 0, 'suspect' => 0, 'spam' => 0, 'manual_ok' => 0, 'manual_spam' => 0]; + $s = $pdo->prepare('SELECT decision, COUNT(*) AS n FROM plugin_spamfilter_log WHERE created_at >= ? GROUP BY decision'); + $s->execute([date('Y-m-d H:i:s', time() - 30 * 86400)]); + foreach ($s->fetchAll() as $r) $out[$r['decision']] = (int) $r['n']; + return $out; +} + +plugin_add_hook('plugin.uninstall.' . SPAMFILTER, function () { + try { + get_db()->exec('DROP TABLE IF EXISTS plugin_spamfilter_log'); + } catch (Throwable $e) { + } +}); diff --git a/Plugins/plugins/spamfilter/migrations/001_protokoll.mysql.sql b/Plugins/plugins/spamfilter/migrations/001_protokoll.mysql.sql new file mode 100644 index 00000000..78004b54 --- /dev/null +++ b/Plugins/plugins/spamfilter/migrations/001_protokoll.mysql.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS plugin_spamfilter_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + ticket_id INT NOT NULL, + ticket_number VARCHAR(40) NOT NULL, + sender VARCHAR(255) NOT NULL, + subject VARCHAR(255) NOT NULL, + score INT NOT NULL, + decision VARCHAR(20) NOT NULL, + reasons TEXT NOT NULL, + created_at DATETIME NOT NULL, + KEY idx_plugin_spamfilter_ticket (ticket_id), + KEY idx_plugin_spamfilter_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 diff --git a/Plugins/plugins/spamfilter/migrations/001_protokoll.sqlite.sql b/Plugins/plugins/spamfilter/migrations/001_protokoll.sqlite.sql new file mode 100644 index 00000000..d9947859 --- /dev/null +++ b/Plugins/plugins/spamfilter/migrations/001_protokoll.sqlite.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS plugin_spamfilter_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ticket_id INTEGER NOT NULL, + ticket_number VARCHAR(40) NOT NULL, + sender VARCHAR(255) NOT NULL, + subject VARCHAR(255) NOT NULL, + score INTEGER NOT NULL, + decision VARCHAR(20) NOT NULL, + reasons TEXT NOT NULL, + created_at DATETIME NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_plugin_spamfilter_ticket ON plugin_spamfilter_log (ticket_id); +CREATE INDEX IF NOT EXISTS idx_plugin_spamfilter_created ON plugin_spamfilter_log (created_at) diff --git a/Plugins/plugins/spamfilter/pages/spamfilter.php b/Plugins/plugins/spamfilter/pages/spamfilter.php new file mode 100644 index 00000000..6868ee10 --- /dev/null +++ b/Plugins/plugins/spamfilter/pages/spamfilter.php @@ -0,0 +1,126 @@ + '', 'email' => '', 'subject' => '', 'message' => '']; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verify_csrf(); + $action = $_POST['action'] ?? ''; + if ($action === 'save') { + if (!$isAdmin) { + $error = 'Einstellungen ändern darf nur ein Administrator.'; + } else { + plugin_set_setting(SPAMFILTER, 'enabled', !empty($_POST['enabled']) ? '1' : '0'); + plugin_set_setting(SPAMFILTER, 'auto', !empty($_POST['auto']) ? '1' : '0'); + plugin_set_setting(SPAMFILTER, 'ban', !empty($_POST['ban']) ? '1' : '0'); + $spamAt = max(20, min(200, (int) ($_POST['spam_at'] ?? 70))); + $suspectAt = max(10, min($spamAt - 1, (int) ($_POST['suspect_at'] ?? 35))); + plugin_set_setting(SPAMFILTER, 'spam_at', (string) $spamAt); + plugin_set_setting(SPAMFILTER, 'suspect_at', (string) $suspectAt); + foreach (['allow', 'block', 'words', 'brands'] as $k) plugin_set_setting(SPAMFILTER, $k, mb_substr(trim((string) ($_POST[$k] ?? '')), 0, 8000)); + $message = 'Einstellungen gespeichert.'; + } + } elseif ($action === 'test') { + $testIn = ['name' => (string) ($_POST['name'] ?? ''), 'email' => (string) ($_POST['email'] ?? ''), 'subject' => (string) ($_POST['subject'] ?? ''), 'message' => (string) ($_POST['message'] ?? '')]; + $testResult = spamfilter_score($pdo, $testIn); + } elseif ($action === 'not_spam') { + $message = spamfilter_not_spam($pdo, (int) ($_POST['ticket_id'] ?? 0), (int) $staff['id'], !empty($_POST['allow_sender'])) ? 'Das Ticket ist wieder offen.' : null; + if ($message === null) $error = 'Das Ticket wurde nicht gefunden.'; + } elseif ($action === 'confirm_spam') { + $message = spamfilter_confirm_spam($pdo, (int) ($_POST['ticket_id'] ?? 0), (int) $staff['id'], !empty($_POST['ban_sender'])) ? 'Das Ticket wurde als Spam abgelegt.' : null; + if ($message === null) $error = 'Das Ticket konnte nicht als Spam abgelegt werden.'; + } +} + +$cfg = spamfilter_config(); +$stats = spamfilter_stats($pdo); +$log = $pdo->query('SELECT l.*, t.is_spam FROM plugin_spamfilter_log l LEFT JOIN tickets t ON t.id = l.ticket_id ORDER BY l.id DESC LIMIT 100')->fetchAll(); +$labels = ['ok' => 'Unauffällig', 'suspect' => 'Verdacht', 'spam' => 'Spam abgelegt', 'manual_ok' => 'Kein Spam (korrigiert)', 'manual_spam' => 'Spam (von Hand)']; +?> +

Spamfilter

+
+
+ +
+

Die letzten 30 Tage

+

+ Spam abgelegt · + im Verdacht · + unauffällig · + von Hand freigegeben +

+

Ab Punkten gilt ein Ticket als Spam, ab bekommt es den Tag „Spam-Verdacht“. Die Filterung ist ausgeschaltet' ?>.

+
+ +
+

Protokoll

+

Noch keine Tickets bewertet.

+ + + + + + + + + + + + + + +
TicketAbsenderPunkteEntscheidungGründe
#
+ +
+ +
+ +
+ +
+ +
+ + +

Ausprobieren

+

Zeigt, wie der Filter eine Nachricht bewerten würde (ohne etwas zu speichern).

+
+
+
+
+ + +
+ +

Punkte → + = $cfg['spam_at'] ? 'Spam' : ($testResult['score'] >= $cfg['suspect_at'] ? 'Verdacht' : 'unauffällig')) ?>

+
  • Keine Auffälligkeiten.
+ +
+ + +
+ + +

Einstellungen

+ + + +
+
+
+
+ + + + + + + + +
+
+ diff --git a/Plugins/plugins/spamfilter/plugin.json b/Plugins/plugins/spamfilter/plugin.json new file mode 100644 index 00000000..0eeff6fd --- /dev/null +++ b/Plugins/plugins/spamfilter/plugin.json @@ -0,0 +1,9 @@ +{ + "slug": "spamfilter", + "name": "Spam- und Missbrauchsfilter", + "version": "1.0.0", + "description": "Bewertet jedes neue Ticket mit Punkten (Phishing-Wörter, Marken-Nachahmung, Links, Wegwerf-Adressen, Wiederholungen) und legt eindeutigen Spam automatisch ab. Zweifelsfälle bekommen den Tag „Spam-Verdacht“. Mit Freigabe- und Sperrliste, Protokoll und Korrekturknopf („Kein Spam“) sowie einer Bewertung in der Seitenleiste des Tickets.", + "author": "DeskHub", + "requires": "1.0.0", + "permissions": ["hooks.tickets", "ui.menu", "ui.sidebar", "db.migrations"] +}