Upload via GUI (290 Dateien)
This commit is contained in:
@@ -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";
|
||||||
|
}
|
||||||
@@ -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;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">×</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>
|
||||||
@@ -84,7 +84,13 @@ function require_staff_login(): array
|
|||||||
{
|
{
|
||||||
$staff = current_staff();
|
$staff = current_staff();
|
||||||
if (!$staff) {
|
if (!$staff) {
|
||||||
flash('error', 'Bitte melden Sie sich an, um fortzufahren.');
|
// 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');
|
redirect(staff_base_path() . '/login.php');
|
||||||
}
|
}
|
||||||
// Inaktivitäts-Timeout
|
// Inaktivitäts-Timeout
|
||||||
@@ -94,14 +100,17 @@ function require_staff_login(): array
|
|||||||
if ($last && (time() - $last) > ($timeout * 60)) {
|
if ($last && (time() - $last) > ($timeout * 60)) {
|
||||||
staff_logout();
|
staff_logout();
|
||||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||||
if (!$userNavigation) {
|
$_SESSION['staff_timed_out'] = time(); // merken, damit die Anmeldeseite den Grund nennen kann (siehe oben)
|
||||||
// Hintergrund-Abruf (Aktualisierung der Liste, Chat-Abfrage …): kurze Antwort statt Weiterleitung auf die Anmeldeseite
|
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);
|
http_response_code(401);
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
echo json_encode(['error' => 'Session abgelaufen', 'sessionExpired' => true]);
|
echo json_encode(['error' => 'Session abgelaufen', 'sessionExpired' => true]);
|
||||||
exit;
|
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');
|
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
|
// Nur echte Bedienung hält die Sitzung am Leben: ein vom Nutzer ausgelöster Seitenaufruf oder der Ping der Seite bei Klicks und
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ require_once __DIR__ . '/reports.php';
|
|||||||
require_once __DIR__ . '/fcm.php';
|
require_once __DIR__ . '/fcm.php';
|
||||||
require_once __DIR__ . '/staff_activity.php';
|
require_once __DIR__ . '/staff_activity.php';
|
||||||
require_once __DIR__ . '/notifications.php';
|
require_once __DIR__ . '/notifications.php';
|
||||||
|
require_once __DIR__ . '/ticket_extras.php';
|
||||||
require_once __DIR__ . '/plugins.php';
|
require_once __DIR__ . '/plugins.php';
|
||||||
require_once __DIR__ . '/app_downloads.php';
|
require_once __DIR__ . '/app_downloads.php';
|
||||||
require_once __DIR__ . '/theme_catalog.php';
|
require_once __DIR__ . '/theme_catalog.php';
|
||||||
|
|||||||
@@ -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. */
|
/** Text der Benachrichtigung: standardmäßig nur mit Ticketnummer (privat), mit "Details" auch Betreff und Kunde. */
|
||||||
function fcm_message(string $type, array $ticket): array
|
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';
|
$title = $titles[$type] ?? 'DeskHub';
|
||||||
$number = (string) ($ticket['ticket_number'] ?? '');
|
$number = (string) ($ticket['ticket_number'] ?? '');
|
||||||
if (get_setting('fcm_show_details', '0') === '1') {
|
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
|
function fcm_event_types(): array
|
||||||
{
|
{
|
||||||
return ['ticket_new' => 'Neues Ticket', 'ticket_reply' => 'Neue Kundenantwort', 'ticket_assigned' => 'Ticket mir zugewiesen', 'chat_new' => 'Neuer Live-Chat',
|
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 © <?= date('Y') ?> <?= e(get_setting('display_name', config()['site']['name'])) ?> – Alle Rechte vorbehalten.
|
Copyright © <?= date('Y') ?> <?= e(get_setting('display_name', config()['site']['name'])) ?> – Alle Rechte vorbehalten.
|
||||||
</footer>
|
</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 -->
|
<!-- Live-Chat-Widget: kann jederzeit unter Einstellungen → Allgemein wieder deaktiviert werden -->
|
||||||
<style>
|
<style>
|
||||||
@keyframes lc-pop { 0%{transform:scale(.8);opacity:0} 100%{transform:scale(1);opacity:1} }
|
@keyframes lc-pop { 0%{transform:scale(.8);opacity:0} 100%{transform:scale(1);opacity:1} }
|
||||||
|
|||||||
@@ -1851,6 +1851,7 @@ function admin_section_permissions(): array
|
|||||||
'manage_escalations' => 'Eskalationsregeln verwalten',
|
'manage_escalations' => 'Eskalationsregeln verwalten',
|
||||||
'view_time_entries' => 'Zeiterfassung einsehen',
|
'view_time_entries' => 'Zeiterfassung einsehen',
|
||||||
'manage_business_hours' => 'Geschäftszeiten verwalten',
|
'manage_business_hours' => 'Geschäftszeiten verwalten',
|
||||||
|
'manage_assistant' => 'Digitalen Assistenten verwalten',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user