diff --git a/API.md b/API.md index 83d9719..11f2692 100644 --- a/API.md +++ b/API.md @@ -13,6 +13,7 @@ Implementierung: Vanilla-PHP (kein Framework), Persistenz in SQLite - [Grundlagen](#grundlagen) - [Authentifizierung](#authentifizierung) - [Agenten](#agenten) +- [Sprachaufnahmen](#sprachaufnahmen) - [Tickets](#tickets) - [Events](#events) - [Systeme](#systeme) @@ -100,7 +101,10 @@ Auth: `X-Admin-Key`. ### `GET /api/v1/agents` — Alle Agenten -Response: `{ "agents": [ ‹Agent-Objekte wie oben, ohne api_key› ] }` +Response: `{ "agents": [ ‹Agent-Objekte wie oben, ohne api_key› ] }`. Jeder Agent traegt +zusaetzlich `pending_recordings` (Issue #85): Anzahl offener Sprachaufnahmen in seiner +Warteschlange (`status: "new"`), Grundlage fuer den Zaehler auf seinem Schreibtisch in der +Buero-Visualisierung, siehe [Sprachaufnahmen](#sprachaufnahmen). Oeffentlich, unauthentifiziert (siehe [Grundlagen](#grundlagen)) - damit laesst sich `last_seen_at`/`is_present` jedes Agenten jederzeit von aussen pruefen, z.B. auf der @@ -179,6 +183,69 @@ curl -X POST "$MONITOR_API_BASE/agents/$ID/activity" \ -d '{"state": "auth_expired", "current_ticket": null}' ``` +## Sprachaufnahmen + +Issue #85 (Folge-Ticket aus #84): Sprachnotizen fuer einen Agenten, aufgenommen ueber +`record.html` im Handy-Browser. Die eigentliche Transkription passiert agent-seitig +(whisper.cpp/faster-whisper, siehe [fds2610/Agent#17](https://codeberg.org/fds2610/Agent/issues/17)) - +diese API liefert nur die Rohaufnahme mit Status. + +In der Buero-Visualisierung verlinkt ein Klick auf die Personen-Figur jedes registrierten +Agenten auf `record.html?agent=‹id›`; ein kleiner Zaehler an seinem Schreibtisch zeigt, wie +viele Aufnahmen dort noch auf Abholung warten (`pending_recordings`, siehe +[Agenten](#agenten)). + +### `POST /api/v1/agents/{id}/recordings` — Aufnahme hochladen + +Auth: keine. Der Upload kommt aus `record.html`, einer oeffentlich verlinkten Seite im +Handy-Browser eines Menschen, nicht von einem registrierten Agenten - es gibt dort keinen +API-/Admin-Key einzugeben. Die Agenten-Id kommt als URL-Segment mit. + +Request: `multipart/form-data` mit Feld `audio` (Datei). Erlaubte MIME-Typen: `audio/webm`, +`audio/ogg`, `audio/mp4`, `audio/mpeg`, `audio/wav`, `audio/x-wav` - andere lehnt der +Endpunkt mit `422` ab. Groessenlimit: `MONITOR_MAX_RECORDING_BYTES` (Default 25 MiB), darueber +`413`. + +```json +// Response 201 +{ + "recording": { + "id": 1, "agent_id": 3, "mime_type": "audio/webm", "size_bytes": 48213, + "status": "new", "created_at": "2026-09-03T10:00:00Z", "processed_at": null + } +} +``` + +`404`, falls die Agenten-Id nicht existiert. + +### `GET /api/v1/agents/{id}/recordings` — Eigene Warteschlange abfragen + +Auth: `X-Api-Key` des betroffenen Agenten (ein Agent darf nur seine eigene Warteschlange +abfragen, sonst `403`) - fuer das regelmaessige Abfragen aus [fds2610/Agent#17](https://codeberg.org/fds2610/Agent/issues/17). + +Query-Parameter `status` optional (`new` oder `done`), ohne Filter kommen beide zurueck. + +Response: `{ "recordings": [ ‹Aufnahme-Objekte wie oben› ] }`, sortiert nach `created_at`. + +### `GET /api/v1/recordings/{id}/audio` — Audiodatei herunterladen + +Auth: `X-Api-Key` des Agenten, dem die Aufnahme gehoert (sonst `403`). + +Response: die Rohdatei mit dem beim Upload erkannten `Content-Type`, kein JSON. + +### `PATCH /api/v1/recordings/{id}` — Als verarbeitet markieren + +Auth: `X-Api-Key` des Agenten, dem die Aufnahme gehoert (sonst `403`). Nach erfolgreicher +Abholung/Transkription markiert der Agent die Aufnahme so, damit sie nicht erneut in +`GET .../recordings?status=new` auftaucht. + +```json +// Request +{ "status": "done" } +``` + +Response 200: aktualisiertes Aufnahme-Objekt (`status: "done"`, `processed_at` gesetzt). + ## Tickets Spalten (F3): `backlog`, `bereit`, `in_arbeit`, `qa`, `testing`, `fertig`, `done`. diff --git a/api/docker/Dockerfile b/api/docker/Dockerfile index ade73ef..f91c913 100644 --- a/api/docker/Dockerfile +++ b/api/docker/Dockerfile @@ -15,4 +15,7 @@ COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh ENTRYPOINT ["entrypoint.sh"] -CMD ["php", "-S", "0.0.0.0:8080", "-t", "public"] +# upload_max_filesize/post_max_size ueber dem PHP-Default (2M/8M) fuer Sprachaufnahmen +# (Issue #85, record.html): muss >= MONITOR_MAX_RECORDING_BYTES aus docker-compose.yml sein, +# post_max_size zusaetzlich etwas darueber wegen des Multipart-Overheads (Feldname/Boundary). +CMD ["php", "-d", "upload_max_filesize=30M", "-d", "post_max_size=32M", "-S", "0.0.0.0:8080", "-t", "public"] diff --git a/api/src/Application.php b/api/src/Application.php index 8a1b099..2cd4054 100644 --- a/api/src/Application.php +++ b/api/src/Application.php @@ -16,6 +16,7 @@ use Monitor\Api\Controller\RepoConfigController; use Monitor\Api\Controller\StreamController; use Monitor\Api\Controller\SystemController; use Monitor\Api\Controller\TicketController; +use Monitor\Api\Controller\VoiceRecordingController; use Monitor\Api\Controller\WebhookController; use Monitor\Api\Db\Database; use Monitor\Api\Http\MethodNotAllowedException; @@ -33,6 +34,7 @@ use Monitor\Api\Repository\LegacyStatusFileReader; use Monitor\Api\Repository\RepoConfigRepository; use Monitor\Api\Repository\SystemRepository; use Monitor\Api\Repository\TicketRepository; +use Monitor\Api\Repository\VoiceRecordingRepository; use Monitor\Api\Sync\CodebergSyncService; use Monitor\Api\Sync\CurlCodebergClient; use Monitor\Api\Sync\RepoHealthChecker; @@ -61,12 +63,14 @@ final class Application $repoConfigs = new RepoConfigRepository($db); $hostConfigs = new HostConfigRepository($db); $hooks = new HookCounter($config->hooksCounterPath()); + $recordings = new VoiceRecordingRepository($db, $config->recordingsPath(), $config->maxRecordingUploadBytes()); $this->agentAuth = new AgentAuthenticator($agents); $this->adminAuth = new AdminAuthenticator($config->adminApiKey()); $this->webhookAuth = new WebhookAuthenticator($config->codebergWebhookSecret()); - $agentController = new AgentController($agents); + $agentController = new AgentController($agents, $recordings); + $recordingController = new VoiceRecordingController($recordings, $agents); // Issue #51: der "Board neu einlesen"-Knopf synchronisiert direkt im api-Container, // unabhaengig vom periodisch/webhook-getriebenen sync-Container (bin/sync-codeberg.php) // - beide teilen sich denselben CodebergSyncService, nur der Aufloeser ist verschieden. @@ -80,7 +84,7 @@ final class Application $repoConfigController = new RepoConfigController($repoConfigs, $tickets, new RepoHealthChecker($codebergClient)); $webhookController = new WebhookController(new SyncTrigger($config->syncTriggerPath()), $hooks); $streamController = new StreamController( - new ChangeFeed($tickets, $events, $systems, $kpis, $agents, $hooks), + new ChangeFeed($tickets, $events, $systems, $kpis, $agents, $hooks, $recordings), $config->sseMaxIterations(), $config->ssePollIntervalSeconds(), ); @@ -148,6 +152,36 @@ final class Application 'agent' ); + // Sprachaufnahmen (Issue #85): Upload aus record.html ist bewusst unauthentifiziert - + // die Aufnahme-Webseite laeuft im Handy-Browser eines Menschen, nicht eines + // registrierten Agenten, es gibt dort also keinen Api-/Admin-Key einzugeben. Abholen/ + // Markieren dagegen hinter dem Agenten-Key wie die uebrigen Bot-Vorgaenge, mit + // Ownership-Check in VoiceRecordingController (ein Agent sieht nur seine eigene + // Warteschlange). + $this->router->add( + 'POST', + '/api/v1/agents/{id}/recordings', + fn (Request $r, array $p) => $recordingController->upload($r, $p) + ); + $this->router->add( + 'GET', + '/api/v1/agents/{id}/recordings', + fn (Request $r, array $p, array $agent) => $recordingController->index($r, $p, $agent), + 'agent' + ); + $this->router->add( + 'GET', + '/api/v1/recordings/{id}/audio', + fn (Request $r, array $p, array $agent) => $recordingController->download($r, $p, $agent), + 'agent' + ); + $this->router->add( + 'PATCH', + '/api/v1/recordings/{id}', + fn (Request $r, array $p, array $agent) => $recordingController->markProcessed($r, $p, $agent), + 'agent' + ); + // SSE-Push (F13): oeffentlich wie die anderen GET-Endpunkte, liefert nur bereits // oeffentliche Daten (welche Ressourcen sich geaendert haben, keine Inhalte). $this->router->add('GET', '/api/v1/stream', fn (Request $r, array $p) => $streamController->stream($r)); diff --git a/api/src/Config.php b/api/src/Config.php index c814fb0..6054486 100644 --- a/api/src/Config.php +++ b/api/src/Config.php @@ -118,4 +118,24 @@ final class Config { return $this->get('MONITOR_HOOKS_COUNTER_FILE', dirname(__DIR__) . '/data/hooks-count'); } + + /** + * Ablageort der Rohaufnahmen aus der Sprachaufnahme-Warteschlange (Issue #85). Liegt wie + * die SQLite-Datei per Default im geteilten ./api-Volume, damit sie einen Container-Neustart + * uebersteht. + */ + public function recordingsPath(): string + { + return $this->get('MONITOR_RECORDINGS_PATH', dirname(__DIR__) . '/data/recordings'); + } + + /** + * Obergrenze fuer eine einzelne Sprachaufnahme (Issue #85), Default 25 MiB - reicht fuer + * eine mehrminuetige Sprachnotiz in komprimiertem Format (webm/Opus o.ae.) reichlich, ohne + * dass ein vergessen laufender Rekorder die Warteschlange sprengt. + */ + public function maxRecordingUploadBytes(): int + { + return max(1, (int) $this->get('MONITOR_MAX_RECORDING_BYTES', (string) (25 * 1024 * 1024))); + } } diff --git a/api/src/Controller/AgentController.php b/api/src/Controller/AgentController.php index d8e81ab..0a586b6 100644 --- a/api/src/Controller/AgentController.php +++ b/api/src/Controller/AgentController.php @@ -8,16 +8,31 @@ use InvalidArgumentException; use Monitor\Api\Http\Request; use Monitor\Api\Http\Response; use Monitor\Api\Repository\AgentRepository; +use Monitor\Api\Repository\VoiceRecordingRepository; final class AgentController { - public function __construct(private readonly AgentRepository $agents) - { + public function __construct( + private readonly AgentRepository $agents, + private readonly VoiceRecordingRepository $recordings, + ) { } + /** + * Issue #85: jeder Agent traegt zusaetzlich pending_recordings - die Anzahl offener + * Sprachaufnahmen in seiner Warteschlange, Grundlage fuer den Zaehler auf seinem + * Schreibtisch in der Buero-Visualisierung. + */ public function index(Request $request): Response { - return Response::json(['agents' => $this->agents->all()]); + $pendingCounts = $this->recordings->pendingCountsByAgent(); + + $agents = array_map( + fn (array $agent) => $agent + ['pending_recordings' => $pendingCounts[(int) $agent['id']] ?? 0], + $this->agents->all() + ); + + return Response::json(['agents' => $agents]); } public function register(Request $request): Response diff --git a/api/src/Controller/VoiceRecordingController.php b/api/src/Controller/VoiceRecordingController.php new file mode 100644 index 0000000..ca0a6da --- /dev/null +++ b/api/src/Controller/VoiceRecordingController.php @@ -0,0 +1,135 @@ + $params */ + public function upload(Request $request, array $params): Response + { + $agentId = (int) $params['id']; + if ($this->agents->findById($agentId) === null) { + return Response::error('Unbekannter Agent: ' . $agentId, 404); + } + + $file = $request->files['audio'] ?? null; + if (!is_array($file) || !isset($file['tmp_name'], $file['error'])) { + return Response::error('Feld "audio" (Datei) ist erforderlich.', 422); + } + + $error = (int) $file['error']; + if ($error === UPLOAD_ERR_INI_SIZE || $error === UPLOAD_ERR_FORM_SIZE) { + return Response::error('Datei zu gross.', 413); + } + if ($error !== UPLOAD_ERR_OK) { + return Response::error('Datei-Upload fehlgeschlagen.', 422); + } + + $contents = @file_get_contents((string) $file['tmp_name']); + if ($contents === false) { + return Response::error('Datei-Upload fehlgeschlagen.', 422); + } + + try { + $recording = $this->recordings->store($agentId, $contents, (string) ($file['type'] ?? '')); + } catch (InvalidArgumentException $e) { + return Response::error($e->getMessage(), 422); + } + + return Response::json(['recording' => $recording], 201); + } + + /** + * @param array $params + * @param array $authenticatedAgent + */ + public function index(Request $request, array $params, array $authenticatedAgent): Response + { + if ((int) $params['id'] !== (int) $authenticatedAgent['id']) { + return Response::error('Ein Agent darf nur seine eigene Warteschlange abfragen.', 403); + } + + $status = isset($request->query['status']) ? (string) $request->query['status'] : null; + + try { + $recordings = $this->recordings->listForAgent((int) $params['id'], $status); + } catch (InvalidArgumentException $e) { + return Response::error($e->getMessage(), 400); + } + + return Response::json(['recordings' => $recordings]); + } + + /** + * @param array $params + * @param array $authenticatedAgent + */ + public function download(Request $request, array $params, array $authenticatedAgent): Response + { + $recording = $this->recordings->findById((int) $params['id']); + if ($recording === null) { + return Response::error('Aufnahme nicht gefunden.', 404); + } + if ((int) $recording['agent_id'] !== (int) $authenticatedAgent['id']) { + return Response::error('Diese Aufnahme gehoert einem anderen Agenten.', 403); + } + + $bytes = $this->recordings->readAudio((int) $recording['id']); + if ($bytes === null) { + return Response::error('Audiodatei nicht gefunden.', 404); + } + + return Response::binary($bytes, $recording['mime_type'], 200, [ + 'Content-Disposition' => 'attachment; filename="recording-' . $recording['id'] . '"', + ]); + } + + /** + * @param array $params + * @param array $authenticatedAgent + */ + public function markProcessed(Request $request, array $params, array $authenticatedAgent): Response + { + $recording = $this->recordings->findById((int) $params['id']); + if ($recording === null) { + return Response::error('Aufnahme nicht gefunden.', 404); + } + if ((int) $recording['agent_id'] !== (int) $authenticatedAgent['id']) { + return Response::error('Diese Aufnahme gehoert einem anderen Agenten.', 403); + } + + $status = (string) ($request->body['status'] ?? ''); + if ($status !== 'done') { + return Response::error('Feld "status" muss "done" sein.', 422); + } + + $updated = $this->recordings->markDone((int) $recording['id']); + + return Response::json(['recording' => $updated]); + } +} diff --git a/api/src/Db/migrations/006_voice_recordings.sql b/api/src/Db/migrations/006_voice_recordings.sql new file mode 100644 index 0000000..f93c040 --- /dev/null +++ b/api/src/Db/migrations/006_voice_recordings.sql @@ -0,0 +1,21 @@ +-- Issue #85: Warteschlange fuer Sprachaufnahmen an einen Agenten (Sprachnotizen per Handy, +-- siehe API.md "Sprachaufnahmen"). Die eigentliche Transkription passiert agent-seitig +-- (whisper.cpp/faster-whisper, siehe fds2610/Agent#17) - hier wird nur die Rohaufnahme mit +-- Status zwischengelagert, bis der Agent sie abgeholt und verarbeitet hat. Die Audiodatei +-- selbst liegt nicht in SQLite, sondern unter Config::recordingsPath() (storage_name ist ihr +-- Dateiname dort). +CREATE TABLE voice_recordings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id INTEGER NOT NULL, + storage_name TEXT NOT NULL UNIQUE, + mime_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'done')), + created_at TEXT NOT NULL, + processed_at TEXT, + FOREIGN KEY (agent_id) REFERENCES agents (id) +); + +-- Bedient sowohl den Abhol-Endpoint (Filter nach Status je Agent) als auch die +-- Zaehlung offener Aufnahmen je Agent fuer den Schreibtisch-Zaehler (GET /api/v1/agents). +CREATE INDEX idx_voice_recordings_agent_status ON voice_recordings (agent_id, status); diff --git a/api/src/Http/Request.php b/api/src/Http/Request.php index d6cf806..440e3b5 100644 --- a/api/src/Http/Request.php +++ b/api/src/Http/Request.php @@ -12,6 +12,10 @@ final class Request * HMAC-Signaturpruefung des Codeberg-Webhooks (siehe Auth\WebhookAuthenticator): * die Signatur gilt fuer die exakten Bytes, ein erneut kodiertes $body-Array * ergaebe eine andere Pruefsumme. + * @param array $files - Rohform von $_FILES (Issue #85: Multipart-Upload + * der Sprachaufnahmen). Bei multipart/form-data ist php://input nicht lesbar, PHP + * parst den Body in diesem Fall stattdessen selbst in $_FILES/$_POST - $rawBody bleibt + * fuer solche Requests leer. */ private function __construct( public readonly string $method, @@ -20,6 +24,7 @@ final class Request public readonly array $body, private readonly array $headers, public readonly string $rawBody = '', + public readonly array $files = [], ) { } @@ -54,13 +59,15 @@ final class Request $headers['content-type'] = (string) $_SERVER['CONTENT_TYPE']; } - return new self($method, $path, $query, $body, $headers, $rawBody); + return new self($method, $path, $query, $body, $headers, $rawBody, $_FILES ?? []); } /** * @param array $body * @param string|null $rawBody - nur angeben, wenn der Test genau diese Bytes braucht * (Signaturpruefung). Sonst wird $body als JSON serialisiert. + * @param array $files - fuer Tests von Multipart-Uploads (Issue #85), Form + * wie $_FILES: {feldname: {name, type, tmp_name, error, size}}. */ public static function create( string $method, @@ -69,6 +76,7 @@ final class Request array $body = [], array $headers = [], ?string $rawBody = null, + array $files = [], ): self { $normalized = []; foreach ($headers as $name => $value) { @@ -77,7 +85,7 @@ final class Request $rawBody ??= $body === [] ? '' : (string) json_encode($body); - return new self($method, rtrim($path, '/') ?: '/', $query, $body, $normalized, $rawBody); + return new self($method, rtrim($path, '/') ?: '/', $query, $body, $normalized, $rawBody, $files); } public function header(string $name): ?string diff --git a/api/src/Http/Response.php b/api/src/Http/Response.php index e4b3972..b7d8e90 100644 --- a/api/src/Http/Response.php +++ b/api/src/Http/Response.php @@ -19,6 +19,9 @@ final class Response // Gesetzt fuer Antworten, deren Body kein JSON ist (Issue #70: die hosts.conf, die // check_fds.sh direkt in eine Datei schreibt) - dann ist $data der fertige Body. private readonly ?string $contentType = null, + // Zusaetzliche Header ueber Content-Type hinaus (Issue #85: Content-Disposition beim + // Herunterladen einer Sprachaufnahme). + private readonly array $extraHeaders = [], ) { } @@ -44,6 +47,17 @@ final class Response return new self($status, $body, null, 'text/plain; charset=utf-8'); } + /** + * Rohe Bytes mit beliebigem Content-Type (Issue #85: GET /api/v1/recordings/{id}/audio + * liefert die aufgenommene Audiodatei unveraendert, keine JSON-Huelle). + * + * @param array $headers - zusaetzliche Header (z.B. Content-Disposition). + */ + public static function binary(string $body, string $contentType, int $status = 200, array $headers = []): self + { + return new self($status, $body, null, $contentType, $headers); + } + /** * Antwort auf einen CORS-Preflight (OPTIONS): 204 ohne Body, die eigentliche Auskunft * stehen in den Access-Control-Headern aus send(). @@ -117,6 +131,9 @@ final class Response if ($this->contentType !== null) { header('Content-Type: ' . $this->contentType); + foreach ($this->extraHeaders as $name => $value) { + header($name . ': ' . $value); + } echo (string) $this->data; return; diff --git a/api/src/Realtime/ChangeFeed.php b/api/src/Realtime/ChangeFeed.php index 2dcfcf2..b8294ed 100644 --- a/api/src/Realtime/ChangeFeed.php +++ b/api/src/Realtime/ChangeFeed.php @@ -10,6 +10,7 @@ use Monitor\Api\Repository\EventRepository; use Monitor\Api\Repository\KpiRepository; use Monitor\Api\Repository\SystemRepository; use Monitor\Api\Repository\TicketRepository; +use Monitor\Api\Repository\VoiceRecordingRepository; /** * Erkennt, ob sich seit dem letzten Blick etwas an Tickets/Events/Systemen/KPIs/Agenten/ @@ -28,6 +29,7 @@ final class ChangeFeed private readonly KpiRepository $kpis, private readonly AgentRepository $agents, private readonly HookCounter $hooks, + private readonly VoiceRecordingRepository $recordings, ) { } @@ -41,6 +43,10 @@ final class ChangeFeed 'kpis' => $this->kpis->fingerprint(), 'agents' => $this->agents->fingerprint(), 'hooks' => $this->hooks->fingerprint(), + // Issue #85: neue/abgeholte Sprachaufnahmen aendern zwar keinen Agenten-Datensatz + // selbst, sollen aber denselben SSE-Push ausloesen wie andere Ressourcen - der + // Schreibtisch-Zaehler (agent.pending_recordings) haengt schliesslich daran. + 'recordings' => $this->recordings->fingerprint(), ]; } diff --git a/api/src/Repository/VoiceRecordingRepository.php b/api/src/Repository/VoiceRecordingRepository.php new file mode 100644 index 0000000..1053ebc --- /dev/null +++ b/api/src/Repository/VoiceRecordingRepository.php @@ -0,0 +1,207 @@ + Dateiendung. record.html liefert nur, was + * MediaRecorder im jeweiligen Browser tatsaechlich erzeugt (webm/Opus in Chrome/Firefox, + * mp4/AAC in Safari) - alles andere lehnt store() ab, statt beliebige Endungen aus einem + * clientseitig gesetzten Content-Type uebernehmen. + * + * @var array + */ + private const ALLOWED_MIME_TYPES = [ + 'audio/webm' => 'webm', + 'audio/ogg' => 'ogg', + 'audio/mp4' => 'm4a', + 'audio/mpeg' => 'mp3', + 'audio/wav' => 'wav', + 'audio/x-wav' => 'wav', + ]; + + public function __construct( + private readonly Database $db, + private readonly string $storageDir, + private readonly int $maxUploadBytes = 25 * 1024 * 1024, + ) { + } + + /** + * @return array das neu angelegte Aufnahme-Objekt, ohne storage_name (siehe + * toPublic()). + */ + public function store(int $agentId, string $contents, string $rawMimeType): array + { + $sizeBytes = strlen($contents); + if ($sizeBytes === 0) { + throw new InvalidArgumentException('Leere Datei.'); + } + if ($sizeBytes > $this->maxUploadBytes) { + throw new InvalidArgumentException('Datei zu gross (Limit ' . $this->maxUploadBytes . ' Bytes).'); + } + + $mimeType = strtolower(trim(explode(';', $rawMimeType)[0])); + $extension = self::ALLOWED_MIME_TYPES[$mimeType] ?? null; + if ($extension === null) { + throw new InvalidArgumentException('Nicht unterstuetzter Audio-Typ: ' . $mimeType); + } + + if (!is_dir($this->storageDir) && !mkdir($this->storageDir, 0775, true) && !is_dir($this->storageDir)) { + throw new InvalidArgumentException('Ablageverzeichnis konnte nicht angelegt werden.'); + } + + // Zufaelliger Dateiname statt eines vom Client gewaehlten - verhindert Kollisionen + // und Pfad-Traversal ueber einen manipulierten Dateinamen im Upload. + $storageName = bin2hex(random_bytes(16)) . '.' . $extension; + if (file_put_contents($this->storageDir . '/' . $storageName, $contents) === false) { + throw new InvalidArgumentException('Datei konnte nicht gespeichert werden.'); + } + + $now = $this->db->now(); + $stmt = $this->db->pdo()->prepare( + 'INSERT INTO voice_recordings (agent_id, storage_name, mime_type, size_bytes, status, created_at) + VALUES (:agent_id, :storage_name, :mime_type, :size_bytes, :status, :created_at)' + ); + $stmt->execute([ + 'agent_id' => $agentId, + 'storage_name' => $storageName, + 'mime_type' => $mimeType, + 'size_bytes' => $sizeBytes, + 'status' => 'new', + 'created_at' => $now, + ]); + + return $this->toPublic($this->findRawOrFail((int) $this->db->pdo()->lastInsertId())); + } + + /** @return array> */ + public function listForAgent(int $agentId, ?string $status = null): array + { + if ($status !== null && !in_array($status, ['new', 'done'], true)) { + throw new InvalidArgumentException('Unbekannter Status: ' . $status); + } + + $sql = 'SELECT id, agent_id, mime_type, size_bytes, status, created_at, processed_at + FROM voice_recordings WHERE agent_id = :agent_id'; + $params = ['agent_id' => $agentId]; + if ($status !== null) { + $sql .= ' AND status = :status'; + $params['status'] = $status; + } + $sql .= ' ORDER BY created_at ASC'; + + $stmt = $this->db->pdo()->prepare($sql); + $stmt->execute($params); + + return $stmt->fetchAll(); + } + + /** Fuer Controller-Ownership-Checks (download/markProcessed) - enthaelt bewusst storage_name. */ + public function findById(int $id): ?array + { + $stmt = $this->db->pdo()->prepare( + 'SELECT id, agent_id, storage_name, mime_type, size_bytes, status, created_at, processed_at + FROM voice_recordings WHERE id = :id' + ); + $stmt->execute(['id' => $id]); + $row = $stmt->fetch(); + + return $row === false ? null : $row; + } + + public function readAudio(int $id): ?string + { + $recording = $this->findById($id); + if ($recording === null) { + return null; + } + + $path = $this->storageDir . '/' . $recording['storage_name']; + if (!is_file($path)) { + return null; + } + + $contents = file_get_contents($path); + + return $contents === false ? null : $contents; + } + + /** @return array */ + public function markDone(int $id): array + { + $stmt = $this->db->pdo()->prepare( + 'UPDATE voice_recordings SET status = :status, processed_at = :now WHERE id = :id' + ); + $stmt->execute(['status' => 'done', 'now' => $this->db->now(), 'id' => $id]); + + return $this->toPublic($this->findRawOrFail($id)); + } + + /** + * Anzahl offener (status='new') Aufnahmen je Agent - Grundlage fuer den Zaehler auf dem + * Schreibtisch (GET /api/v1/agents, siehe AgentController::index()). + * + * @return array Agent-Id => Anzahl. + */ + public function pendingCountsByAgent(): array + { + $stmt = $this->db->pdo()->query( + "SELECT agent_id, COUNT(*) AS count FROM voice_recordings WHERE status = 'new' GROUP BY agent_id" + ); + + $counts = []; + foreach ($stmt->fetchAll() as $row) { + $counts[(int) $row['agent_id']] = (int) $row['count']; + } + + return $counts; + } + + /** Fingerabdruck fuer den SSE-Endpoint (Realtime\ChangeFeed, F13). */ + public function fingerprint(): string + { + $row = $this->db->pdo()->query( + 'SELECT COUNT(*) AS c, MAX(COALESCE(processed_at, created_at)) AS m FROM voice_recordings' + )->fetch(); + + return $row['c'] . ':' . ($row['m'] ?? ''); + } + + /** @return array */ + private function findRawOrFail(int $id): array + { + $recording = $this->findById($id); + if ($recording === null) { + throw new InvalidArgumentException('Aufnahme nicht gefunden: ' . $id); + } + + return $recording; + } + + /** + * Blendet storage_name aus, bevor eine Aufnahme als JSON zurueckgeht - der Dateiname auf + * der Platte ist ein internes Implementierungsdetail, kein oeffentliches Feld. + * + * @param array $row @return array + */ + private function toPublic(array $row): array + { + unset($row['storage_name']); + + return $row; + } +} diff --git a/api/tests/Unit/ApplicationTest.php b/api/tests/Unit/ApplicationTest.php index fd44337..fd2a88b 100644 --- a/api/tests/Unit/ApplicationTest.php +++ b/api/tests/Unit/ApplicationTest.php @@ -249,6 +249,99 @@ final class ApplicationTest extends TestCase rmdir(dirname($hooksPath)); } + /** + * Issue #85: voller Ablauf einer Sprachaufnahme - Upload aus record.html (unauthentifiziert, + * Agenten-Id per URL), Abholen/Herunterladen/Markieren durch den Agenten selbst (hinter + * seinem eigenen X-Api-Key). + */ + public function testVoiceRecordingFullFlow(): void + { + $storageDir = sys_get_temp_dir() . '/monitor-app-recordings-' . uniqid(); + $app = new Application(Database::inMemory(), new Config([ + 'MONITOR_STATUS_FILE' => '/does/not/exist', + 'MONITOR_ADMIN_API_KEY' => 'admin-secret', + 'MONITOR_RECORDINGS_PATH' => $storageDir, + ])); + + $registerResponse = $app->handle(Request::create( + 'POST', + '/api/v1/agents', + body: ['display_name' => 'Alice', 'role' => 'entwickler'], + headers: ['X-Admin-Key' => 'admin-secret'] + )); + $apiKey = $registerResponse->body()['api_key']; + $agentId = $registerResponse->body()['agent']['id']; + + $tmpUploadFile = sys_get_temp_dir() . '/monitor-app-upload-' . uniqid(); + file_put_contents($tmpUploadFile, 'echte audiodaten'); + + $uploadResponse = $app->handle(Request::create( + 'POST', + '/api/v1/agents/' . $agentId . '/recordings', + files: ['audio' => [ + 'name' => 'aufnahme.webm', + 'type' => 'audio/webm', + 'tmp_name' => $tmpUploadFile, + 'error' => UPLOAD_ERR_OK, + 'size' => filesize($tmpUploadFile), + ]] + )); + self::assertSame(201, $uploadResponse->status); + $recordingId = $uploadResponse->body()['recording']['id']; + + $agentsResponse = $app->handle(Request::create('GET', '/api/v1/agents')); + self::assertSame(1, $agentsResponse->body()['agents'][0]['pending_recordings']); + + $listResponse = $app->handle(Request::create( + 'GET', + '/api/v1/agents/' . $agentId . '/recordings', + query: ['status' => 'new'], + headers: ['X-Api-Key' => $apiKey] + )); + self::assertSame(200, $listResponse->status); + self::assertCount(1, $listResponse->body()['recordings']); + + $downloadResponse = $app->handle(Request::create( + 'GET', + '/api/v1/recordings/' . $recordingId . '/audio', + headers: ['X-Api-Key' => $apiKey] + )); + self::assertSame(200, $downloadResponse->status); + self::assertSame('echte audiodaten', $downloadResponse->body()); + + $markResponse = $app->handle(Request::create( + 'PATCH', + '/api/v1/recordings/' . $recordingId, + body: ['status' => 'done'], + headers: ['X-Api-Key' => $apiKey] + )); + self::assertSame(200, $markResponse->status); + self::assertSame('done', $markResponse->body()['recording']['status']); + + $agentsAfterResponse = $app->handle(Request::create('GET', '/api/v1/agents')); + self::assertSame(0, $agentsAfterResponse->body()['agents'][0]['pending_recordings']); + + unlink($tmpUploadFile); + foreach (glob($storageDir . '/*') ?: [] as $file) { + unlink($file); + } + rmdir($storageDir); + } + + public function testVoiceRecordingUploadForUnknownAgentReturns404(): void + { + $response = $this->app->handle(Request::create('POST', '/api/v1/agents/999/recordings')); + + self::assertSame(404, $response->status); + } + + public function testVoiceRecordingListRequiresAgentKey(): void + { + $response = $this->app->handle(Request::create('GET', '/api/v1/agents/1/recordings')); + + self::assertSame(401, $response->status); + } + /** * Hooks-KPI (Issue #38): oeffentlich wie die anderen GET-Endpunkte, zaehlt akzeptierte * Webhook-Zustellungen unabhaengig vom Event-Typ. diff --git a/api/tests/Unit/Controller/AgentControllerTest.php b/api/tests/Unit/Controller/AgentControllerTest.php index 60ca743..0a8d713 100644 --- a/api/tests/Unit/Controller/AgentControllerTest.php +++ b/api/tests/Unit/Controller/AgentControllerTest.php @@ -9,6 +9,7 @@ use Monitor\Api\Db\Database; use Monitor\Api\Http\Request; use Monitor\Api\Repository\AgentRepository; use Monitor\Api\Repository\TicketRepository; +use Monitor\Api\Repository\VoiceRecordingRepository; use PHPUnit\Framework\TestCase; final class AgentControllerTest extends TestCase @@ -16,13 +17,27 @@ final class AgentControllerTest extends TestCase private AgentController $controller; private AgentRepository $repository; private TicketRepository $tickets; + private VoiceRecordingRepository $recordings; + private string $recordingsStorageDir; protected function setUp(): void { $db = Database::inMemory(); $this->repository = new AgentRepository($db); $this->tickets = new TicketRepository($db); - $this->controller = new AgentController($this->repository); + $this->recordingsStorageDir = sys_get_temp_dir() . '/monitor-agent-controller-recordings-' . uniqid(); + $this->recordings = new VoiceRecordingRepository($db, $this->recordingsStorageDir); + $this->controller = new AgentController($this->repository, $this->recordings); + } + + protected function tearDown(): void + { + if (is_dir($this->recordingsStorageDir)) { + foreach (glob($this->recordingsStorageDir . '/*') ?: [] as $file) { + unlink($file); + } + rmdir($this->recordingsStorageDir); + } } public function testRegisterRejectsMissingDisplayName(): void @@ -103,4 +118,30 @@ final class AgentControllerTest extends TestCase self::assertCount(1, $response->body()['agents']); } + + /** + * Issue #85: der Zaehler auf dem Schreibtisch haengt an diesem Feld - GET /api/v1/agents + * reichert jeden Agenten um die Anzahl offener Sprachaufnahmen an. + */ + public function testIndexIncludesPendingRecordingsCount(): void + { + $registration = $this->repository->register('Alice', 'entwickler'); + $agentId = (int) $registration['agent']['id']; + $done = $this->recordings->store($agentId, 'a', 'audio/webm'); + $this->recordings->store($agentId, 'b', 'audio/webm'); + $this->recordings->markDone((int) $done['id']); + + $response = $this->controller->index(Request::create('GET', '/api/v1/agents')); + + self::assertSame(1, $response->body()['agents'][0]['pending_recordings']); + } + + public function testIndexDefaultsPendingRecordingsToZero(): void + { + $this->repository->register('Alice', 'entwickler'); + + $response = $this->controller->index(Request::create('GET', '/api/v1/agents')); + + self::assertSame(0, $response->body()['agents'][0]['pending_recordings']); + } } diff --git a/api/tests/Unit/Controller/StreamControllerTest.php b/api/tests/Unit/Controller/StreamControllerTest.php index 9db0c73..a5d1d91 100644 --- a/api/tests/Unit/Controller/StreamControllerTest.php +++ b/api/tests/Unit/Controller/StreamControllerTest.php @@ -14,6 +14,7 @@ use Monitor\Api\Repository\EventRepository; use Monitor\Api\Repository\KpiRepository; use Monitor\Api\Repository\SystemRepository; use Monitor\Api\Repository\TicketRepository; +use Monitor\Api\Repository\VoiceRecordingRepository; use PHPUnit\Framework\TestCase; final class StreamControllerTest extends TestCase @@ -21,19 +22,22 @@ final class StreamControllerTest extends TestCase private TicketRepository $tickets; private ChangeFeed $changeFeed; private string $hooksPath; + private string $recordingsStorageDir; protected function setUp(): void { $db = Database::inMemory(); $this->tickets = new TicketRepository($db); $this->hooksPath = sys_get_temp_dir() . '/monitor-stream-hooks-' . uniqid() . '/hooks-count'; + $this->recordingsStorageDir = sys_get_temp_dir() . '/monitor-stream-recordings-' . uniqid(); $this->changeFeed = new ChangeFeed( $this->tickets, new EventRepository($db), new SystemRepository($db), new KpiRepository($db), new AgentRepository($db), - new HookCounter($this->hooksPath) + new HookCounter($this->hooksPath), + new VoiceRecordingRepository($db, $this->recordingsStorageDir) ); } @@ -45,6 +49,12 @@ final class StreamControllerTest extends TestCase if (is_dir(dirname($this->hooksPath))) { rmdir(dirname($this->hooksPath)); } + if (is_dir($this->recordingsStorageDir)) { + foreach (glob($this->recordingsStorageDir . '/*') ?: [] as $file) { + unlink($file); + } + rmdir($this->recordingsStorageDir); + } } public function testStreamSendsHeartbeatsWhenNothingChanges(): void diff --git a/api/tests/Unit/Controller/VoiceRecordingControllerTest.php b/api/tests/Unit/Controller/VoiceRecordingControllerTest.php new file mode 100644 index 0000000..89a5513 --- /dev/null +++ b/api/tests/Unit/Controller/VoiceRecordingControllerTest.php @@ -0,0 +1,223 @@ +agents = new AgentRepository($db); + $this->storageDir = sys_get_temp_dir() . '/monitor-recordings-controller-' . uniqid(); + $this->recordings = new VoiceRecordingRepository($db, $this->storageDir); + $this->controller = new VoiceRecordingController($this->recordings, $this->agents); + + $this->tmpUploadFile = sys_get_temp_dir() . '/monitor-upload-' . uniqid(); + file_put_contents($this->tmpUploadFile, 'echte audiodaten'); + } + + protected function tearDown(): void + { + if (file_exists($this->tmpUploadFile)) { + unlink($this->tmpUploadFile); + } + if (is_dir($this->storageDir)) { + foreach (glob($this->storageDir . '/*') ?: [] as $file) { + unlink($file); + } + rmdir($this->storageDir); + } + } + + /** @return array ein $_FILES-artiges Feld fuer Request::create(). */ + private function uploadedAudioField(int $error = UPLOAD_ERR_OK): array + { + return [ + 'name' => 'aufnahme.webm', + 'type' => 'audio/webm', + 'tmp_name' => $this->tmpUploadFile, + 'error' => $error, + 'size' => filesize($this->tmpUploadFile), + ]; + } + + public function testUploadRejectsUnknownAgent(): void + { + $response = $this->controller->upload( + Request::create('POST', '/x', files: ['audio' => $this->uploadedAudioField()]), + ['id' => '999'] + ); + + self::assertSame(404, $response->status); + } + + public function testUploadRejectsMissingFile(): void + { + $agentId = (int) $this->agents->register('Alice', 'entwickler')['agent']['id']; + + $response = $this->controller->upload(Request::create('POST', '/x'), ['id' => (string) $agentId]); + + self::assertSame(422, $response->status); + } + + public function testUploadRejectsTooLargeUploadError(): void + { + $agentId = (int) $this->agents->register('Alice', 'entwickler')['agent']['id']; + + $response = $this->controller->upload( + Request::create('POST', '/x', files: ['audio' => $this->uploadedAudioField(UPLOAD_ERR_INI_SIZE)]), + ['id' => (string) $agentId] + ); + + self::assertSame(413, $response->status); + } + + public function testUploadStoresRecordingForKnownAgent(): void + { + $agentId = (int) $this->agents->register('Alice', 'entwickler')['agent']['id']; + + $response = $this->controller->upload( + Request::create('POST', '/x', files: ['audio' => $this->uploadedAudioField()]), + ['id' => (string) $agentId] + ); + + self::assertSame(201, $response->status); + self::assertSame($agentId, $response->body()['recording']['agent_id']); + self::assertSame('new', $response->body()['recording']['status']); + } + + public function testIndexRejectsMismatchedAgentId(): void + { + $registration = $this->agents->register('Alice', 'entwickler'); + $other = $this->agents->register('Bob', 'qa'); + + $response = $this->controller->index( + Request::create('GET', '/x'), + ['id' => (string) $other['agent']['id']], + $registration['agent'] + ); + + self::assertSame(403, $response->status); + } + + public function testIndexListsOwnRecordings(): void + { + $registration = $this->agents->register('Alice', 'entwickler'); + $agentId = (int) $registration['agent']['id']; + $this->recordings->store($agentId, 'a', 'audio/webm'); + + $response = $this->controller->index( + Request::create('GET', '/x'), + ['id' => (string) $agentId], + $registration['agent'] + ); + + self::assertSame(200, $response->status); + self::assertCount(1, $response->body()['recordings']); + } + + public function testDownloadRejectsForeignAgent(): void + { + $registration = $this->agents->register('Alice', 'entwickler'); + $other = $this->agents->register('Bob', 'qa'); + $recording = $this->recordings->store((int) $other['agent']['id'], 'a', 'audio/webm'); + + $response = $this->controller->download( + Request::create('GET', '/x'), + ['id' => (string) $recording['id']], + $registration['agent'] + ); + + self::assertSame(403, $response->status); + } + + public function testDownloadReturnsAudioBytesForOwnRecording(): void + { + $registration = $this->agents->register('Alice', 'entwickler'); + $agentId = (int) $registration['agent']['id']; + $recording = $this->recordings->store($agentId, 'echte audiodaten', 'audio/webm'); + + $response = $this->controller->download( + Request::create('GET', '/x'), + ['id' => (string) $recording['id']], + $registration['agent'] + ); + + self::assertSame(200, $response->status); + self::assertSame('echte audiodaten', $response->body()); + } + + public function testDownloadReturns404ForUnknownRecording(): void + { + $registration = $this->agents->register('Alice', 'entwickler'); + + $response = $this->controller->download( + Request::create('GET', '/x'), + ['id' => '999'], + $registration['agent'] + ); + + self::assertSame(404, $response->status); + } + + public function testMarkProcessedRejectsForeignAgent(): void + { + $registration = $this->agents->register('Alice', 'entwickler'); + $other = $this->agents->register('Bob', 'qa'); + $recording = $this->recordings->store((int) $other['agent']['id'], 'a', 'audio/webm'); + + $response = $this->controller->markProcessed( + Request::create('PATCH', '/x', body: ['status' => 'done']), + ['id' => (string) $recording['id']], + $registration['agent'] + ); + + self::assertSame(403, $response->status); + } + + public function testMarkProcessedRejectsUnsupportedStatus(): void + { + $registration = $this->agents->register('Alice', 'entwickler'); + $agentId = (int) $registration['agent']['id']; + $recording = $this->recordings->store($agentId, 'a', 'audio/webm'); + + $response = $this->controller->markProcessed( + Request::create('PATCH', '/x', body: ['status' => 'archived']), + ['id' => (string) $recording['id']], + $registration['agent'] + ); + + self::assertSame(422, $response->status); + } + + public function testMarkProcessedMarksOwnRecordingDone(): void + { + $registration = $this->agents->register('Alice', 'entwickler'); + $agentId = (int) $registration['agent']['id']; + $recording = $this->recordings->store($agentId, 'a', 'audio/webm'); + + $response = $this->controller->markProcessed( + Request::create('PATCH', '/x', body: ['status' => 'done']), + ['id' => (string) $recording['id']], + $registration['agent'] + ); + + self::assertSame(200, $response->status); + self::assertSame('done', $response->body()['recording']['status']); + } +} diff --git a/api/tests/Unit/Realtime/ChangeFeedTest.php b/api/tests/Unit/Realtime/ChangeFeedTest.php index 70dcce2..2f25a5a 100644 --- a/api/tests/Unit/Realtime/ChangeFeedTest.php +++ b/api/tests/Unit/Realtime/ChangeFeedTest.php @@ -12,6 +12,7 @@ use Monitor\Api\Repository\EventRepository; use Monitor\Api\Repository\KpiRepository; use Monitor\Api\Repository\SystemRepository; use Monitor\Api\Repository\TicketRepository; +use Monitor\Api\Repository\VoiceRecordingRepository; use PHPUnit\Framework\TestCase; final class ChangeFeedTest extends TestCase @@ -21,6 +22,8 @@ final class ChangeFeedTest extends TestCase private EventRepository $events; private HookCounter $hooks; private string $hooksPath; + private VoiceRecordingRepository $recordings; + private string $recordingsStorageDir; protected function setUp(): void { @@ -32,8 +35,10 @@ final class ChangeFeedTest extends TestCase $agents = new AgentRepository($db); $this->hooksPath = sys_get_temp_dir() . '/monitor-changefeed-hooks-' . uniqid() . '/hooks-count'; $this->hooks = new HookCounter($this->hooksPath); + $this->recordingsStorageDir = sys_get_temp_dir() . '/monitor-changefeed-recordings-' . uniqid(); + $this->recordings = new VoiceRecordingRepository($db, $this->recordingsStorageDir); - $this->changeFeed = new ChangeFeed($this->tickets, $this->events, $systems, $kpis, $agents, $this->hooks); + $this->changeFeed = new ChangeFeed($this->tickets, $this->events, $systems, $kpis, $agents, $this->hooks, $this->recordings); } protected function tearDown(): void @@ -44,13 +49,22 @@ final class ChangeFeedTest extends TestCase if (is_dir(dirname($this->hooksPath))) { rmdir(dirname($this->hooksPath)); } + if (is_dir($this->recordingsStorageDir)) { + foreach (glob($this->recordingsStorageDir . '/*') ?: [] as $file) { + unlink($file); + } + rmdir($this->recordingsStorageDir); + } } - public function testCurrentFingerprintCoversAllSixResources(): void + public function testCurrentFingerprintCoversAllSevenResources(): void { $fingerprint = $this->changeFeed->currentFingerprint(); - self::assertSame(['tickets', 'events', 'systems', 'kpis', 'agents', 'hooks'], array_keys($fingerprint)); + self::assertSame( + ['tickets', 'events', 'systems', 'kpis', 'agents', 'hooks', 'recordings'], + array_keys($fingerprint) + ); } public function testDiffIsEmptyWithoutChanges(): void diff --git a/api/tests/Unit/Repository/VoiceRecordingRepositoryTest.php b/api/tests/Unit/Repository/VoiceRecordingRepositoryTest.php new file mode 100644 index 0000000..ff6314e --- /dev/null +++ b/api/tests/Unit/Repository/VoiceRecordingRepositoryTest.php @@ -0,0 +1,127 @@ +agentId = (int) $agents->register('Alice', 'entwickler')['agent']['id']; + + $this->storageDir = sys_get_temp_dir() . '/monitor-recordings-' . uniqid(); + $this->recordings = new VoiceRecordingRepository($db, $this->storageDir, 1024); + } + + protected function tearDown(): void + { + if (is_dir($this->storageDir)) { + foreach (glob($this->storageDir . '/*') ?: [] as $file) { + unlink($file); + } + rmdir($this->storageDir); + } + } + + public function testStoreRejectsEmptyFile(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->recordings->store($this->agentId, '', 'audio/webm'); + } + + public function testStoreRejectsFilesAboveTheLimit(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->recordings->store($this->agentId, str_repeat('x', 2000), 'audio/webm'); + } + + public function testStoreRejectsUnsupportedMimeTypes(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->recordings->store($this->agentId, 'daten', 'application/octet-stream'); + } + + public function testStoreAcceptsMimeTypeWithCodecsParameter(): void + { + $recording = $this->recordings->store($this->agentId, 'daten', 'audio/webm;codecs=opus'); + + self::assertSame('audio/webm', $recording['mime_type']); + } + + public function testStoreWritesFileAndDbRowAndHidesStorageName(): void + { + $recording = $this->recordings->store($this->agentId, 'echte audiodaten', 'audio/webm'); + + self::assertArrayNotHasKey('storage_name', $recording); + self::assertSame($this->agentId, $recording['agent_id']); + self::assertSame('new', $recording['status']); + self::assertSame(strlen('echte audiodaten'), $recording['size_bytes']); + self::assertSame('echte audiodaten', $this->recordings->readAudio((int) $recording['id'])); + } + + public function testListForAgentFiltersByStatus(): void + { + $recording = $this->recordings->store($this->agentId, 'a', 'audio/ogg'); + $this->recordings->store($this->agentId, 'b', 'audio/ogg'); + $this->recordings->markDone((int) $recording['id']); + + self::assertCount(1, $this->recordings->listForAgent($this->agentId, 'new')); + self::assertCount(1, $this->recordings->listForAgent($this->agentId, 'done')); + self::assertCount(2, $this->recordings->listForAgent($this->agentId)); + } + + public function testListForAgentRejectsUnknownStatus(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->recordings->listForAgent($this->agentId, 'archived'); + } + + public function testMarkDoneSetsProcessedAt(): void + { + $recording = $this->recordings->store($this->agentId, 'a', 'audio/wav'); + + $updated = $this->recordings->markDone((int) $recording['id']); + + self::assertSame('done', $updated['status']); + self::assertNotNull($updated['processed_at']); + } + + public function testPendingCountsByAgentCountsOnlyNewRecordings(): void + { + $first = $this->recordings->store($this->agentId, 'a', 'audio/mpeg'); + $this->recordings->store($this->agentId, 'b', 'audio/mpeg'); + $this->recordings->markDone((int) $first['id']); + + self::assertSame([$this->agentId => 1], $this->recordings->pendingCountsByAgent()); + } + + public function testReadAudioReturnsNullForUnknownId(): void + { + self::assertNull($this->recordings->readAudio(999)); + } + + public function testFingerprintChangesWhenARecordingIsAdded(): void + { + $before = $this->recordings->fingerprint(); + $this->recordings->store($this->agentId, 'a', 'audio/webm'); + + self::assertNotSame($before, $this->recordings->fingerprint()); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index a461efa..773b493 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,6 +43,12 @@ services: # Zaehldatei fuer den Hooks-KPI (Issue #38, GET /api/v1/webhooks/stats): wird bei jedem # Start dieses Containers von entrypoint.sh auf 0 zurueckgesetzt. MONITOR_HOOKS_COUNTER_FILE: /app/data/hooks-count + # Sprachaufnahmen (Issue #85): Ablageort der Rohaufnahmen aus record.html und + # Obergrenze je Aufnahme. Die Groessengrenze muss zur Dockerfile-CMD passen + # (upload_max_filesize/post_max_size), sonst weist PHP grosse Uploads schon vor + # VoiceRecordingRepository ab. + MONITOR_RECORDINGS_PATH: /app/data/recordings + MONITOR_MAX_RECORDING_BYTES: ${MONITOR_MAX_RECORDING_BYTES:-26214400} # Haelt Backlog/Done aus Codeberg aktuell (F4, siehe API.md Abschnitt "Codeberg-Sync"): # laeuft dasselbe Image wie api und teilt sich ueber das ./api-Volume dieselbe SQLite-Datei. diff --git a/html/css/office.css b/html/css/office.css index d69a65b..a98c18e 100644 --- a/html/css/office.css +++ b/html/css/office.css @@ -10,6 +10,7 @@ --color-note: #fde68a; --color-note-border: #d9b34f; --color-note-stack-badge: #d97706; + --color-recording-badge: #dc2626; --color-monitor-frame: #2b2f38; --color-monitor-screen: #10131a; --color-monitor-placeholder: #1a2436; @@ -331,6 +332,19 @@ header.office-header h1 { fill: var(--color-desk-chair); } +/* Zaehler offener Sprachaufnahmen (Issue #85), oben links am Schreibtisch. */ +.recording-badge-circle { + fill: var(--color-recording-badge); + stroke: #7c2d12; + stroke-width: 2; +} + +.recording-badge-count { + fill: #ffffff; + font-size: 13px; + font-weight: 700; +} + .person-head { fill: #f3d9b1; stroke: #d8b98a; diff --git a/html/css/record.css b/html/css/record.css new file mode 100644 index 0000000..86d9a12 --- /dev/null +++ b/html/css/record.css @@ -0,0 +1,91 @@ +/* Aufnahme-Webseite (Issue #85): bewusst schlicht und mobil-first, laeuft im Handy-Browser + eines Menschen - kein SVG, keine Szene, nur ein einfaches Formular. */ + +:root { + --color-text: #1f2937; + --color-accent: #2563eb; + --color-danger: #dc2626; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + padding: 24px 16px 48px; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + color: var(--color-text); + background: #eef2f7; +} + +.record-header { + text-align: center; +} + +.record-header h1 { + margin: 0 0 4px; + font-size: 22px; +} + +.record-main { + width: 100%; + max-width: 420px; + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; +} + +.record-status { + min-height: 1.4em; + margin: 0; + text-align: center; + color: #475569; +} + +.record-status.status-error { + color: var(--color-danger); + font-weight: 600; +} + +.record-controls { + display: flex; + gap: 12px; + justify-content: center; + flex-wrap: wrap; +} + +.record-button { + min-width: 160px; + padding: 16px 20px; + font-size: 17px; + font-weight: 600; + border: none; + border-radius: 999px; + cursor: pointer; + color: #ffffff; + background: var(--color-accent); +} + +.record-button:disabled { + opacity: 0.6; + cursor: default; +} + +.record-button-stop { + background: var(--color-danger); +} + +.record-button-discard { + background: #64748b; +} + +.record-preview { + width: 100%; +} diff --git a/html/js/office-api.js b/html/js/office-api.js index ac13c5d..7ad6feb 100644 --- a/html/js/office-api.js +++ b/html/js/office-api.js @@ -53,6 +53,49 @@ export async function fetchAgents() { return agents; } +/** + * Issue #85: record.html zeigt den Namen des Agenten, fuer den gerade aufgenommen wird - holt + * dafuer einfach die volle (kleine) Agentenliste und filtert lokal, ein eigener + * GET-/agents/{id}-Endpunkt existiert nicht. + */ +export async function fetchAgent(agentId) { + const agents = await fetchAgents(); + return agents.find((agent) => String(agent.id) === String(agentId)) ?? null; +} + +/** + * Verlinkt von der Buero-Szene aus (Klick auf die Personen-Figur eines registrierten Agenten, + * Issue #85) auf die Aufnahme-Webseite. record.html liegt neben office.html im selben + * Verzeichnis, daher ein rein relativer Pfad statt ueber apiBase() (record.html spricht die + * API zwar auch an, ist selbst aber keine API-Route). + */ +export function recordingUrl(agentId) { + return `record.html?agent=${encodeURIComponent(agentId)}`; +} + +/** + * Laedt eine im Browser aufgenommene Sprachnotiz hoch (Issue #85). Unauthentifiziert wie der + * Endpunkt selbst - record.html laeuft im Handy-Browser eines Menschen, nicht eines + * registrierten Agenten, ohne Api-/Admin-Key. FormData statt JSON, weil die Audiodatei ein + * Blob ist; der Browser setzt den multipart-Content-Type inkl. Boundary selbst, ein manuell + * gesetzter Header wuerde die Boundary kaputt machen. + */ +export async function uploadRecording(agentId, blob, filename) { + const formData = new FormData(); + formData.append("audio", blob, filename); + + const response = await fetch(`${apiBase()}/agents/${encodeURIComponent(agentId)}/recordings`, { + method: "POST", + body: formData, + }); + + if (!response.ok) { + const payload = await response.json().catch(() => null); + throw new Error(payload?.error || `Upload fehlgeschlagen: HTTP ${response.status}`); + } + return response.json(); +} + /** * Hooks-KPI (Issue #38): Anzahl der seit dem letzten Start des api-Containers empfangenen * Webhooks, siehe API.md "GET /api/v1/webhooks/stats". Anders als die anderen fetch*()- diff --git a/html/js/office-render.js b/html/js/office-render.js index 02750e7..b00cc44 100644 --- a/html/js/office-render.js +++ b/html/js/office-render.js @@ -79,6 +79,7 @@ import { updateHostConfig, deleteHostConfig, deleteSystem, + recordingUrl, getStoredAdminKey, setStoredAdminKey, } from "./office-api.js"; @@ -230,6 +231,18 @@ function renderStackBadge(x, y, count, stackColors = []) { * @param {() => void} onReloadClick - Issue #51: Klick auf das Reload-Symbol neben der * Ueberschrift stoesst ein komplettes Neueinlesen des Boards an. */ +// Issue #85: kleiner Zaehler-Kreis fuer offene Sprachaufnahmen auf dem Schreibtisch eines +// Agenten - gleiches Prinzip wie renderStackBadge() (Kreis + zentrierte Zahl), aber +// eigenstaendig und kleiner, weil er neben dem rollenabhaengigen Deko-Symbol Platz haben muss. +function renderRecordingBadge(cx, cy, count) { + const g = el("g", { class: "recording-badge" }); + g.appendChild(el("circle", { cx, cy, r: 12, class: "recording-badge-circle" })); + g.appendChild( + text(String(count), { x: cx, y: cy + 4, class: "recording-badge-count", "text-anchor": "middle" }) + ); + return g; +} + function renderBoard(ticketCounts, groupedTickets, onColumnClick, onReloadClick) { const columns = buildBoardColumnLayout(ticketCounts, groupedTickets); const g = el("g", { class: "board" }); @@ -650,6 +663,13 @@ function renderDeskAt(origin, agent, width = DESK.w) { ); g.appendChild(el("circle", { cx: width / 2, cy: 40 + DESK.h + 30, r: 22, class: "desk-chair" })); + // Issue #85: Zaehler offener Sprachaufnahmen am Schreibtisch dieses Agenten - oben links, + // damit er nicht mit der rollenabhaengigen Deko (star/flower/check/gear) oben rechts + // kollidiert. Nur sichtbar, wenn tatsaechlich etwas in der Warteschlange liegt. + if (agent && agent.pending_recordings > 0) { + g.appendChild(renderRecordingBadge(14, 4, agent.pending_recordings)); + } + if (role) { if (role.deco === "star") { g.appendChild( @@ -934,11 +954,17 @@ function updateAgentsLayer(layer, placements) { const id = String(agent.id ?? `guest-${index}`); seen.add(id); + // Echte registrierte Agenten haben eine numerische Id von der API; Demo-Agenten und die + // guest-N-Fallback-Id kennen keine Aufnahme-Warteschlange, sind also nicht klickbar. + const isRealAgent = typeof agent.id !== "undefined" && id !== PO_AGENT_ID; + let node = agentAvatarElements.get(id); if (!node) { node = createAgentAvatarElement(); if (id === PO_AGENT_ID) { makePoAvatarClickable(node); + } else if (isRealAgent) { + makeAgentAvatarClickable(node, agent.id); } agentAvatarElements.set(id, node); layer.appendChild(node); @@ -947,9 +973,9 @@ function updateAgentsLayer(layer, placements) { const feet = feetForLocation(agent.location, homeFeet); const showName = agent.location !== AGENT_LOCATIONS.DESK; updateAgentAvatarElement(node, agent, feet, showName); - if (id === PO_AGENT_ID) { + if (id === PO_AGENT_ID || isRealAgent) { // updateAgentAvatarElement() setzt class komplett neu (Pose) - ohne diese Zeile - // verloere der PO die Klickbarkeit beim ersten Zustandswechsel. + // verloere die Figur ihre Klickbarkeit beim ersten Zustandswechsel. node.classList.add("clickable"); } }); @@ -974,6 +1000,20 @@ function makePoAvatarClickable(node) { node.addEventListener("click", openRepoConfigModal); } +// Issue #85: ein Klick auf die Personen-Figur eines registrierten Agenten (nicht auf den +// synthetischen PO-Platz, siehe makePoAvatarClickable) oeffnet die Aufnahme-Webseite fuer +// diesen Agenten in einem neuen Tab - die laufende Buero-Szene bleibt dabei unangetastet +// stehen, genau wie ein Modal es waere, nur dass record.html eine eigene Seite ist (braucht +// Mikrofon-Zugriff und ist fuers Handy gedacht). +function makeAgentAvatarClickable(node, agentId) { + const title = document.createElementNS(SVG_NS, "title"); + title.textContent = "Sprachaufnahme fuer diesen Agenten aufnehmen"; + node.appendChild(title); + node.addEventListener("click", () => { + window.open(recordingUrl(agentId), "_blank", "noopener"); + }); +} + function clearIdleCosmeticTimer(agentId) { const handle = idleCosmeticTimers.get(agentId); if (handle) { diff --git a/html/js/record.js b/html/js/record.js new file mode 100644 index 0000000..e542b1e --- /dev/null +++ b/html/js/record.js @@ -0,0 +1,151 @@ +// Aufnahme-Webseite fuer Sprachnotizen an einen Agenten (Issue #85, Folge-Ticket aus #84). +// Laeuft im Handy-Browser eines Menschen, getrennt von der Buero-Visualisierung +// (office.html/office-render.js) - reines Vanilla-JS/DOM ohne Bundler, wie der Rest des +// Frontends. navigator.mediaDevices.getUserMedia()/MediaRecorder verlangen HTTPS (Ausnahme: +// localhost) - ohne das bricht init() kontrolliert mit einer Fehlermeldung ab, statt dass der +// Start-Knopf stumm nichts tut. + +import { fetchAgent, uploadRecording } from "./office-api.js"; + +const agentNameEl = document.getElementById("agent-name"); +const statusEl = document.getElementById("status"); +const startButton = document.getElementById("start-button"); +const stopButton = document.getElementById("stop-button"); +const uploadButton = document.getElementById("upload-button"); +const discardButton = document.getElementById("discard-button"); +const audioPreview = document.getElementById("audio-preview"); + +const agentId = new URLSearchParams(window.location.search).get("agent"); + +// MediaRecorder erzeugt je nach Browser unterschiedliche Container/Codecs - der erste vom +// Browser unterstuetzte Typ gewinnt. webm/Opus zuerst (Chrome/Firefox/Desktop), mp4/AAC als +// Fallback fuer Safari/iOS (kennt kein webm). Muss zur Whitelist in +// VoiceRecordingRepository::ALLOWED_MIME_TYPES passen. +const CANDIDATE_MIME_TYPES = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"]; + +let mediaRecorder = null; +let mediaStream = null; +let recordedChunks = []; +let recordedBlob = null; + +function setStatus(message, isError = false) { + statusEl.textContent = message; + statusEl.classList.toggle("status-error", isError); +} + +function pickMimeType() { + if (typeof MediaRecorder === "undefined" || typeof MediaRecorder.isTypeSupported !== "function") { + return ""; + } + return CANDIDATE_MIME_TYPES.find((type) => MediaRecorder.isTypeSupported(type)) || ""; +} + +async function init() { + if (!agentId) { + setStatus("Kein Agent angegeben - der Link muss ?agent= enthalten.", true); + startButton.disabled = true; + return; + } + + try { + const agent = await fetchAgent(agentId); + if (!agent) { + setStatus(`Unbekannter Agent (Id ${agentId}).`, true); + startButton.disabled = true; + return; + } + agentNameEl.textContent = agent.display_name ?? agent.name ?? `Agent ${agentId}`; + } catch { + // API evtl. gerade nicht erreichbar - der Upload braucht sie erst am Ende, die Aufnahme + // selbst funktioniert auch ohne Namen, deshalb hier nicht hart blockieren. + agentNameEl.textContent = `Agent ${agentId}`; + } + + if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") { + setStatus("Aufnahme wird von diesem Browser nicht unterstuetzt (HTTPS oder localhost noetig).", true); + startButton.disabled = true; + } +} + +async function startRecording() { + recordedChunks = []; + recordedBlob = null; + audioPreview.hidden = true; + uploadButton.hidden = true; + discardButton.hidden = true; + + try { + mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch (error) { + setStatus(`Mikrofon-Zugriff nicht möglich: ${error.message}`, true); + return; + } + + const mimeType = pickMimeType(); + mediaRecorder = mimeType ? new MediaRecorder(mediaStream, { mimeType }) : new MediaRecorder(mediaStream); + + mediaRecorder.addEventListener("dataavailable", (event) => { + if (event.data.size > 0) { + recordedChunks.push(event.data); + } + }); + + mediaRecorder.addEventListener("stop", () => { + mediaStream.getTracks().forEach((track) => track.stop()); + recordedBlob = new Blob(recordedChunks, { type: mediaRecorder.mimeType || "audio/webm" }); + audioPreview.src = URL.createObjectURL(recordedBlob); + audioPreview.hidden = false; + uploadButton.hidden = false; + discardButton.hidden = false; + setStatus("Aufnahme beendet - anhören und hochladen oder verwerfen."); + }); + + mediaRecorder.start(); + startButton.hidden = true; + stopButton.hidden = false; + setStatus("Aufnahme läuft …"); +} + +function stopRecording() { + if (mediaRecorder && mediaRecorder.state !== "inactive") { + mediaRecorder.stop(); + } + stopButton.hidden = true; + startButton.hidden = false; +} + +function discardRecording() { + recordedBlob = null; + audioPreview.removeAttribute("src"); + audioPreview.hidden = true; + uploadButton.hidden = true; + discardButton.hidden = true; + setStatus("Aufnahme verworfen."); +} + +async function upload() { + if (!recordedBlob) { + return; + } + + uploadButton.disabled = true; + setStatus("Wird hochgeladen …"); + + const extension = (recordedBlob.type.split("/")[1] || "webm").split(";")[0]; + try { + await uploadRecording(agentId, recordedBlob, `aufnahme.${extension}`); + setStatus("Hochgeladen, danke!"); + discardRecording(); + } catch (error) { + setStatus(error.message, true); + } finally { + uploadButton.disabled = false; + } +} + +startButton.addEventListener("click", startRecording); +stopButton.addEventListener("click", stopRecording); +uploadButton.addEventListener("click", upload); +discardButton.addEventListener("click", discardRecording); + +init(); diff --git a/html/record.html b/html/record.html new file mode 100644 index 0000000..eda4718 --- /dev/null +++ b/html/record.html @@ -0,0 +1,38 @@ + + + + + + + Sprachaufnahme + + + +
+

Sprachaufnahme

+

Für: …

+
+ +
+

+ +
+ + +
+ + + +
+ + +
+
+ + + + + +