diff --git a/Plugins/plugins/community-bot-1.0.1.zip b/Plugins/plugins/community-bot-1.0.1.zip
new file mode 100644
index 00000000..db87a212
Binary files /dev/null and b/Plugins/plugins/community-bot-1.0.1.zip differ
diff --git a/Plugins/plugins/community-bot/lib.php b/Plugins/plugins/community-bot/lib.php
new file mode 100644
index 00000000..e3e18ee2
--- /dev/null
+++ b/Plugins/plugins/community-bot/lib.php
@@ -0,0 +1,354 @@
+ strtolower(trim((string) plugin_setting(COMMUNITYBOT, 'discord_key', ''))),
+ 'discord_app' => trim((string) plugin_setting(COMMUNITYBOT, 'discord_app', '')),
+ 'discord_token' => $secret('discord_token'),
+ 'telegram_token' => $secret('telegram_token'),
+ 'telegram_secret' => $secret('telegram_secret'),
+ 'department_id' => (int) plugin_setting(COMMUNITYBOT, 'department_id', '0'),
+ 'priority_id' => (int) plugin_setting(COMMUNITYBOT, 'priority_id', '0'),
+ 'limit' => max(1, min(50, (int) plugin_setting(COMMUNITYBOT, 'limit', '3'))),
+ 'notify' => plugin_setting(COMMUNITYBOT, 'notify', '1') === '1',
+ ];
+}
+
+function communitybot_set_secret(string $key, string $value): void
+{
+ plugin_set_setting(COMMUNITYBOT, $key, $value === '' ? '' : encrypt_secret($value));
+}
+
+/** HTTP-Aufruf (für Tests austauschbar über $GLOBALS['communitybot_http']). Liefert ['status' => int, 'body' => string]. */
+function communitybot_http(string $method, string $url, array $headers = [], ?string $body = null): array
+{
+ if (isset($GLOBALS['communitybot_http']) && is_callable($GLOBALS['communitybot_http'])) {
+ return ($GLOBALS['communitybot_http'])($method, $url, $headers, $body);
+ }
+ $ctx = stream_context_create(['http' => [
+ 'method' => $method,
+ 'header' => implode("\r\n", $headers),
+ 'content' => $body ?? '',
+ 'timeout' => 8,
+ 'ignore_errors' => true,
+ ]]);
+ $res = @file_get_contents($url, false, $ctx);
+ $status = 0;
+ foreach ($http_response_header ?? [] as $h) if (preg_match('#^HTTP/\S+\s+(\d+)#', $h, $m)) $status = (int) $m[1];
+ return ['status' => $status, 'body' => $res === false ? '' : (string) $res];
+}
+
+function communitybot_json(string $method, string $url, array $headers, ?array $data): array
+{
+ $r = communitybot_http($method, $url, array_merge(['Content-Type: application/json', 'User-Agent: DeskHub-CommunityBot (https://deskhub.example, 1.0)'], $headers), $data === null ? null : json_encode($data, JSON_UNESCAPED_UNICODE));
+ return ['status' => $r['status'], 'json' => json_decode($r['body'], true) ?: [], 'body' => $r['body']];
+}
+
+function communitybot_clip(string $text, int $max): string
+{
+ $text = trim(preg_replace('/\s+/u', ' ', strip_tags(html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'))));
+ return mb_strlen($text) > $max ? mb_substr($text, 0, $max - 1) . '…' : $text;
+}
+
+// ── Gemeinsame Befehle ─────────────────────────────────────────────────────
+
+/** Platzhalter-Adresse; Mails an ".invalid" werden vom Kern nie verschickt. */
+function communitybot_email(string $platform, string $user): string
+{
+ return $platform . '-' . preg_replace('/[^a-z0-9]/i', '', $user) . '@' . $platform . '.invalid';
+}
+
+function communitybot_find_link(PDO $pdo, string $platform, string $user, string $number): ?array
+{
+ $number = ltrim(trim($number), '#');
+ if ($number === '') return null;
+ $s = $pdo->prepare('SELECT t.*, l.ext_chat FROM plugin_communitybot_links l JOIN tickets t ON t.id = l.ticket_id WHERE l.platform = ? AND l.ext_user = ? AND t.ticket_number = ? ORDER BY l.id DESC LIMIT 1');
+ $s->execute([$platform, $user, $number]);
+ return $s->fetch() ?: null;
+}
+
+function communitybot_help(string $platform): string
+{
+ $p = $platform === 'telegram' ? '/ticket Betreff | Nachricht' : '/ticket betreff:… nachricht:…';
+ return "So geht's:\n{$p} – neues Ticket anlegen\n/status Nummer – Stand eines Tickets\n/antwort Nummer Text – Ihrem Ticket etwas hinzufügen";
+}
+
+function communitybot_cmd_ticket(PDO $pdo, string $platform, string $user, string $chat, string $name, string $subject, string $message): string
+{
+ $cfg = communitybot_config();
+ $subject = trim($subject);
+ $message = trim($message);
+ if ($message === '' && $subject !== '') { $message = $subject; $subject = ''; }
+ if ($message === '') return 'Bitte beschreiben Sie Ihr Anliegen: ' . ($platform === 'telegram' ? '/ticket Betreff | Nachricht' : '/ticket mit Betreff und Nachricht');
+ if ($subject === '') $subject = communitybot_clip($message, 60);
+ $subject = mb_substr($subject, 0, 200);
+ $message = mb_substr($message, 0, 4000);
+
+ $c = $pdo->prepare('SELECT COUNT(*) FROM plugin_communitybot_links WHERE platform = ? AND ext_user = ? AND created_at >= ?');
+ $c->execute([$platform, $user, date('Y-m-d H:i:s', time() - 3600)]);
+ if ((int) $c->fetchColumn() >= $cfg['limit']) return 'Sie haben in der letzten Stunde schon ' . $cfg['limit'] . ' Tickets angelegt. Bitte versuchen Sie es später noch einmal oder ergänzen Sie ein bestehendes Ticket mit /antwort.';
+
+ $label = $platform === 'discord' ? 'Discord' : 'Telegram';
+ $name = trim(preg_replace('/[\x00-\x1F]+/u', ' ', $name));
+ if ($name === '') $name = $label . '-Nutzer';
+ try {
+ $r = public_ticket_create($pdo, [
+ 'name' => mb_substr($name, 0, 120),
+ 'email' => communitybot_email($platform, $user),
+ 'subject' => $subject,
+ 'message' => $message,
+ 'departmentId' => $cfg['department_id'] ?: null,
+ 'priorityId' => $cfg['priority_id'] ?: null,
+ ], '0.0.0.0', ['activity' => 'Ticket über ' . $label . ' erstellt', 'tag' => $label]);
+ } catch (AdminModelException $e) {
+ return 'Das Ticket konnte nicht angelegt werden: ' . $e->getMessage();
+ } catch (Throwable $e) {
+ error_log('communitybot ticket: ' . $e->getMessage());
+ return 'Das Ticket konnte gerade nicht angelegt werden. Bitte versuchen Sie es später noch einmal.';
+ }
+ $pdo->prepare('INSERT INTO plugin_communitybot_links (ticket_id, platform, ext_user, ext_chat, ext_name, created_at) VALUES (?,?,?,?,?,?)')
+ ->execute([$r['id'], $platform, $user, $chat, mb_substr($name, 0, 120), db_now()]);
+ $hint = $cfg['notify'] ? ' Wir melden uns hier, sobald es eine Antwort gibt.' : '';
+ return 'Ihr Ticket #' . $r['number'] . ' wurde angelegt.' . $hint . ' Stand abfragen mit /status ' . $r['number'] . '.';
+}
+
+function communitybot_cmd_status(PDO $pdo, string $platform, string $user, string $number): string
+{
+ if (trim($number) === '') {
+ $s = $pdo->prepare('SELECT t.ticket_number, t.subject, t.status FROM plugin_communitybot_links l JOIN tickets t ON t.id = l.ticket_id WHERE l.platform = ? AND l.ext_user = ? ORDER BY l.id DESC LIMIT 5');
+ $s->execute([$platform, $user]);
+ $rows = $s->fetchAll();
+ if (!$rows) return 'Sie haben über diesen Kanal noch kein Ticket angelegt.';
+ return "Ihre letzten Tickets:\n" . implode("\n", array_map(fn($t) => '#' . $t['ticket_number'] . ' – ' . status_label((string) $t['status'])[0] . ' – ' . communitybot_clip((string) $t['subject'], 50), $rows));
+ }
+ $t = communitybot_find_link($pdo, $platform, $user, $number);
+ if (!$t) return 'Dieses Ticket gibt es nicht oder es gehört nicht zu Ihnen.';
+ return 'Ticket #' . $t['ticket_number'] . ' „' . communitybot_clip((string) $t['subject'], 60) . '“: ' . status_label((string) $t['status'])[0] . ' (zuletzt geändert ' . format_dt((string) $t['updated_at']) . ').';
+}
+
+function communitybot_cmd_reply(PDO $pdo, string $platform, string $user, string $number, string $text): string
+{
+ $text = trim($text);
+ if (trim($number) === '' || $text === '') return 'So geht es: /antwort Ticketnummer Ihre Nachricht';
+ $t = communitybot_find_link($pdo, $platform, $user, $number);
+ if (!$t) return 'Dieses Ticket gibt es nicht oder es gehört nicht zu Ihnen.';
+ if ($t['status'] === 'closed') return 'Das Ticket #' . $t['ticket_number'] . ' ist geschlossen. Bitte legen Sie mit /ticket ein neues an.';
+ $now = db_now();
+ $pdo->prepare('INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)')->execute([$t['id'], 'customer', mb_substr($text, 0, 4000), $now]);
+ $reopen = in_array($t['status'], ['answered', 'resolved'], true);
+ $new = $reopen ? 'open' : $t['status'];
+ $pdo->prepare('UPDATE tickets SET status = ?, updated_at = ? WHERE id = ?')->execute([$new, $now, $t['id']]);
+ log_activity($pdo, (int) $t['id'], null, 'Kunde hat geantwortet (' . ($platform === 'discord' ? 'Discord' : 'Telegram') . ')');
+ if ($reopen) log_activity($pdo, (int) $t['id'], null, 'Ticket wieder geöffnet');
+ webhook_dispatch($pdo, 'ticket.replied', (int) $t['id'], ['author' => 'customer']);
+ if ($new !== $t['status']) webhook_dispatch($pdo, 'ticket.status_changed', (int) $t['id'], ['previousStatus' => $t['status']]);
+ return 'Ihre Nachricht wurde zu Ticket #' . $t['ticket_number'] . ' hinzugefügt.';
+}
+
+// ── Discord ────────────────────────────────────────────────────────────────
+
+function communitybot_discord_verify(array $req, string $publicKeyHex): bool
+{
+ $sig = (string) ($req['headers']['x-signature-ed25519'] ?? '');
+ $ts = (string) ($req['headers']['x-signature-timestamp'] ?? '');
+ if ($publicKeyHex === '' || !ctype_xdigit($publicKeyHex) || strlen($publicKeyHex) !== 64 || !ctype_xdigit($sig) || strlen($sig) !== 128 || !ctype_digit($ts)) return false;
+ if (abs(time() - (int) $ts) > 600) return false;
+ if (!function_exists('sodium_crypto_sign_verify_detached')) return false;
+ try {
+ return sodium_crypto_sign_verify_detached(hex2bin($sig), $ts . $req['body'], hex2bin($publicKeyHex));
+ } catch (Throwable $e) {
+ return false;
+ }
+}
+
+function communitybot_discord(array $req): array
+{
+ $cfg = communitybot_config();
+ if (!communitybot_discord_verify($req, $cfg['discord_key'])) return ['status' => 401, 'json' => ['error' => 'Ungültige Signatur.']];
+ $in = json_decode($req['body'], true);
+ if (!is_array($in)) return ['status' => 400, 'json' => ['error' => 'Ungültige Anfrage.']];
+ if (($in['type'] ?? 0) === 1) return ['status' => 200, 'json' => ['type' => 1]];
+ if (($in['type'] ?? 0) !== 2) return ['status' => 200, 'json' => ['type' => 4, 'data' => ['content' => 'Das wird nicht unterstützt.', 'flags' => 64]]];
+
+ $user = $in['member']['user'] ?? $in['user'] ?? [];
+ $uid = (string) ($user['id'] ?? '');
+ if (!preg_match('/^\d{5,25}$/', $uid)) return ['status' => 400, 'json' => ['error' => 'Kein Nutzer.']];
+ $name = (string) ($user['global_name'] ?? $user['username'] ?? '');
+ $opt = [];
+ foreach ((array) ($in['data']['options'] ?? []) as $o) if (isset($o['name'])) $opt[(string) $o['name']] = (string) ($o['value'] ?? '');
+ $pdo = get_db();
+ $chat = (string) ($in['channel_id'] ?? '');
+ switch ((string) ($in['data']['name'] ?? '')) {
+ case 'ticket': $text = communitybot_cmd_ticket($pdo, 'discord', $uid, $chat, $name, $opt['betreff'] ?? '', $opt['nachricht'] ?? ''); break;
+ case 'status': $text = communitybot_cmd_status($pdo, 'discord', $uid, $opt['nummer'] ?? ''); break;
+ case 'antwort': $text = communitybot_cmd_reply($pdo, 'discord', $uid, $opt['nummer'] ?? '', $opt['text'] ?? ''); break;
+ default: $text = communitybot_help('discord');
+ }
+ // flags 64: nur der Absender sieht die Antwort; keine Erwähnungen aus Nutzertext
+ return ['status' => 200, 'json' => ['type' => 4, 'data' => ['content' => mb_substr($text, 0, 1900), 'flags' => 64, 'allowed_mentions' => ['parse' => []]]]];
+}
+
+function communitybot_discord_commands(): array
+{
+ $str = fn(string $n, string $d, bool $req, int $max = 1000) => ['type' => 3, 'name' => $n, 'description' => $d, 'required' => $req, 'max_length' => $max];
+ return [
+ ['name' => 'ticket', 'description' => 'Ein Support-Ticket anlegen', 'options' => [$str('betreff', 'Worum geht es?', true, 100), $str('nachricht', 'Bitte beschreiben Sie Ihr Anliegen', true, 1000)]],
+ ['name' => 'status', 'description' => 'Stand eines Tickets abfragen', 'options' => [$str('nummer', 'Ticketnummer (leer lassen für Ihre letzten)', false, 40)]],
+ ['name' => 'antwort', 'description' => 'Zu einem Ihrer Tickets etwas hinzufügen', 'options' => [$str('nummer', 'Ticketnummer', true, 40), $str('text', 'Ihre Nachricht', true, 1000)]],
+ ];
+}
+
+function communitybot_discord_register(): array
+{
+ $cfg = communitybot_config();
+ if ($cfg['discord_app'] === '' || $cfg['discord_token'] === '') return ['ok' => false, 'message' => 'Bitte Anwendungs-ID und Bot-Token eintragen.'];
+ $r = communitybot_json('PUT', 'https://discord.com/api/v10/applications/' . rawurlencode($cfg['discord_app']) . '/commands', ['Authorization: Bot ' . $cfg['discord_token']], communitybot_discord_commands());
+ if ($r['status'] >= 200 && $r['status'] < 300) return ['ok' => true, 'message' => 'Die Befehle /ticket, /status und /antwort sind bei Discord eingetragen (es kann bis zu einer Stunde dauern, bis sie überall erscheinen).'];
+ return ['ok' => false, 'message' => 'Discord hat abgelehnt (Status ' . $r['status'] . '): ' . communitybot_clip((string) ($r['json']['message'] ?? $r['body']), 200)];
+}
+
+function communitybot_discord_dm(string $userId, string $text): bool
+{
+ $cfg = communitybot_config();
+ if ($cfg['discord_token'] === '') return false;
+ $h = ['Authorization: Bot ' . $cfg['discord_token']];
+ $ch = communitybot_json('POST', 'https://discord.com/api/v10/users/@me/channels', $h, ['recipient_id' => $userId]);
+ $id = (string) ($ch['json']['id'] ?? '');
+ if ($id === '') return false;
+ $m = communitybot_json('POST', 'https://discord.com/api/v10/channels/' . rawurlencode($id) . '/messages', $h, ['content' => mb_substr($text, 0, 1900), 'allowed_mentions' => ['parse' => []]]);
+ return $m['status'] >= 200 && $m['status'] < 300;
+}
+
+// ── Telegram ───────────────────────────────────────────────────────────────
+
+function communitybot_telegram(array $req): array
+{
+ $cfg = communitybot_config();
+ $given = (string) ($req['headers']['x-telegram-bot-api-secret-token'] ?? '');
+ if ($cfg['telegram_secret'] === '' || !hash_equals($cfg['telegram_secret'], $given)) return ['status' => 401, 'json' => ['error' => 'Ungültiges Token.']];
+ $in = json_decode($req['body'], true);
+ $msg = is_array($in) ? ($in['message'] ?? null) : null;
+ if (!is_array($msg) || !isset($msg['chat']['id'], $msg['from']['id']) || !is_string($msg['text'] ?? null)) return ['status' => 200, 'json' => new stdClass()];
+ if (!empty($msg['from']['is_bot'])) return ['status' => 200, 'json' => new stdClass()];
+ if (!preg_match('/^\/([a-z_]+)(?:@\w+)?(?:\s+(.*))?$/isu', trim($msg['text']), $m)) return ['status' => 200, 'json' => new stdClass()];
+
+ $uid = (string) $msg['from']['id'];
+ $chat = (string) $msg['chat']['id'];
+ $name = trim(((string) ($msg['from']['first_name'] ?? '')) . ' ' . ((string) ($msg['from']['last_name'] ?? '')));
+ if ($name === '') $name = (string) ($msg['from']['username'] ?? '');
+ $arg = trim((string) ($m[2] ?? ''));
+ $pdo = get_db();
+ switch (strtolower($m[1])) {
+ case 'ticket':
+ $parts = array_map('trim', explode('|', $arg, 2));
+ $text = communitybot_cmd_ticket($pdo, 'telegram', $uid, $chat, $name, count($parts) === 2 ? $parts[0] : '', count($parts) === 2 ? $parts[1] : $parts[0]);
+ break;
+ case 'status': $text = communitybot_cmd_status($pdo, 'telegram', $uid, $arg); break;
+ case 'antwort':
+ $p = preg_split('/\s+/u', $arg, 2) ?: [''];
+ $text = communitybot_cmd_reply($pdo, 'telegram', $uid, $p[0] ?? '', $p[1] ?? '');
+ break;
+ case 'start': case 'hilfe': case 'help': $text = "Hallo! Ich nehme Ihre Support-Anfragen entgegen.\n" . communitybot_help('telegram'); break;
+ default: return ['status' => 200, 'json' => new stdClass()];
+ }
+ // Antwort direkt im Webhook-Ergebnis: kein zusätzlicher Aufruf nötig
+ $out = ['method' => 'sendMessage', 'chat_id' => $msg['chat']['id'], 'text' => mb_substr($text, 0, 3900)];
+ if (isset($msg['message_id'])) $out['reply_to_message_id'] = $msg['message_id'];
+ return ['status' => 200, 'json' => $out];
+}
+
+function communitybot_telegram_send(string $chatId, string $text): bool
+{
+ $cfg = communitybot_config();
+ if ($cfg['telegram_token'] === '') return false;
+ $r = communitybot_json('POST', 'https://api.telegram.org/bot' . $cfg['telegram_token'] . '/sendMessage', [], ['chat_id' => $chatId, 'text' => mb_substr($text, 0, 3900)]);
+ return $r['status'] >= 200 && $r['status'] < 300;
+}
+
+function communitybot_telegram_register(): array
+{
+ $cfg = communitybot_config();
+ if ($cfg['telegram_token'] === '') return ['ok' => false, 'message' => 'Bitte das Bot-Token von @BotFather eintragen.'];
+ if ($cfg['telegram_secret'] === '') {
+ $cfg['telegram_secret'] = bin2hex(random_bytes(24));
+ communitybot_set_secret('telegram_secret', $cfg['telegram_secret']);
+ }
+ $r = communitybot_json('POST', 'https://api.telegram.org/bot' . $cfg['telegram_token'] . '/setWebhook', [], [
+ 'url' => communitybot_url('telegram'), 'secret_token' => $cfg['telegram_secret'], 'allowed_updates' => ['message'], 'drop_pending_updates' => true,
+ ]);
+ if (!empty($r['json']['ok'])) {
+ communitybot_json('POST', 'https://api.telegram.org/bot' . $cfg['telegram_token'] . '/setMyCommands', [], ['commands' => [
+ ['command' => 'ticket', 'description' => 'Ticket anlegen: /ticket Betreff | Nachricht'],
+ ['command' => 'status', 'description' => 'Stand eines Tickets'],
+ ['command' => 'antwort', 'description' => 'Zu einem Ticket antworten'],
+ ['command' => 'hilfe', 'description' => 'Hilfe'],
+ ]]);
+ return ['ok' => true, 'message' => 'Der Telegram-Bot ist verbunden.'];
+ }
+ return ['ok' => false, 'message' => 'Telegram hat abgelehnt: ' . communitybot_clip((string) ($r['json']['description'] ?? $r['body']), 200)];
+}
+
+function communitybot_url(string $endpoint): string
+{
+ return rtrim(base_url(), '/') . '/api/public/v1/plugin_webhook.php?p=' . COMMUNITYBOT . '&e=' . $endpoint;
+}
+
+// ── Benachrichtigungen ─────────────────────────────────────────────────────
+
+function communitybot_send_to(array $link, string $text): bool
+{
+ return $link['platform'] === 'discord'
+ ? communitybot_discord_dm((string) $link['ext_user'], $text)
+ : communitybot_telegram_send((string) $link['ext_chat'], $text);
+}
+
+function communitybot_links(PDO $pdo, int $ticketId): array
+{
+ // pro Nutzer nur einmal, auch wenn mehrere Verknüpfungen bestehen
+ $s = $pdo->prepare('SELECT platform, ext_user, MAX(ext_chat) AS ext_chat FROM plugin_communitybot_links WHERE ticket_id = ? GROUP BY platform, ext_user');
+ $s->execute([$ticketId]);
+ return $s->fetchAll();
+}
+
+function communitybot_notify_reply(PDO $pdo, int $ticketId): void
+{
+ if (!communitybot_config()['notify']) return;
+ $links = communitybot_links($pdo, $ticketId);
+ if (!$links) return;
+ $t = $pdo->prepare('SELECT ticket_number, is_spam FROM tickets WHERE id = ?');
+ $t->execute([$ticketId]);
+ $ticket = $t->fetch();
+ if (!$ticket || !empty($ticket['is_spam'])) return;
+ $m = $pdo->prepare("SELECT message FROM ticket_threads WHERE ticket_id = ? AND author_type = 'staff' AND is_internal_note = 0 ORDER BY id DESC LIMIT 1");
+ $m->execute([$ticketId]);
+ $msg = (string) $m->fetchColumn();
+ if ($msg === '') return;
+ $text = 'Neue Antwort zu Ticket #' . $ticket['ticket_number'] . ":\n\n" . communitybot_clip($msg, 1200) . "\n\nAntworten: /antwort " . $ticket['ticket_number'] . ' Ihre Nachricht';
+ foreach ($links as $l) {
+ try { communitybot_send_to($l, $text); } catch (Throwable $e) { error_log('communitybot notify: ' . $e->getMessage()); }
+ }
+}
+
+function communitybot_notify_status(PDO $pdo, int $ticketId): void
+{
+ if (!communitybot_config()['notify']) return;
+ $links = communitybot_links($pdo, $ticketId);
+ if (!$links) return;
+ $t = $pdo->prepare('SELECT ticket_number, status, is_spam FROM tickets WHERE id = ?');
+ $t->execute([$ticketId]);
+ $ticket = $t->fetch();
+ if (!$ticket || !empty($ticket['is_spam']) || !in_array($ticket['status'], ['resolved', 'closed'], true)) return;
+ $text = 'Ihr Ticket #' . $ticket['ticket_number'] . ' ist jetzt „' . status_label((string) $ticket['status'])[0] . '“. Wenn noch etwas offen ist, antworten Sie mit /antwort ' . $ticket['ticket_number'] . ' Ihre Nachricht.';
+ foreach ($links as $l) {
+ try { communitybot_send_to($l, $text); } catch (Throwable $e) { error_log('communitybot notify: ' . $e->getMessage()); }
+ }
+}
diff --git a/Plugins/plugins/community-bot/migrations/001_verknuepfung.mysql.sql b/Plugins/plugins/community-bot/migrations/001_verknuepfung.mysql.sql
new file mode 100644
index 00000000..75b31e12
--- /dev/null
+++ b/Plugins/plugins/community-bot/migrations/001_verknuepfung.mysql.sql
@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS plugin_communitybot_links (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ ticket_id INT NOT NULL,
+ platform VARCHAR(20) NOT NULL,
+ ext_user VARCHAR(64) NOT NULL,
+ ext_chat VARCHAR(64) NOT NULL DEFAULT '',
+ ext_name VARCHAR(120) NOT NULL DEFAULT '',
+ created_at DATETIME NOT NULL,
+ KEY idx_plugin_communitybot_ticket (ticket_id),
+ KEY idx_plugin_communitybot_user (platform, ext_user, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
diff --git a/Plugins/plugins/community-bot/migrations/001_verknuepfung.sqlite.sql b/Plugins/plugins/community-bot/migrations/001_verknuepfung.sqlite.sql
new file mode 100644
index 00000000..8574cadb
--- /dev/null
+++ b/Plugins/plugins/community-bot/migrations/001_verknuepfung.sqlite.sql
@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS plugin_communitybot_links (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ticket_id INTEGER NOT NULL,
+ platform TEXT NOT NULL,
+ ext_user TEXT NOT NULL,
+ ext_chat TEXT NOT NULL DEFAULT '',
+ ext_name TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_plugin_communitybot_ticket ON plugin_communitybot_links (ticket_id);
+CREATE INDEX IF NOT EXISTS idx_plugin_communitybot_user ON plugin_communitybot_links (platform, ext_user, created_at)
diff --git a/Plugins/plugins/community-bot/pages/community-bot.php b/Plugins/plugins/community-bot/pages/community-bot.php
new file mode 100644
index 00000000..35b6043a
--- /dev/null
+++ b/Plugins/plugins/community-bot/pages/community-bot.php
@@ -0,0 +1,123 @@
+Discord und Telegram
Diese Seite ist nur für Administratoren zugänglich.
'; return; }
+$message = null;
+$error = null;
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ verify_csrf();
+ if (!$isAdmin) {
+ $error = 'Einstellungen ändern darf nur ein Administrator.';
+ } else {
+ $action = $_POST['action'] ?? '';
+ if ($action === 'save') {
+ $key = strtolower(trim((string) ($_POST['discord_key'] ?? '')));
+ if ($key !== '' && !preg_match('/^[0-9a-f]{64}$/', $key)) {
+ $error = 'Der öffentliche Schlüssel von Discord besteht aus 64 Zeichen (0–9, a–f).';
+ } else {
+ plugin_set_setting(COMMUNITYBOT, 'discord_key', $key);
+ plugin_set_setting(COMMUNITYBOT, 'discord_app', preg_replace('/[^0-9]/', '', (string) ($_POST['discord_app'] ?? '')));
+ // Geheimnisse: leeres Feld lässt den bisherigen Wert stehen
+ foreach (['discord_token', 'telegram_token'] as $k) {
+ $v = trim((string) ($_POST[$k] ?? ''));
+ if ($v !== '') communitybot_set_secret($k, $v);
+ }
+ if (!empty($_POST['clear_discord'])) communitybot_set_secret('discord_token', '');
+ if (!empty($_POST['clear_telegram'])) { communitybot_set_secret('telegram_token', ''); communitybot_set_secret('telegram_secret', ''); }
+ plugin_set_setting(COMMUNITYBOT, 'department_id', (string) (int) ($_POST['department_id'] ?? 0));
+ plugin_set_setting(COMMUNITYBOT, 'priority_id', (string) (int) ($_POST['priority_id'] ?? 0));
+ plugin_set_setting(COMMUNITYBOT, 'limit', (string) max(1, min(50, (int) ($_POST['limit'] ?? 3))));
+ plugin_set_setting(COMMUNITYBOT, 'notify', !empty($_POST['notify']) ? '1' : '0');
+ $message = 'Einstellungen gespeichert.';
+ }
+ } elseif ($action === 'discord_register') {
+ $r = communitybot_discord_register();
+ if ($r['ok']) $message = $r['message']; else $error = $r['message'];
+ } elseif ($action === 'telegram_register') {
+ $r = communitybot_telegram_register();
+ if ($r['ok']) $message = $r['message']; else $error = $r['message'];
+ }
+ }
+}
+
+$cfg = communitybot_config();
+$depts = $pdo->query('SELECT id, name FROM departments ORDER BY name')->fetchAll();
+$prios = $pdo->query('SELECT id, name FROM priorities ORDER BY sort_order')->fetchAll();
+$recent = $pdo->query('SELECT l.*, t.ticket_number, t.subject, t.status FROM plugin_communitybot_links l JOIN tickets t ON t.id = l.ticket_id ORDER BY l.id DESC LIMIT 30')->fetchAll();
+$week = (int) $pdo->query("SELECT COUNT(*) FROM plugin_communitybot_links WHERE created_at >= '" . date('Y-m-d H:i:s', time() - 7 * 86400) . "'")->fetchColumn();
+?>
+
Discord und Telegram
+
= e($message) ?>
+
= e($error) ?>
+
+
+
Zustand
+
= $week ?> Tickets in den letzten 7 Tagen über Discord oder Telegram angelegt.
+
+ Discord: = ($cfg['discord_key'] !== '' ? 'Schlüssel eingetragen' : 'Schlüssel fehlt') ?>
+ · Benachrichtigung per Direktnachricht: = $cfg['discord_token'] !== '' ? 'möglich' : 'Bot-Token fehlt' ?>
+ Telegram: = $cfg['telegram_token'] !== '' ? '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“: = e(communitybot_url('discord')) ?>
+ Telegram wird mit dem Knopf unten automatisch verbunden.
+ 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 = e(datenschutz_frist_label($cfg)) ?> = $cfg['basis'] === 'closed' ? 'nach dem Schließen oder Lösen' : 'nach der Erstellung' ?> werden unwiderruflich entfernt: = e(implode(', ', array_map(fn($k) => datenschutz_field_labels()[$k], $cfg['fields']))) ?>. Eine Wiederherstellung ist nicht möglich= in_array('email', $cfg['fields'], true) ? ' (die Ersatzadresse ist ein Zufallswert)' : '' ?>.
+
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: = $count ?> Ticket(s). Letzter Lauf: = e(plugin_setting(DATENSCHUTZ, 'last_run', '') ? format_dt((string) plugin_setting(DATENSCHUTZ, 'last_run', '')) : 'noch nicht') ?>.
+ Die automatische Anonymisierung ist noch ausgeschaltet. Beim Einschalten würden sofort = $count ?> vorhandene Ticket(s) anonymisiert.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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.
+
+
+
+
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.
+
Wann
Art
Tickets
Hinweis
Von
+
+
= e(format_dt((string) $l['created_at'])) ?>
= e($kinds[$l['kind']] ?? $l['kind']) ?>
= (int) $l['tickets'] ?>
= e((string) $l['note']) ?>
= e((string) ($l['staff_name'] ?? '')) ?>
+
+
+ 1): ?>
+
+ 1): ?>← Neuere ·
+ Seite = $seite ?> von = $pages ?>
+ · Ältere →
+
+
+
+
+
diff --git a/Plugins/plugins/datenschutz/plugin.json b/Plugins/plugins/datenschutz/plugin.json
new file mode 100644
index 00000000..ef42f0d0
--- /dev/null
+++ b/Plugins/plugins/datenschutz/plugin.json
@@ -0,0 +1,9 @@
+{
+ "slug": "datenschutz",
+ "name": "Datenschutz (Anonymisierung und Löschung)",
+ "version": "1.2.0",
+ "description": "Kundendaten aus Tickets ohne Registrierung werden nach einer frei wählbaren Frist (Tage, Wochen, Monate, Jahre; Standard 30 Tage) unwiderruflich anonymisiert. Sie wählen selbst, was: nur Name, nur E-Mail-Adresse, beides oder komplett inklusive IP, Mitleser, Zusatzfelder, Nachrichten und Anhänge. Daten registrierter Kunden bleiben, bis der Kunde die Löschung beantragt; der Administrator führt sie mit einem Klick aus. Alle Einstellungen nur für Administratoren.",
+ "author": "DeskHub",
+ "requires": "1.0.0",
+ "permissions": ["hooks.cron", "ui.menu", "db.migrations"]
+}
diff --git a/Plugins/plugins/datenschutz/plugin.php b/Plugins/plugins/datenschutz/plugin.php
new file mode 100644
index 00000000..494ce7b3
--- /dev/null
+++ b/Plugins/plugins/datenschutz/plugin.php
@@ -0,0 +1,11 @@
+
diff --git a/Plugins/plugins/lang-de/plugin.json b/Plugins/plugins/lang-de/plugin.json
new file mode 100644
index 00000000..5a5fced4
--- /dev/null
+++ b/Plugins/plugins/lang-de/plugin.json
@@ -0,0 +1,17 @@
+{
+ "slug": "lang-de",
+ "name": "Deutsch (Sprachpaket)",
+ "version": "1.0.0",
+ "description": "Deutsch ist die Quellsprache von DeskHub und immer eingebaut. Dieses Paket macht Deutsch im Sprachwechsler sichtbar und dient als Vorlage für eigene Sprachpakete (plugin.json, plugin.php, flag.svg, strings.json).",
+ "author": "DeskHub",
+ "entry": "plugin.php",
+ "permissions": [],
+ "language": {
+ "code": "de",
+ "name": "Deutsch",
+ "flag": "flag.svg",
+ "strings": "strings.json",
+ "htmlLang": "de"
+ },
+ "requires": "1.0.35"
+}
\ No newline at end of file
diff --git a/Plugins/plugins/lang-de/plugin.php b/Plugins/plugins/lang-de/plugin.php
new file mode 100644
index 00000000..1e6d704f
--- /dev/null
+++ b/Plugins/plugins/lang-de/plugin.php
@@ -0,0 +1,2 @@
+
diff --git a/Plugins/plugins/lang-en/plugin.json b/Plugins/plugins/lang-en/plugin.json
new file mode 100644
index 00000000..414c45d4
--- /dev/null
+++ b/Plugins/plugins/lang-en/plugin.json
@@ -0,0 +1,17 @@
+{
+ "slug": "lang-en",
+ "name": "English (Sprachpaket)",
+ "version": "1.0.1",
+ "description": "Englische Oberfläche für Kundenportal und Mitarbeiterbereich. Nach der Aktivierung erscheint die britische Flagge zum Umschalten. Nicht übersetzte Texte bleiben deutsch.",
+ "author": "DeskHub",
+ "entry": "plugin.php",
+ "permissions": [],
+ "language": {
+ "code": "en",
+ "name": "English",
+ "flag": "flag.svg",
+ "strings": "strings.json",
+ "htmlLang": "en"
+ },
+ "requires": "1.0.35"
+}
\ No newline at end of file
diff --git a/Plugins/plugins/lang-en/plugin.php b/Plugins/plugins/lang-en/plugin.php
new file mode 100644
index 00000000..64b90daf
--- /dev/null
+++ b/Plugins/plugins/lang-en/plugin.php
@@ -0,0 +1,2 @@
+ */
+function rundmail_presets(): array
+{
+ return [
+ 'wartung' => ['Geplante Wartung', "Guten Tag {name},\n\nwir führen am [Datum] von [Uhrzeit] bis [Uhrzeit] eine Wartung durch. In dieser Zeit ist unser Dienst möglicherweise nicht erreichbar.\n\nIhr offenes Ticket bleibt davon unberührt, wir melden uns danach bei Ihnen.\n\nVielen Dank für Ihr Verständnis."],
+ 'stoerung' => ['Aktuelle Störung', "Guten Tag {name},\n\nzurzeit gibt es eine Störung: [Beschreibung]. Wir arbeiten an der Behebung und halten Sie auf dem Laufenden.\n\nIhr offenes Ticket ist dadurch möglicherweise ebenfalls betroffen. Sie müssen nichts weiter tun.\n\nVielen Dank für Ihre Geduld."],
+ 'behoben' => ['Störung behoben', "Guten Tag {name},\n\ndie Störung ist behoben. Alles läuft wieder wie gewohnt.\n\nSollte bei Ihnen noch etwas nicht funktionieren, antworten Sie einfach auf Ihr Ticket.\n\nVielen Dank für Ihre Geduld."],
+ ];
+}
+
+/** Gültige Adresse eines echten Kunden (keine Platzhalter, keine anonymisierten oder gesperrten Adressen). */
+function rundmail_usable_email(string $email): bool
+{
+ $email = strtolower(trim($email));
+ if (!filter_var($email, FILTER_VALIDATE_EMAIL)) return false;
+ if (str_ends_with($email, '.invalid') || str_ends_with($email, '@deleted.local') || str_starts_with($email, 'anon_')) return false;
+ if (function_exists('is_banned') && is_banned($email)) return false;
+ return true;
+}
+
+/** Abteilungen, an deren Kunden der Mitarbeiter schreiben darf (null = alle). */
+function rundmail_allowed_departments(array $staff): ?array
+{
+ $ids = staff_department_ids($staff);
+ return $ids === null ? null : array_map('intval', $ids);
+}
+
+/** Auswahl für das Formular. @return list */
+function rundmail_groups(PDO $pdo, array $staff): array
+{
+ $allowed = rundmail_allowed_departments($staff);
+ $out = [['key' => 'open', 'label' => 'Alle Kunden mit offenen Tickets' . ($allowed === null ? '' : ' (meiner Abteilungen)')]];
+ foreach ($pdo->query('SELECT id, name FROM departments ORDER BY name')->fetchAll() as $d) {
+ if ($allowed !== null && !in_array((int) $d['id'], $allowed, true)) continue;
+ $out[] = ['key' => 'department:' . $d['id'], 'label' => 'Kunden mit offenen Tickets in Abteilung „' . $d['name'] . '“'];
+ }
+ foreach ($pdo->query('SELECT id, name FROM organizations ORDER BY name')->fetchAll() as $o) {
+ $out[] = ['key' => 'organization:' . $o['id'], 'label' => 'Organisation „' . $o['name'] . '“' . ($allowed === null ? ' (alle Mitglieder)' : ' (Mitglieder mit offenem Ticket)')];
+ }
+ return $out;
+}
+
+/** @return array{type:string,id:?int,label:string}|null */
+function rundmail_parse_group(PDO $pdo, string $key): ?array
+{
+ if ($key === 'open') return ['type' => 'open', 'id' => null, 'label' => 'Kunden mit offenen Tickets'];
+ if (!preg_match('/^(department|organization):(\d+)$/', $key, $m)) return null;
+ $table = $m[1] === 'department' ? 'departments' : 'organizations';
+ $s = $pdo->prepare("SELECT name FROM {$table} WHERE id = ?");
+ $s->execute([(int) $m[2]]);
+ $name = $s->fetchColumn();
+ if ($name === false) return null;
+ return ['type' => $m[1], 'id' => (int) $m[2], 'label' => ($m[1] === 'department' ? 'Abteilung ' : 'Organisation ') . $name];
+}
+
+/** Empfänger der Gruppe, einmal je Adresse. @return list */
+function rundmail_recipients(PDO $pdo, array $staff, string $type, ?int $id): array
+{
+ $allowed = rundmail_allowed_departments($staff);
+ if ($type === 'department' && $allowed !== null && !in_array((int) $id, $allowed, true)) return [];
+ $open = "t.status NOT IN ('resolved','closed') AND t.is_spam = 0 AND t.merged_into IS NULL";
+ $params = [];
+ $deptSql = '';
+ if ($type === 'department') { $deptSql = ' AND t.department_id = ?'; $params[] = (int) $id; }
+ elseif ($allowed !== null) {
+ if (!$allowed) return [];
+ $deptSql = ' AND t.department_id IN (' . implode(',', array_fill(0, count($allowed), '?')) . ')';
+ $params = array_merge($params, $allowed);
+ }
+ if ($type === 'organization') {
+ if ($allowed === null) {
+ $stmt = $pdo->prepare('SELECT email, name FROM users WHERE organization_id = ? ORDER BY id');
+ $stmt->execute([(int) $id]);
+ } else {
+ $stmt = $pdo->prepare("SELECT DISTINCT u.email, u.name FROM users u JOIN tickets t ON t.user_id = u.id WHERE u.organization_id = ? AND {$open}{$deptSql} ORDER BY u.id");
+ $stmt->execute(array_merge([(int) $id], $params));
+ }
+ } else {
+ $stmt = $pdo->prepare("SELECT t.requester_email AS email, t.requester_name AS name FROM tickets t WHERE {$open}{$deptSql} ORDER BY t.id");
+ $stmt->execute($params);
+ }
+ $seen = [];
+ $out = [];
+ foreach ($stmt->fetchAll() as $r) {
+ $mail = strtolower(trim((string) $r['email']));
+ if (isset($seen[$mail]) || !rundmail_usable_email($mail)) continue;
+ $seen[$mail] = true;
+ $out[] = ['email' => $mail, 'name' => trim((string) $r['name'])];
+ }
+ return $out;
+}
+
+/** Betreff und Mail-Text für einen Empfänger. @return array{0:string,1:string} */
+function rundmail_render(array $msg, string $name): array
+{
+ $name = $name !== '' ? $name : 'Kunde';
+ $text = str_replace('{name}', $name, (string) $msg['body']);
+ $html = '
' . nl2br(e($text)) . '
'
+ . '
Sie erhalten diese Nachricht, weil Sie bei uns ein offenes Ticket haben bzw. zur genannten Kundengruppe gehören. Bitte antworten Sie bei Fragen auf Ihr Ticket.
';
+ if (function_exists('email_wrap')) $html = email_wrap($html);
+ return [(string) $msg['subject'], $html];
+}
+
+/** Versand einer einzelnen Mail (für Tests austauschbar über $GLOBALS['rundmail_send']). */
+function rundmail_send(string $to, string $subject, string $html): bool
+{
+ if (isset($GLOBALS['rundmail_send']) && is_callable($GLOBALS['rundmail_send'])) return (bool) ($GLOBALS['rundmail_send'])($to, $subject, $html);
+ return (bool) send_mail($to, $subject, $html);
+}
+
+/** Prüft Betreff und Text. */
+function rundmail_validate(string $subject, string $body): ?string
+{
+ if (trim($subject) === '' || mb_strlen($subject) > 150) return 'Bitte einen Betreff angeben (höchstens 150 Zeichen).';
+ if (trim($body) === '' || mb_strlen($body) > 5000) return 'Bitte einen Text angeben (höchstens 5000 Zeichen).';
+ if (preg_match('/[\r\n]/', $subject)) return 'Der Betreff darf nur eine Zeile sein.';
+ return null;
+}
+
+/** Legt die Nachricht an und startet den Versand. @return array{ok:bool,error?:string,id?:int,total?:int} */
+function rundmail_create(PDO $pdo, array $staff, string $groupKey, string $subject, string $body): array
+{
+ if ($err = rundmail_validate($subject, $body)) return ['ok' => false, 'error' => $err];
+ $group = rundmail_parse_group($pdo, $groupKey);
+ if (!$group) return ['ok' => false, 'error' => 'Bitte eine Empfängergruppe wählen.'];
+ $isAdmin = ($staff['role'] ?? '') === 'admin';
+ $recent = $pdo->prepare('SELECT COUNT(*) FROM plugin_rundmail_messages WHERE sender_id = ? AND created_at >= ?');
+ $recent->execute([(int) $staff['id'], date('Y-m-d H:i:s', time() - 3600)]);
+ if ((int) $recent->fetchColumn() >= ($isAdmin ? 20 : 5)) return ['ok' => false, 'error' => 'Sie haben in der letzten Stunde schon viele Rundmails verschickt. Bitte später noch einmal.'];
+ $rcpt = rundmail_recipients($pdo, $staff, $group['type'], $group['id']);
+ if (!$rcpt) return ['ok' => false, 'error' => 'In dieser Gruppe gibt es keine Empfänger (oder Sie dürfen ihr nicht schreiben).'];
+ $max = $isAdmin ? 2000 : 200;
+ if (count($rcpt) > $max) return ['ok' => false, 'error' => 'Zu viele Empfänger (' . count($rcpt) . '). Erlaubt sind höchstens ' . $max . '.' . ($isAdmin ? '' : ' Größere Verteiler darf nur ein Administrator senden.')];
+ $pdo->prepare("INSERT INTO plugin_rundmail_messages (subject, body, group_type, group_id, group_label, sender_id, sender_name, status, total, created_at) VALUES (?,?,?,?,?,?,?,'sending',?,?)")
+ ->execute([trim($subject), trim($body), $group['type'], $group['id'], mb_substr($group['label'], 0, 190), (int) $staff['id'], mb_substr((string) $staff['name'], 0, 120), count($rcpt), db_now()]);
+ $id = (int) $pdo->lastInsertId();
+ $ins = $pdo->prepare('INSERT INTO plugin_rundmail_queue (message_id, email, name) VALUES (?,?,?)');
+ foreach ($rcpt as $r) $ins->execute([$id, $r['email'], mb_substr($r['name'], 0, 120)]);
+ rundmail_process($pdo, $id, RUNDMAIL_BATCH);
+ return ['ok' => true, 'id' => $id, 'total' => count($rcpt)];
+}
+
+/** Verschickt die nächsten Mails (einer Nachricht oder der ältesten offenen). @return int Zahl der bearbeiteten Empfänger */
+function rundmail_process(PDO $pdo, ?int $messageId = null, int $batch = RUNDMAIL_BATCH): int
+{
+ if ($messageId === null) {
+ $messageId = (int) $pdo->query("SELECT id FROM plugin_rundmail_messages WHERE status = 'sending' ORDER BY id LIMIT 1")->fetchColumn();
+ if (!$messageId) return 0;
+ }
+ $m = $pdo->prepare("SELECT * FROM plugin_rundmail_messages WHERE id = ? AND status = 'sending'");
+ $m->execute([$messageId]);
+ $msg = $m->fetch();
+ if (!$msg) return 0;
+ $q = $pdo->prepare("SELECT * FROM plugin_rundmail_queue WHERE message_id = ? AND status = 'pending' ORDER BY id LIMIT " . max(1, $batch));
+ $q->execute([$messageId]);
+ $n = 0;
+ foreach ($q->fetchAll() as $row) {
+ [$subject, $html] = rundmail_render($msg, (string) $row['name']);
+ try {
+ $ok = rundmail_send((string) $row['email'], $subject, $html);
+ $err = $ok ? null : 'Versand fehlgeschlagen (E-Mail-Versand eingerichtet?)';
+ } catch (Throwable $e) {
+ $ok = false;
+ $err = mb_substr($e->getMessage(), 0, 240);
+ }
+ $pdo->prepare('UPDATE plugin_rundmail_queue SET status = ?, error = ?, sent_at = ? WHERE id = ?')->execute([$ok ? 'sent' : 'failed', $err, db_now(), $row['id']]);
+ $n++;
+ }
+ $c = $pdo->prepare("SELECT SUM(CASE WHEN status = 'sent' THEN 1 ELSE 0 END), SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) FROM plugin_rundmail_queue WHERE message_id = ?");
+ $c->execute([$messageId]);
+ [$sent, $failed, $pending] = array_map('intval', array_values($c->fetch(PDO::FETCH_NUM)));
+ if ($pending === 0) $pdo->prepare("UPDATE plugin_rundmail_messages SET sent = ?, failed = ?, status = 'done', finished_at = ? WHERE id = ?")->execute([$sent, $failed, db_now(), $messageId]);
+ else $pdo->prepare('UPDATE plugin_rundmail_messages SET sent = ?, failed = ? WHERE id = ?')->execute([$sent, $failed, $messageId]);
+ return $n;
+}
+
+/** Hintergrundlauf: nächste Mails der laufenden Nachrichten versenden. */
+function rundmail_tick(PDO $pdo): int
+{
+ return rundmail_process($pdo, null, RUNDMAIL_BATCH);
+}
+
+/** Bricht den Versand ab (noch nicht gesendete Mails entfallen). Verfasser oder Administrator. */
+function rundmail_cancel(PDO $pdo, array $staff, int $id): bool
+{
+ $s = $pdo->prepare("SELECT sender_id FROM plugin_rundmail_messages WHERE id = ? AND status = 'sending'");
+ $s->execute([$id]);
+ $owner = $s->fetchColumn();
+ if ($owner === false || (($staff['role'] ?? '') !== 'admin' && (int) $owner !== (int) $staff['id'])) return false;
+ $pdo->prepare("UPDATE plugin_rundmail_queue SET status = 'cancelled' WHERE message_id = ? AND status = 'pending'")->execute([$id]);
+ $c = $pdo->prepare("SELECT SUM(CASE WHEN status = 'sent' THEN 1 ELSE 0 END), SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) FROM plugin_rundmail_queue WHERE message_id = ?");
+ $c->execute([$id]);
+ [$sent, $failed] = array_map('intval', array_values($c->fetch(PDO::FETCH_NUM)));
+ $pdo->prepare("UPDATE plugin_rundmail_messages SET status = 'cancelled', sent = ?, failed = ?, finished_at = ? WHERE id = ?")->execute([$sent, $failed, db_now(), $id]);
+ return true;
+}
+
+/** Probemail an den angemeldeten Mitarbeiter. */
+function rundmail_test(array $staff, string $subject, string $body): array
+{
+ if ($err = rundmail_validate($subject, $body)) return ['ok' => false, 'error' => $err];
+ [$s, $html] = rundmail_render(['subject' => '[TEST] ' . trim($subject), 'body' => trim($body)], (string) $staff['name']);
+ $ok = rundmail_send((string) $staff['email'], $s, $html);
+ return $ok ? ['ok' => true] : ['ok' => false, 'error' => 'Die Probemail konnte nicht gesendet werden (E-Mail-Versand eingerichtet?).'];
+}
+
+/** Verlauf: Administratoren sehen alle, Mitarbeiter ihre eigenen. */
+function rundmail_history(PDO $pdo, array $staff, int $limit = 40): array
+{
+ if (($staff['role'] ?? '') === 'admin') return $pdo->query('SELECT * FROM plugin_rundmail_messages ORDER BY id DESC LIMIT ' . max(1, $limit))->fetchAll();
+ $s = $pdo->prepare('SELECT * FROM plugin_rundmail_messages WHERE sender_id = ? ORDER BY id DESC LIMIT ' . max(1, $limit));
+ $s->execute([(int) $staff['id']]);
+ return $s->fetchAll();
+}
diff --git a/Plugins/plugins/rundmail/migrations/001_rundmail.mysql.sql b/Plugins/plugins/rundmail/migrations/001_rundmail.mysql.sql
new file mode 100644
index 00000000..48b93915
--- /dev/null
+++ b/Plugins/plugins/rundmail/migrations/001_rundmail.mysql.sql
@@ -0,0 +1,27 @@
+CREATE TABLE IF NOT EXISTS plugin_rundmail_messages (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ subject VARCHAR(200) NOT NULL,
+ body TEXT NOT NULL,
+ group_type VARCHAR(20) NOT NULL,
+ group_id INT NULL,
+ group_label VARCHAR(190) NOT NULL DEFAULT '',
+ sender_id INT NULL,
+ sender_name VARCHAR(120) NOT NULL DEFAULT '',
+ status VARCHAR(12) NOT NULL DEFAULT 'sending',
+ total INT NOT NULL DEFAULT 0,
+ sent INT NOT NULL DEFAULT 0,
+ failed INT NOT NULL DEFAULT 0,
+ created_at DATETIME NOT NULL,
+ finished_at DATETIME NULL,
+ KEY idx_plugin_rundmail_msg_status (status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+CREATE TABLE IF NOT EXISTS plugin_rundmail_queue (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ message_id INT NOT NULL,
+ email VARCHAR(190) NOT NULL,
+ name VARCHAR(120) NOT NULL DEFAULT '',
+ status VARCHAR(10) NOT NULL DEFAULT 'pending',
+ error VARCHAR(250) NULL,
+ sent_at DATETIME NULL,
+ KEY idx_plugin_rundmail_queue (message_id, status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
diff --git a/Plugins/plugins/rundmail/migrations/001_rundmail.sqlite.sql b/Plugins/plugins/rundmail/migrations/001_rundmail.sqlite.sql
new file mode 100644
index 00000000..220163b9
--- /dev/null
+++ b/Plugins/plugins/rundmail/migrations/001_rundmail.sqlite.sql
@@ -0,0 +1,27 @@
+CREATE TABLE IF NOT EXISTS plugin_rundmail_messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ subject TEXT NOT NULL,
+ body TEXT NOT NULL,
+ group_type TEXT NOT NULL,
+ group_id INTEGER NULL,
+ group_label TEXT NOT NULL DEFAULT '',
+ sender_id INTEGER NULL,
+ sender_name TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'sending',
+ total INTEGER NOT NULL DEFAULT 0,
+ sent INTEGER NOT NULL DEFAULT 0,
+ failed INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL,
+ finished_at TEXT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_plugin_rundmail_msg_status ON plugin_rundmail_messages (status);
+CREATE TABLE IF NOT EXISTS plugin_rundmail_queue (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ message_id INTEGER NOT NULL,
+ email TEXT NOT NULL,
+ name TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'pending',
+ error TEXT NULL,
+ sent_at TEXT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_plugin_rundmail_queue ON plugin_rundmail_queue (message_id, status)
diff --git a/Plugins/plugins/rundmail/pages/rundmail.php b/Plugins/plugins/rundmail/pages/rundmail.php
new file mode 100644
index 00000000..1f9ae23c
--- /dev/null
+++ b/Plugins/plugins/rundmail/pages/rundmail.php
@@ -0,0 +1,110 @@
+ (string) ($_POST['group'] ?? ''), 'subject' => (string) ($_POST['subject'] ?? ''), 'body' => (string) ($_POST['body'] ?? '')];
+$preview = null;
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ verify_csrf();
+ $action = $_POST['action'] ?? '';
+ if ($action === 'preview') {
+ $g = rundmail_parse_group($pdo, $form['group']);
+ $err = rundmail_validate($form['subject'], $form['body']);
+ if (!$g) $error = 'Bitte eine Empfängergruppe wählen.';
+ elseif ($err) $error = $err;
+ else {
+ $rcpt = rundmail_recipients($pdo, $staff, $g['type'], $g['id']);
+ if (!$rcpt) $error = 'In dieser Gruppe gibt es keine Empfänger (oder Sie dürfen ihr nicht schreiben).';
+ else $preview = ['group' => $g, 'rcpt' => $rcpt];
+ }
+ } elseif ($action === 'send') {
+ if (empty($_POST['confirm'])) { $error = 'Bitte bestätigen, dass Text und Empfänger geprüft sind.'; }
+ else {
+ $r = rundmail_create($pdo, $staff, $form['group'], $form['subject'], $form['body']);
+ if ($r['ok']) {
+ $message = 'Die Nachricht geht an ' . $r['total'] . ' Empfänger. Der Versand läuft im Hintergrund, den Stand sehen Sie unten.';
+ $form = ['group' => '', 'subject' => '', 'body' => ''];
+ } else $error = $r['error'];
+ }
+ } elseif ($action === 'test') {
+ $r = rundmail_test($staff, $form['subject'], $form['body']);
+ if ($r['ok']) $message = 'Die Probemail ist an ' . $staff['email'] . ' unterwegs.'; else $error = $r['error'];
+ } elseif ($action === 'cancel') {
+ if (rundmail_cancel($pdo, $staff, (int) ($_POST['id'] ?? 0))) $message = 'Der Versand wurde abgebrochen.'; else $error = 'Dieser Versand lässt sich nicht abbrechen.';
+ }
+}
+// Offene Versände weiterschieben, damit der Stand beim Ansehen nicht stehen bleibt
+if ($_SERVER['REQUEST_METHOD'] !== 'POST') rundmail_tick($pdo);
+
+$groups = rundmail_groups($pdo, $staff);
+$history = rundmail_history($pdo, $staff);
+$presets = rundmail_presets();
+$statusLabels = ['sending' => 'Läuft', 'done' => 'Fertig', 'cancelled' => 'Abgebrochen'];
+?>
+
';
+ }
+ // 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 .= '
Ab = (int) $cfg['spam_at'] ?> Punkten gilt ein Ticket als Spam= $cfg['auto'] ? ' und wird automatisch abgelegt' : ' (nur markiert, die automatische Ablage ist aus)' ?>, ab = (int) $cfg['suspect_at'] ?> bekommt es den Tag „Spam-Verdacht“. Die Filterung ist = $cfg['enabled'] ? 'eingeschaltet' : 'ausgeschaltet' ?>.