518 lines
25 KiB
PHP
518 lines
25 KiB
PHP
<?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 = ?, 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;
|
||
}
|