From cbf6139e747414cd1b176ef44c16ee6a4dde50f6 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Sat, 24 Oct 2026 20:15:15 +0000 Subject: [PATCH] Upload via GUI (94 Dateien) --- Plugins/plugins/community-bot-1.0.1.zip | Bin 0 -> 11218 bytes Plugins/plugins/community-bot/lib.php | 354 +++++ .../migrations/001_verknuepfung.mysql.sql | 11 + .../migrations/001_verknuepfung.sqlite.sql | 11 + .../community-bot/pages/community-bot.php | 123 ++ Plugins/plugins/community-bot/plugin.json | 9 + Plugins/plugins/community-bot/plugin.php | 19 + Plugins/plugins/datenschutz-1.2.0.zip | Bin 0 -> 12156 bytes Plugins/plugins/datenschutz/lib.php | 280 ++++ .../migrations/001_protokoll.mysql.sql | 9 + .../migrations/001_protokoll.sqlite.sql | 9 + .../migrations/002_erledigt.mysql.sql | 4 + .../migrations/002_erledigt.sqlite.sql | 4 + .../plugins/datenschutz/pages/datenschutz.php | 179 +++ Plugins/plugins/datenschutz/plugin.json | 9 + Plugins/plugins/datenschutz/plugin.php | 11 + Plugins/plugins/lang-de-1.0.0.zip | Bin 0 -> 1162 bytes Plugins/plugins/lang-de/flag.svg | 1 + Plugins/plugins/lang-de/plugin.json | 17 + Plugins/plugins/lang-de/plugin.php | 2 + Plugins/plugins/lang-de/strings.json | 3 + Plugins/plugins/lang-en-1.0.1.zip | Bin 0 -> 22750 bytes Plugins/plugins/lang-en/flag.svg | 1 + Plugins/plugins/lang-en/plugin.json | 17 + Plugins/plugins/lang-en/plugin.php | 2 + Plugins/plugins/lang-en/strings.json | 1142 +++++++++++++++++ Plugins/plugins/rundmail-1.0.0.zip | Bin 0 -> 9041 bytes Plugins/plugins/rundmail/lib.php | 220 ++++ .../migrations/001_rundmail.mysql.sql | 27 + .../migrations/001_rundmail.sqlite.sql | 27 + Plugins/plugins/rundmail/pages/rundmail.php | 110 ++ Plugins/plugins/rundmail/plugin.json | 9 + Plugins/plugins/rundmail/plugin.php | 10 + Plugins/plugins/schichtuebergabe-1.1.3.zip | Bin 0 -> 9140 bytes Plugins/plugins/schichtuebergabe/lib.php | 237 ++++ .../migrations/001_notizen.mysql.sql | 15 + .../migrations/001_notizen.sqlite.sql | 15 + .../pages/schichtuebergabe.php | 104 ++ Plugins/plugins/schichtuebergabe/plugin.json | 9 + Plugins/plugins/schichtuebergabe/plugin.php | 17 + Plugins/plugins/spamfilter/lib.php | 300 +++++ .../migrations/001_protokoll.mysql.sql | 13 + .../migrations/001_protokoll.sqlite.sql | 13 + .../plugins/spamfilter/pages/spamfilter.php | 126 ++ Plugins/plugins/spamfilter/plugin.json | 9 + 45 files changed, 3478 insertions(+) create mode 100644 Plugins/plugins/community-bot-1.0.1.zip create mode 100644 Plugins/plugins/community-bot/lib.php create mode 100644 Plugins/plugins/community-bot/migrations/001_verknuepfung.mysql.sql create mode 100644 Plugins/plugins/community-bot/migrations/001_verknuepfung.sqlite.sql create mode 100644 Plugins/plugins/community-bot/pages/community-bot.php create mode 100644 Plugins/plugins/community-bot/plugin.json create mode 100644 Plugins/plugins/community-bot/plugin.php create mode 100644 Plugins/plugins/datenschutz-1.2.0.zip create mode 100644 Plugins/plugins/datenschutz/lib.php create mode 100644 Plugins/plugins/datenschutz/migrations/001_protokoll.mysql.sql create mode 100644 Plugins/plugins/datenschutz/migrations/001_protokoll.sqlite.sql create mode 100644 Plugins/plugins/datenschutz/migrations/002_erledigt.mysql.sql create mode 100644 Plugins/plugins/datenschutz/migrations/002_erledigt.sqlite.sql create mode 100644 Plugins/plugins/datenschutz/pages/datenschutz.php create mode 100644 Plugins/plugins/datenschutz/plugin.json create mode 100644 Plugins/plugins/datenschutz/plugin.php create mode 100644 Plugins/plugins/lang-de-1.0.0.zip create mode 100644 Plugins/plugins/lang-de/flag.svg create mode 100644 Plugins/plugins/lang-de/plugin.json create mode 100644 Plugins/plugins/lang-de/plugin.php create mode 100644 Plugins/plugins/lang-de/strings.json create mode 100644 Plugins/plugins/lang-en-1.0.1.zip create mode 100644 Plugins/plugins/lang-en/flag.svg create mode 100644 Plugins/plugins/lang-en/plugin.json create mode 100644 Plugins/plugins/lang-en/plugin.php create mode 100644 Plugins/plugins/lang-en/strings.json create mode 100644 Plugins/plugins/rundmail-1.0.0.zip create mode 100644 Plugins/plugins/rundmail/lib.php create mode 100644 Plugins/plugins/rundmail/migrations/001_rundmail.mysql.sql create mode 100644 Plugins/plugins/rundmail/migrations/001_rundmail.sqlite.sql create mode 100644 Plugins/plugins/rundmail/pages/rundmail.php create mode 100644 Plugins/plugins/rundmail/plugin.json create mode 100644 Plugins/plugins/rundmail/plugin.php create mode 100644 Plugins/plugins/schichtuebergabe-1.1.3.zip create mode 100644 Plugins/plugins/schichtuebergabe/lib.php create mode 100644 Plugins/plugins/schichtuebergabe/migrations/001_notizen.mysql.sql create mode 100644 Plugins/plugins/schichtuebergabe/migrations/001_notizen.sqlite.sql create mode 100644 Plugins/plugins/schichtuebergabe/pages/schichtuebergabe.php create mode 100644 Plugins/plugins/schichtuebergabe/plugin.json create mode 100644 Plugins/plugins/schichtuebergabe/plugin.php create mode 100644 Plugins/plugins/spamfilter/lib.php create mode 100644 Plugins/plugins/spamfilter/migrations/001_protokoll.mysql.sql create mode 100644 Plugins/plugins/spamfilter/migrations/001_protokoll.sqlite.sql create mode 100644 Plugins/plugins/spamfilter/pages/spamfilter.php create mode 100644 Plugins/plugins/spamfilter/plugin.json 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 0000000000000000000000000000000000000000..db87a2129be9e010fff6309943cd7d50d17905d4 GIT binary patch literal 11218 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9066233de2786b1542c3071e2c32da525a26e263 GIT binary patch literal 12156 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 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 @@ +TB&O>Xm!;R9bL4As z;BmOPcX`IEd0}cfJgEy$xR%VBnIgi>`}9Xr(v5@vRR3RpERs<7^t*;csC~MUvGy6! zzXB^#(xO-nP1zIsD{?~PLZ)_K+aJ4|mbSYc4x9h z=AGF;FYH(O{X75t+Q;yqmH`iuEJK67I*G?ofZGq_@3fc zzo`8MH=QT7`zhJ5hICDmKV%}cVq)L-g^dYT6QUL~Pg!={jV0bucjANxhIwpfjpg*( zq@G30zrnYpqEF>>Z^6l~a!GzC!96{GopKL9e3so?ZNjnIi6i>-c?I9Eev35|e{O8n zeR!Z*)VG%NiR|)4OKyhl)J~UiGr#VmYjkk;-S8v-?--?C%=G;DvV(PfKDUg&fQ4G} z@)ngXvb#$vt}7UUI$k+N6WoUk`Xro7-uj&na8S@^ZqbqxpH= zXMc(#LKu_?xUALJWC26A1c-$&L%1NLK=<@U-a`&NtQYKpYnC=WyQHkr!Xj+YC@gtf zVCLnYcT&u(#G0GFdA_e$71f@3Xsub)mP?a=GhT1FacAK%-n{N_45>Ml|MzU&C?3;O zShjGcZ1lGJbeHtM6D?oON{W;FSoG%D)R;*v$7?0`p}H)@@9Ez~Ko?yAVi6>l6_*rc z=A{>dBj2>wkn50xh|BLeNgcT-7@5kHwh1|I5WL%Sv$b^UUwe(VJ4Y5TIej#y)9iDi z-Fx5pj6W~7p4MGIVf~Gj65Q%t#{_Tp-8*eNuP3#&H~Gj`VaI}Fs}iKn_c+yR%JuL% zcYZZ-yTH0EW2WEV<=M%9O}Z{5cc#gC?US7!m2iH=%6a$f6UA={{bdM@I{H+*_QDO8 z_O{N#d91&V8tms&e|@=P9`8-|0B=SnduH634(Ks3*wzT5(6b=2ZfyAhqL+bTTjOFR z-AK6u*#K 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 @@ +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⪚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�fu8 zy-QN1ZE`*0dfH}3f#GTE#mvt=&ImJ7Yv5>)nkyyAbfqS8rLYL0M@DCUVv$x~;fRA% zeNVRDL#}j04-KisXE`^$z6ZfUeICtQJNkEtsjERMF)YrcOl3iN@}~Inn{sbbsvRGM zeK2G?&=dY%R*_hj-bXyQbne75OFpXiz`SkE?W9WmPAlh6?zto1XR~=7CZ}1dMz)uE z8st|W?H%le%dgQnP~b6prxGhi-PC!02RYS8R|7!{V>^-#8t=+~>Z(!lD4qDY^$Ktq z30qQ_L(Q(X28#HC6DWEPExc#F#ShtUz6yf~Fg2A#G@~Yet@6$?IFnqVt};@7XRrF8 z*_(v7BeuVsYMw5TSr@LCVXvucnGj>?T5u#G04g)5Ns)*uy>a7Yt!h zS8K7Q_N`1)iZRdk2DpyuQ{0HrBm>C>7WTf{>@$}9`MP%f_0b@G>d9zfZEYP;-x}?% zG?1v~+~kWm`j$uRcpyv0H~$TI*-4@>PTY`qDhhyi?{ZQhaHt z-$hTEmhm9HV&(J4yku`D8Try_hF8MU@*(BYtkuBBMWWT);x5^mPeW40Ti-79LQmn;8g5({~HTf38pa~d!S1p8GPEs!Mq{m!r|51hv8Dl0WAr(R9woB#H zl(N4Gl>n>fH(_k^sK2d3uAm}!<@GyK-io&1eSm$WN9(B(Tj&&=cO;&5y(vLcqNZon zbJDla+d8u|&ne2dVFYN5A>ic-wgB~0bz}Dtaxizu5o=xZ4Sh9T>&K4;1DE^ZKe4&= zLUY;3_#pil)=rOluz{rAV*ptWGAR^1CcR3XtySJd*q|ql^mUQAp_@mFrSIuDRHP-S zwE1h$>tkq*bwLX)zCMcX{s~^9&QhyaULqeaS#o1Nnc>BcDVk1=IK%wJdr2wu(d5C! zyyP-Y8GY&67VO_oI@o+Rra+zvFZd=d*2=p1?migN84-SnL%}%J1b;zb+lkqsrG}NW zG||TO*(=ius*B(S3BqiT{!S|2PA3lnRmg|ay-*(kmziuBN&$s z>gD9-EX`OK?=xoE$3mQ=&GU3Gd$sOQQj@&r2$1aqRN0|L`R$E9m-W-XOuEqCqGFh` z$+QM9WHQ>Q=cn3jRh(&(?lHi-ud;%LSLGq74mRApv73!^{5_nM)(x;6to% z*dLtP>nI^7r`eN1nYZ2&`|R44moM9#_>iB1k#7)qUr(v7x9%6EB&dFFa|Vo$SkRm_ z<{vG8xlpkf%xKQkeN+4##G2J#Bi2YV~Y}^E5a1`^#Bw zFoes6`o>Eli~W@K@FBQ)HClPegA2$B!l27sgINRoE;RAZ2VDFKUy<`;FPS&UwvOM;qtuxvf~-Rny;8?s*(dqjf>S@bmf6@4j50L*qs5i4mbF zhS=>CwIPv`VWLAv)%fE|#C9QUXwfnJ4(7_O^vy@B048e*m@ABHMGvfFUXBBMYI?og ztcWMzTqNcM+05k;vYME|+5hnPil9Sn_=0*@3e6@FM!v`9qNT?EOijMYLGJswrRCro z?|g2-(9fqeOnol8)WLA5uRo}^@b1^+ec9FJ{vBGdbAp9!C ztRCiyu5Fs22iRW?DLjG%;{Px5{p%wA@6jIqujT)0eg8@SC(Z5uuyAng;k19{xBpB3 zLwftq0RKtd`tJZqf6cl7C%|8k>p%Jb 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 @@ +Z0)TW79dVjcpFmqSBka?Uqc zzAB+Zu4{ZAnqujCQLu}rUUHJ>o<>56-AydtE=6@X8spt)%^pnXn{#ZuJw$DfTPDK$>r^UnJGe10hM(u!En8>Cw1h8N~%pD(~8=aD5P zf^{!x<#hvJENxRV(@vDPV(L;1kq+~ji33}O4Z(Tr1(?&V{7Mfjg*eksyD}GvW3#uO zmZ_CfT8M8)Nukbx?-`BE6eL?UuTWghx)-X4L-F<5w>w-bK5^0{uA9t@2a7QeF3&nI zOu8_TM|$w?dy>}fr(+CwwaCJK7ZYIZOX2863aLsyqW*z4>*Q}ob$#-NTnK*P9v^>J z2Eb+=j$I5?G>rsz7h^AA6V-~SAY;Iu5f?k4Z=fHWuwc)aR)a13`g^f8%4g^L8YuJvfb=~_Q@rBouYMt4&WU%YW>0K+_seeu46#pWASs4Dj8j`!`%O^uXFVHy zhZF=)_k${l*1Uk?QAr%&CW(0AgnH8lp07P-nmqL6+*@PNchlW_8TBs?=(ahmz|Z}D zSviP2Ci;>~+5siJqR>LtJqsgj$zto`Xpo;Jb{XXT+?W=8%I2)91cbXq+E)OL+SFu{ zSV0_;Ng_e03c)+CxWs2RI`l#p+xu2Dfk2Hn#7NG^u@6or70E2a|gn6It0Apr`5~yBK+e=$H?$4#eJh z2Yb=5v-EqWz(pg(}%tv!i9bkWnU6eak6yR1;G7 z3$oQT%~sL9@y^28(+5G0JF8vF_W-lB57FQ4--n z9%MXEgxI{Ryzbc!mm@ArPsOihP*~&3h_B>d-=1D5>lgJ8s$-t3mu9+tSq-i5es|*4 zfh?>V5{9o#9TLO_;i`6#T!_e%96kq<5TS=vAza<+FHb`_5i}z3tP~vR!^FN0lT0=s z?QSAT!e)z1#V&r*829ALnoz7G;bN)vecSQ977rUkUFCUy8G7fzYdf(6vY;XefvUV_ z>)J^_Tf?4wZ%k1>rE)pVnyE<)!Q`h@&B99xYdyr7_7_GO8a}XSWK&?EAxEOL{2m3i zJ?NG&XfO1-VW+6z=bqG`=YtNW!9S`NQ}R|jj!2|;YOLUl5d>(=3B^hDI&G^gX>VDE zg5v+Qj4BRYK0UG5GLy)vcbmW1igENto~@>N9Nj zMB_>!Kl0%qX_r479>>&9)H@d`2_V_#;+%H@(z zx%}!j+V{~lqxtw{l$?F;qbS@xJUsnA0@{^4f*j?z)kjUHF*1I5Q9DinA-^tP zIw&45Kd2+cB4456E2>&lQwGOE4It&I!t%BwY+4lJetIHkTjL6vW=IB-5-pCB7L}m@ z_=lEDyXDOE!fML1@mf8b+^W0H`S6QbQ5UNrM0qiBN06oxB~>yl@L`ffG_x(wjqNJ- zjCvXNAHSya*?89IbJcy&O@KWYH~K9Qqrmim+RQ%Ag`-uoSjK+v?U}%s%4M5$iFeic zB9n~&q&sGt?qv2dSmq+N;_%6-if|!BE2GPsudB(6aebu~-kl3xt5IV$I`9-0^r#p< zkWP0W&EyENd=886Zot7XheeDS?#oF+K*~!t)%2>D#2c8f4Yn`OXI0 z#9+-RPmf8Fu0tC_#K+^NV)iC}sCX#`X=GQ;;5Q6{sAE_I_z-LxQ+@x+&`F9^Z^v+G zGg|Vi5e3tpP^g`*L)F1B;eY;(*?~7~c zf%a~{HpB0U&Z`7}i+5^3Uq*jc`?2=>c;^nnB|OM>SSd6i)4vSp-LdfM+pkUlsaz=rSM(eWs!e{w?0)kyz?PW!DjN7t!P=XUq! zad&Ish6o*xE`o1x9JS;Wzsr$QD>q98EXf3w?}4(kDp@p_Cf*&`d-{{=dG7{2Q67Ar zE+lJ70@MW$ts)YyWHPusWz8$I`*1#D=DJw27`rcTPf%XuT=*9fBdV7cM@xaKt@+b9 zlws-YOI<~mgVq{ynrMp{v;EjGnSs3nLd+W5ETs(6H%|K#ar<5k-mE8DyleIC{S)ov zRe-18hv|~F{Xi{SXoLxba!wb!t-`cG&yHd=B$!td4&1)Y*rE9qsJn+DTmOjsM2*M% z3^1`aT3P@?nxKv-0pwSIX?J}V9Yf!lUq+4GacW()o-W-!r>^)-l+7B=)bgck4Jz!Q zjc;z3Veow!()ClUE~l7Uv%ezP_q-i+XfQ~jqOOx<_mYygX!WFSs0LH?kx|8^<>W8$ zGq7Bn_C+bSr&TUO1u8a8$X;Wa@H3t<3>gpmbb>*1cfLLG4@?tFG}rzt z?T~aT(_o7NQq7Ax#eZzK9^$O zfEB%Q#R%eS2=AVS{8FPB6mQ`lM+R(e0Y6^z}PO)G<5-U|( zFSf(-zu?HYh?fl)Hr(v|7=ELi`Mv^ec&GXW1z`nr5-~mHSIW_y|9Qu)6e(P#w+h$2Rzy}* z4NvQexV8ZTsM&CbMwS`1#UnZr)&qmkpGx_An4xw@#u+6B(leC~xQ;|f(;fKDNlD`= zDg?!Ww#;rpMUxcBIaac(YrR^WGoMj41C*>uVx26#YvMlpSF9#%TJ9-G(T%v;x@d@} zVbM=GNyQzmhqPw+(sxNNiFsX%ca z%Cjcn6CQf;Axp^t#Xls}VL3Ulr8S2Lk_Y>c4SV`)ny}yo#k8s_A3}*`2Kj9`A|kk% z*UTiby$kj#tV?Td0mOo)ugW;nCa?8Ii**(uMVIOO7!r-VM_Ln&_OvrvHlRot9;i)> z*2P6Nj>z#NiK7*?Tf{1M5UNw!S29?VAM4kGVu6=S=_y&SHsDUOfq*J?V5f)!{JnHr zz6^^Zws?_RCSRsO+VBalK?>VfJs5g$71rkSR+(PUWJYxZ}I!nWnySl6uJ|I zwXgGn*S6j0{W^mT#)V!`6t6rj)Sf^q>7I{sG~GI~fR^pGHcn4>Cb35wk-gF!0lBIAfQJ z;l@1pedn0V>aroWt_oh}TyS{bPG#yKD8C@P>i9*S5S87)yP-0Y+`f2EUHJHwhyGNh zKC@xG`*!{vO7TB!5YQhRBz`3|Q40b9*oFcC=>BPg*jSi38aP|n**Y<^u(0a=%N}8} z@o;jmX8QAGf_3ZySTMW~)K|GFFGVJjtu?)bE40}Af#pAoi=<2#6me`49?xN9pb1&q zii_^Eo?Rwu1&mRl`|iihy4Jgd3#A9n{ir`Y>gSAB$0AY~bOb}Mm z`xxy_p{6AdKJMADc{%kSG-K>D%z4TfsI)BA}&XtyIC%5H@~0h9(Rv!5&q zE)sKsJ5e(AKro{NQDo}Wj`G%Ks}dYKM`JR`U;dzm2peLGxm`u6C*Jc#-@P!xP-g5y z1Cl0Jl$bk>VyWS!!!Bi&9%D{bfm<*Ze+%T|eg-X(Vv#=BT=oJn)1f&+j6#SRLbXn@ zYWgnp4OK6sp{?Poms}t8Zl@u3R1M0JvDb3mu7UrsRQa0nwonJo-5J&My!Q19p_<24 zfx*sa-8-v$k<_>5&dTA6T;{23j_S;34XoL9u-i}FpGw(FfqYBcJ`n$vB9hZk6uLhY z_5PuV{(nQ!A95_5P5$Dj5Iknv%YxEzKz}C@$8)o;3||zEmqCXECm+`%drVjTM4Hsa zjI1s#Qv!K_)+C3akIB-OUh}_+V(f-@2Oh${+ zRDFxG!eI3STjyZKD*~1AF|+80z#a!I2Iw+DU?>Lv&$~N^;V)abF4e?L< zVu$V7r+3~8y9liFHoAj_vbtIhBI9;Vehs_166_>8Rl-BP^q3+qnLKKJY^5Lf!$^EL z|4jK`<{*f@VKV?808k(R0Pz2ra(e?a6DQ{XY=%ERVWp;(;|dSj+qtqn&w8#3rK}^G z8M?rsBUKtjJ^GN92bMmP-iD!=tyIW6VnbaJL0w6EbjQvr)@c|7jHUneZF8SWiiROXT1E@M3 zSfTKb#(c>2zvsE=LA{i+Bi|cl$4kfRgWAlDeY0C^(<6tBwx4KrK+R1l4Ky!6;AO*S zG@P6avyL}h6ECrQ*?GW|N5K5@<47$A(W9f?hn?MrS%IvV+l7%E<2xV6tGhlc4O%1#9sQRB zM#y&^#mT&KO~s3nIik^A8zB^iPtB>`bAC&j(j%FX6lHT}r5v)V2-CeTy594)@ujUQ)6A$K$6^rxD>H4vR7DpF zqlw4b2`%KeTDdr3L2BCx-;`q!8swuCi|$Da%d_0%A>!n7%?Jsli5Fn;r05nh7OmP{ z+ONqjkCdU-MeN^8r}|Kup4m6MynhAYyl$x`RSXsE7yn=`vc)V!$#jG^OTxBA9UH2H zQ+jq5zo5^7Vz3oR9G_lI`F%2Zmr*vj-wiLIp5#i|9}*aKiO)r6AgF`tn?{3<|N_&H-VNnQyG%4YaSTtiHgw>SD+mizas$?+VPIMR}4maQ0?N8)ygS3XnXn z(->n@Ncpi0$wR;Q7tjO(pb6|6gs2*lCANs9zRIBEp(2g=e5!|@AoSeRE>x9XFLkn_ z_+~0Co)K(BJq5poS+@Lu(CO#PURHi6rIpn@6@5qxg(pF zZ&0}%C_tT#U3TGtQ2FVa(vh1R6Be44ly35!D0Mt9 zF#`|{0VWh37#&iHJA4@R4iI0jm!%N zlHDd8=7p9G;lTQ|g%I~+F@9+7q0Xrvsjl*2shzVDCBGh>Hpk=@fJ#k)6TaKwIyKw?Oc zOWHH=h5|lXt`aY3S|d}L0g_}(Dw5-*)p$n+{zu+q%p;F5_Ssk6N|`UEiK@lr_LWmy zv)w1O;*DLF{IW0M48$G6r#|EEkW0-yp@>%j%x)NSJQT8li!g36nF!elUL64-YAu(T zjj8Bp>Y0&|8x?@ROCi)`2!rL`*^~iRqU@nyI|3aQ+`yE&`gw0Mf~Q=MbvfnEOM@?0 zhn;^#apTSJrHkcO>H-Wc&h|nNmXZ)N*0O`TzVY*KXRU~3aL8G2$S_5)JY&IbV+q~c zC3p8^vSu6u9c7>eQXvH)f=M5NjL*LOJZT*v)owyQ?gFHGv;8Y08>ZAScBk1e=P?(? zpBBHI-IGx1D)m@kEPdCv(1j1+4yh&2xpYC~cg7*ibevPzlp8Jx4jY>Mt-GP=wEktP z;HqzO<{3Gxvf2A1KWa^u9Vl3ll^VRkuLZHtX`NwJy>YJ9&J|sh&W)>-uVZVHl-Vlf zCR`1%)e#gQYf0>HAzZbOSax5Qkz>alT{v9(5IM{TEhi~Zc!hj2^fs#oLPSVOpFU8J zR&P!fyz)g^X)pQG%6XiNUL!T1q@Jxw)&E}W4m!Yf4)dbUjlD*WrH_d)VT%UN3Le=O<)z=wvn zQz+9#cm@;ASh(klUNGoL1;OC15|`O$m7O$vXv3;c3x!~PG2LzCn=ey(Tg=)|J+*k# zWg4L$4JRP8Y|ZwU^ECd{Ypen6cHCl*?La>5xwuxsI$KtZ zQe|xF+9kkO6a2%f4UH9-6|abId#zhpjbWrZ{(?U~gl%o3WK7k|LK(dlfJr@?dxH#t zT^Ol5HeS*_Fp0;!g@JbRYP3T4%1-{zDgk2lpdSv&uq_axu)nHw@VQX$Oqm*&9!Mzf z&182f*e>bFbc}wks3V_9#WTr~RW}dY*UP=IkqNF&yW0qAPoh*TQlOt!893osO&S4Q z-qZ=+GFzR?s!SQRe)Ns6wnWI-VAqS_1Dfyr_jR}Wf?IJ%*L>a;iqOKpKPf5zg;o03 z?NAtK@@ij{8nK?5*9J~B6=&d=tUZ-0LJAFBY-4z84xc#4f>Wv<^WJIZ(!koA6+yh*lW`+jLFYCMd(w+^1J&dig^TOg34Zzf z)e9;;1=18A!OM4VmJ+WboXt+vD9K>uRuxc-+111lk$JVw;Q8*GkdV3<41P&5g*Vyb zI|;vITxnwY!FW3PXFPr`AXy2JLj}7S)G))_z~Q+;BFiS|$F)5&vxXfVYG;8zzvc8r zYzKN4hE+EZ;88MAU*_*6u((-N#$M;8)Wtb=pF-^8XOS?a*U2$4C_K!~p`CAbW0ma{ zsV?1?`*Rd5t}%TCR=P?wPqt~@hpY5tS)q0TFQ|~}p~LG_ppw>i@3mF-*3Fv;bPj;0 zq$a)TH5L!eEY(Zg#Hy+GqP$P=R8MHqpWyeBP7MPs`p$xbR{Sr6#i!y^7R;AGwFNj4 z^0sT+1!1Iw4`LrHTIW4m!AYCG|5YvQ?O=lS|EL9SNB{uizZ_kyUCb2u)WjZq`T=0 z7gR~!MA66K@OryUDy<2GUbjvB5U`7BvsY1uzrK&x$gF%p+7z`%6sM<4nAX8843l)W(Q1X0w;!$^8|$F& z5N<5tfMeq?kFqBeE+tNe=Cn^q(!4jVi8hr4xLJ;2bJwTdcfh|H`AbYVHP%Y({b54n z4=d>ZhLJxaLN2yP2AUNyil<#1FH^9_1+a_Qm9K2IF^br*D)Z!h(YZiN9ZfYi&#^`FA+iUz{i+7VM-}6@JIXi^hRPJZCPZwu)e!e zt80N#WB39!IX8a!X3jp1k3`j^?R!qQ>rQ?6^&jH_0#XR-|9u1FPip?p)8zlx@Gp(^ zulpGPjQ)4V#s7^30Gj+^Q2!DA|FtpxJMMp1+kfMhq5Us#|JL6B1parL@^4_mKYjY2 z%l)4U^PfO}t^B`t_}@T_ME?Z(yWjtb@b4<}Zv;z{|B3LYJ{4twAO4#3;m_Otr;Pnb H|N8q6th=b} literal 0 HcmV?d00001 diff --git a/Plugins/plugins/rundmail/lib.php b/Plugins/plugins/rundmail/lib.php new file mode 100644 index 00000000..9eb434f3 --- /dev/null +++ b/Plugins/plugins/rundmail/lib.php @@ -0,0 +1,220 @@ + */ +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 @@ +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 T|BsL1|J>Jqy1JV3A8-E!X5H(E literal 0 HcmV?d00001 diff --git a/Plugins/plugins/schichtuebergabe/lib.php b/Plugins/plugins/schichtuebergabe/lib.php new file mode 100644 index 00000000..51c7bad5 --- /dev/null +++ b/Plugins/plugins/schichtuebergabe/lib.php @@ -0,0 +1,237 @@ +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"] +}