0) { @ob_end_flush(); } @flush(); } // Session-Schreibsperre freigeben: wir haben oben bereits alles Nötige in $_SESSION // eingetragen, ein direkt danach folgender Poll desselben Besuchers soll nicht auf // die Hintergrundarbeit hier warten müssen. if (session_status() === PHP_SESSION_ACTIVE) session_write_close(); } /** Prüft, ob der aktuelle Besucher Zugriff auf dieses Ticket hat (Gast-Session oder eingeloggter Kunde). */ function chat_has_access(array $ticket, ?array $user): bool { if ($user && $ticket['user_id'] == $user['id']) return true; return !empty($_SESSION['guest_ticket_access'][$ticket['ticket_number']]); } function chat_load_ticket(PDO $pdo, string $number): ?array { $stmt = $pdo->prepare('SELECT * FROM tickets WHERE ticket_number = ?'); $stmt->execute([$number]); return $stmt->fetch() ?: null; } /** Aktuelle Nachrichten (kundensichtbar) für die Widget-Darstellung. */ function chat_messages(PDO $pdo, int $ticketId): array { $stmt = $pdo->prepare( "SELECT tt.id, tt.author_type, tt.message, tt.created_at, s.name AS staff_name FROM ticket_threads tt LEFT JOIN staff s ON s.id = tt.staff_id WHERE tt.ticket_id = ? AND tt.is_internal_note = 0 ORDER BY tt.created_at ASC, tt.id ASC" ); $stmt->execute([$ticketId]); $attStmt = $pdo->prepare('SELECT id, original_name, filesize FROM ticket_attachments WHERE thread_id = ? ORDER BY id'); return array_map(function (array $m) use ($attStmt) { $attStmt->execute([$m['id']]); return [ 'id' => (int) $m['id'], 'authorType' => $m['author_type'], 'authorName' => $m['author_type'] === 'staff' ? ($m['staff_name'] ?: 'Support') : null, 'message' => chat_plain_text($m['message']), 'createdAt' => $m['created_at'], 'attachments' => array_map(fn(array $a) => [ 'id' => (int) $a['id'], 'name' => $a['original_name'], 'size' => (int) $a['filesize'], ], $attStmt->fetchAll()), ]; }, $stmt->fetchAll()); } // api_plain_text() lebt in api/staff/v1/common.php (nicht in jedem Kontext geladen) – // hier eine schlanke, eigenständige Variante fürs Kundenportal. function chat_plain_text(string $html): string { $text = preg_replace('/<(br|p|div)[^>]*>/i', "\n", $html); return trim(html_entity_decode(strip_tags($text ?? $html), ENT_QUOTES)); } /** * Liefert eine garantiert existierende Abteilungs-ID: Regel-Vorgabe zuerst, sonst die * konfigurierte Live-Chat-/Standardabteilung – aber nur, wenn diese ID auch wirklich noch * existiert (Abteilungen können im Adminportal jederzeit umbenannt/gelöscht werden). * Gibt null zurück, wenn es überhaupt keine Abteilung gibt (Grundvoraussetzung fehlt). */ function chat_resolve_department(PDO $pdo, ?int $ruleDeptId): ?int { $candidate = $ruleDeptId ?? (int) (get_setting('live_chat_department_id') ?: get_setting('default_department_id', '1')); if ($candidate) { $stmt = $pdo->prepare('SELECT COUNT(*) FROM departments WHERE id = ? AND is_active = 1'); $stmt->execute([$candidate]); if ($stmt->fetchColumn()) return $candidate; } $fallback = $pdo->query('SELECT id FROM departments WHERE is_active = 1 ORDER BY is_default DESC, id ASC LIMIT 1')->fetchColumn() ?: $pdo->query('SELECT id FROM departments ORDER BY is_default DESC, id ASC LIMIT 1')->fetchColumn(); return $fallback ? (int) $fallback : null; } /** * Liefert eine garantiert existierende Priorität (volle Zeile, für calc_sla_dates()): * Regel-Vorgabe zuerst, sonst die mit der niedrigsten sort_order. Gibt null zurück, * wenn gar keine Priorität existiert. */ function chat_resolve_priority(PDO $pdo, ?int $rulePrioId): ?array { if ($rulePrioId) { $stmt = $pdo->prepare('SELECT * FROM priorities WHERE id = ?'); $stmt->execute([$rulePrioId]); $row = $stmt->fetch(); if ($row) return $row; } return $pdo->query('SELECT * FROM priorities ORDER BY sort_order ASC, id ASC LIMIT 1')->fetch() ?: null; } /** * Ist gerade mindestens ein aktiver Mitarbeiter "Verfügbar" (siehe staff/availability.php)? * Wer seinen Status noch nie gesetzt hat, gilt wie dort als "Verfügbar" (Standardwert). * Rein informativ fürs Widget – blockiert das Chatten selbst nicht, der Kunde kann * trotzdem schreiben, die Nachricht wartet dann eben bis jemand wieder verfügbar ist. */ function chat_any_staff_available(PDO $pdo): bool { // Wichtig: Ein Mitarbeiter ohne staff_availability-Zeile (Status nie gesetzt) zählt // NICHT als verfügbar. Das entspricht der Team-Übersicht in staff/availability.php, // die eine fehlende Zeile ebenfalls als "Offline" behandelt – sonst würde der Chat // "online" anzeigen, obwohl kein einziger Mitarbeiter seinen Status je auf // "Verfügbar" gestellt hat. staff_run_auto_offline($pdo); // Wer lange inaktiv war, gilt nicht mehr als erreichbar $stmt = $pdo->query( "SELECT COUNT(*) FROM staff s INNER JOIN staff_availability sa ON sa.staff_id = s.id WHERE s.is_active = 1 AND sa.status = 'available'" ); return (int) $stmt->fetchColumn() > 0; } if (get_setting('live_chat_enabled', '0') !== '1') { chat_json(['error' => 'Live-Chat ist derzeit nicht verfügbar.'], 404); } if (get_setting('helpdesk_status', 'online') === 'offline') { chat_json(['error' => 'Das Supportcenter ist derzeit nicht erreichbar.'], 503); } $pdo = get_db(); $user = current_user(); $action = $_SERVER['REQUEST_METHOD'] === 'POST' ? ($_POST['action'] ?? '') : ($_GET['action'] ?? ''); // ── Aktiven Chat der Session ermitteln (für state/send/poll) ─────────────────── $activeNumber = $_SESSION['live_chat_ticket_number'] ?? null; $activeTicket = $activeNumber ? chat_load_ticket($pdo, $activeNumber) : null; if ($activeTicket && !chat_has_access($activeTicket, $user)) { $activeTicket = null; unset($_SESSION['live_chat_ticket_number']); } // "Mitarbeiter tippt gerade" fürs Widget: true, solange typing_staff_until (siehe // staff/live_chat.php Aktion "typing") noch in der Zukunft liegt. function chat_staff_is_typing(?array $ticket): bool { return $ticket && !empty($ticket['typing_staff_until']) && strtotime($ticket['typing_staff_until']) > time(); } if ($action === 'state') { if (!$activeTicket) chat_json(['active' => false, 'staffAvailable' => chat_any_staff_available($pdo)]); chat_json([ 'active' => true, 'number' => $activeTicket['ticket_number'], 'status' => $activeTicket['status'], 'staffAvailable' => chat_any_staff_available($pdo), 'staffTyping' => chat_staff_is_typing($activeTicket), 'convertedToTicket' => $activeTicket['source'] !== 'chat', 'messages' => chat_messages($pdo, (int) $activeTicket['id']), ]); } if ($action === 'poll') { if (!$activeTicket) chat_json(['active' => false]); chat_json([ 'active' => true, 'status' => $activeTicket['status'], 'staffAvailable' => chat_any_staff_available($pdo), 'staffTyping' => chat_staff_is_typing($activeTicket), 'convertedToTicket' => $activeTicket['source'] !== 'chat', 'messages' => chat_messages($pdo, (int) $activeTicket['id']), ]); } if ($_SERVER['REQUEST_METHOD'] !== 'POST') { chat_json(['error' => 'Methode nicht erlaubt.'], 405); } // Eigene, JSON-taugliche CSRF-Prüfung statt der gemeinsamen verify_csrf(): die bricht bei // ungültigem Token mit einer reinen Textausgabe ab, die das Widget nicht als JSON parsen kann. $submittedCsrf = $_POST['csrf_token'] ?? ''; if (!$submittedCsrf || !hash_equals($_SESSION['csrf_token'] ?? '', $submittedCsrf)) { chat_json(['error' => 'Ihre Sitzung ist abgelaufen. Bitte laden Sie die Seite neu und versuchen Sie es erneut.', 'csrfExpired' => true], 403); } // Token nach Verbrauch rotieren, wie verify_csrf() es auch tut – außer bei "typing": // das feuert bei jedem Tastenanschlag (gedrosselt) und würde sonst mit einem parallel // abgeschickten "send" um denselben Token wettlaufen und diesen gelegentlich ungültig // machen. Ein verpasstes Tipp-Signal ist folgenlos, ein fehlgeschlagenes "send" nicht. if ($action !== 'typing') { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } if ($action === 'typing') { if ($activeTicket && $activeTicket['status'] !== 'closed') { $pdo->prepare('UPDATE tickets SET typing_customer_until = ?, updated_at = updated_at WHERE id = ?') ->execute([date('Y-m-d H:i:s', time() + 6), $activeTicket['id']]); } chat_json(['ok' => true]); } // Sicherheitsnetz: Ein unerwarteter Fehler soll dem Kunden nie eine leere/kaputte Antwort // liefern (das Widget könnte sie dann nicht auswerten) – stattdessen sauberes JSON // zurückgeben und den echten Fehler nur server-seitig loggen (nicht an den Kunden ausliefern). try { if ($action === 'start') { chat_handle_start($pdo, $user); } elseif ($action === 'send') { chat_handle_send($pdo, $user, $activeTicket); } else { chat_json(['error' => 'Unbekannte Aktion.'], 422); } } catch (Throwable $e) { error_log('live_chat.php: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); chat_json(['error' => 'Es ist ein technischer Fehler aufgetreten. Bitte versuchen Sie es später erneut.'], 500); } /** * Prüft einen optionalen Datei-Anhang (Feld "attachment", wie beim normalen * Ticket-Formular in ticket_new.php) über die gemeinsame handle_ticket_upload(). * Gibt null zurück, wenn keine Datei mitgeschickt wurde. Bei einem ungültigen * Upload wird direkt mit chat_json() geantwortet (never zurückkehrend). */ function chat_validate_upload(): ?array { if (empty($_FILES['attachment']['name'])) return null; $result = handle_ticket_upload($_FILES['attachment']); if (isset($result['error'])) { chat_json(['error' => $result['error']], 422); } return $result; } /** Startet einen neuen Live-Chat: legt ein Ticket + die erste Nachricht an. */ function chat_handle_start(PDO $pdo, ?array $user): never { if (!rate_limit_check('live_chat_start', client_ip(), 10, 20)) { chat_json(['error' => 'Zu viele Chat-Anfragen. Bitte versuchen Sie es in ein paar Minuten erneut.'], 429); } // Honeypot wie beim normalen Ticket-Formular if (!empty($_POST['website'])) { chat_json(['error' => 'Es ist ein Fehler aufgetreten.'], 422); } $name = trim($_POST['name'] ?? ($user['name'] ?? '')); $email = trim($_POST['email'] ?? ($user['email'] ?? '')); $message = trim($_POST['message'] ?? ''); if ($name === '') chat_json(['error' => 'Bitte geben Sie Ihren Namen an.'], 422); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) chat_json(['error' => 'Bitte geben Sie eine gültige E-Mail-Adresse an.'], 422); // Anhang schon hier prüfen (nicht erst nach dem Ticket-Insert) – ein ungültiger // Upload soll kein leeres/halbfertiges Ticket hinterlassen (wie in ticket_new.php). $uploadInfo = chat_validate_upload(); // Nachricht darf leer sein, WENN ein Anhang mitgeschickt wurde (z.B. nur ein Foto). if ($message === '' && !$uploadInfo) chat_json(['error' => 'Bitte geben Sie eine Nachricht ein.'], 422); if (mb_strlen($message) > 5000) chat_json(['error' => 'Nachricht ist zu lang.'], 422); // Spaltenbreiten in der Datenbank sind strikt (requester_name VARCHAR(120), // requester_email VARCHAR(150)) – Name großzügig kürzen statt abzulehnen, // eine zu lange E-Mail dagegen ablehnen (Kürzen würde sie ungültig machen). if (mb_strlen($name) > 120) $name = mb_substr($name, 0, 120); if (mb_strlen($email) > 150) chat_json(['error' => 'Die E-Mail-Adresse ist zu lang.'], 422); if (is_banned($email)) { // Wie beim Ticket-Formular: kommentarlos abweisen, nicht verraten dass gezielt gesperrt wurde. chat_json(['error' => 'Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.'], 422); } // Ist gerade niemand als "Verfügbar" markiert, wird daraus kein "Live-Chat" (keine // Live-Chat-Markierung/Tag, taucht nicht im Live-Chat-Postfach auf) – stattdessen ein // ganz normales Ticket, genau wie über das Kontaktformular. Für den Kunden ändert sich // an der Widget-Bedienung nichts (er kann trotzdem weiterschreiben und bekommt im // selben Fenster eine Antwort, sobald wieder jemand da ist). $staffAvailable = chat_any_staff_available($pdo); $ticketSource = $staffAvailable ? 'chat' : 'web'; $ruleResult = evaluate_ticket_rules($pdo, [ 'subject' => 'Live-Chat: ' . mb_substr($message, 0, 60), 'message' => $message, 'requester_email' => $email, 'requester_name' => $name, 'source' => $ticketSource, ]); if ($ruleResult['reject']) { chat_json(['error' => 'Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.'], 422); } $deptId = chat_resolve_department($pdo, $ruleResult['set_department']); if (!$deptId) { chat_json(['error' => 'Live-Chat ist derzeit nicht verfügbar (keine Abteilung eingerichtet).'], 503); } $priority = chat_resolve_priority($pdo, $ruleResult['set_priority']); if (!$priority) { chat_json(['error' => 'Live-Chat ist derzeit nicht verfügbar (keine Priorität eingerichtet).'], 503); } $prioId = (int) $priority['id']; $helpTopicId = $ruleResult['set_help_topic']; $sla = calc_sla_dates($priority, null, (int) $deptId, $helpTopicId ? (int) $helpTopicId : null); $clientIp = client_ip(); if (get_setting('ip_anonymize', '0') === '1') { $clientIp = preg_replace('/\.(\d+)$/', '.0', $clientIp); $clientIp = preg_replace('/:[0-9a-f]{1,4}$/', ':0', $clientIp); } $subject = 'Live-Chat: ' . mb_substr($message, 0, 80); $ticketNumber = generate_ticket_number($pdo); $now = db_now(); $pdo->prepare( 'INSERT INTO tickets (ticket_number, subject, user_id, requester_name, requester_email, department_id, help_topic_id, priority_id, status, source, requester_ip, first_response_due_at, resolution_due_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)' )->execute([ $ticketNumber, $subject, $user['id'] ?? null, $name, $email, $deptId, $helpTopicId, $prioId, 'open', $ticketSource, $clientIp, $sla['first_response_due_at'], $sla['resolution_due_at'], $now, $now, ]); $ticketId = (int) $pdo->lastInsertId(); $pdo->prepare( 'INSERT INTO ticket_threads (ticket_id, author_type, user_id, message, is_internal_note, created_at) VALUES (?,?,?,?,0,?)' )->execute([$ticketId, 'customer', $user['id'] ?? null, $message, $now]); $threadId = (int) $pdo->lastInsertId(); if ($uploadInfo) { $pdo->prepare( 'INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)' )->execute([$threadId, $uploadInfo['original_name'], $uploadInfo['stored_name'], $uploadInfo['filesize'], $now]); } log_activity($pdo, $ticketId, null, 'Live-Chat gestartet'); $assignTo = $ruleResult['assign_staff']; // Einstellung der Abteilung ("Neue Tickets automatisch zuweisen") vor dem letzten Bearbeiter $deptAssignee = $assignTo === null ? department_auto_assignee($pdo, (int) $deptId) : null; if ($deptAssignee !== null) { $assignTo = $deptAssignee; log_activity($pdo, $ticketId, null, 'Automatisch zugewiesen (Einstellung der Abteilung)'); } elseif ($assignTo === null) { $prevStmt = $pdo->prepare( "SELECT assigned_staff_id FROM tickets WHERE LOWER(requester_email) = LOWER(?) AND assigned_staff_id IS NOT NULL AND status NOT IN ('closed','resolved') AND id != ? ORDER BY updated_at DESC LIMIT 1" ); $prevStmt->execute([$email, $ticketId]); $prevId = $prevStmt->fetchColumn(); if ($prevId) $assignTo = (int) $prevId; } if ($assignTo !== null) { $pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$assignTo, $ticketId]); } foreach ($ruleResult['add_tags'] as $tagName) { apply_tag_to_ticket($pdo, $ticketId, $tagName); } if ($ticketSource === 'chat') { apply_tag_to_ticket($pdo, $ticketId, 'Live-Chat'); } // Vor dem Flush (nicht danach), damit der Kunde die Auto-Antwort sofort in seiner // ersten Chat-Antwort sieht, statt erst beim nächsten Poll bis zu 8s später. apply_auto_reply($pdo, $ticketId, $ruleResult['auto_reply']); $_SESSION['guest_ticket_access'][$ticketNumber] = true; $_SESSION['live_chat_ticket_number'] = $ticketNumber; // Ab hier ist der Chat für den Kunden fertig eingerichtet – Antwort sofort raus, // damit sich der Start "live" anfühlt. Benachrichtigungen (die je nach Anzahl // Mitarbeiter mehrere SMTP-Verbindungen brauchen) laufen danach im Hintergrund. chat_flush_response([ 'active' => true, 'number' => $ticketNumber, 'status' => 'open', 'convertedToTicket' => $ticketSource !== 'chat', 'messages' => chat_messages($pdo, $ticketId), ]); $deptStmt = $pdo->prepare('SELECT name, email FROM departments WHERE id = ?'); $deptStmt->execute([$deptId]); $dept = $deptStmt->fetch(); $ticketForNotify = [ 'ticket_number' => $ticketNumber, 'subject' => $subject, 'requester_name' => $name, 'requester_email' => $email, 'department_id' => $deptId, 'department_name' => $dept['name'] ?? null, ]; // Bestätigungs-Mail an den Kunden – genau wie beim normalen Ticket-Formular // (ticket_new.php). Ohne die hat der Kunde keinerlei Nachweis/Ticketnummer, an die // er sich halten kann, falls er den Tab schließt – besonders wichtig, wenn gerade // kein Mitarbeiter online ist und er nicht sofort eine Antwort im Chat bekommt. try { notify_customer_ticket_created($ticketForNotify); } catch (Throwable $e) { error_log('live_chat.php Bestätigungs-Mail: ' . $e->getMessage()); } notify_new_ticket_channels($ticketForNotify, $dept['email'] ?? null); notify_staff_new_ticket($ticketForNotify); webhook_dispatch($pdo, 'ticket.created', $ticketId); if (file_exists(__DIR__ . '/includes/push.php')) { require_once __DIR__ . '/includes/push.php'; push_new_chat_message($deptId, $assignTo, $ticketNumber, $name); } exit; } /** Sendet eine weitere Nachricht im bereits laufenden Chat. */ function chat_handle_send(PDO $pdo, ?array $user, ?array $activeTicket): never { if (!$activeTicket) chat_json(['error' => 'Kein aktiver Chat gefunden.'], 404); if (!rate_limit_check('live_chat_send', client_ip(), 30, 60)) { chat_json(['error' => 'Zu viele Nachrichten. Bitte kurz warten.'], 429); } // Ein bereits geschlossener Chat wird nicht stillschweigend wieder geöffnet – // die nächste Nachricht startet stattdessen einen neuen Chat/Ticket. if ($activeTicket['status'] === 'closed') { unset($_SESSION['live_chat_ticket_number']); chat_json(['error' => 'Dieser Chat wurde beendet.', 'ended' => true], 410); } $message = trim($_POST['message'] ?? ''); $uploadInfo = chat_validate_upload(); // Nachricht darf leer sein, WENN ein Anhang mitgeschickt wurde (z.B. nur ein Foto // ohne Begleittext) – sonst wie gehabt Pflichtfeld. if ($message === '' && !$uploadInfo) chat_json(['error' => 'Bitte geben Sie eine Nachricht ein.'], 422); if (mb_strlen($message) > 5000) chat_json(['error' => 'Nachricht ist zu lang.'], 422); $now = db_now(); $pdo->prepare( 'INSERT INTO ticket_threads (ticket_id, author_type, user_id, message, is_internal_note, created_at) VALUES (?,?,?,?,0,?)' )->execute([$activeTicket['id'], 'customer', $user['id'] ?? null, $message, $now]); $newThreadId = (int) $pdo->lastInsertId(); if ($uploadInfo) { $pdo->prepare( 'INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)' )->execute([$newThreadId, $uploadInfo['original_name'], $uploadInfo['stored_name'], $uploadInfo['filesize'], $now]); } $newStatus = in_array($activeTicket['status'], ['answered', 'resolved'], true) ? 'open' : $activeTicket['status']; $pdo->prepare('UPDATE tickets SET status = ?, updated_at = ? WHERE id = ?')->execute([$newStatus, $now, $activeTicket['id']]); log_activity($pdo, $activeTicket['id'], null, 'Live-Chat-Nachricht empfangen'); // Antwort sofort raus, Discord/Telegram/Push (können pro Aufruf mehrere Netzwerk- // Requests bedeuten) laufen danach im Hintergrund – siehe chat_flush_response(). chat_flush_response([ 'active' => true, 'number' => $activeTicket['ticket_number'], 'status' => $newStatus, 'convertedToTicket' => $activeTicket['source'] !== 'chat', 'messages' => chat_messages($pdo, (int) $activeTicket['id']), ]); notify_new_reply_channels($activeTicket, $message); if (file_exists(__DIR__ . '/includes/push.php')) { require_once __DIR__ . '/includes/push.php'; push_new_chat_message( (int) $activeTicket['department_id'], $activeTicket['assigned_staff_id'] !== null ? (int) $activeTicket['assigned_staff_id'] : null, $activeTicket['ticket_number'], $activeTicket['requester_name'] ); } exit; }