Upload via GUI (290 Dateien)

This commit is contained in:
2026-10-23 17:22:17 +00:00
parent 2edb95aecc
commit 234099d0e7
10 changed files with 768 additions and 7 deletions
+23
View File
@@ -0,0 +1,23 @@
<?php
// Direkter Web-Zugriff wird geblockt (nur CLI oder Einbindung aus dem Backend/Pseudo-Cron).
if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) { http_response_code(403); exit('Kein Web-Zugriff.'); }
/**
* cron/process_snoozes.php
*
* Holt Tickets aus der Wiedervorlage zurück, sobald der Zeitpunkt erreicht ist (Ticket erscheint wieder in den Listen, der
* Mitarbeiter bekommt eine Benachrichtigung). Läuft alle paar Minuten, z. B. per Cronjob:
* * /5 * * * * /usr/bin/php /pfad/zu/deskhub/cron/process_snoozes.php
*/
require_once __DIR__ . '/../includes/bootstrap.php';
/** @return array{woken:int, log:string[]} */
function run_process_snoozes(PDO $pdo, bool $verbose = false): array
{
$n = ticket_snooze_wake_due($pdo);
set_setting('cron_process_snoozes_last_run', db_now());
return ['woken' => $n, 'log' => ['[' . date('Y-m-d H:i:s') . "] Wiedervorlagen geprüft, {$n} Ticket(s) zurückgeholt."]];
}
if (php_sapi_name() === 'cli') {
foreach (run_process_snoozes(get_db(), true)['log'] as $line) echo $line . "\n";
}
@@ -0,0 +1,63 @@
<?php
// Direkter Web-Zugriff wird geblockt (nur CLI oder Einbindung aus dem Backend/Pseudo-Cron).
if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) { http_response_code(403); exit('Kein Web-Zugriff.'); }
/**
* cron/remind_customers.php
*
* Schickt Kunden eine freundliche Erinnerung, wenn sie auf eine Antwort (Status "Beantwortet") mehrere Tage nicht reagiert haben.
* Pro Antwort wird nur eine Erinnerung gesendet. Ist das automatische Schließen eingeschaltet, nennt die Mail, wann das Ticket
* geschlossen wird. Einstellbar unter Einstellungen → Allgemein ("Kunden erinnern").
*/
require_once __DIR__ . '/../includes/bootstrap.php';
/** @return array{sent:int, log:string[]} */
function run_remind_customers(PDO $pdo, bool $verbose = false): array
{
$log = [];
if (!customer_reminder_enabled()) {
return ['sent' => 0, 'log' => ['[' . date('Y-m-d H:i:s') . '] Kundenerinnerung ist deaktiviert – nichts zu tun.']];
}
$days = customer_reminder_days();
$threshold = date('Y-m-d H:i:s', time() - $days * 86400);
$closeDays = get_setting('auto_close_enabled', '0') === '1' ? max(1, (int) get_setting('auto_close_days', '7')) : 0;
$stmt = $pdo->prepare(
"SELECT t.*, d.name AS department_name FROM tickets t LEFT JOIN departments d ON d.id = t.department_id
WHERE t.status = 'answered' AND t.is_spam = 0 AND t.source <> 'chat' AND t.updated_at < ?
AND (t.reminder_sent_at IS NULL OR t.reminder_sent_at < t.updated_at)
AND (t.snoozed_until IS NULL OR t.snoozed_until <= ?)
ORDER BY t.updated_at LIMIT 100"
);
$stmt->execute([$threshold, db_now()]);
$sent = 0;
foreach ($stmt->fetchAll() as $t) {
if (empty($t['requester_email']) || !filter_var($t['requester_email'], FILTER_VALIDATE_EMAIL)) continue;
$closeNote = '';
if ($closeDays > $days) {
$closeNote = '<p style="margin:16px 0 0;color:#475569;font-size:14px;">Hören wir bis dahin nichts von Ihnen, schließen wir das Ticket in ' . ($closeDays - $days) . ' ' . (($closeDays - $days) === 1 ? 'Tag' : 'Tagen') . ' automatisch. Sie können es jederzeit wieder öffnen, indem Sie antworten.</p>';
} elseif ($closeDays > 0) {
$closeNote = '<p style="margin:16px 0 0;color:#475569;font-size:14px;">Hören wir von Ihnen nichts mehr, schließen wir das Ticket in Kürze automatisch. Sie können es jederzeit wieder öffnen, indem Sie antworten.</p>';
}
[$subject, $body] = render_email_template('customer_reminder', [
'ticket_number' => $t['ticket_number'],
'subject' => e($t['subject']),
'customer_name' => e($t['requester_name'] ?? ''),
'ticket_url' => e(ticket_access_url($t)),
'close_note' => $closeNote,
], "Erinnerung zu Ihrem Ticket [#{$t['ticket_number']}]", email_customer_reminder_default_body());
$from = resolve_from_address($t['department_id'] ?? null, $t['priority_id'] ?? null);
email_send_and_log($t['requester_email'], $subject, email_wrap($body), $from, 'Erinnerung an den Kunden', $t['ticket_number']);
// updated_at bleibt unverändert, damit das automatische Schließen seine Frist behält
$pdo->prepare('UPDATE tickets SET reminder_sent_at = ?, updated_at = updated_at WHERE id = ?')->execute([db_now(), $t['id']]);
log_activity($pdo, (int) $t['id'], null, 'Erinnerung an den Kunden gesendet');
$sent++;
if ($verbose) $log[] = "[ok] Erinnerung für Ticket #{$t['ticket_number']}";
}
set_setting('cron_remind_customers_last_run', db_now());
$log[] = '[' . date('Y-m-d H:i:s') . "] Kundenerinnerung: {$sent} Mail(s) gesendet (nach {$days} Tagen ohne Reaktion).";
return ['sent' => $sent, 'log' => $log];
}
if (php_sapi_name() === 'cli') {
foreach (run_remind_customers(get_db(), true)['log'] as $line) echo $line . "\n";
}
+103
View File
@@ -1227,6 +1227,109 @@ function run_migrations(PDO $pdo, bool $verbose = false): array
}
}
// Migration 0044: Antworten des digitalen Assistenten (zusätzlich zur Wissensdatenbank).
if (!isset($done['php_0044_assistant_answers'])) {
try {
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
$pdo->exec("CREATE TABLE IF NOT EXISTS assistant_answers (
id $ai,
title VARCHAR(255) NOT NULL,
keywords TEXT NULL,
answer TEXT NOT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
hits INT NOT NULL DEFAULT 0,
created_at DATETIME NULL,
updated_at DATETIME NULL
)$eng");
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0044_assistant_answers']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0044_assistant_answers';
} catch (Throwable $e) {
$result['errors'][] = 'php_0044_assistant_answers: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0044_assistant_answers - ' . $e->getMessage();
}
}
// Migration 0045: Protokoll der Fragen an den digitalen Assistenten.
if (!isset($done['php_0045_assistant_log'])) {
try {
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
$pdo->exec("CREATE TABLE IF NOT EXISTS assistant_log (
id $ai,
question VARCHAR(300) NOT NULL,
result VARCHAR(20) NOT NULL,
source VARCHAR(10) NULL,
matched_id INT NULL,
matched_title VARCHAR(255) NULL,
ticket_number VARCHAR(40) NULL,
created_at DATETIME NOT NULL
)$eng");
$pdo->exec('CREATE INDEX idx_assistant_log_created ON assistant_log (created_at)');
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0045_assistant_log']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0045_assistant_log';
} catch (Throwable $e) {
$result['errors'][] = 'php_0045_assistant_log: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0045_assistant_log - ' . $e->getMessage();
}
}
// Migration 0046: Wiedervorlage, Beobachter, Kundenerinnerung, Rückmeldung zu Antworten des Assistenten.
if (!isset($done['php_0046_ticket_extras'])) {
try {
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$has = function (string $table, string $col) use ($pdo): bool {
try { $pdo->query("SELECT $col FROM $table LIMIT 1"); return true; } catch (Throwable $e) { return false; }
};
if (!$has('tickets', 'snoozed_until')) $pdo->exec('ALTER TABLE tickets ADD COLUMN snoozed_until DATETIME NULL');
if (!$has('tickets', 'snoozed_by')) $pdo->exec('ALTER TABLE tickets ADD COLUMN snoozed_by INT NULL');
if (!$has('tickets', 'reminder_sent_at')) $pdo->exec('ALTER TABLE tickets ADD COLUMN reminder_sent_at DATETIME NULL');
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
$pdo->exec("CREATE TABLE IF NOT EXISTS ticket_followers (
ticket_id INT NOT NULL,
staff_id INT NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (ticket_id, staff_id)
)$eng");
if (!$has('assistant_log', 'helpful')) $pdo->exec('ALTER TABLE assistant_log ADD COLUMN helpful TINYINT NULL');
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0046_ticket_extras']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0046_ticket_extras';
} catch (Throwable $e) {
$result['errors'][] = 'php_0046_ticket_extras: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0046_ticket_extras - ' . $e->getMessage();
}
}
// Migration 0047: einmalige Anmelde-Adressen für die Übergabe von der App an die Weboberfläche.
if (!isset($done['php_0047_web_login_tokens'])) {
try {
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
$pdo->exec("CREATE TABLE IF NOT EXISTS web_login_tokens (
id $ai,
staff_id INT NOT NULL,
token_hash VARCHAR(64) NOT NULL,
path VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL,
expires_at DATETIME NOT NULL,
used_at DATETIME NULL
)$eng");
$pdo->exec('CREATE UNIQUE INDEX idx_web_login_tokens_hash ON web_login_tokens (token_hash)');
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0047_web_login_tokens']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0047_web_login_tokens';
} catch (Throwable $e) {
$result['errors'][] = 'php_0047_web_login_tokens: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0047_web_login_tokens - ' . $e->getMessage();
}
}
return $result;
}
+347
View File
@@ -0,0 +1,347 @@
<?php
/**
* includes/assistant.php
*
* Digitaler Assistent für das Kundenportal: Er beantwortet Fragen aus der Wissensdatenbank und aus zusätzlich im Backend hinterlegten
* Antworten (Verwaltung → Digitaler Assistent). Findet er nichts, bietet er an, ein Ticket zu erstellen, und fragt dafür alles Nötige ab.
* Er ist nur aktiv, wenn der Live-Chat ausgeschaltet oder gerade niemand erreichbar ist (oder in der Einstellung "immer", zum Testen).
*
* Die Antworten werden ohne externen KI-Dienst gesucht (Stichwort-Treffer in Titel und Text); es verlassen keine Kundenfragen den Server.
*/
require_once __DIR__ . '/mail_text.php';
function assistant_enabled(): bool
{
return get_setting('assistant_enabled', '0') === '1';
}
/** Ist gerade mindestens ein Mitarbeiter als "Verfügbar" gemeldet (wie bei der Anzeige im Live-Chat)? */
function assistant_staff_available(PDO $pdo): bool
{
staff_run_auto_offline($pdo);
$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;
}
/** Darf der Assistent jetzt Kunden ansprechen? */
function assistant_active(PDO $pdo): bool
{
if (!assistant_enabled()) return false;
if (get_setting('helpdesk_status', 'online') === 'offline') return false;
if (get_setting('assistant_mode', 'offline_only') === 'always') return true;
if (get_setting('live_chat_enabled', '0') !== '1') return true; // Live-Chat ausgeschaltet
return !assistant_staff_available($pdo); // Live-Chat an, aber niemand erreichbar
}
// ── Suche ────────────────────────────────────────────────────────────────────
const ASSISTANT_STOPWORDS = [
'der', 'die', 'das', 'den', 'dem', 'des', 'ein', 'eine', 'einen', 'einem', 'einer', 'und', 'oder', 'aber', 'ich', 'du', 'wir', 'ihr', 'sie',
'er', 'es', 'ist', 'sind', 'war', 'hat', 'haben', 'habe', 'kann', 'koennen', 'kannst', 'wie', 'was', 'wo', 'wann', 'warum', 'wer', 'wird',
'werden', 'mein', 'meine', 'meinen', 'meinem', 'mir', 'mich', 'dir', 'dich', 'auch', 'nicht', 'kein', 'keine', 'bitte', 'hallo', 'guten',
'tag', 'danke', 'mit', 'fuer', 'von', 'zu', 'zum', 'zur', 'im', 'in', 'an', 'auf', 'aus', 'bei', 'nach', 'ueber', 'um', 'so', 'wenn',
'dass', 'da', 'dort', 'hier', 'noch', 'nur', 'schon', 'mal', 'gibt', 'moechte', 'moechten', 'wuerde', 'will', 'brauche', 'hilfe', 'frage',
'bekomme', 'bekommen', 'bekommt', 'erhalte', 'erhalten', 'kriege', 'kriegen', 'finde', 'finden', 'moechtest', 'gerne', 'gern', 'eigentlich', 'denn', 'bin', 'bist', 'habt', 'hab', 'gibts',
'wieso', 'weshalb', 'welche', 'welcher', 'welches', 'muss', 'soll', 'sollen', 'man', 'etwas', 'mehr', 'sehr', 'ganz', 'dann', 'doch',
];
/** Kleinbuchstaben ohne Umlaute und Sonderzeichen, Wörter getrennt. */
function assistant_normalize(string $s): string
{
$s = mb_strtolower($s, 'UTF-8');
$s = strtr($s, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss', 'é' => 'e', 'è' => 'e', 'à' => 'a', 'á' => 'a']);
return trim((string) preg_replace('/[^a-z0-9]+/', ' ', $s));
}
/** Grobe Wortstamm-Bildung, damit "Passwörter" und "Passwort" oder "Rechnungen" und "Rechnung" zusammenpassen. */
function assistant_stem(string $w): string
{
foreach (['ungen', 'ung', 'heit', 'keit', 'en', 'er', 'es', 'em', 'e', 'n', 's'] as $suffix) {
$len = strlen($w);
if ($len >= strlen($suffix) + 4 && str_ends_with($w, $suffix)) return substr($w, 0, $len - strlen($suffix));
}
return $w;
}
/**
* Gleichbedeutende Wörter (normalisiert, ohne Umlaute): Frage und Artikel müssen nicht dieselben Worte benutzen, z. B. „Zugang zum Discord“
* und „Wie trete ich dem Server bei?“. Das erste Wort einer Gruppe steht für alle.
*/
const ASSISTANT_SYNONYMS = [
['beitreten', 'beitritt', 'beitrete', 'trete', 'tritt', 'zugang', 'zutritt', 'einladung', 'einladungslink', 'invite', 'join', 'joinen', 'anschliessen', 'eintreten', 'teilnehmen', 'mitmachen'],
['loeschen', 'entfernen', 'entfernt', 'geloescht', 'loesche'],
['fehler', 'problem', 'probleme', 'stoerung', 'bug', 'absturz', 'abstuerzen', 'kaputt', 'funktioniert'],
['gesperrt', 'sperre', 'sperren', 'bann', 'ban', 'gebannt', 'blockiert'],
['kontakt', 'erreichen', 'erreiche', 'ansprechen', 'ansprechpartner', 'schreiben'],
['kosten', 'preis', 'preise', 'gebuehr', 'gebuehren', 'bezahlen', 'kostenlos', 'gratis'],
['regel', 'regeln', 'richtlinie', 'richtlinien', 'verhaltensregeln'],
['senden', 'einsenden', 'einreichen', 'hochladen', 'schicken', 'uebermitteln', 'einschicken', 'veroeffentlichen'],
['passwort', 'kennwort', 'zugangsdaten', 'anmeldedaten', 'login'],
];
/**
* Vom Admin gepflegte Gruppen (Einstellung "assistant_synonyms": eine Gruppe je Zeile, Wörter durch Komma getrennt).
*
* @return array<int,string[]> normalisierte Einzelwörter je Gruppe
*/
function assistant_custom_synonyms(?string $text = null): array
{
$text ??= (string) get_setting('assistant_synonyms', '');
$groups = [];
foreach (preg_split('/\R/', $text) ?: [] as $line) {
$words = [];
foreach (explode(',', $line) as $w) {
$n = assistant_normalize($w);
if ($n !== '' && !str_contains($n, ' ') && strlen($n) >= 3) $words[$n] = true;
}
if (count($words) >= 2) $groups[] = array_slice(array_keys($words), 0, 20);
if (count($groups) >= 100) break;
}
return $groups;
}
/** @return array<string,string> Wortstamm => Stamm des Gruppenworts (eigene Gruppen überschreiben die eingebauten) */
function assistant_synonym_map(): array
{
static $map = null;
if ($map === null) {
$map = [];
foreach (array_merge(ASSISTANT_SYNONYMS, assistant_custom_synonyms()) as $group) {
$head = assistant_stem($group[0]);
foreach ($group as $w) $map[assistant_stem($w)] = $head;
}
}
return $map;
}
/** @return string[] eindeutige Wortstämme ohne Füllwörter */
function assistant_tokens(string $text): array
{
$out = [];
$syn = assistant_synonym_map();
foreach (explode(' ', assistant_normalize($text)) as $w) {
if (strlen($w) < 3 || in_array($w, ASSISTANT_STOPWORDS, true)) continue;
$s = assistant_stem($w);
$out[$syn[$s] ?? $s] = true;
}
return array_keys($out);
}
/** Text aus HTML, gekürzt an einer Wort- bzw. Satzgrenze. */
function assistant_excerpt(string $html, int $max = 600): string
{
$text = mail_html_to_text($html);
if (mb_strlen($text) <= $max) return $text;
$cut = mb_substr($text, 0, $max);
$dot = max((int) mb_strrpos($cut, '. '), (int) mb_strrpos($cut, "\n"));
if ($dot > $max * 0.5) return rtrim(mb_substr($cut, 0, $dot + 1)) . ' …';
$space = (int) mb_strrpos($cut, ' ');
return rtrim($space > 0 ? mb_substr($cut, 0, $space) : $cut) . ' …';
}
/**
* Sucht die beste Antwort auf eine Frage: zuerst die im Backend hinterlegten Antworten (Stichwörter), dann die Artikel der
* Wissensdatenbank (Titel zählt mehr als Text). Gibt null zurück, wenn nichts ausreichend passt.
*
* @return array{source:string,text:string,title:string,link:?string,id:int,others:array<int,array{title:string,link:string}>}|null
*/
function assistant_search(PDO $pdo, string $question, bool $countHit = true): ?array
{
$q = assistant_tokens($question);
if (!$q) return null;
$need = max(1, (int) ceil(count($q) / 2)); // mindestens die Hälfte der Suchwörter muss vorkommen
$candidates = [];
// Zusätzlich im Backend hinterlegte Antworten
try {
foreach ($pdo->query('SELECT id, title, keywords, answer FROM assistant_answers WHERE is_active = 1')->fetchAll() as $a) {
$words = array_flip(assistant_tokens($a['title'] . ' ' . ($a['keywords'] ?? '')));
$kw = array_flip(assistant_tokens((string) ($a['keywords'] ?? '')));
$hit = 0;
$kwHit = 0;
foreach ($q as $t) { if (isset($words[$t])) $hit++; if (isset($kw[$t])) $kwHit++; }
// Ein ausdrücklich hinterlegtes Stichwort genügt (z. B. „Discord“); Wörter nur aus dem Titel müssen zur Hälfte passen
if ($hit >= $need || $kwHit >= 1) $candidates[] = ['score' => 5.0 * max($hit, $kwHit) / count($q) + 100 + $kwHit, 'source' => 'custom', 'id' => (int) $a['id'], 'title' => (string) $a['title'], 'text' => trim((string) $a['answer']), 'link' => null];
}
} catch (Throwable $e) {
// Tabelle fehlt (Datenbank noch nicht aktualisiert): nur die Wissensdatenbank
}
// Wissensdatenbank (nur wenn sie im Kundenportal eingeschaltet ist)
if (get_setting('kb_enabled', '1') === '1') {
foreach ($pdo->query('SELECT a.id, a.title, a.slug, a.body, c.name AS category FROM kb_articles a LEFT JOIN kb_categories c ON c.id = a.category_id WHERE a.is_published = 1 ORDER BY a.id DESC LIMIT 500')->fetchAll() as $a) {
$titleWords = array_flip(assistant_tokens($a['title'] . ' ' . (string) ($a['category'] ?? ''))); // die Kategorie (z. B. „Discord“) zählt wie der Titel
$bodyWords = array_flip(assistant_tokens(mb_substr(strip_tags((string) $a['body']), 0, 20000)));
$score = 0.0;
$hit = 0;
foreach ($q as $t) {
if (isset($titleWords[$t])) { $score += 4; $hit++; }
elseif (isset($bodyWords[$t])) { $score += 1.5; $hit++; }
}
if ($hit >= $need && $score >= 2.5) {
$candidates[] = ['score' => $score / count($q), 'source' => 'kb', 'id' => (int) $a['id'], 'title' => (string) $a['title'], 'text' => assistant_excerpt((string) $a['body']), 'link' => '/kb_article.php?slug=' . rawurlencode((string) $a['slug'])];
}
}
}
if (!$candidates) return null;
usort($candidates, fn($a, $b) => $b['score'] <=> $a['score']);
$best = $candidates[0];
$others = [];
foreach (array_slice($candidates, 1) as $c) {
// nur wirklich ähnlich gute Artikel als weiterer Hinweis (nicht jeder schwache Treffer)
$strong = $best['source'] === 'kb' ? $c['score'] >= $best['score'] * 0.75 : $c['score'] >= 3.0;
if ($c['source'] === 'kb' && $strong && count($others) < 2) $others[] = ['title' => $c['title'], 'link' => $c['link']];
}
if ($countHit && $best['source'] === 'custom') {
try { $pdo->prepare('UPDATE assistant_answers SET hits = hits + 1 WHERE id = ?')->execute([$best['id']]); } catch (Throwable $e) { /* Zähler ist unwichtig */ }
}
return ['source' => $best['source'], 'text' => $best['text'], 'title' => $best['title'], 'link' => $best['link'], 'id' => $best['id'], 'others' => $others];
}
// ── Protokoll der Fragen ─────────────────────────────────────────────────────
/** Ergebnisse im Protokoll (Schlüssel => Beschriftung). */
function assistant_log_results(): array
{
return ['custom' => 'Meine Antwort gefunden', 'kb' => 'Wissensdatenbank', 'none' => 'Keine Antwort', 'ticket_request' => 'Wollte ein Ticket', 'ticket_created' => 'Ticket erstellt'];
}
/** Wie viele Tage Fragen aufbewahrt werden (0 = es wird nichts protokolliert). */
function assistant_log_days(): int
{
return max(0, min(3650, (int) get_setting('assistant_log_days', '90')));
}
/**
* Hält eine Frage und das Ergebnis fest. Es werden bewusst nur Frage, Ergebnis und Treffer gespeichert (keine Adresse, kein Name);
* Fehler dabei dürfen den Assistenten nie stören. Alte Einträge werden nebenbei gelöscht.
*/
function assistant_log_write(PDO $pdo, string $question, string $result, ?array $match = null, ?string $ticketNumber = null): ?int
{
$days = assistant_log_days();
if ($days === 0) return null;
try {
$pdo->prepare('INSERT INTO assistant_log (question, result, source, matched_id, matched_title, ticket_number, created_at) VALUES (?,?,?,?,?,?,?)')
->execute([mb_substr($question, 0, 300), $result, $match['source'] ?? null, isset($match['id']) ? (int) $match['id'] : null, isset($match['title']) ? mb_substr((string) $match['title'], 0, 255) : null, $ticketNumber, db_now()]);
$id = (int) $pdo->lastInsertId();
if (random_int(1, 25) === 1) assistant_log_cleanup($pdo);
return $id > 0 ? $id : null;
} catch (Throwable $e) {
// Tabelle fehlt (Datenbank noch nicht aktualisiert) oder ähnliches: nicht schlimm
return null;
}
}
/** Auswahl im Filter des Fragen-Logs: Ergebnisse plus die Rückmeldung der Kunden. */
function assistant_log_filters(): array
{
return assistant_log_results() + ['helpful' => 'Hat geholfen', 'not_helpful' => 'Hat nicht geholfen'];
}
/** Löscht Einträge, die älter als die Aufbewahrungszeit sind. @return int Anzahl gelöschter Einträge */
function assistant_log_cleanup(PDO $pdo): int
{
$days = assistant_log_days();
$cut = date('Y-m-d H:i:s', time() - max(1, $days) * 86400);
$stmt = $pdo->prepare('DELETE FROM assistant_log WHERE created_at < ?');
$stmt->execute([$cut]);
return $stmt->rowCount();
}
/**
* Liest das Protokoll mit Filtern: result (Schlüssel aus assistant_log_results), period (today|7|30|all), q (Suchtext).
*
* @return array{rows:array,total:int,pages:int,page:int}
*/
function assistant_log_list(PDO $pdo, array $filter, int $page = 1, int $perPage = 50): array
{
$where = [];
$params = [];
$result = (string) ($filter['result'] ?? '');
if ($result === 'helpful') { $where[] = 'helpful = 1'; }
elseif ($result === 'not_helpful') { $where[] = 'helpful = 0'; }
elseif (array_key_exists($result, assistant_log_results())) { $where[] = 'result = ?'; $params[] = $result; }
$since = match ((string) ($filter['period'] ?? 'all')) {
'today' => date('Y-m-d 00:00:00'),
'7' => date('Y-m-d H:i:s', time() - 7 * 86400),
'30' => date('Y-m-d H:i:s', time() - 30 * 86400),
default => null,
};
if ($since !== null) { $where[] = 'created_at >= ?'; $params[] = $since; }
$q = trim((string) ($filter['q'] ?? ''));
if ($q !== '') {
$like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], mb_substr($q, 0, 100)) . '%';
$where[] = "(question LIKE ? ESCAPE '\\' OR matched_title LIKE ? ESCAPE '\\')";
array_push($params, $like, $like);
}
$sqlWhere = $where ? ' WHERE ' . implode(' AND ', $where) : '';
$count = $pdo->prepare('SELECT COUNT(*) FROM assistant_log' . $sqlWhere);
$count->execute($params);
$total = (int) $count->fetchColumn();
$pages = max(1, (int) ceil($total / $perPage));
$page = max(1, min($pages, $page));
$stmt = $pdo->prepare('SELECT * FROM assistant_log' . $sqlWhere . ' ORDER BY id DESC LIMIT ' . $perPage . ' OFFSET ' . (($page - 1) * $perPage));
$stmt->execute($params);
return ['rows' => $stmt->fetchAll(), 'total' => $total, 'pages' => $pages, 'page' => $page];
}
/** Kennzahlen des Protokolls im gewählten Zeitraum: Anzahl je Ergebnis. @return array<string,int> */
function assistant_log_stats(PDO $pdo, string $period = 'all'): array
{
$stats = array_fill_keys(array_keys(assistant_log_filters()), 0);
$since = match ($period) { 'today' => date('Y-m-d 00:00:00'), '7' => date('Y-m-d H:i:s', time() - 7 * 86400), '30' => date('Y-m-d H:i:s', time() - 30 * 86400), default => null };
$stmt = $pdo->prepare('SELECT result, COUNT(*) AS n FROM assistant_log' . ($since !== null ? ' WHERE created_at >= ?' : '') . ' GROUP BY result');
$stmt->execute($since !== null ? [$since] : []);
foreach ($stmt->fetchAll() as $r) $stats[$r['result']] = (int) $r['n'];
$fb = $pdo->prepare('SELECT helpful, COUNT(*) AS n FROM assistant_log WHERE helpful IS NOT NULL' . ($since !== null ? ' AND created_at >= ?' : '') . ' GROUP BY helpful');
$fb->execute($since !== null ? [$since] : []);
$stats['helpful'] = 0;
$stats['not_helpful'] = 0;
foreach ($fb->fetchAll() as $r) $stats[(int) $r['helpful'] === 1 ? 'helpful' : 'not_helpful'] = (int) $r['n'];
return $stats;
}
/**
* Häufig gestellte Fragen ohne Antwort (gleiche Fragen zusammengefasst, am häufigsten zuerst): Vorlage für neue Antworten.
*
* @return array<int,array{question:string,n:int,last:string}>
*/
function assistant_log_unanswered(PDO $pdo, string $period = 'all', int $limit = 10): array
{
$since = match ($period) { 'today' => date('Y-m-d 00:00:00'), '7' => date('Y-m-d H:i:s', time() - 7 * 86400), '30' => date('Y-m-d H:i:s', time() - 30 * 86400), default => null };
$stmt = $pdo->prepare("SELECT question, created_at FROM assistant_log WHERE result = 'none'" . ($since !== null ? ' AND created_at >= ?' : '') . ' ORDER BY id DESC LIMIT 2000');
$stmt->execute($since !== null ? [$since] : []);
$groups = [];
foreach ($stmt->fetchAll() as $r) {
$key = implode(' ', assistant_tokens($r['question'])) ?: assistant_normalize($r['question']);
if ($key === '') continue;
if (!isset($groups[$key])) $groups[$key] = ['question' => $r['question'], 'n' => 0, 'last' => $r['created_at']];
$groups[$key]['n']++;
}
usort($groups, fn($a, $b) => [$b['n'], $b['last']] <=> [$a['n'], $a['last']]);
return array_slice(array_values($groups), 0, $limit);
}
/** Öffentliche Hilfethemen als Auswahl für die Ticket-Erstellung. @return array<int,array{id:int,name:string}> */
function assistant_help_topics(PDO $pdo): array
{
$out = [];
foreach ($pdo->query('SELECT id, name FROM help_topics WHERE is_active = 1 AND is_public = 1 ORDER BY sort_order, name')->fetchAll() as $t) {
$out[] = ['id' => (int) $t['id'], 'name' => (string) $t['name']];
}
return $out;
}
/** Texte des Assistenten (aus den Einstellungen, mit Vorgaben). */
function assistant_texts(): array
{
return [
'name' => trim((string) get_setting('assistant_name', '')) ?: 'Digitaler Assistent',
'greeting' => trim((string) get_setting('assistant_greeting', '')) ?: 'Hallo! Ich bin der digitale Assistent. Gerade ist niemand direkt erreichbar, aber ich kann Ihre Fragen beantworten oder ein Ticket für Sie erstellen. Wie kann ich helfen?',
'no_answer' => trim((string) get_setting('assistant_no_answer', '')) ?: 'Dazu habe ich leider keine passende Antwort. Soll ich ein Ticket für Sie erstellen? Unser Team meldet sich dann per E-Mail.',
];
}
@@ -0,0 +1,207 @@
<?php
/**
* includes/assistant_widget.php: Chat-Fenster des digitalen Assistenten im Kundenportal (wird von footer_customer.php eingebunden,
* nur wenn assistant_active() wahr ist). Alle Texte werden per textContent eingesetzt, nie als HTML.
*/
$__as = assistant_texts();
?>
<style>
#as-bubble{position:fixed;bottom:20px;right:20px;width:56px;height:56px;border-radius:50%;background:var(--primary);color:#fff;display:flex;align-items:center;justify-content:center;font-size:1.5rem;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.25);z-index:9990;border:0}
#as-panel{display:none;position:fixed;bottom:88px;right:20px;width:360px;max-width:calc(100vw - 32px);height:min(540px,calc(100vh - 120px));background:#fff;color:#1f2937;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,.28);z-index:9991;flex-direction:column;overflow:hidden;font-size:.88rem}
#as-panel.open{display:flex}
#as-head{background:var(--primary);color:#fff;padding:12px 14px;display:flex;align-items:center;gap:8px;flex-shrink:0}
#as-head strong{font-size:.92rem}
#as-close{margin-left:auto;cursor:pointer;font-size:1.3rem;line-height:1;opacity:.9;background:none;border:0;color:#fff}
#as-msgs{flex:1;overflow-y:auto;padding:12px;background:#f7f8fa;display:flex;flex-direction:column;gap:8px}
.as-row{max-width:88%;padding:8px 11px;border-radius:12px;white-space:pre-wrap;word-wrap:break-word;line-height:1.4}
.as-bot{align-self:flex-start;background:#fff;border:1px solid #e5e7eb}
.as-user{align-self:flex-end;background:var(--primary);color:#fff}
.as-src{font-size:.72rem;color:#6b7280;margin-top:4px}
.as-btns{align-self:flex-start;display:flex;flex-wrap:wrap;gap:6px;max-width:95%}
.as-btns button{border:1px solid var(--primary);background:#fff;color:var(--primary);border-radius:16px;padding:5px 11px;font-size:.8rem;cursor:pointer}
.as-btns button.as-main{background:var(--primary);color:#fff}
#as-form{display:flex;gap:8px;padding:10px 12px;border-top:1px solid #eee;flex-shrink:0;background:#fff}
#as-input{flex:1;padding:9px 10px;border:1px solid #d1d5db;border-radius:6px;font-size:.88rem}
#as-send{padding:8px 14px}
#as-note{display:none;background:#fffbeb;border-bottom:1px solid #fde68a;color:#92400e;font-size:.76rem;padding:6px 12px}
</style>
<button type="button" id="as-bubble" aria-label="<?= e($__as['name']) ?> öffnen" title="<?= e($__as['name']) ?>">💬</button>
<div id="as-panel" role="dialog" aria-label="<?= e($__as['name']) ?>">
<div id="as-head"><span style="font-size:1.1rem">🤖</span><strong id="as-title"></strong><button type="button" id="as-close" aria-label="Schließen">&times;</button></div>
<div id="as-note">Ein Mitarbeiter ist jetzt erreichbar. Laden Sie die Seite neu, um den Live-Chat zu nutzen.</div>
<div id="as-msgs" aria-live="polite"></div>
<form id="as-form" autocomplete="off">
<input type="text" id="as-hp" name="hp_website" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true">
<input type="text" id="as-input" maxlength="300" placeholder="Ihre Frage …" aria-label="Ihre Nachricht">
<button type="submit" class="btn" id="as-send">Senden</button>
</form>
</div>
<script>
(function () {
var CSRF = <?= json_encode(csrf_token()) ?>;
var $ = function (id) { return document.getElementById(id); };
var panel = $('as-panel'), msgs = $('as-msgs'), input = $('as-input'), form = $('as-form'), sendBtn = $('as-send');
var cfg = null, started = false, busy = false;
var mode = 'ask'; // 'ask' = Fragen stellen, 'ticket' = Angaben für ein Ticket sammeln
var step = '', data = {}, lastQuestion = '';
function api(action, fields) {
var fd = new FormData();
fd.append('action', action); fd.append('csrf_token', CSRF);
Object.keys(fields || {}).forEach(function (k) { fd.append(k, fields[k]); });
return fetch('/assistant.php', { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function (r) { return r.json().then(function (j) { j.__status = r.status; return j; }); });
}
function scroll() { msgs.scrollTop = msgs.scrollHeight; }
// Text mit anklickbaren https-Adressen, ohne HTML einzusetzen
function fill(el, text) {
var re = /(https?:\/\/[^\s<>"']+)/g, last = 0, m;
while ((m = re.exec(text)) !== null) {
if (m.index > last) el.appendChild(document.createTextNode(text.slice(last, m.index)));
var a = document.createElement('a'); a.href = m[1]; a.target = '_blank'; a.rel = 'noopener noreferrer'; a.textContent = m[1]; a.style.color = 'inherit'; a.style.textDecoration = 'underline';
el.appendChild(a); last = m.index + m[1].length;
}
if (last < text.length) el.appendChild(document.createTextNode(text.slice(last)));
}
function say(text, who, extra) {
var row = document.createElement('div'); row.className = 'as-row ' + (who === 'user' ? 'as-user' : 'as-bot');
fill(row, text);
if (extra && extra.source) { var s = document.createElement('div'); s.className = 'as-src'; s.textContent = extra.source; row.appendChild(s); }
if (extra && extra.link) { var l = document.createElement('a'); l.href = extra.link; l.target = '_blank'; l.rel = 'noopener'; l.textContent = 'Ganzen Artikel lesen'; l.style.cssText = 'display:block;margin-top:6px;font-size:.78rem'; row.appendChild(l); }
msgs.appendChild(row); scroll(); return row;
}
function buttons(list) {
var box = document.createElement('div'); box.className = 'as-btns';
list.forEach(function (b) {
var el = document.createElement('button'); el.type = 'button'; el.textContent = b.label; if (b.main) el.className = 'as-main';
el.addEventListener('click', function () { box.remove(); if (b.user !== false) say(b.label, 'user'); b.fn(); });
box.appendChild(el);
});
msgs.appendChild(box); scroll();
}
function setBusy(b) { busy = b; sendBtn.disabled = b; input.disabled = b; if (!b) input.focus(); }
// ── Fragen ────────────────────────────────────────────────────────────────
function ask(text) {
lastQuestion = text; setBusy(true);
api('ask', { message: text }).then(function (r) {
setBusy(false);
if (r.error) { say(r.error, 'bot'); if (r.csrfExpired || r.active === false) input.disabled = true; return; }
if (r.type === 'ticket') { startTicket(); return; }
if (r.type === 'smalltalk') { say(r.text, 'bot'); return; }
if (r.type === 'answer') {
say(r.text, 'bot', { source: (r.source === 'kb' ? 'Aus der Wissensdatenbank: ' : '') + (r.source === 'kb' ? r.title : ''), link: r.link });
(r.others || []).forEach(function (o) { var row = say('Das könnte auch helfen: ' + o.title, 'bot'); var a = document.createElement('a'); a.href = o.link; a.target = '_blank'; a.rel = 'noopener'; a.textContent = ' Öffnen'; row.appendChild(a); });
var lid = r.logId || 0;
function feedback(ok) { if (lid) api('feedback', { logId: lid, helpful: ok ? 1 : 0 }).catch(function () {}); }
buttons([
{ label: 'Das hat geholfen', fn: function () { feedback(true); say('Schön, dass ich helfen konnte! Haben Sie noch eine Frage?', 'bot'); } },
{ label: 'Ticket erstellen', main: true, fn: function () { feedback(false); startTicket(); } }
]);
return;
}
say(r.text || (cfg && cfg.noAnswer), 'bot');
buttons([
{ label: 'Ja, Ticket erstellen', main: true, fn: startTicket },
{ label: 'Nein, andere Frage', fn: function () { say('Gern. Was möchten Sie wissen?', 'bot'); } }
]);
}).catch(function () { setBusy(false); say('Das hat leider nicht geklappt. Bitte versuchen Sie es noch einmal.', 'bot'); });
}
// ── Ticket: Angaben Schritt für Schritt abfragen ──────────────────────────
function startTicket() {
mode = 'ticket'; data = {};
if (cfg.helpTopics && cfg.helpTopics.length) {
step = 'topic'; say('Worum geht es in Ihrer Anfrage?', 'bot');
var list = cfg.helpTopics.slice(0, 8).map(function (t) { return { label: t.name, fn: function () { data.helpTopicId = t.id; askName(); } }; });
list.push({ label: 'Etwas anderes', fn: function () { askName(); } });
buttons(list);
} else { askName(); }
}
function askName() {
step = 'name';
if (cfg.user && cfg.user.name) { data.name = cfg.user.name; if (cfg.user.email) { data.email = cfg.user.email; askSubject(); return; } askEmail(); return; }
say('Wie heißen Sie?', 'bot'); input.placeholder = 'Ihr Name'; input.focus();
}
function askEmail() { step = 'email'; say('Unter welcher E-Mail-Adresse erreichen wir Sie? Dorthin schicken wir die Antwort.', 'bot'); input.placeholder = 'name@beispiel.de'; input.focus(); }
function askSubject() {
step = 'subject'; say('Wie lautet der Betreff? (Kurz in einem Satz)', 'bot'); input.placeholder = 'Betreff';
if (lastQuestion) buttons([{ label: 'Meine Frage übernehmen', user: false, fn: function () { data.subject = lastQuestion.slice(0, 120); say(data.subject, 'user'); askMessage(); } }]);
input.focus();
}
function askMessage() {
step = 'message'; say('Bitte beschreiben Sie Ihr Anliegen so genau wie möglich.', 'bot'); input.placeholder = 'Ihre Beschreibung';
if (lastQuestion) buttons([{ label: 'Meine Frage als Beschreibung', user: false, fn: function () { data.message = lastQuestion; say(data.message, 'user'); confirmTicket(); } }]);
input.focus();
}
function confirmTicket() {
step = 'confirm'; input.placeholder = '';
say('Das habe ich notiert:\nName: ' + data.name + '\nE-Mail: ' + data.email + '\nBetreff: ' + data.subject + '\nBeschreibung: ' + data.message + '\n\nSoll ich das Ticket so erstellen?', 'bot');
confirmButtons();
}
function cancelTicket() { mode = 'ask'; step = ''; input.placeholder = 'Ihre Frage …'; say('Alles klar, ich habe nichts erstellt. Wie kann ich sonst helfen?', 'bot'); }
function confirmButtons() {
buttons([
{ label: 'Ticket erstellen', main: true, fn: sendTicket },
{ label: 'Abbrechen', fn: cancelTicket }
]);
}
function sendTicket() {
setBusy(true);
var f = { name: data.name, email: data.email, subject: data.subject, message: data.message, hp_website: $('as-hp').value };
if (data.helpTopicId) f.helpTopicId = data.helpTopicId;
api('ticket', f).then(function (r) {
setBusy(false);
if (r.ok) {
mode = 'ask'; step = ''; input.placeholder = 'Ihre Frage …';
say('Ihr Ticket wurde erstellt. Die Nummer lautet ' + r.number + '. Wir melden uns per E-Mail bei Ihnen, eine Bestätigung ist unterwegs.', 'bot');
return;
}
say(r.error || 'Das Ticket konnte nicht erstellt werden.', 'bot');
if (r.__status === 422) { buttons([{ label: 'Angaben ändern', main: true, fn: function () { startTicket(); } }, { label: 'Abbrechen', fn: function () { mode = 'ask'; step = ''; input.placeholder = 'Ihre Frage …'; } }]); }
}).catch(function () { setBusy(false); say('Das hat leider nicht geklappt. Bitte versuchen Sie es noch einmal.', 'bot'); });
}
form.addEventListener('submit', function (e) {
e.preventDefault();
var text = input.value.trim(); if (!text || busy) return;
input.value = '';
Array.prototype.forEach.call(msgs.querySelectorAll('.as-btns'), function (b) { b.remove(); }); // offene Vorschläge entfallen, wenn der Kunde selbst tippt
say(text, 'user');
if (mode === 'ask') { ask(text); return; }
if (step === 'name') { data.name = text.slice(0, 120); askEmail(); }
else if (step === 'email') {
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(text)) { say('Das sieht nicht wie eine E-Mail-Adresse aus. Bitte noch einmal, zum Beispiel name@beispiel.de.', 'bot'); return; }
data.email = text; askSubject();
}
else if (step === 'subject') { data.subject = text.slice(0, 200); askMessage(); }
else if (step === 'message') { data.message = text; confirmTicket(); }
else if (step === 'confirm') {
// auch getippte Antworten gelten (ja / ticket erstellen / nein / abbrechen)
var t = text.toLowerCase().replace(/[^a-zäöüß ]/g, '').trim();
if (/^(ja|jap|jo|ok|okay|klar|gerne|bitte|passt|stimmt|richtig|yes|ticket erstellen|erstellen|absenden|senden|abschicken|bestätigen)\b/.test(t)) { sendTicket(); }
else if (/^(nein|nee|no|abbrechen|stopp|stop|doch nicht|abbruch)\b/.test(t)) { cancelTicket(); }
else { say('Bitte antworten Sie mit „Ja“ oder „Nein“, oder wählen Sie eine Schaltfläche.', 'bot'); confirmButtons(); }
}
});
function open() {
panel.classList.add('open');
if (started) { input.focus(); return; }
started = true;
api('status').then(function (r) {
if (!r.active) { say('Der Assistent ist gerade nicht verfügbar.', 'bot'); input.disabled = true; return; }
cfg = r; $('as-title').textContent = r.name; say(r.greeting, 'bot');
buttons([{ label: 'Ticket erstellen', fn: startTicket }]);
input.focus();
}).catch(function () { say('Der Assistent ist gerade nicht erreichbar.', 'bot'); });
}
$('as-title').textContent = <?= json_encode($__as['name'], JSON_UNESCAPED_UNICODE) ?>;
$('as-bubble').addEventListener('click', function () { if (panel.classList.contains('open')) panel.classList.remove('open'); else open(); });
$('as-close').addEventListener('click', function () { panel.classList.remove('open'); });
// Wird zwischendurch ein Mitarbeiter erreichbar, einen Hinweis zeigen (der Live-Chat erscheint nach dem Neuladen)
setInterval(function () {
api('status').then(function (r) { if (r && r.active === false) $('as-note').style.display = 'block'; }).catch(function () {});
}, 60000);
})();
</script>
+12 -3
View File
@@ -84,7 +84,13 @@ function require_staff_login(): array
{
$staff = current_staff();
if (!$staff) {
// War die Sitzung gerade wegen Inaktivität abgelaufen (ein Hintergrund-Abruf hat sie schon beendet), den Grund nennen
if (!empty($_SESSION['staff_timed_out'])) {
unset($_SESSION['staff_timed_out']);
flash('error', 'Sie wurden wegen Inaktivität abgemeldet. Bitte melden Sie sich erneut an.');
} else {
flash('error', 'Bitte melden Sie sich an, um fortzufahren.');
}
redirect(staff_base_path() . '/login.php');
}
// Inaktivitäts-Timeout
@@ -94,14 +100,17 @@ function require_staff_login(): array
if ($last && (time() - $last) > ($timeout * 60)) {
staff_logout();
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
if (!$userNavigation) {
// Hintergrund-Abruf (Aktualisierung der Liste, Chat-Abfrage …): kurze Antwort statt Weiterleitung auf die Anmeldeseite
$_SESSION['staff_timed_out'] = time(); // merken, damit die Anmeldeseite den Grund nennen kann (siehe oben)
if (!staff_is_document_request()) {
// Hintergrund-Abruf (Aktualisierung der Liste, Chat-Abfrage …): kurze Antwort statt Weiterleitung auf die Anmeldeseite.
// Das Laden einer Seite dagegen führt immer zur Anmeldung, auch wenn der Browser es selbst ausgelöst hat (Tab neu geladen).
http_response_code(401);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'Session abgelaufen', 'sessionExpired' => true]);
exit;
}
$_SESSION['flash'] = [['type'=>'error','message'=>'Sie wurden wegen Inaktivität abgemeldet.']];
unset($_SESSION['staff_timed_out']);
$_SESSION['flash'] = [['type'=>'error','message'=>'Sie wurden wegen Inaktivität abgemeldet. Bitte melden Sie sich erneut an.']];
redirect('/staff/login.php');
}
// Nur echte Bedienung hält die Sitzung am Leben: ein vom Nutzer ausgelöster Seitenaufruf oder der Ping der Seite bei Klicks und
+1
View File
@@ -22,6 +22,7 @@ require_once __DIR__ . '/reports.php';
require_once __DIR__ . '/fcm.php';
require_once __DIR__ . '/staff_activity.php';
require_once __DIR__ . '/notifications.php';
require_once __DIR__ . '/ticket_extras.php';
require_once __DIR__ . '/plugins.php';
require_once __DIR__ . '/app_downloads.php';
require_once __DIR__ . '/theme_catalog.php';
+2 -2
View File
@@ -371,7 +371,7 @@ function fcm_recipients_for_department(PDO $pdo, ?int $deptId): array
/** Text der Benachrichtigung: standardmäßig nur mit Ticketnummer (privat), mit "Details" auch Betreff und Kunde. */
function fcm_message(string $type, array $ticket): array
{
$titles = ['ticket_new' => 'Neues Ticket', 'ticket_reply' => 'Neue Antwort', 'ticket_assigned' => 'Ticket zugewiesen', 'chat_new' => 'Neuer Live-Chat', 'ticket_sla' => 'SLA überschritten', 'ticket_mention' => 'Erwähnung'];
$titles = ['ticket_new' => 'Neues Ticket', 'ticket_reply' => 'Neue Antwort', 'ticket_assigned' => 'Ticket zugewiesen', 'chat_new' => 'Neuer Live-Chat', 'ticket_sla' => 'SLA überschritten', 'ticket_mention' => 'Erwähnung', 'ticket_snooze' => 'Wiedervorlage fällig', 'ticket_follow' => 'Beobachtetes Ticket'];
$title = $titles[$type] ?? 'DeskHub';
$number = (string) ($ticket['ticket_number'] ?? '');
if (get_setting('fcm_show_details', '0') === '1') {
@@ -431,7 +431,7 @@ function fcm_notify_assignment(array $ticket, int $staffId, ?int $actorId = null
function fcm_event_types(): array
{
return ['ticket_new' => 'Neues Ticket', 'ticket_reply' => 'Neue Kundenantwort', 'ticket_assigned' => 'Ticket mir zugewiesen', 'chat_new' => 'Neuer Live-Chat',
'ticket_sla' => 'SLA überschritten', 'ticket_mention' => 'Ich wurde erwähnt'];
'ticket_sla' => 'SLA überschritten', 'ticket_mention' => 'Ich wurde erwähnt', 'ticket_snooze' => 'Wiedervorlage fällig', 'ticket_follow' => 'Beobachtetes Ticket aktualisiert'];
}
/**
@@ -67,7 +67,14 @@ document.addEventListener('keydown', function(e){ if(e.key==='Escape') closeLega
Copyright &copy; <?= date('Y') ?> <?= e(get_setting('display_name', config()['site']['name'])) ?> &ndash; Alle Rechte vorbehalten.
</footer>
<?php if (get_setting('live_chat_enabled', '0') === '1'): ?>
<?php
// Digitaler Assistent: nur wenn der Live-Chat ausgeschaltet oder gerade niemand erreichbar ist; dann ersetzt er das Live-Chat-Fenster
require_once __DIR__ . '/assistant.php';
$__assistantOn = false;
try { $__assistantOn = assistant_active(get_db()); } catch (Throwable $e) { /* Datenbank noch nicht aktualisiert: kein Assistent */ }
if ($__assistantOn) require __DIR__ . '/assistant_widget.php';
?>
<?php if (get_setting('live_chat_enabled', '0') === '1' && !$__assistantOn): ?>
<!-- Live-Chat-Widget: kann jederzeit unter Einstellungen → Allgemein wieder deaktiviert werden -->
<style>
@keyframes lc-pop { 0%{transform:scale(.8);opacity:0} 100%{transform:scale(1);opacity:1} }
+1
View File
@@ -1851,6 +1851,7 @@ function admin_section_permissions(): array
'manage_escalations' => 'Eskalationsregeln verwalten',
'view_time_entries' => 'Zeiterfassung einsehen',
'manage_business_hours' => 'Geschäftszeiten verwalten',
'manage_assistant' => 'Digitalen Assistenten verwalten',
];
}