Files
BetiX/app/Http/Controllers/SupportChatController.php
Dolo 0280278978
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Initialer Laravel Commit für BetiX
2026-04-04 18:01:50 +02:00

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']);
}
}