401 lines
18 KiB
PHP
401 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
class SupportChatController extends Controller
|
|
{
|
|
private const SESSION_KEY = 'support_chat';
|
|
|
|
private function ensureEnabled()
|
|
{
|
|
// Use config/env directly to avoid cache/DB dependency if possible,
|
|
// or assume cache is file-based (which is fine).
|
|
$flag = cache()->get('support_chat_enabled');
|
|
$enabled = is_null($flag) ? (bool) config('app.support_chat_enabled', env('SUPPORT_CHAT_ENABLED', true)) : (bool) $flag;
|
|
if (!$enabled) {
|
|
abort(503, 'Support chat is currently unavailable.');
|
|
}
|
|
}
|
|
|
|
private function isChatEnabled(): bool
|
|
{
|
|
$flag = cache()->get('support_chat_enabled');
|
|
return is_null($flag) ? (bool) config('app.support_chat_enabled', env('SUPPORT_CHAT_ENABLED', true)) : (bool) $flag;
|
|
}
|
|
|
|
private function sessionState(Request $request): array
|
|
{
|
|
$state = $request->session()->get(self::SESSION_KEY, [
|
|
'thread_id' => null,
|
|
'status' => 'new',
|
|
'topic' => null,
|
|
'messages' => [],
|
|
'data_access_granted' => false,
|
|
]);
|
|
|
|
if (!empty($state['thread_id'])) {
|
|
$cached = cache()->get('support_threads:'.$state['thread_id']);
|
|
if (is_array($cached)) {
|
|
$state['status'] = $cached['status'] ?? $state['status'];
|
|
foreach ($cached['messages'] ?? [] as $msg) {
|
|
if (($msg['sender'] ?? '') === 'agent') {
|
|
$state['status'] = 'agent';
|
|
break;
|
|
}
|
|
}
|
|
if (count($cached['messages'] ?? []) > count($state['messages'])) {
|
|
$state['messages'] = $cached['messages'];
|
|
}
|
|
}
|
|
}
|
|
return $state;
|
|
}
|
|
|
|
private function saveState(Request $request, array $state): void
|
|
{
|
|
$state['messages'] = array_slice($state['messages'], -100);
|
|
$state['updated_at'] = now()->toIso8601String();
|
|
$request->session()->put(self::SESSION_KEY, $state);
|
|
$this->persistThread($request, $state);
|
|
}
|
|
|
|
private function persistThread(Request $request, array $state): void
|
|
{
|
|
if (empty($state['thread_id'])) return;
|
|
$user = $request->user();
|
|
|
|
// Construct user data safely without assuming local DB columns exist if Auth is mocked
|
|
$userData = null;
|
|
if ($user) {
|
|
$userData = [
|
|
'id' => $user->id ?? 'guest',
|
|
'username' => $user->username ?? $user->name ?? 'Guest',
|
|
'email' => $user->email ?? '',
|
|
'avatar_url' => $user->avatar_url ?? $user->profile_photo_url ?? null,
|
|
];
|
|
}
|
|
|
|
$record = [
|
|
'id' => $state['thread_id'],
|
|
'status' => $state['status'] ?? 'new',
|
|
'topic' => $state['topic'] ?? null,
|
|
'user' => $userData,
|
|
'messages' => $state['messages'] ?? [],
|
|
'updated_at' => now()->toIso8601String(),
|
|
];
|
|
|
|
// Cache is file/redis based, so this is fine (no SQL DB)
|
|
cache()->put('support_threads:'.$state['thread_id'], $record, now()->addDay());
|
|
|
|
$index = cache()->get('support_threads_index', []);
|
|
$found = false;
|
|
foreach ($index as &$row) {
|
|
if (($row['id'] ?? null) === $record['id']) {
|
|
$row['updated_at'] = $record['updated_at'];
|
|
$row['status'] = $record['status'];
|
|
$row['topic'] = $record['topic'];
|
|
$row['user'] = $record['user'];
|
|
$found = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!$found) {
|
|
$index[] = [
|
|
'id' => $record['id'],
|
|
'updated_at' => $record['updated_at'],
|
|
'status' => $record['status'],
|
|
'topic' => $record['topic'],
|
|
'user' => $record['user'],
|
|
];
|
|
}
|
|
cache()->put('support_threads_index', $index, now()->addDay());
|
|
}
|
|
|
|
public function start(Request $request)
|
|
{
|
|
// ensureEnabled removed here to allow handoff logic
|
|
$user = $request->user();
|
|
abort_unless($user, 401);
|
|
|
|
$data = $request->validate(['topic' => 'nullable|string|max:60']);
|
|
$enabled = $this->isChatEnabled();
|
|
|
|
$state = $this->sessionState($request);
|
|
if (!$state['thread_id']) {
|
|
$state['thread_id'] = (string) Str::uuid();
|
|
$state['status'] = $enabled ? 'ai' : 'handoff';
|
|
$state['topic'] = $data['topic'] ?? null;
|
|
$state['messages'] = [];
|
|
$state['data_access_granted'] = false;
|
|
$state['user'] = [
|
|
'id' => $user->id,
|
|
'username' => $user->username ?? $user->name,
|
|
'email' => $user->email
|
|
];
|
|
|
|
if (!empty($data['topic'])) {
|
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'user', 'body' => 'Thema gewählt: ' . $data['topic'], 'at' => now()->toIso8601String()];
|
|
|
|
if ($enabled) {
|
|
$aiReply = $this->askOllama($state);
|
|
if ($aiReply) {
|
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'ai', 'body' => $aiReply, 'at' => now()->toIso8601String()];
|
|
} else {
|
|
// Ollama offline or returned nothing — fall back to human handoff
|
|
$state['status'] = 'handoff';
|
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Unser KI-Assistent ist gerade nicht erreichbar. Ein Mitarbeiter wird sich in Kürze bei dir melden.', 'at' => now()->toIso8601String()];
|
|
}
|
|
} else {
|
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Der KI-Assistent ist derzeit inaktiv. Ein Mitarbeiter wird sich in Kürze bei dir melden.', 'at' => now()->toIso8601String()];
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->saveState($request, $state);
|
|
return response()->json($state);
|
|
}
|
|
|
|
public function close(Request $request)
|
|
{
|
|
abort_unless(Auth::check(), 401);
|
|
$state = $this->sessionState($request);
|
|
|
|
if (!empty($state['thread_id'])) {
|
|
cache()->forget('support_threads:'.$state['thread_id']);
|
|
$index = cache()->get('support_threads_index', []);
|
|
$index = array_filter($index, fn($item) => $item['id'] !== $state['thread_id']);
|
|
cache()->put('support_threads_index', array_values($index), now()->addDay());
|
|
}
|
|
|
|
$request->session()->forget(self::SESSION_KEY);
|
|
|
|
return response()->json(['thread_id' => null, 'status' => 'closed', 'topic' => null, 'messages' => []]);
|
|
}
|
|
|
|
public function message(Request $request)
|
|
{
|
|
$user = $request->user();
|
|
abort_unless($user, 401);
|
|
|
|
$data = $request->validate(['text' => 'required|string|min:1|max:1000']);
|
|
$text = trim($data['text']);
|
|
$state = $this->sessionState($request);
|
|
$enabled = $this->isChatEnabled();
|
|
|
|
if (!$state['thread_id'] || ($state['status'] ?? null) === 'closed') {
|
|
return response()->json(['error' => 'No active chat thread.'], 422);
|
|
}
|
|
|
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'user', 'body' => $text, 'at' => now()->toIso8601String()];
|
|
|
|
if (!$enabled) {
|
|
$lastMsg = collect($state['messages'])->where('sender', 'system')->last();
|
|
if (!$lastMsg || !Str::contains($lastMsg['body'], 'Mitarbeiter')) {
|
|
$state['status'] = 'handoff';
|
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Deine Nachricht wurde empfangen. Bitte warte auf einen Mitarbeiter.', 'at' => now()->toIso8601String()];
|
|
}
|
|
$this->saveState($request, $state);
|
|
return response()->json($state);
|
|
}
|
|
|
|
if (Str::of(Str::lower($text))->contains(['ja', 'yes', 'erlaubt', 'okay', 'ok', 'zugriff'])) {
|
|
$lastSystemMsg = collect($state['messages'])->where('sender', 'system')->last();
|
|
if ($lastSystemMsg && Str::contains($lastSystemMsg['body'], 'zugreifen')) {
|
|
$state['data_access_granted'] = true;
|
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Zugriff erlaubt. Ich sehe mir deine Daten an...', 'at' => now()->toIso8601String()];
|
|
$aiReply = $this->askOllama($state);
|
|
if ($aiReply) {
|
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'ai', 'body' => $aiReply, 'at' => now()->toIso8601String()];
|
|
}
|
|
$this->saveState($request, $state);
|
|
return response()->json($state);
|
|
}
|
|
}
|
|
|
|
if (Str::contains($text, ['🛑', ':stop:', 'STOP'])) {
|
|
$state['status'] = 'stopped';
|
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Verstanden. Soll ein Mitarbeiter übernehmen?', 'at' => now()->toIso8601String()];
|
|
$this->saveState($request, $state);
|
|
return response()->json($state);
|
|
}
|
|
|
|
if ($state['status'] === 'stopped' && Str::of(Str::lower($text))->contains(['ja', 'yes', 'y'])) {
|
|
return $this->handoff($request);
|
|
}
|
|
|
|
if (in_array($state['status'], ['handoff', 'agent'])) {
|
|
$this->saveState($request, $state);
|
|
return response()->json($state);
|
|
}
|
|
|
|
$aiReply = $this->askOllama($state);
|
|
|
|
if ($aiReply && (str_contains($aiReply, '[HANDOFF]') || trim($aiReply) === '[HANDOFF]')) {
|
|
return $this->handoff($request);
|
|
}
|
|
|
|
if ($aiReply && (str_contains($aiReply, '[REQUEST_DATA]') || trim($aiReply) === '[REQUEST_DATA]')) {
|
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Um dir besser helfen zu können, müsste ich auf deine Kontodaten (Wallet, Boni, etc.) zugreifen. Ist das in Ordnung? (Antworte mit "Ja")', 'at' => now()->toIso8601String()];
|
|
$this->saveState($request, $state);
|
|
return response()->json($state);
|
|
}
|
|
|
|
if ($aiReply) {
|
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'ai', 'body' => $aiReply, 'at' => now()->toIso8601String()];
|
|
} else {
|
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Ich konnte gerade keine Antwort generieren. Bitte versuche es erneut oder stoppe die KI mit 🛑.', 'at' => now()->toIso8601String()];
|
|
}
|
|
|
|
$this->saveState($request, $state);
|
|
return response()->json($state);
|
|
}
|
|
|
|
private function askOllama(array $state): ?string
|
|
{
|
|
$host = rtrim(env('OLLAMA_HOST', 'http://127.0.0.1:11434'), '/');
|
|
$model = env('OLLAMA_MODEL', 'llama3');
|
|
if (!$host) return null;
|
|
|
|
$topic = $state['topic'] ? (string) $state['topic'] : 'Allgemeiner Support';
|
|
$hasAccess = $state['data_access_granted'] ?? false;
|
|
|
|
$system = "Du bist ein hilfsbereiter Casino-Support-Assistent. Antworte knapp, freundlich und in deutscher Sprache. Frage maximal eine Rückfrage gleichzeitig. Wenn du eine Frage nicht beantworten kannst oder der Nutzer frustriert wirkt, antworte NUR mit dem Wort `[HANDOFF]`. Thema: {$topic}.";
|
|
|
|
if (!$hasAccess) {
|
|
$system .= "\n\nWICHTIG: Du hast AKTUELL KEINEN ZUGRIFF auf Nutzerdaten. Wenn der Nutzer nach persönlichen Informationen fragt (z.B. E-Mail, Guthaben, Boni, Transaktionen), antworte SOFORT und AUSSCHLIESSLICH mit dem Wort `[REQUEST_DATA]`. Erkläre nichts, entschuldige dich nicht. Nur `[REQUEST_DATA]`.";
|
|
} else {
|
|
$contextJson = '';
|
|
try {
|
|
// REPLACED DB CALLS WITH API CALLS
|
|
$u = Auth::user();
|
|
if ($u) {
|
|
$apiBase = config('app.api_url');
|
|
// We assume the user is authenticated via a token or session that is passed along.
|
|
// Since we are in a local environment acting as a client, we might need to forward the token.
|
|
// For now, we assume the API endpoints are protected and we need a way to call them.
|
|
// If Auth::user() works, it means we have a session.
|
|
|
|
// Fetch data from external API
|
|
// Note: In a real microservice setup, we would pass the user's token.
|
|
// Here we try to fetch from the API using the configured base URL.
|
|
|
|
// Example: Fetch Wallets
|
|
$walletsResp = Http::acceptJson()->get($apiBase . '/user/wallets');
|
|
$wallets = $walletsResp->ok() ? $walletsResp->json() : [];
|
|
|
|
// Example: Fetch Bonuses
|
|
$bonusesResp = Http::acceptJson()->get($apiBase . '/user/bonuses/active');
|
|
$activeBonuses = $bonusesResp->ok() ? $bonusesResp->json() : [];
|
|
|
|
$ctx = [
|
|
'user' => [
|
|
'username' => $u->username ?? $u->name,
|
|
'email' => $u->email,
|
|
'id' => $u->id
|
|
],
|
|
'wallets' => $wallets,
|
|
'active_bonuses' => $activeBonuses,
|
|
];
|
|
$contextJson = json_encode($ctx, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// Fallback if API calls fail
|
|
\Illuminate\Support\Facades\Log::error('Failed to fetch remote context: ' . $e->getMessage());
|
|
}
|
|
|
|
if ($contextJson) {
|
|
$system .= "\n\nNutzerdaten (ZUGRIFF ERLAUBT):\n" . $contextJson . "\n\nDU DARFST DIESE DATEN JETZT VERWENDEN. Die Daten im JSON beziehen sich AUSSCHLIESSLICH auf den aktuellen Gesprächspartner. Wenn der Nutzer nach seinen EIGENEN Daten fragt (z.B. 'meine E-Mail', 'mein Guthaben'), antworte ihm direkt mit den Werten aus dem JSON. Wenn der Nutzer nach den Daten einer ANDEREN Person fragt (z.B. 'die E-Mail von Bingo'), musst du die Anfrage ablehnen und antworten, dass du nur Auskunft über sein eigenes Konto geben darfst.";
|
|
}
|
|
}
|
|
|
|
$recent = array_slice($state['messages'], -10);
|
|
$chatText = $system."\n\n";
|
|
foreach ($recent as $m) {
|
|
$role = $m['sender'] === 'user' ? 'User' : ucfirst($m['sender']);
|
|
$chatText .= "{$role}: {$m['body']}\n";
|
|
}
|
|
$chatText .= "Assistant:";
|
|
|
|
try {
|
|
$res = Http::timeout(12)->post($host.'/api/generate', [
|
|
'model' => $model,
|
|
'prompt' => $chatText,
|
|
'stream' => false,
|
|
'options' => ['temperature' => 0.3, 'num_predict' => 180],
|
|
]);
|
|
if (!$res->ok()) return null;
|
|
$json = $res->json();
|
|
$out = trim((string)($json['response'] ?? ''));
|
|
return $out ?: null;
|
|
} catch (\Throwable $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public function status(Request $request) { return response()->json($this->sessionState($request)); }
|
|
|
|
public function stop(Request $request)
|
|
{
|
|
abort_unless(Auth::check(), 401);
|
|
$state = $this->sessionState($request);
|
|
if (!$state['thread_id'] || ($state['status'] ?? null) === 'closed') {
|
|
return response()->json(['error' => 'No active chat thread.'], 422);
|
|
}
|
|
if ($state['status'] === 'ai') {
|
|
$state['status'] = 'stopped';
|
|
$state['messages'][] = [
|
|
'id' => Str::uuid()->toString(),
|
|
'sender' => 'system',
|
|
'body' => 'KI-Assistent gestoppt. Du kannst einen Mitarbeiter anfordern.',
|
|
'at' => now()->toIso8601String(),
|
|
];
|
|
$this->saveState($request, $state);
|
|
}
|
|
return response()->json($state);
|
|
}
|
|
|
|
public function handoff(Request $request) {
|
|
$this->ensureEnabled();
|
|
abort_unless(Auth::check(), 401);
|
|
$state = $this->sessionState($request);
|
|
if (!$state['thread_id']) return response()->json(['message' => 'No active support thread'], 422);
|
|
$state['status'] = 'handoff';
|
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Ich leite dich an einen Mitarbeiter weiter. Bitte habe einen Moment Geduld.', 'at' => now()->toIso8601String()];
|
|
$this->saveState($request, $state);
|
|
$webhook = env('SUPPORT_DASHBOARD_WEBHOOK_URL');
|
|
if ($webhook) { try { Http::timeout(5)->post($webhook, ['event' => 'support.handoff', 'thread_id' => $state['thread_id']]); } catch (\Throwable $e) {} }
|
|
return response()->json(['message' => 'Hand-off requested', 'state' => $state]);
|
|
}
|
|
public function stream(Request $request) {
|
|
$state = $this->sessionState($request);
|
|
if (empty($state['thread_id'])) return response('', 204);
|
|
$threadId = $state['thread_id'];
|
|
return new StreamedResponse(function () use ($request, $threadId) {
|
|
$send = fn($data) => print('data: ' . json_encode($data) . "\n\n");
|
|
$start = time();
|
|
$lastUpdated = null;
|
|
$lastCount = -1;
|
|
while (time() - $start < 60) {
|
|
usleep(500000);
|
|
$cached = cache()->get('support_threads:'.$threadId);
|
|
$nowState = is_array($cached) ? $cached : $this->sessionState($request);
|
|
$count = count($nowState['messages'] ?? []);
|
|
$updated = $nowState['updated_at'] ?? null;
|
|
if ($count !== $lastCount || $updated !== $lastUpdated) {
|
|
$send($nowState);
|
|
$lastCount = $count;
|
|
$lastUpdated = $updated;
|
|
}
|
|
if ((time() - $start) % 15 === 0) { print(": ping\n\n"); }
|
|
@ob_flush(); @flush();
|
|
}
|
|
}, 200, ['Content-Type' => 'text/event-stream', 'Cache-Control' => 'no-cache', 'Connection' => 'keep-alive', 'X-Accel-Buffering' => 'no']);
|
|
}
|
|
}
|