Upload via GUI (94 Dateien)
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,354 @@
|
|||||||
|
<?php
|
||||||
|
// Community-Bot: Discord- und Telegram-Anbindung. Alle Funktionen beginnen mit communitybot_.
|
||||||
|
|
||||||
|
const COMMUNITYBOT = 'community-bot';
|
||||||
|
|
||||||
|
function communitybot_config(): array
|
||||||
|
{
|
||||||
|
$secret = static function (string $key): string {
|
||||||
|
$v = (string) plugin_setting(COMMUNITYBOT, $key, '');
|
||||||
|
if ($v === '') return '';
|
||||||
|
try { return decrypt_secret($v); } catch (Throwable $e) { return ''; }
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
'discord_key' => strtolower(trim((string) plugin_setting(COMMUNITYBOT, 'discord_key', ''))),
|
||||||
|
'discord_app' => trim((string) plugin_setting(COMMUNITYBOT, 'discord_app', '')),
|
||||||
|
'discord_token' => $secret('discord_token'),
|
||||||
|
'telegram_token' => $secret('telegram_token'),
|
||||||
|
'telegram_secret' => $secret('telegram_secret'),
|
||||||
|
'department_id' => (int) plugin_setting(COMMUNITYBOT, 'department_id', '0'),
|
||||||
|
'priority_id' => (int) plugin_setting(COMMUNITYBOT, 'priority_id', '0'),
|
||||||
|
'limit' => max(1, min(50, (int) plugin_setting(COMMUNITYBOT, 'limit', '3'))),
|
||||||
|
'notify' => plugin_setting(COMMUNITYBOT, 'notify', '1') === '1',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function communitybot_set_secret(string $key, string $value): void
|
||||||
|
{
|
||||||
|
plugin_set_setting(COMMUNITYBOT, $key, $value === '' ? '' : encrypt_secret($value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTTP-Aufruf (für Tests austauschbar über $GLOBALS['communitybot_http']). Liefert ['status' => int, 'body' => string]. */
|
||||||
|
function communitybot_http(string $method, string $url, array $headers = [], ?string $body = null): array
|
||||||
|
{
|
||||||
|
if (isset($GLOBALS['communitybot_http']) && is_callable($GLOBALS['communitybot_http'])) {
|
||||||
|
return ($GLOBALS['communitybot_http'])($method, $url, $headers, $body);
|
||||||
|
}
|
||||||
|
$ctx = stream_context_create(['http' => [
|
||||||
|
'method' => $method,
|
||||||
|
'header' => implode("\r\n", $headers),
|
||||||
|
'content' => $body ?? '',
|
||||||
|
'timeout' => 8,
|
||||||
|
'ignore_errors' => true,
|
||||||
|
]]);
|
||||||
|
$res = @file_get_contents($url, false, $ctx);
|
||||||
|
$status = 0;
|
||||||
|
foreach ($http_response_header ?? [] as $h) if (preg_match('#^HTTP/\S+\s+(\d+)#', $h, $m)) $status = (int) $m[1];
|
||||||
|
return ['status' => $status, 'body' => $res === false ? '' : (string) $res];
|
||||||
|
}
|
||||||
|
|
||||||
|
function communitybot_json(string $method, string $url, array $headers, ?array $data): array
|
||||||
|
{
|
||||||
|
$r = communitybot_http($method, $url, array_merge(['Content-Type: application/json', 'User-Agent: DeskHub-CommunityBot (https://deskhub.example, 1.0)'], $headers), $data === null ? null : json_encode($data, JSON_UNESCAPED_UNICODE));
|
||||||
|
return ['status' => $r['status'], 'json' => json_decode($r['body'], true) ?: [], 'body' => $r['body']];
|
||||||
|
}
|
||||||
|
|
||||||
|
function communitybot_clip(string $text, int $max): string
|
||||||
|
{
|
||||||
|
$text = trim(preg_replace('/\s+/u', ' ', strip_tags(html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'))));
|
||||||
|
return mb_strlen($text) > $max ? mb_substr($text, 0, $max - 1) . '…' : $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gemeinsame Befehle ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Platzhalter-Adresse; Mails an ".invalid" werden vom Kern nie verschickt. */
|
||||||
|
function communitybot_email(string $platform, string $user): string
|
||||||
|
{
|
||||||
|
return $platform . '-' . preg_replace('/[^a-z0-9]/i', '', $user) . '@' . $platform . '.invalid';
|
||||||
|
}
|
||||||
|
|
||||||
|
function communitybot_find_link(PDO $pdo, string $platform, string $user, string $number): ?array
|
||||||
|
{
|
||||||
|
$number = ltrim(trim($number), '#');
|
||||||
|
if ($number === '') return null;
|
||||||
|
$s = $pdo->prepare('SELECT t.*, l.ext_chat FROM plugin_communitybot_links l JOIN tickets t ON t.id = l.ticket_id WHERE l.platform = ? AND l.ext_user = ? AND t.ticket_number = ? ORDER BY l.id DESC LIMIT 1');
|
||||||
|
$s->execute([$platform, $user, $number]);
|
||||||
|
return $s->fetch() ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function communitybot_help(string $platform): string
|
||||||
|
{
|
||||||
|
$p = $platform === 'telegram' ? '/ticket Betreff | Nachricht' : '/ticket betreff:… nachricht:…';
|
||||||
|
return "So geht's:\n{$p} – neues Ticket anlegen\n/status Nummer – Stand eines Tickets\n/antwort Nummer Text – Ihrem Ticket etwas hinzufügen";
|
||||||
|
}
|
||||||
|
|
||||||
|
function communitybot_cmd_ticket(PDO $pdo, string $platform, string $user, string $chat, string $name, string $subject, string $message): string
|
||||||
|
{
|
||||||
|
$cfg = communitybot_config();
|
||||||
|
$subject = trim($subject);
|
||||||
|
$message = trim($message);
|
||||||
|
if ($message === '' && $subject !== '') { $message = $subject; $subject = ''; }
|
||||||
|
if ($message === '') return 'Bitte beschreiben Sie Ihr Anliegen: ' . ($platform === 'telegram' ? '/ticket Betreff | Nachricht' : '/ticket mit Betreff und Nachricht');
|
||||||
|
if ($subject === '') $subject = communitybot_clip($message, 60);
|
||||||
|
$subject = mb_substr($subject, 0, 200);
|
||||||
|
$message = mb_substr($message, 0, 4000);
|
||||||
|
|
||||||
|
$c = $pdo->prepare('SELECT COUNT(*) FROM plugin_communitybot_links WHERE platform = ? AND ext_user = ? AND created_at >= ?');
|
||||||
|
$c->execute([$platform, $user, date('Y-m-d H:i:s', time() - 3600)]);
|
||||||
|
if ((int) $c->fetchColumn() >= $cfg['limit']) return 'Sie haben in der letzten Stunde schon ' . $cfg['limit'] . ' Tickets angelegt. Bitte versuchen Sie es später noch einmal oder ergänzen Sie ein bestehendes Ticket mit /antwort.';
|
||||||
|
|
||||||
|
$label = $platform === 'discord' ? 'Discord' : 'Telegram';
|
||||||
|
$name = trim(preg_replace('/[\x00-\x1F]+/u', ' ', $name));
|
||||||
|
if ($name === '') $name = $label . '-Nutzer';
|
||||||
|
try {
|
||||||
|
$r = public_ticket_create($pdo, [
|
||||||
|
'name' => mb_substr($name, 0, 120),
|
||||||
|
'email' => communitybot_email($platform, $user),
|
||||||
|
'subject' => $subject,
|
||||||
|
'message' => $message,
|
||||||
|
'departmentId' => $cfg['department_id'] ?: null,
|
||||||
|
'priorityId' => $cfg['priority_id'] ?: null,
|
||||||
|
], '0.0.0.0', ['activity' => 'Ticket über ' . $label . ' erstellt', 'tag' => $label]);
|
||||||
|
} catch (AdminModelException $e) {
|
||||||
|
return 'Das Ticket konnte nicht angelegt werden: ' . $e->getMessage();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('communitybot ticket: ' . $e->getMessage());
|
||||||
|
return 'Das Ticket konnte gerade nicht angelegt werden. Bitte versuchen Sie es später noch einmal.';
|
||||||
|
}
|
||||||
|
$pdo->prepare('INSERT INTO plugin_communitybot_links (ticket_id, platform, ext_user, ext_chat, ext_name, created_at) VALUES (?,?,?,?,?,?)')
|
||||||
|
->execute([$r['id'], $platform, $user, $chat, mb_substr($name, 0, 120), db_now()]);
|
||||||
|
$hint = $cfg['notify'] ? ' Wir melden uns hier, sobald es eine Antwort gibt.' : '';
|
||||||
|
return 'Ihr Ticket #' . $r['number'] . ' wurde angelegt.' . $hint . ' Stand abfragen mit /status ' . $r['number'] . '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function communitybot_cmd_status(PDO $pdo, string $platform, string $user, string $number): string
|
||||||
|
{
|
||||||
|
if (trim($number) === '') {
|
||||||
|
$s = $pdo->prepare('SELECT t.ticket_number, t.subject, t.status FROM plugin_communitybot_links l JOIN tickets t ON t.id = l.ticket_id WHERE l.platform = ? AND l.ext_user = ? ORDER BY l.id DESC LIMIT 5');
|
||||||
|
$s->execute([$platform, $user]);
|
||||||
|
$rows = $s->fetchAll();
|
||||||
|
if (!$rows) return 'Sie haben über diesen Kanal noch kein Ticket angelegt.';
|
||||||
|
return "Ihre letzten Tickets:\n" . implode("\n", array_map(fn($t) => '#' . $t['ticket_number'] . ' – ' . status_label((string) $t['status'])[0] . ' – ' . communitybot_clip((string) $t['subject'], 50), $rows));
|
||||||
|
}
|
||||||
|
$t = communitybot_find_link($pdo, $platform, $user, $number);
|
||||||
|
if (!$t) return 'Dieses Ticket gibt es nicht oder es gehört nicht zu Ihnen.';
|
||||||
|
return 'Ticket #' . $t['ticket_number'] . ' „' . communitybot_clip((string) $t['subject'], 60) . '“: ' . status_label((string) $t['status'])[0] . ' (zuletzt geändert ' . format_dt((string) $t['updated_at']) . ').';
|
||||||
|
}
|
||||||
|
|
||||||
|
function communitybot_cmd_reply(PDO $pdo, string $platform, string $user, string $number, string $text): string
|
||||||
|
{
|
||||||
|
$text = trim($text);
|
||||||
|
if (trim($number) === '' || $text === '') return 'So geht es: /antwort Ticketnummer Ihre Nachricht';
|
||||||
|
$t = communitybot_find_link($pdo, $platform, $user, $number);
|
||||||
|
if (!$t) return 'Dieses Ticket gibt es nicht oder es gehört nicht zu Ihnen.';
|
||||||
|
if ($t['status'] === 'closed') return 'Das Ticket #' . $t['ticket_number'] . ' ist geschlossen. Bitte legen Sie mit /ticket ein neues an.';
|
||||||
|
$now = db_now();
|
||||||
|
$pdo->prepare('INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)')->execute([$t['id'], 'customer', mb_substr($text, 0, 4000), $now]);
|
||||||
|
$reopen = in_array($t['status'], ['answered', 'resolved'], true);
|
||||||
|
$new = $reopen ? 'open' : $t['status'];
|
||||||
|
$pdo->prepare('UPDATE tickets SET status = ?, updated_at = ? WHERE id = ?')->execute([$new, $now, $t['id']]);
|
||||||
|
log_activity($pdo, (int) $t['id'], null, 'Kunde hat geantwortet (' . ($platform === 'discord' ? 'Discord' : 'Telegram') . ')');
|
||||||
|
if ($reopen) log_activity($pdo, (int) $t['id'], null, 'Ticket wieder geöffnet');
|
||||||
|
webhook_dispatch($pdo, 'ticket.replied', (int) $t['id'], ['author' => 'customer']);
|
||||||
|
if ($new !== $t['status']) webhook_dispatch($pdo, 'ticket.status_changed', (int) $t['id'], ['previousStatus' => $t['status']]);
|
||||||
|
return 'Ihre Nachricht wurde zu Ticket #' . $t['ticket_number'] . ' hinzugefügt.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Discord ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function communitybot_discord_verify(array $req, string $publicKeyHex): bool
|
||||||
|
{
|
||||||
|
$sig = (string) ($req['headers']['x-signature-ed25519'] ?? '');
|
||||||
|
$ts = (string) ($req['headers']['x-signature-timestamp'] ?? '');
|
||||||
|
if ($publicKeyHex === '' || !ctype_xdigit($publicKeyHex) || strlen($publicKeyHex) !== 64 || !ctype_xdigit($sig) || strlen($sig) !== 128 || !ctype_digit($ts)) return false;
|
||||||
|
if (abs(time() - (int) $ts) > 600) return false;
|
||||||
|
if (!function_exists('sodium_crypto_sign_verify_detached')) return false;
|
||||||
|
try {
|
||||||
|
return sodium_crypto_sign_verify_detached(hex2bin($sig), $ts . $req['body'], hex2bin($publicKeyHex));
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function communitybot_discord(array $req): array
|
||||||
|
{
|
||||||
|
$cfg = communitybot_config();
|
||||||
|
if (!communitybot_discord_verify($req, $cfg['discord_key'])) return ['status' => 401, 'json' => ['error' => 'Ungültige Signatur.']];
|
||||||
|
$in = json_decode($req['body'], true);
|
||||||
|
if (!is_array($in)) return ['status' => 400, 'json' => ['error' => 'Ungültige Anfrage.']];
|
||||||
|
if (($in['type'] ?? 0) === 1) return ['status' => 200, 'json' => ['type' => 1]];
|
||||||
|
if (($in['type'] ?? 0) !== 2) return ['status' => 200, 'json' => ['type' => 4, 'data' => ['content' => 'Das wird nicht unterstützt.', 'flags' => 64]]];
|
||||||
|
|
||||||
|
$user = $in['member']['user'] ?? $in['user'] ?? [];
|
||||||
|
$uid = (string) ($user['id'] ?? '');
|
||||||
|
if (!preg_match('/^\d{5,25}$/', $uid)) return ['status' => 400, 'json' => ['error' => 'Kein Nutzer.']];
|
||||||
|
$name = (string) ($user['global_name'] ?? $user['username'] ?? '');
|
||||||
|
$opt = [];
|
||||||
|
foreach ((array) ($in['data']['options'] ?? []) as $o) if (isset($o['name'])) $opt[(string) $o['name']] = (string) ($o['value'] ?? '');
|
||||||
|
$pdo = get_db();
|
||||||
|
$chat = (string) ($in['channel_id'] ?? '');
|
||||||
|
switch ((string) ($in['data']['name'] ?? '')) {
|
||||||
|
case 'ticket': $text = communitybot_cmd_ticket($pdo, 'discord', $uid, $chat, $name, $opt['betreff'] ?? '', $opt['nachricht'] ?? ''); break;
|
||||||
|
case 'status': $text = communitybot_cmd_status($pdo, 'discord', $uid, $opt['nummer'] ?? ''); break;
|
||||||
|
case 'antwort': $text = communitybot_cmd_reply($pdo, 'discord', $uid, $opt['nummer'] ?? '', $opt['text'] ?? ''); break;
|
||||||
|
default: $text = communitybot_help('discord');
|
||||||
|
}
|
||||||
|
// flags 64: nur der Absender sieht die Antwort; keine Erwähnungen aus Nutzertext
|
||||||
|
return ['status' => 200, 'json' => ['type' => 4, 'data' => ['content' => mb_substr($text, 0, 1900), 'flags' => 64, 'allowed_mentions' => ['parse' => []]]]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function communitybot_discord_commands(): array
|
||||||
|
{
|
||||||
|
$str = fn(string $n, string $d, bool $req, int $max = 1000) => ['type' => 3, 'name' => $n, 'description' => $d, 'required' => $req, 'max_length' => $max];
|
||||||
|
return [
|
||||||
|
['name' => 'ticket', 'description' => 'Ein Support-Ticket anlegen', 'options' => [$str('betreff', 'Worum geht es?', true, 100), $str('nachricht', 'Bitte beschreiben Sie Ihr Anliegen', true, 1000)]],
|
||||||
|
['name' => 'status', 'description' => 'Stand eines Tickets abfragen', 'options' => [$str('nummer', 'Ticketnummer (leer lassen für Ihre letzten)', false, 40)]],
|
||||||
|
['name' => 'antwort', 'description' => 'Zu einem Ihrer Tickets etwas hinzufügen', 'options' => [$str('nummer', 'Ticketnummer', true, 40), $str('text', 'Ihre Nachricht', true, 1000)]],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function communitybot_discord_register(): array
|
||||||
|
{
|
||||||
|
$cfg = communitybot_config();
|
||||||
|
if ($cfg['discord_app'] === '' || $cfg['discord_token'] === '') return ['ok' => false, 'message' => 'Bitte Anwendungs-ID und Bot-Token eintragen.'];
|
||||||
|
$r = communitybot_json('PUT', 'https://discord.com/api/v10/applications/' . rawurlencode($cfg['discord_app']) . '/commands', ['Authorization: Bot ' . $cfg['discord_token']], communitybot_discord_commands());
|
||||||
|
if ($r['status'] >= 200 && $r['status'] < 300) return ['ok' => true, 'message' => 'Die Befehle /ticket, /status und /antwort sind bei Discord eingetragen (es kann bis zu einer Stunde dauern, bis sie überall erscheinen).'];
|
||||||
|
return ['ok' => false, 'message' => 'Discord hat abgelehnt (Status ' . $r['status'] . '): ' . communitybot_clip((string) ($r['json']['message'] ?? $r['body']), 200)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function communitybot_discord_dm(string $userId, string $text): bool
|
||||||
|
{
|
||||||
|
$cfg = communitybot_config();
|
||||||
|
if ($cfg['discord_token'] === '') return false;
|
||||||
|
$h = ['Authorization: Bot ' . $cfg['discord_token']];
|
||||||
|
$ch = communitybot_json('POST', 'https://discord.com/api/v10/users/@me/channels', $h, ['recipient_id' => $userId]);
|
||||||
|
$id = (string) ($ch['json']['id'] ?? '');
|
||||||
|
if ($id === '') return false;
|
||||||
|
$m = communitybot_json('POST', 'https://discord.com/api/v10/channels/' . rawurlencode($id) . '/messages', $h, ['content' => mb_substr($text, 0, 1900), 'allowed_mentions' => ['parse' => []]]);
|
||||||
|
return $m['status'] >= 200 && $m['status'] < 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Telegram ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function communitybot_telegram(array $req): array
|
||||||
|
{
|
||||||
|
$cfg = communitybot_config();
|
||||||
|
$given = (string) ($req['headers']['x-telegram-bot-api-secret-token'] ?? '');
|
||||||
|
if ($cfg['telegram_secret'] === '' || !hash_equals($cfg['telegram_secret'], $given)) return ['status' => 401, 'json' => ['error' => 'Ungültiges Token.']];
|
||||||
|
$in = json_decode($req['body'], true);
|
||||||
|
$msg = is_array($in) ? ($in['message'] ?? null) : null;
|
||||||
|
if (!is_array($msg) || !isset($msg['chat']['id'], $msg['from']['id']) || !is_string($msg['text'] ?? null)) return ['status' => 200, 'json' => new stdClass()];
|
||||||
|
if (!empty($msg['from']['is_bot'])) return ['status' => 200, 'json' => new stdClass()];
|
||||||
|
if (!preg_match('/^\/([a-z_]+)(?:@\w+)?(?:\s+(.*))?$/isu', trim($msg['text']), $m)) return ['status' => 200, 'json' => new stdClass()];
|
||||||
|
|
||||||
|
$uid = (string) $msg['from']['id'];
|
||||||
|
$chat = (string) $msg['chat']['id'];
|
||||||
|
$name = trim(((string) ($msg['from']['first_name'] ?? '')) . ' ' . ((string) ($msg['from']['last_name'] ?? '')));
|
||||||
|
if ($name === '') $name = (string) ($msg['from']['username'] ?? '');
|
||||||
|
$arg = trim((string) ($m[2] ?? ''));
|
||||||
|
$pdo = get_db();
|
||||||
|
switch (strtolower($m[1])) {
|
||||||
|
case 'ticket':
|
||||||
|
$parts = array_map('trim', explode('|', $arg, 2));
|
||||||
|
$text = communitybot_cmd_ticket($pdo, 'telegram', $uid, $chat, $name, count($parts) === 2 ? $parts[0] : '', count($parts) === 2 ? $parts[1] : $parts[0]);
|
||||||
|
break;
|
||||||
|
case 'status': $text = communitybot_cmd_status($pdo, 'telegram', $uid, $arg); break;
|
||||||
|
case 'antwort':
|
||||||
|
$p = preg_split('/\s+/u', $arg, 2) ?: [''];
|
||||||
|
$text = communitybot_cmd_reply($pdo, 'telegram', $uid, $p[0] ?? '', $p[1] ?? '');
|
||||||
|
break;
|
||||||
|
case 'start': case 'hilfe': case 'help': $text = "Hallo! Ich nehme Ihre Support-Anfragen entgegen.\n" . communitybot_help('telegram'); break;
|
||||||
|
default: return ['status' => 200, 'json' => new stdClass()];
|
||||||
|
}
|
||||||
|
// Antwort direkt im Webhook-Ergebnis: kein zusätzlicher Aufruf nötig
|
||||||
|
$out = ['method' => 'sendMessage', 'chat_id' => $msg['chat']['id'], 'text' => mb_substr($text, 0, 3900)];
|
||||||
|
if (isset($msg['message_id'])) $out['reply_to_message_id'] = $msg['message_id'];
|
||||||
|
return ['status' => 200, 'json' => $out];
|
||||||
|
}
|
||||||
|
|
||||||
|
function communitybot_telegram_send(string $chatId, string $text): bool
|
||||||
|
{
|
||||||
|
$cfg = communitybot_config();
|
||||||
|
if ($cfg['telegram_token'] === '') return false;
|
||||||
|
$r = communitybot_json('POST', 'https://api.telegram.org/bot' . $cfg['telegram_token'] . '/sendMessage', [], ['chat_id' => $chatId, 'text' => mb_substr($text, 0, 3900)]);
|
||||||
|
return $r['status'] >= 200 && $r['status'] < 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
function communitybot_telegram_register(): array
|
||||||
|
{
|
||||||
|
$cfg = communitybot_config();
|
||||||
|
if ($cfg['telegram_token'] === '') return ['ok' => false, 'message' => 'Bitte das Bot-Token von @BotFather eintragen.'];
|
||||||
|
if ($cfg['telegram_secret'] === '') {
|
||||||
|
$cfg['telegram_secret'] = bin2hex(random_bytes(24));
|
||||||
|
communitybot_set_secret('telegram_secret', $cfg['telegram_secret']);
|
||||||
|
}
|
||||||
|
$r = communitybot_json('POST', 'https://api.telegram.org/bot' . $cfg['telegram_token'] . '/setWebhook', [], [
|
||||||
|
'url' => communitybot_url('telegram'), 'secret_token' => $cfg['telegram_secret'], 'allowed_updates' => ['message'], 'drop_pending_updates' => true,
|
||||||
|
]);
|
||||||
|
if (!empty($r['json']['ok'])) {
|
||||||
|
communitybot_json('POST', 'https://api.telegram.org/bot' . $cfg['telegram_token'] . '/setMyCommands', [], ['commands' => [
|
||||||
|
['command' => 'ticket', 'description' => 'Ticket anlegen: /ticket Betreff | Nachricht'],
|
||||||
|
['command' => 'status', 'description' => 'Stand eines Tickets'],
|
||||||
|
['command' => 'antwort', 'description' => 'Zu einem Ticket antworten'],
|
||||||
|
['command' => 'hilfe', 'description' => 'Hilfe'],
|
||||||
|
]]);
|
||||||
|
return ['ok' => true, 'message' => 'Der Telegram-Bot ist verbunden.'];
|
||||||
|
}
|
||||||
|
return ['ok' => false, 'message' => 'Telegram hat abgelehnt: ' . communitybot_clip((string) ($r['json']['description'] ?? $r['body']), 200)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function communitybot_url(string $endpoint): string
|
||||||
|
{
|
||||||
|
return rtrim(base_url(), '/') . '/api/public/v1/plugin_webhook.php?p=' . COMMUNITYBOT . '&e=' . $endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Benachrichtigungen ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function communitybot_send_to(array $link, string $text): bool
|
||||||
|
{
|
||||||
|
return $link['platform'] === 'discord'
|
||||||
|
? communitybot_discord_dm((string) $link['ext_user'], $text)
|
||||||
|
: communitybot_telegram_send((string) $link['ext_chat'], $text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function communitybot_links(PDO $pdo, int $ticketId): array
|
||||||
|
{
|
||||||
|
// pro Nutzer nur einmal, auch wenn mehrere Verknüpfungen bestehen
|
||||||
|
$s = $pdo->prepare('SELECT platform, ext_user, MAX(ext_chat) AS ext_chat FROM plugin_communitybot_links WHERE ticket_id = ? GROUP BY platform, ext_user');
|
||||||
|
$s->execute([$ticketId]);
|
||||||
|
return $s->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function communitybot_notify_reply(PDO $pdo, int $ticketId): void
|
||||||
|
{
|
||||||
|
if (!communitybot_config()['notify']) return;
|
||||||
|
$links = communitybot_links($pdo, $ticketId);
|
||||||
|
if (!$links) return;
|
||||||
|
$t = $pdo->prepare('SELECT ticket_number, is_spam FROM tickets WHERE id = ?');
|
||||||
|
$t->execute([$ticketId]);
|
||||||
|
$ticket = $t->fetch();
|
||||||
|
if (!$ticket || !empty($ticket['is_spam'])) return;
|
||||||
|
$m = $pdo->prepare("SELECT message FROM ticket_threads WHERE ticket_id = ? AND author_type = 'staff' AND is_internal_note = 0 ORDER BY id DESC LIMIT 1");
|
||||||
|
$m->execute([$ticketId]);
|
||||||
|
$msg = (string) $m->fetchColumn();
|
||||||
|
if ($msg === '') return;
|
||||||
|
$text = 'Neue Antwort zu Ticket #' . $ticket['ticket_number'] . ":\n\n" . communitybot_clip($msg, 1200) . "\n\nAntworten: /antwort " . $ticket['ticket_number'] . ' Ihre Nachricht';
|
||||||
|
foreach ($links as $l) {
|
||||||
|
try { communitybot_send_to($l, $text); } catch (Throwable $e) { error_log('communitybot notify: ' . $e->getMessage()); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function communitybot_notify_status(PDO $pdo, int $ticketId): void
|
||||||
|
{
|
||||||
|
if (!communitybot_config()['notify']) return;
|
||||||
|
$links = communitybot_links($pdo, $ticketId);
|
||||||
|
if (!$links) return;
|
||||||
|
$t = $pdo->prepare('SELECT ticket_number, status, is_spam FROM tickets WHERE id = ?');
|
||||||
|
$t->execute([$ticketId]);
|
||||||
|
$ticket = $t->fetch();
|
||||||
|
if (!$ticket || !empty($ticket['is_spam']) || !in_array($ticket['status'], ['resolved', 'closed'], true)) return;
|
||||||
|
$text = 'Ihr Ticket #' . $ticket['ticket_number'] . ' ist jetzt „' . status_label((string) $ticket['status'])[0] . '“. Wenn noch etwas offen ist, antworten Sie mit /antwort ' . $ticket['ticket_number'] . ' Ihre Nachricht.';
|
||||||
|
foreach ($links as $l) {
|
||||||
|
try { communitybot_send_to($l, $text); } catch (Throwable $e) { error_log('communitybot notify: ' . $e->getMessage()); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
// Seite des Community-Bot-Plugins. Verfügbar: $staff, $pdo.
|
||||||
|
$isAdmin = ($staff['role'] ?? '') === 'admin';
|
||||||
|
if (!$isAdmin) { echo '<h1>Discord und Telegram</h1><div class="flash error">Diese Seite ist nur für Administratoren zugänglich.</div>'; 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();
|
||||||
|
?>
|
||||||
|
<h1>Discord und Telegram</h1>
|
||||||
|
<?php if ($message): ?><div class="flash success"><?= e($message) ?></div><?php endif; ?>
|
||||||
|
<?php if ($error): ?><div class="flash error"><?= e($error) ?></div><?php endif; ?>
|
||||||
|
|
||||||
|
<div class="card" style="max-width:900px;margin-bottom:16px">
|
||||||
|
<h3>Zustand</h3>
|
||||||
|
<p style="font-size:.92rem"><strong><?= $week ?></strong> Tickets in den letzten 7 Tagen über Discord oder Telegram angelegt.</p>
|
||||||
|
<p style="font-size:.88rem">
|
||||||
|
Discord: <?= ($cfg['discord_key'] !== '' ? '<strong style="color:#15803d">Schlüssel eingetragen</strong>' : '<strong style="color:#b45309">Schlüssel fehlt</strong>') ?>
|
||||||
|
· Benachrichtigung per Direktnachricht: <?= $cfg['discord_token'] !== '' ? 'möglich' : '<strong style="color:#b45309">Bot-Token fehlt</strong>' ?><br>
|
||||||
|
Telegram: <?= $cfg['telegram_token'] !== '' ? '<strong style="color:#15803d">Token eingetragen</strong>' : '<strong style="color:#b45309">Token fehlt</strong>' ?>
|
||||||
|
<?php if (!function_exists('sodium_crypto_sign_verify_detached')): ?><br><strong style="color:#b91c1c">Auf diesem Server fehlt die PHP-Erweiterung „sodium“, Discord-Anfragen können nicht geprüft werden.</strong><?php endif; ?>
|
||||||
|
</p>
|
||||||
|
<p class="muted" style="font-size:.82rem">Adressen für die Einrichtung (nur aus dem Internet erreichbar, mit https):<br>
|
||||||
|
Discord „Interactions Endpoint URL“: <code><?= e(communitybot_url('discord')) ?></code><br>
|
||||||
|
Telegram wird mit dem Knopf unten automatisch verbunden.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($isAdmin): ?>
|
||||||
|
<form method="post" class="card" style="max-width:900px;margin-bottom:16px">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="action" value="save">
|
||||||
|
<h3>Einstellungen</h3>
|
||||||
|
<h4 style="margin:10px 0 4px">Discord</h4>
|
||||||
|
<p class="muted" style="font-size:.82rem">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.</p>
|
||||||
|
<label>Anwendungs-ID</label><input type="text" name="discord_app" value="<?= e($cfg['discord_app']) ?>" inputmode="numeric">
|
||||||
|
<label style="margin-top:8px">Öffentlicher Schlüssel (Public Key)</label><input type="text" name="discord_key" value="<?= e($cfg['discord_key']) ?>" maxlength="64" autocomplete="off">
|
||||||
|
<label style="margin-top:8px">Bot-Token (für die Befehle und Direktnachrichten) <?= $cfg['discord_token'] !== '' ? '– gespeichert, leer lassen zum Behalten' : '' ?></label>
|
||||||
|
<input type="password" name="discord_token" value="" autocomplete="new-password">
|
||||||
|
<?php if ($cfg['discord_token'] !== ''): ?><label style="font-size:.82rem"><input type="checkbox" name="clear_discord" value="1"> Gespeichertes Bot-Token entfernen</label><?php endif; ?>
|
||||||
|
|
||||||
|
<h4 style="margin:14px 0 4px">Telegram</h4>
|
||||||
|
<p class="muted" style="font-size:.82rem">Bei @BotFather mit /newbot einen Bot anlegen und das Token hier eintragen.</p>
|
||||||
|
<label>Bot-Token <?= $cfg['telegram_token'] !== '' ? '– gespeichert, leer lassen zum Behalten' : '' ?></label>
|
||||||
|
<input type="password" name="telegram_token" value="" autocomplete="new-password">
|
||||||
|
<?php if ($cfg['telegram_token'] !== ''): ?><label style="font-size:.82rem"><input type="checkbox" name="clear_telegram" value="1"> Telegram-Token entfernen und Verbindung trennen</label><?php endif; ?>
|
||||||
|
|
||||||
|
<h4 style="margin:14px 0 4px">Neue Tickets</h4>
|
||||||
|
<div style="display:flex;gap:14px;flex-wrap:wrap">
|
||||||
|
<div><label>Abteilung</label><select name="department_id"><option value="0">Standard</option><?php foreach ($depts as $d): ?><option value="<?= (int) $d['id'] ?>" <?= (int) $d['id'] === $cfg['department_id'] ? 'selected' : '' ?>><?= e($d['name']) ?></option><?php endforeach; ?></select></div>
|
||||||
|
<div><label>Priorität</label><select name="priority_id"><option value="0">Standard</option><?php foreach ($prios as $p): ?><option value="<?= (int) $p['id'] ?>" <?= (int) $p['id'] === $cfg['priority_id'] ? 'selected' : '' ?>><?= e($p['name']) ?></option><?php endforeach; ?></select></div>
|
||||||
|
<div><label>Höchstens Tickets pro Stunde und Person</label><input type="number" name="limit" min="1" max="50" value="<?= (int) $cfg['limit'] ?>" style="max-width:120px"></div>
|
||||||
|
</div>
|
||||||
|
<label style="display:block;margin-top:10px"><input type="checkbox" name="notify" value="1" <?= $cfg['notify'] ? 'checked' : '' ?>> Absender bei Antworten und bei Lösung benachrichtigen</label>
|
||||||
|
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card" style="max-width:900px;margin-bottom:16px">
|
||||||
|
<h3>Verbinden</h3>
|
||||||
|
<p class="muted" style="font-size:.85rem">Erst speichern, dann verbinden.</p>
|
||||||
|
<form method="post" style="display:inline"><?= csrf_field() ?><input type="hidden" name="action" value="discord_register"><button class="btn secondary" type="submit">Discord-Befehle eintragen</button></form>
|
||||||
|
<form method="post" style="display:inline"><?= csrf_field() ?><input type="hidden" name="action" value="telegram_register"><button class="btn secondary" type="submit">Telegram-Bot verbinden</button></form>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Zuletzt angelegt</h3>
|
||||||
|
<?php if (!$recent): ?><p class="muted">Noch keine Tickets über Discord oder Telegram.</p><?php else: ?>
|
||||||
|
<table style="width:100%">
|
||||||
|
<thead><tr><th>Ticket</th><th>Kanal</th><th>Absender</th><th>Status</th><th>Angelegt</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($recent as $l): ?>
|
||||||
|
<tr>
|
||||||
|
<td><a href="/staff/ticket_view.php?n=<?= e(urlencode((string) $l['ticket_number'])) ?>">#<?= e((string) $l['ticket_number']) ?></a><div class="muted" style="font-size:.76rem"><?= e(mb_strimwidth((string) $l['subject'], 0, 50, '…')) ?></div></td>
|
||||||
|
<td><?= $l['platform'] === 'discord' ? 'Discord' : 'Telegram' ?></td>
|
||||||
|
<td><?= e((string) $l['ext_name']) ?></td>
|
||||||
|
<td><?= e(status_label((string) $l['status'])[0]) ?></td>
|
||||||
|
<td><?= e(format_dt((string) $l['created_at'])) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
// Community-Bot: nimmt Befehle von Discord und Telegram entgegen und meldet Antworten zurück.
|
||||||
|
require_once __DIR__ . '/lib.php';
|
||||||
|
|
||||||
|
plugin_register_webhook('discord', fn(array $req) => 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');
|
||||||
Binary file not shown.
@@ -0,0 +1,280 @@
|
|||||||
|
<?php
|
||||||
|
// Datenschutz: Anonymisierung von Tickets ohne Registrierung und Löschung registrierter Kunden auf Antrag. Alle Funktionen beginnen mit datenschutz_.
|
||||||
|
|
||||||
|
const DATENSCHUTZ = 'datenschutz';
|
||||||
|
const DATENSCHUTZ_TEXT_REMOVED = '[Nachricht aus Datenschutzgründen entfernt]';
|
||||||
|
|
||||||
|
/** Was sich anonymisieren lässt (Schlüssel => 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<string,array{0:string,1:list<string>}> */
|
||||||
|
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<string,string> $tabs Schlüssel => Beschriftung */
|
||||||
|
function datenschutz_tabs(string $active, array $tabs): string
|
||||||
|
{
|
||||||
|
$html = '<div class="plugin-tabs" style="display:flex;gap:4px;flex-wrap:wrap;border-bottom:1px solid var(--border,#e3e9ee);margin:0 0 18px">';
|
||||||
|
foreach ($tabs as $key => $label) {
|
||||||
|
$on = $key === $active;
|
||||||
|
$html .= '<a href="' . htmlspecialchars(datenschutz_tab_url($key), ENT_QUOTES) . '" style="padding:8px 16px;border-radius:8px 8px 0 0;text-decoration:none;font-size:.9rem;'
|
||||||
|
. ($on ? 'background:var(--card-bg,#fff);border:1px solid var(--border,#e3e9ee);border-bottom-color:var(--card-bg,#fff);font-weight:600;color:inherit;position:relative;top:1px' : 'color:var(--muted,#64748b);border:1px solid transparent') . '">'
|
||||||
|
. htmlspecialchars($label) . '</a>';
|
||||||
|
}
|
||||||
|
return $html . '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „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) {
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS plugin_datenschutz_done (
|
||||||
|
ticket_id INTEGER PRIMARY KEY,
|
||||||
|
done_at TEXT NOT NULL
|
||||||
|
)
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<?php
|
||||||
|
// Seite des Datenschutz-Plugins. Verfügbar: $staff, $pdo. Nur für Administratoren.
|
||||||
|
if (($staff['role'] ?? '') !== 'admin') { echo '<h1>Datenschutz</h1><div class="flash error">Diese Seite ist nur für Administratoren zugänglich.</div>'; 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'];
|
||||||
|
?>
|
||||||
|
<h1>Datenschutz</h1>
|
||||||
|
<?php if ($message): ?><div class="flash success"><?= e($message) ?></div><?php endif; ?>
|
||||||
|
<?php if ($error): ?><div class="flash error"><?= e($error) ?></div><?php endif; ?>
|
||||||
|
|
||||||
|
<?= datenschutz_tabs($tab, $tabs) ?>
|
||||||
|
|
||||||
|
<?php if ($coreOn): ?>
|
||||||
|
<div class="flash error" style="max-width:900px">
|
||||||
|
Die automatische Datenlöschung von DeskHub (Einstellungen → Datenschutz) ist eingeschaltet. Sie anonymisiert nach ihrer Frist <strong>auch registrierte Kunden</strong>. Das widerspricht der Regel „Daten registrierter Kunden bleiben bis zur Löschung auf Antrag“. Schalten Sie sie aus, dieses Plugin übernimmt die Aufgabe.
|
||||||
|
<form method="post" style="margin-top:8px"><?= csrf_field() ?><input type="hidden" name="action" value="core_off"><button class="btn secondary" type="submit">Automatische Löschung von DeskHub ausschalten</button></form>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($tab === 'uebersicht'): ?>
|
||||||
|
<div class="card" style="max-width:900px;margin-bottom:16px">
|
||||||
|
<h3>Regeln</h3>
|
||||||
|
<ul style="font-size:.92rem;line-height:1.6;padding-left:18px">
|
||||||
|
<li><strong>Tickets ohne Registrierung:</strong> Nach <?= e(datenschutz_frist_label($cfg)) ?> <?= $cfg['basis'] === 'closed' ? 'nach dem Schließen oder Lösen' : 'nach der Erstellung' ?> werden unwiderruflich entfernt: <strong><?= e(implode(', ', array_map(fn($k) => datenschutz_field_labels()[$k], $cfg['fields']))) ?></strong>. Eine Wiederherstellung ist nicht möglich<?= in_array('email', $cfg['fields'], true) ? ' (die Ersatzadresse ist ein Zufallswert)' : '' ?>.</li>
|
||||||
|
<li><strong>Registrierte Kunden:</strong> Die Daten bleiben gespeichert, bis der Kunde die Löschung beantragt. Sie führen die Löschung unten aus.</li>
|
||||||
|
</ul>
|
||||||
|
<p style="margin-top:8px">
|
||||||
|
<?php if ($cfg['enabled']): ?><strong style="color:#157a45">✓ Automatische Anonymisierung ist eingeschaltet.</strong> Zurzeit fällig: <strong><?= $count ?></strong> Ticket(s). Letzter Lauf: <?= e(plugin_setting(DATENSCHUTZ, 'last_run', '') ? format_dt((string) plugin_setting(DATENSCHUTZ, 'last_run', '')) : 'noch nicht') ?>.
|
||||||
|
<?php else: ?><strong style="color:#b45309">Die automatische Anonymisierung ist noch ausgeschaltet.</strong> Beim Einschalten würden sofort <strong><?= $count ?></strong> vorhandene Ticket(s) anonymisiert.<?php endif; ?>
|
||||||
|
</p>
|
||||||
|
<?php if ($cfg['enabled']): ?>
|
||||||
|
<form method="post" style="display:inline" onsubmit="return confirm('Jetzt <?= (int) $count ?> Ticket(s) unwiderruflich anonymisieren?')"><?= csrf_field() ?><input type="hidden" name="action" value="run"><button class="btn secondary" type="submit">Jetzt ausführen</button></form>
|
||||||
|
<form method="post" style="display:inline"><?= csrf_field() ?><input type="hidden" name="action" value="disable"><button class="btn secondary" type="submit">Ausschalten</button></form>
|
||||||
|
<?php else: ?>
|
||||||
|
<form method="post"><?= csrf_field() ?><input type="hidden" name="action" value="enable">
|
||||||
|
<label style="font-weight:400"><input type="checkbox" name="confirm" value="1" style="width:auto"> Ich weiß, dass die Anonymisierung unwiderruflich ist und sofort <?= $count ?> vorhandene Ticket(s) betrifft.</label>
|
||||||
|
<div class="btn-row"><button class="btn" type="submit">Automatische Anonymisierung einschalten</button></div></form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($tab === 'einstellungen'): ?>
|
||||||
|
<form method="post" class="card" style="max-width:900px;margin-bottom:16px">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="action" value="settings">
|
||||||
|
<h3>Einstellungen (nur Administratoren)</h3>
|
||||||
|
<label>Frist, nach der anonymisiert wird</label>
|
||||||
|
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
|
||||||
|
<input type="number" name="amount" min="1" max="3650" value="<?= (int) $cfg['amount'] ?>" style="max-width:110px">
|
||||||
|
<select name="unit" style="max-width:140px"><?php foreach (datenschutz_units() as $k => $l): ?><option value="<?= e($k) ?>" <?= $cfg['unit'] === $k ? 'selected' : '' ?>><?= e($l) ?></option><?php endforeach; ?></select>
|
||||||
|
<select name="basis"><option value="closed" <?= $cfg['basis'] === 'closed' ? 'selected' : '' ?>>nach dem Schließen bzw. Lösen des Tickets</option><option value="created" <?= $cfg['basis'] === 'created' ? 'selected' : '' ?>>nach der Erstellung des Tickets</option></select>
|
||||||
|
</div>
|
||||||
|
<label style="display:block;margin-top:8px;font-weight:400"><input type="checkbox" name="include_open" value="1" style="width:auto" <?= $cfg['include_open'] ? 'checked' : '' ?>> Auch offene Tickets anonymisieren, wenn sie seit Fristablauf nicht bearbeitet wurden (sonst nur geschlossene und gelöste)</label>
|
||||||
|
|
||||||
|
<label style="margin-top:14px">Was soll anonymisiert werden?</label>
|
||||||
|
<div id="ds-presets" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px">
|
||||||
|
<?php foreach (datenschutz_presets() as $key => [$plabel, $pfields]): ?><button type="button" class="btn secondary" style="padding:3px 11px;font-size:.8rem" data-fields="<?= e(implode(',', $pfields)) ?>"><?= e($plabel) ?></button><?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php foreach (datenschutz_field_labels() as $key => $flabel): ?>
|
||||||
|
<label style="display:block;font-weight:400"><input type="checkbox" name="fields[]" value="<?= e($key) ?>" style="width:auto" <?= in_array($key, $cfg['fields'], true) ? 'checked' : '' ?>> <?= e($flabel) ?></label>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<p class="muted" style="font-size:.8rem;margin-top:6px">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.</p>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var box = document.getElementById('ds-presets');
|
||||||
|
box.addEventListener('click', function (e) {
|
||||||
|
var b = e.target.closest('button[data-fields]'); if (!b) return;
|
||||||
|
var want = b.getAttribute('data-fields').split(',');
|
||||||
|
b.form.querySelectorAll('input[name="fields[]"]').forEach(function (c) { c.checked = want.indexOf(c.value) !== -1; });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($tab === 'antrag'): ?>
|
||||||
|
<div class="card" style="max-width:900px;margin-bottom:16px">
|
||||||
|
<h3>Löschung auf Antrag eines registrierten Kunden</h3>
|
||||||
|
<p class="muted" style="font-size:.85rem">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.</p>
|
||||||
|
<form method="post" style="display:flex;gap:8px;flex-wrap:wrap"><?= csrf_field() ?><input type="hidden" name="action" value="find"><input type="text" name="email" value="<?= e($searched) ?>" placeholder="E-Mail-Adresse des Kunden" style="flex:1;min-width:220px" required><button class="btn secondary" type="submit">Suchen</button></form>
|
||||||
|
<?php if ($found && !$found['already']): ?>
|
||||||
|
<form method="post" style="margin-top:12px;border-top:1px solid var(--border,#e3e9ee);padding-top:12px" onsubmit="return confirm('Die Daten dieses Kunden jetzt unwiderruflich löschen?')">
|
||||||
|
<?= csrf_field() ?><input type="hidden" name="action" value="delete_customer"><input type="hidden" name="user_id" value="<?= (int) $found['user']['id'] ?>"><input type="hidden" name="email" value="<?= e($searched) ?>">
|
||||||
|
<p><strong><?= e((string) $found['user']['name']) ?></strong> (<?= e((string) $found['user']['email']) ?>)<br><?= (int) $found['tickets'] ?> Ticket(s), davon <?= (int) $found['open'] ?> offen.</p>
|
||||||
|
<?php if ($found['open']): ?><label style="font-weight:400"><input type="checkbox" name="close_open" value="1" style="width:auto"> Offene Tickets schließen und anonymisieren</label><?php endif; ?>
|
||||||
|
<label style="display:block;font-weight:400"><input type="checkbox" name="confirm" value="1" style="width:auto"> Der Kunde hat die Löschung beantragt. Ich weiß, dass sie unwiderruflich ist.</label>
|
||||||
|
<div class="btn-row"><button class="btn danger" type="submit">Daten unwiderruflich löschen</button></div>
|
||||||
|
</form>
|
||||||
|
<?php elseif ($found): ?><p class="muted" style="margin-top:10px">Dieser Kunde ist bereits anonymisiert.</p><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($tab === 'protokoll'): ?>
|
||||||
|
<div class="card" style="max-width:900px">
|
||||||
|
<h3>Protokoll</h3>
|
||||||
|
<?php if (!$log): ?><p class="muted">Noch keine Einträge. Das Protokoll enthält keine Kundendaten, bei Löschanträgen nur eine verkürzte Adresse.</p><?php else: ?>
|
||||||
|
<table style="width:100%"><thead><tr><th>Wann</th><th>Art</th><th>Tickets</th><th>Hinweis</th><th>Von</th></tr></thead><tbody>
|
||||||
|
<?php foreach ($log as $l): ?>
|
||||||
|
<tr><td class="muted" style="white-space:nowrap"><?= e(format_dt((string) $l['created_at'])) ?></td><td><?= e($kinds[$l['kind']] ?? $l['kind']) ?></td><td><?= (int) $l['tickets'] ?></td><td><?= e((string) $l['note']) ?></td><td><?= e((string) ($l['staff_name'] ?? '')) ?></td></tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody></table>
|
||||||
|
<?php if ($pages > 1): ?>
|
||||||
|
<p class="muted" style="margin-top:10px;font-size:.85rem">
|
||||||
|
<?php if ($seite > 1): ?><a href="<?= e(datenschutz_tab_url('protokoll', ['seite' => $seite - 1])) ?>">← Neuere</a> · <?php endif; ?>
|
||||||
|
Seite <?= $seite ?> von <?= $pages ?>
|
||||||
|
<?php if ($seite < $pages): ?> · <a href="<?= e(datenschutz_tab_url('protokoll', ['seite' => $seite + 1])) ?>">Ältere →</a><?php endif; ?>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
// Datenschutz: Anonymisierung im Hintergrund, Löschung auf Antrag über die Seite.
|
||||||
|
require_once __DIR__ . '/lib.php';
|
||||||
|
datenschutz_ensure_schema();
|
||||||
|
|
||||||
|
plugin_add_hook('cron.run', function () {
|
||||||
|
datenschutz_tick(get_db());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Im Adminportal, nur für Administratoren (die Seite prüft das zusätzlich selbst)
|
||||||
|
plugin_register_menu('Datenschutz', 'datenschutz', null, 'admin');
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 3"><rect width="5" height="3" fill="#FFCE00"/><rect width="5" height="2" fill="#DD0000"/><rect width="5" height="1" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 191 B |
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<?php
|
||||||
|
// Sprachpaket: Es gibt hier nichts auszuführen. Die Angaben zur Sprache stehen in plugin.json ("language").
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"_info": "Deutsch ist die Quellsprache: Hier muss nichts übersetzt werden. Für eine eigene Sprache: Schlüssel = deutscher Text, Wert = Übersetzung. {} steht für wechselnde Teile, {#} für Zahlen."
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 30"><clipPath id="s"><path d="M0,0 v30 h60 v-30 z"/></clipPath><clipPath id="t"><path d="M30,15 h30 v15 z v15 h-30 z h-30 v-15 z v-15 h30 z"/></clipPath><g clip-path="url(#s)"><path d="M0,0 v30 h60 v-30 z" fill="#012169"/><path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" stroke-width="6"/><path d="M0,0 L60,30 M60,0 L0,30" clip-path="url(#t)" stroke="#C8102E" stroke-width="4"/><path d="M30,0 v30 M0,15 h60" stroke="#fff" stroke-width="10"/><path d="M30,0 v30 M0,15 h60" stroke="#C8102E" stroke-width="6"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 572 B |
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<?php
|
||||||
|
// Sprachpaket: Es gibt hier nichts auszuführen. Die Übersetzungen stehen in strings.json, die Angaben zur Sprache in plugin.json ("language").
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,220 @@
|
|||||||
|
<?php
|
||||||
|
// Störungsmitteilung und Rundmail. Alle Funktionen beginnen mit rundmail_.
|
||||||
|
|
||||||
|
const RUNDMAIL = 'rundmail';
|
||||||
|
const RUNDMAIL_BATCH = 25;
|
||||||
|
|
||||||
|
/** Vorlagen für typische Anlässe. @return array<string,array{0:string,1:string}> */
|
||||||
|
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<array{key:string,label:string}> */
|
||||||
|
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<array{email:string,name:string}> */
|
||||||
|
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 = '<div>' . nl2br(e($text)) . '</div>'
|
||||||
|
. '<p style="color:#6b7280;font-size:12px;margin-top:20px">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.</p>';
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
// Seite des Rundmail-Plugins. Verfügbar: $staff, $pdo.
|
||||||
|
$isAdmin = ($staff['role'] ?? '') === 'admin';
|
||||||
|
$message = null;
|
||||||
|
$error = null;
|
||||||
|
$form = ['group' => (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'];
|
||||||
|
?>
|
||||||
|
<h1>Störungsmitteilung und Rundmail</h1>
|
||||||
|
<?php if ($message): ?><div class="flash success"><?= e($message) ?></div><?php endif; ?>
|
||||||
|
<?php if ($error): ?><div class="flash error"><?= e($error) ?></div><?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post" class="card" style="max-width:860px;margin-bottom:16px">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<h3>Neue Nachricht</h3>
|
||||||
|
<p class="muted" style="font-size:.85rem">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 <code>{name}</code> wird der Name eingesetzt.<?= $isAdmin ? '' : ' Sie sehen nur Gruppen Ihrer Abteilungen.' ?></p>
|
||||||
|
<label>Empfänger</label>
|
||||||
|
<select name="group" required>
|
||||||
|
<option value="">– Gruppe wählen –</option>
|
||||||
|
<?php foreach ($groups as $g): ?><option value="<?= e($g['key']) ?>" <?= $form['group'] === $g['key'] ? 'selected' : '' ?>><?= e($g['label']) ?></option><?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<label style="margin-top:8px">Vorlage</label>
|
||||||
|
<select id="rm-preset" onchange="var p=window.RM_PRESETS[this.value];if(p){document.getElementById('rm-subject').value=p[0];document.getElementById('rm-body').value=p[1];}">
|
||||||
|
<option value="">– eigene Nachricht –</option>
|
||||||
|
<?php foreach ($presets as $k => $p): ?><option value="<?= e($k) ?>"><?= e($p[0]) ?></option><?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<script>window.RM_PRESETS = <?= json_encode($presets, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) ?>;</script>
|
||||||
|
<label style="margin-top:8px">Betreff</label>
|
||||||
|
<input type="text" id="rm-subject" name="subject" maxlength="150" value="<?= e($form['subject']) ?>" required>
|
||||||
|
<label style="margin-top:8px">Text</label>
|
||||||
|
<textarea id="rm-body" name="body" rows="9" maxlength="5000" required><?= e($form['body']) ?></textarea>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button type="submit" name="action" value="preview" class="btn">Empfänger prüfen</button>
|
||||||
|
<button type="submit" name="action" value="test" class="btn secondary">Probemail an mich</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php if ($preview): ?>
|
||||||
|
<form method="post" class="card" style="max-width:860px;margin-bottom:16px;border-left:4px solid #b45309">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="action" value="send">
|
||||||
|
<input type="hidden" name="group" value="<?= e($form['group']) ?>">
|
||||||
|
<input type="hidden" name="subject" value="<?= e($form['subject']) ?>">
|
||||||
|
<input type="hidden" name="body" value="<?= e($form['body']) ?>">
|
||||||
|
<h3>Vor dem Senden prüfen</h3>
|
||||||
|
<p><strong><?= count($preview['rcpt']) ?></strong> Empfänger: <?= e($preview['group']['label']) ?></p>
|
||||||
|
<p class="muted" style="font-size:.84rem">Zum Beispiel: <?= e(implode(', ', array_map(fn($r) => $r['email'], array_slice($preview['rcpt'], 0, 5)))) ?><?= count($preview['rcpt']) > 5 ? ' …' : '' ?></p>
|
||||||
|
<div style="border:1px solid var(--border,#e3e9ee);border-radius:8px;padding:12px;margin:8px 0;font-size:.9rem"><strong><?= e($form['subject']) ?></strong><br><br><?= nl2br(e(str_replace('{name}', $preview['rcpt'][0]['name'] ?: 'Kunde', $form['body']))) ?></div>
|
||||||
|
<label style="font-weight:400"><input type="checkbox" name="confirm" value="1" style="width:auto"> Text und Empfänger sind geprüft, die Nachricht kann nicht zurückgeholt werden.</label>
|
||||||
|
<div class="btn-row"><button type="submit" class="btn">An <?= count($preview['rcpt']) ?> Empfänger senden</button></div>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3><?= $isAdmin ? 'Verlauf aller Nachrichten' : 'Ihre gesendeten Nachrichten' ?></h3>
|
||||||
|
<?php if (!$history): ?><p class="muted">Noch nichts gesendet.</p><?php else: ?>
|
||||||
|
<table style="width:100%">
|
||||||
|
<thead><tr><th>Wann</th><th>Betreff</th><th>Empfänger</th><th>Von</th><th>Stand</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($history as $h): ?>
|
||||||
|
<tr>
|
||||||
|
<td class="muted" style="white-space:nowrap"><?= e(format_dt((string) $h['created_at'])) ?></td>
|
||||||
|
<td><?= e((string) $h['subject']) ?></td>
|
||||||
|
<td><?= e((string) $h['group_label']) ?></td>
|
||||||
|
<td><?= e((string) $h['sender_name']) ?></td>
|
||||||
|
<td><?= e($statusLabels[$h['status']] ?? $h['status']) ?><div class="muted" style="font-size:.76rem"><?= (int) $h['sent'] ?> von <?= (int) $h['total'] ?> gesendet<?= (int) $h['failed'] ? ', <span style="color:#b42318">' . (int) $h['failed'] . ' fehlgeschlagen</span>' : '' ?></div></td>
|
||||||
|
<td><?php if ($h['status'] === 'sending' && ($isAdmin || (int) $h['sender_id'] === (int) $staff['id'])): ?><form method="post" onsubmit="return confirm('Versand abbrechen? Noch nicht gesendete Mails entfallen.')"><?= csrf_field() ?><input type="hidden" name="action" value="cancel"><input type="hidden" name="id" value="<?= (int) $h['id'] ?>"><button class="btn secondary" type="submit" style="padding:2px 10px;font-size:.78rem">Abbrechen</button></form><?php endif; ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
// Störungsmitteilung und Rundmail: Versand im Hintergrund.
|
||||||
|
require_once __DIR__ . '/lib.php';
|
||||||
|
|
||||||
|
plugin_add_hook('cron.run', function () {
|
||||||
|
rundmail_tick(get_db());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Für Administratoren und Mitarbeiter (Mitarbeiter schreiben nur an Kunden ihrer Abteilungen)
|
||||||
|
plugin_register_menu('Rundmail', 'rundmail');
|
||||||
Binary file not shown.
@@ -0,0 +1,237 @@
|
|||||||
|
<?php
|
||||||
|
// Schichtübergabe. Alle Funktionen beginnen mit schicht_.
|
||||||
|
|
||||||
|
const SCHICHT = 'schichtuebergabe';
|
||||||
|
|
||||||
|
function schicht_keep_days(): int
|
||||||
|
{
|
||||||
|
return max(1, min(365, (int) plugin_setting(SCHICHT, 'keep_days', '30')));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Abteilungen eines Mitarbeiters (Hauptabteilung und weitere); null = Administrator, sieht alle. */
|
||||||
|
function schicht_departments(PDO $pdo, array $staff): ?array
|
||||||
|
{
|
||||||
|
if (($staff['role'] ?? '') === 'admin') return null;
|
||||||
|
$ids = [];
|
||||||
|
if (!empty($staff['department_id'])) $ids[] = (int) $staff['department_id'];
|
||||||
|
$s = $pdo->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<string> */
|
||||||
|
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 = '<div class="card" style="margin-bottom:14px;border-left:4px solid #dc2626"><h3 style="margin:0 0 6px;font-size:.95rem">Wichtige Schichtübergabe</h3>';
|
||||||
|
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 .= '<p style="margin:0 0 8px;font-size:.85rem"><strong>' . e($who) . '</strong> <span class="muted">' . e(format_dt((string) $n['created_at'])) . ($readers !== null ? ' · von ' . $readers . ' gelesen' : '') . '</span><br>' . nl2br(e(mb_strimwidth((string) $n['message'], 0, 300, '…'))) . '</p>';
|
||||||
|
}
|
||||||
|
$html .= '<a class="btn secondary" href="/staff/plugin.php?p=' . SCHICHT . '&page=schichtuebergabe">Alle Notizen öffnen</a></div>';
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ausgeblendete eigene Notizen (je Mitarbeiter, nur die Bestätigung auf der Übersicht). @return list<int> */
|
||||||
|
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 .= '<div class="schicht-banner" role="status" style="margin:0 0 16px;padding:14px 18px;border-radius:12px;border:1px solid ' . $color . ';border-left-width:6px;background:' . $bg . ';color:#16202a">'
|
||||||
|
. '<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">'
|
||||||
|
. '<strong style="font-size:1rem">' . ($important ? '⚠ ' : '') . 'Schichtübergabe: ' . $count . ($count === 1 ? ' neue Notiz' : ' neue Notizen') . ($important ? ' (' . $important . ' wichtig)' : '') . '</strong>'
|
||||||
|
. '<div style="display:flex;gap:8px;flex-wrap:wrap"><a class="btn secondary" href="/staff/plugin.php?p=' . SCHICHT . '&page=schichtuebergabe">Öffnen</a>'
|
||||||
|
. '<form method="post" action="/staff/plugin.php?p=' . SCHICHT . '&page=schichtuebergabe" style="margin:0">' . csrf_field() . '<input type="hidden" name="action" value="mark_all"><button class="btn" type="submit">✓ Gelesen</button></form></div></div>';
|
||||||
|
foreach (array_slice($notes, 0, 3) as $n) {
|
||||||
|
$html .= '<div style="margin-top:10px;font-size:.9rem;line-height:1.45"><strong>' . e((string) $n['author']) . '</strong>'
|
||||||
|
. ' <span style="color:#64748b;font-size:.8rem">' . e(format_dt((string) $n['created_at'])) . ((int) $n['is_important'] ? ' · wichtig' : '') . '</span><br>'
|
||||||
|
. nl2br(e(mb_strimwidth((string) $n['message'], 0, 400, '…'))) . '</div>';
|
||||||
|
}
|
||||||
|
if ($count > 3) $html .= '<div style="margin-top:8px;font-size:.85rem;color:#64748b">… und ' . ($count - 3) . ' weitere</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
}
|
||||||
|
// 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 .= '<div class="schicht-banner schicht-banner-own" role="status" style="margin:0 0 16px;padding:12px 18px;border-radius:12px;border:1px solid #93c5fd;border-left-width:6px;background:#eff6ff;color:#16202a">'
|
||||||
|
. '<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">'
|
||||||
|
. '<strong style="font-size:.95rem">Ihre Notiz an die nächste Schicht' . ((int) $n['is_important'] ? ' (wichtig)' : '') . ' · ' . ($n['department'] ? 'Abteilung ' . e((string) $n['department']) : 'alle Mitarbeiter') . '</strong>'
|
||||||
|
. '<div style="display:flex;gap:8px;flex-wrap:wrap"><a class="btn secondary" href="/staff/plugin.php?p=' . SCHICHT . '&page=schichtuebergabe">Alle Notizen</a>'
|
||||||
|
. '<form method="post" action="/staff/plugin.php?p=' . SCHICHT . '&page=schichtuebergabe" style="margin:0">' . csrf_field() . '<input type="hidden" name="action" value="hide_own"><input type="hidden" name="id" value="' . (int) $n['id'] . '"><button class="btn" type="submit">Ausblenden</button></form></div></div>'
|
||||||
|
. '<div style="margin-top:8px;font-size:.9rem;line-height:1.45">' . nl2br(e(mb_strimwidth((string) $n['message'], 0, 400, '…'))) . '</div>'
|
||||||
|
. '<div style="margin-top:6px;font-size:.8rem;color:#64748b">' . e(format_dt((string) $n['created_at'])) . ' · ' . ($readers ? 'Gelesen von: ' . e(implode(', ', $readers)) : 'noch von niemandem gelesen') . '</div></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
// Seite des Schichtübergabe-Plugins. Verfügbar: $staff, $pdo.
|
||||||
|
$isAdmin = ($staff['role'] ?? '') === 'admin';
|
||||||
|
$message = null;
|
||||||
|
$error = null;
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
verify_csrf();
|
||||||
|
$action = $_POST['action'] ?? '';
|
||||||
|
if ($action === 'create') {
|
||||||
|
$dept = ($_POST['department_id'] ?? '') !== '' ? (int) $_POST['department_id'] : null;
|
||||||
|
$r = schicht_create($pdo, $staff, (string) ($_POST['message'] ?? ''), $dept, !empty($_POST['important']));
|
||||||
|
if ($r['ok']) $message = 'Die Notiz ist gespeichert (unten steht sie ganz oben). Die anderen sehen sie als „neu“ im Menü' . (!empty($_POST['important']) ? ' und in der Seitenleiste jedes Tickets. Sie selbst sehen sie dort die nächsten 12 Stunden ebenfalls, mit der Zahl der Leser' : '') . '.'; else $error = $r['error'];
|
||||||
|
} elseif ($action === 'delete') {
|
||||||
|
if (schicht_delete($pdo, $staff, (int) ($_POST['id'] ?? 0))) $message = 'Die Notiz wurde gelöscht.'; else $error = 'Diese Notiz können Sie nicht löschen.';
|
||||||
|
} elseif ($action === 'mark_read') {
|
||||||
|
schicht_mark_read($pdo, $staff, [(int) ($_POST['id'] ?? 0)]);
|
||||||
|
$message = 'Als gelesen markiert. Der Hinweis ist ausgeblendet.';
|
||||||
|
} elseif ($action === 'hide_own') {
|
||||||
|
schicht_hide_own($staff, (int) ($_POST['id'] ?? 0));
|
||||||
|
$message = 'Die Bestätigung auf der Übersicht ist ausgeblendet. Die Notiz selbst bleibt unten erhalten.';
|
||||||
|
} elseif ($action === 'mark_all') {
|
||||||
|
schicht_mark_read($pdo, $staff, array_map(fn($n) => (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)));
|
||||||
|
?>
|
||||||
|
<h1>Schichtübergabe</h1>
|
||||||
|
<?php if ($message): ?><div class="flash success"><?= e($message) ?></div><?php endif; ?>
|
||||||
|
<?php if ($error): ?><div class="flash error"><?= e($error) ?></div><?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post" class="card" style="max-width:820px;margin-bottom:16px">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="action" value="create">
|
||||||
|
<h3>Notiz für die nächste Schicht</h3>
|
||||||
|
<textarea name="message" rows="4" maxlength="3000" required placeholder="Was muss die nächste Schicht wissen? Offene Punkte, Störungen, besondere Kunden …"></textarea>
|
||||||
|
<div style="display:flex;gap:14px;flex-wrap:wrap;align-items:center;margin-top:8px">
|
||||||
|
<div><label>Gilt für</label>
|
||||||
|
<select name="department_id">
|
||||||
|
<option value="">Alle Mitarbeiter</option>
|
||||||
|
<?php foreach ($depts as $d): ?><option value="<?= (int) $d['id'] ?>">Nur Abteilung: <?= e($d['name']) ?></option><?php endforeach; ?>
|
||||||
|
</select></div>
|
||||||
|
<label style="font-weight:400"><input type="checkbox" name="important" value="1" style="width:auto"> Wichtig (erscheint auch in der Seitenleiste jedes Tickets)</label>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row"><button type="submit" class="btn">Notiz speichern</button></div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card" style="max-width:820px;margin-bottom:16px">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">
|
||||||
|
<h3 style="margin:0">Notizen der letzten <?= schicht_keep_days() ?> Tage</h3>
|
||||||
|
<?php if ($newIds): ?><form method="post"><?= csrf_field() ?><input type="hidden" name="action" value="mark_all"><button class="btn" type="submit">Alle <?= count($newIds) ?> als gelesen markieren</button></form><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php if (!$notes): ?><p class="muted">Noch keine Notizen.</p><?php endif; ?>
|
||||||
|
<?php foreach ($notes as $n): $isNew = in_array((int) $n['id'], $newIds, true); $mine = (int) $n['staff_id'] === (int) $staff['id']; ?>
|
||||||
|
<div style="padding:12px 0;border-top:1px solid var(--border,#e3e9ee);<?= $isNew ? 'background:rgba(15,111,106,.07);margin:0 -10px;padding-left:10px;padding-right:10px' : '' ?>">
|
||||||
|
<div style="display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap">
|
||||||
|
<div style="font-size:.88rem">
|
||||||
|
<strong><?= e((string) $n['author']) ?></strong>
|
||||||
|
<span class="muted"> · <?= e(format_dt((string) $n['created_at'])) ?> · <?= $n['department'] ? 'Abteilung ' . e((string) $n['department']) : 'alle' ?></span>
|
||||||
|
<?php if ($isNew): ?><span class="tag" style="background:#e6f7ee;color:#157a45;border-radius:20px;padding:2px 9px;font-size:.72rem;font-weight:700">neu</span><?php endif; ?>
|
||||||
|
<?php if ($mine): ?><span style="background:#e6edff;color:#2f4fc4;border-radius:20px;padding:2px 9px;font-size:.72rem;font-weight:700">von Ihnen</span><?php endif; ?>
|
||||||
|
<?php if ($n['is_important']): ?><span style="background:#fde8e8;color:#b42318;border-radius:20px;padding:2px 9px;font-size:.72rem;font-weight:700">wichtig</span><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:6px;align-items:flex-start">
|
||||||
|
<?php if ($isNew): ?>
|
||||||
|
<form method="post"><?= csrf_field() ?><input type="hidden" name="action" value="mark_read"><input type="hidden" name="id" value="<?= (int) $n['id'] ?>"><button class="btn" type="submit" style="padding:2px 10px;font-size:.78rem">✓ Gelesen</button></form>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($mine || $isAdmin): ?>
|
||||||
|
<form method="post" onsubmit="return confirm('Notiz löschen?')"><?= csrf_field() ?><input type="hidden" name="action" value="delete"><input type="hidden" name="id" value="<?= (int) $n['id'] ?>"><button class="btn secondary" type="submit" style="padding:2px 10px;font-size:.78rem">Löschen</button></form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="margin:6px 0 0;white-space:normal"><?= nl2br(e((string) $n['message'])) ?></p>
|
||||||
|
<?php if ($mine || $isAdmin): $readers = schicht_readers($pdo, (int) $n['id']); ?>
|
||||||
|
<div class="muted" style="font-size:.76rem;margin-top:4px">Gelesen von: <?= $readers ? e(implode(', ', $readers)) : 'noch niemand' ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($isAdmin): ?>
|
||||||
|
<form method="post" class="card" style="max-width:820px">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="action" value="settings">
|
||||||
|
<h3>Einstellungen (nur Administratoren)</h3>
|
||||||
|
<label for="keep_days">Notizen automatisch löschen nach (Tagen)</label>
|
||||||
|
<input type="number" id="keep_days" name="keep_days" min="1" max="365" value="<?= schicht_keep_days() ?>" style="max-width:120px">
|
||||||
|
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
// Schichtübergabe: Notizen für die nächste Schicht.
|
||||||
|
require_once __DIR__ . '/lib.php';
|
||||||
|
|
||||||
|
plugin_add_hook('cron.run', function () {
|
||||||
|
schicht_cleanup(get_db());
|
||||||
|
});
|
||||||
|
// Deutlicher Hinweis oben auf jeder Seite, solange Notizen ungelesen sind (gilt ab dem aktuellen DeskHub; sonst bleibt der Zähler im Menü)
|
||||||
|
plugin_add_filter('staff.page.top', function ($html, array $ctx) {
|
||||||
|
return $html . schicht_banner_html(get_db(), $ctx['staff'] ?? null, (string) ($ctx['activePage'] ?? ''));
|
||||||
|
});
|
||||||
|
plugin_add_filter('ticket.view.sidebar', function ($html, array $ctx) {
|
||||||
|
return $html . schicht_sidebar_html(get_db(), $ctx['staff'] ?? null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Für alle Mitarbeiter und Administratoren; der Zähler zeigt ungelesene Notizen
|
||||||
|
plugin_register_menu(schicht_menu_label(), 'schichtuebergabe');
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
<?php
|
||||||
|
// Spam-Filter-Plugin: Bewertung, Entscheidung, Korrektur. Alle Funktionen beginnen mit spamfilter_.
|
||||||
|
|
||||||
|
const SPAMFILTER = 'spamfilter';
|
||||||
|
|
||||||
|
/** Eingebaute Wörter und Wendungen (kleingeschrieben) mit Punkten. */
|
||||||
|
function spamfilter_default_words(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ihr konto wurde' => 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 = '<div class="card" style="margin-top:16px;padding:14px 16px"><div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:8px">Spamfilter</div>'
|
||||||
|
. '<div style="font-size:.9rem"><strong style="color:' . e($color) . '">' . e($label) . '</strong> · ' . (int) $row['score'] . ' Punkte</div>';
|
||||||
|
if ($reasons) {
|
||||||
|
$html .= '<ul style="margin:8px 0 0;padding-left:18px;font-size:.78rem;color:var(--muted)">';
|
||||||
|
foreach (array_slice($reasons, 0, 6) as $r) $html .= '<li>' . e((string) $r) . '</li>';
|
||||||
|
$html .= '</ul>';
|
||||||
|
}
|
||||||
|
return $html . '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kennzahlen der letzten 30 Tage. @return array<string,int> */
|
||||||
|
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) {
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
// Seite des Spamfilter-Plugins. Verfügbar: $staff, $pdo.
|
||||||
|
$isAdmin = ($staff['role'] ?? '') === 'admin';
|
||||||
|
$message = null;
|
||||||
|
$error = null;
|
||||||
|
$testResult = null;
|
||||||
|
$testIn = ['name' => '', '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)'];
|
||||||
|
?>
|
||||||
|
<h1>Spamfilter</h1>
|
||||||
|
<?php if ($message): ?><div class="flash success"><?= e($message) ?></div><?php endif; ?>
|
||||||
|
<?php if ($error): ?><div class="flash error"><?= e($error) ?></div><?php endif; ?>
|
||||||
|
|
||||||
|
<div class="card" style="max-width:900px;margin-bottom:16px">
|
||||||
|
<h3>Die letzten 30 Tage</h3>
|
||||||
|
<p style="font-size:.92rem">
|
||||||
|
<strong style="color:#b91c1c"><?= (int) $stats['spam'] + (int) $stats['manual_spam'] ?></strong> Spam abgelegt ·
|
||||||
|
<strong style="color:#b45309"><?= (int) $stats['suspect'] ?></strong> im Verdacht ·
|
||||||
|
<strong><?= (int) $stats['ok'] ?></strong> unauffällig ·
|
||||||
|
<strong><?= (int) $stats['manual_ok'] ?></strong> von Hand freigegeben
|
||||||
|
</p>
|
||||||
|
<p class="muted" style="font-size:.82rem">Ab <strong><?= (int) $cfg['spam_at'] ?></strong> Punkten gilt ein Ticket als Spam<?= $cfg['auto'] ? ' und wird automatisch abgelegt' : ' (nur markiert, die automatische Ablage ist aus)' ?>, ab <strong><?= (int) $cfg['suspect_at'] ?></strong> bekommt es den Tag „Spam-Verdacht“. Die Filterung ist <?= $cfg['enabled'] ? 'eingeschaltet' : '<strong>ausgeschaltet</strong>' ?>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom:16px">
|
||||||
|
<h3>Protokoll</h3>
|
||||||
|
<?php if (!$log): ?><p class="muted">Noch keine Tickets bewertet.</p><?php else: ?>
|
||||||
|
<table style="width:100%">
|
||||||
|
<thead><tr><th>Ticket</th><th>Absender</th><th>Punkte</th><th>Entscheidung</th><th>Gründe</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($log as $l): $reasons = json_decode((string) $l['reasons'], true) ?: []; ?>
|
||||||
|
<tr>
|
||||||
|
<td><a href="/staff/ticket_view.php?n=<?= e(urlencode((string) $l['ticket_number'])) ?>">#<?= e((string) $l['ticket_number']) ?></a><div class="muted" style="font-size:.76rem"><?= e(mb_strimwidth((string) $l['subject'], 0, 40, '…')) ?></div></td>
|
||||||
|
<td><?= e((string) $l['sender']) ?></td>
|
||||||
|
<td><strong><?= (int) $l['score'] ?></strong></td>
|
||||||
|
<td><?= e($labels[$l['decision']] ?? $l['decision']) ?></td>
|
||||||
|
<td class="muted" style="font-size:.78rem"><?= e(implode(' · ', array_slice($reasons, 0, 3))) ?></td>
|
||||||
|
<td style="white-space:nowrap">
|
||||||
|
<?php if (!empty($l['is_spam'])): ?>
|
||||||
|
<form method="post" style="display:inline"><?= csrf_field() ?><input type="hidden" name="action" value="not_spam"><input type="hidden" name="ticket_id" value="<?= (int) $l['ticket_id'] ?>"><label style="font-size:.74rem"><input type="checkbox" name="allow_sender" value="1"> Absender freigeben</label> <button class="btn secondary" type="submit">Kein Spam</button></form>
|
||||||
|
<?php elseif ($l['decision'] === 'suspect'): ?>
|
||||||
|
<form method="post" style="display:inline"><?= csrf_field() ?><input type="hidden" name="action" value="confirm_spam"><input type="hidden" name="ticket_id" value="<?= (int) $l['ticket_id'] ?>"><label style="font-size:.74rem"><input type="checkbox" name="ban_sender" value="1"> Absender sperren</label> <button class="btn danger" type="submit">Doch Spam</button></form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="card" style="max-width:900px;margin-bottom:16px">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="action" value="test">
|
||||||
|
<h3>Ausprobieren</h3>
|
||||||
|
<p class="muted" style="font-size:.85rem">Zeigt, wie der Filter eine Nachricht bewerten würde (ohne etwas zu speichern).</p>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||||||
|
<div><label>Absendername</label><input type="text" name="name" value="<?= e($testIn['name']) ?>" placeholder="Netflix Support"></div>
|
||||||
|
<div><label>Absender-E-Mail</label><input type="text" name="email" value="<?= e($testIn['email']) ?>" placeholder="info@irgendwas.example"></div>
|
||||||
|
</div>
|
||||||
|
<label style="margin-top:8px">Betreff</label><input type="text" name="subject" value="<?= e($testIn['subject']) ?>">
|
||||||
|
<label style="margin-top:8px">Nachricht</label><textarea name="message" rows="4"><?= e($testIn['message']) ?></textarea>
|
||||||
|
<div class="btn-row"><button type="submit" class="btn secondary">Bewerten</button></div>
|
||||||
|
<?php if ($testResult): ?>
|
||||||
|
<p style="margin-top:12px"><strong><?= (int) $testResult['score'] ?> Punkte</strong> →
|
||||||
|
<?= $testResult['allowed'] ? 'freigegeben' : ($testResult['score'] >= $cfg['spam_at'] ? '<strong style="color:#b91c1c">Spam</strong>' : ($testResult['score'] >= $cfg['suspect_at'] ? '<strong style="color:#b45309">Verdacht</strong>' : '<strong style="color:#15803d">unauffällig</strong>')) ?></p>
|
||||||
|
<ul style="font-size:.85rem"><?php foreach ($testResult['reasons'] as $r): ?><li><?= e($r) ?></li><?php endforeach; ?><?php if (!$testResult['reasons']): ?><li class="muted">Keine Auffälligkeiten.</li><?php endif; ?></ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php if ($isAdmin): ?>
|
||||||
|
<form method="post" class="card" style="max-width:900px">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="action" value="save">
|
||||||
|
<h3>Einstellungen</h3>
|
||||||
|
<label><input type="checkbox" name="enabled" value="1" <?= $cfg['enabled'] ? 'checked' : '' ?>> Neue Tickets bewerten</label>
|
||||||
|
<label style="display:block"><input type="checkbox" name="auto" value="1" <?= $cfg['auto'] ? 'checked' : '' ?>> Eindeutigen Spam automatisch ablegen (sonst nur markieren)</label>
|
||||||
|
<label style="display:block"><input type="checkbox" name="ban" value="1" <?= $cfg['ban'] ? 'checked' : '' ?>> Absender von abgelegtem Spam zusätzlich sperren</label>
|
||||||
|
<div style="display:flex;gap:14px;margin-top:10px;flex-wrap:wrap">
|
||||||
|
<div><label>Spam ab Punkten</label><input type="number" name="spam_at" min="20" max="200" value="<?= (int) $cfg['spam_at'] ?>" style="max-width:120px"></div>
|
||||||
|
<div><label>Verdacht ab Punkten</label><input type="number" name="suspect_at" min="10" max="150" value="<?= (int) $cfg['suspect_at'] ?>" style="max-width:120px"></div>
|
||||||
|
</div>
|
||||||
|
<label style="margin-top:10px">Freigabeliste (eine Adresse oder Domain pro Zeile, diese Absender werden nie bewertet)</label>
|
||||||
|
<textarea name="allow" rows="4" placeholder="partner@firma.de meinefirma.de"><?= e((string) plugin_setting(SPAMFILTER, 'allow', '')) ?></textarea>
|
||||||
|
<label style="margin-top:10px">Sperrliste (eine Adresse oder Domain pro Zeile, +100 Punkte)</label>
|
||||||
|
<textarea name="block" rows="3" placeholder="spam-domain.example"><?= e((string) plugin_setting(SPAMFILTER, 'block', '')) ?></textarea>
|
||||||
|
<label style="margin-top:10px">Eigene Wörter (eine Wendung pro Zeile, optional mit Punkten: <code>wort=30</code>)</label>
|
||||||
|
<textarea name="words" rows="4" placeholder="gratis testen=15 gewinnspiel"><?= e((string) plugin_setting(SPAMFILTER, 'words', '')) ?></textarea>
|
||||||
|
<label style="margin-top:10px">Weitere Marken, die gern nachgeahmt werden (eine pro Zeile)</label>
|
||||||
|
<textarea name="brands" rows="3" placeholder="mein-shop"><?= e((string) plugin_setting(SPAMFILTER, 'brands', '')) ?></textarea>
|
||||||
|
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user