Files
deskhub/DeskHub-Webseite/live_chat.php
T
2026-10-23 11:14:28 +00:00

518 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* live_chat.php
*
* Öffentlicher, nicht angemeldeter Live-Chat-Endpunkt für das Kundenportal-Widget
* (siehe includes/footer_customer.php). Jeder Chat wird als ganz normales Ticket
* angelegt (source = 'chat') – Nachrichten sind reguläre ticket_threads-Einträge,
* Mitarbeiter beantworten den Chat einfach über die normale Ticketansicht oder das
* eigene Postfach staff/live_chat.php.
*
* Zugriffsschutz für den Gast: genau wie bei einem normal per Formular erstellten
* Gast-Ticket über $_SESSION['guest_ticket_access'][$ticketNumber] (siehe ticket_new.php).
* Zusätzlich merkt sich die Session unter live_chat_ticket_number den "aktiven" Chat,
* damit das Widget beim nächsten Seitenaufruf automatisch weitermacht.
*
* Wichtig, aus Fehlern dieser Funktion gelernt (MySQL-Produktivbetrieb weicht von der
* lokalen SQLite-Testdatenbank ab):
* - Nie eine Abteilungs-/Prioritäts-ID fest annehmen, immer gegen die echte Tabelle
* prüfen (siehe chat_resolve_department()/chat_resolve_priority()).
* - Feldlängen serverseitig begrenzen/prüfen, MySQL-Spalten sind strikt (VARCHAR).
* - Jeder neue tickets.source-Wert braucht eine Migration (ENUM in MySQL).
* - Nie eine Rohfehlermeldung an den Kunden ausliefern – nur sauber loggen.
*/
require_once __DIR__ . '/includes/bootstrap.php';
header('Content-Type: application/json; charset=utf-8');
function chat_json($data, int $code = 200): never
{
http_response_code($code);
// Aktuellen CSRF-Token immer mitschicken: das Widget nutzt ihn für die jeweils
// nächste Aktion, statt sich auf den (ggf. durch Caching veralteten oder nach
// Rotation ungültigen) beim Seitenaufruf eingebetteten Token zu verlassen.
if (is_array($data) && !isset($data['csrfToken'])) {
$data['csrfToken'] = csrf_token();
}
echo json_encode($data);
exit;
}
/**
* Wie chat_json(), schickt die Antwort aber sofort an den Kunden raus und beendet das
* Skript NICHT – der Aufrufer kann danach noch weiterarbeiten (E-Mail-Versand an ggf.
* mehrere Mitarbeiter, Discord/Telegram/Push), ohne dass der Kunde darauf warten muss.
* Jeder einzelne SMTP-Handshake kostet auf Shared-Hosting spürbar Zeit – bei mehreren
* Empfängern summiert sich das schnell zu einem Chat-Start, der sich alles andere als
* "live" anfühlt. Gleiche Technik wie pseudo_cron_schedule_background()
* (siehe includes/pseudo_cron.php).
*/
function chat_flush_response(array $data): void
{
if (!isset($data['csrfToken'])) {
$data['csrfToken'] = csrf_token();
}
if (ob_get_level() === 0) ob_start();
http_response_code(200);
echo json_encode($data);
if (!headers_sent()) {
header('Content-Length: ' . ob_get_length());
header('Connection: close');
}
if (function_exists('fastcgi_finish_request')) {
@fastcgi_finish_request();
} else {
while (ob_get_level() > 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 = ? 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;
}