monitor/api/src/Http/Response.php
fds2610-bot 9426406ee5 Sprachaufnahme: Agent-Kachel, Aufnahme-Webseite und API-Warteschlange
Setzt den in #84 skizzierten Workflow um: record.html nimmt im
Handy-Browser eine Sprachnotiz per MediaRecorder auf und laedt sie
unauthentifiziert zu POST /api/v1/agents/{id}/recordings hoch. Ein
Klick auf die Personen-Figur jedes registrierten Agenten in der
Buero-Visualisierung verlinkt dorthin, ein Zaehler am Schreibtisch
zeigt offene Aufnahmen. Der Agent selbst holt seine Warteschlange
ueber die bestehenden Api-Key-authentifizierten Endpunkte ab
(GET .../recordings, GET /recordings/{id}/audio) und markiert eine
Aufnahme nach der Transkription per PATCH als erledigt - die
eigentliche Transkription (whisper.cpp/faster-whisper) bleibt
agent-seitig, siehe fds2610/Agent#17.

Schliesst #85
2026-09-03 21:26:28 +02:00

150 lines
5.5 KiB
PHP

<?php
declare(strict_types=1);
namespace Monitor\Api\Http;
final class Response
{
/**
* @param mixed $data
* @param \Closure|null $streamWriter - gesetzt fuer SSE-Antworten (F13, Phase 4): statt
* eines JSON-Bodys schreibt dieser Callback selbst Bytes an den Client (siehe
* StreamController). $data bleibt in dem Fall null.
*/
private function __construct(
public readonly int $status,
private readonly mixed $data,
private readonly ?\Closure $streamWriter = null,
// 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 = [],
) {
}
/** @param mixed $data */
public static function json(mixed $data, int $status = 200): self
{
return new self($status, $data);
}
public static function error(string $message, int $status): self
{
return new self($status, ['error' => $message]);
}
/**
* Klartext-Antwort: $body geht unveraendert raus, ohne JSON-Kodierung. Issue #70: Der
* Ping-Check holt seine hosts.conf ueber die API und schreibt den Body direkt in die
* Datei - eine JSON-Huelle muesste er in Shell wieder aufdroeseln, was bei jeder
* unerwarteten Zeile die Ueberwachungsliste beschaedigen koennte.
*/
public static function text(string $body, int $status = 200): self
{
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<string, string> $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().
*/
public static function preflight(): self
{
return new self(204, null);
}
/**
* @param \Closure $writer - wird von send() mit gesetzten SSE-Headern aufgerufen und
* schreibt selbst per echo/flush() an den Client (typischerweise eine Polling-Schleife).
*/
public static function stream(\Closure $writer): self
{
return new self(200, null, $writer);
}
public function isStream(): bool
{
return $this->streamWriter !== null;
}
/** Fuer Tests: den Stream-Callback ohne echte HTTP-Header/Output-Handling ausfuehren. */
public function writer(): ?\Closure
{
return $this->streamWriter;
}
public function send(): void
{
// Frontend (html/, Port 8090 im Docker-Compose-Setup) und API (Port 8091) laufen auf
// unterschiedlichen Origins - ohne CORS wuerde der Browser sowohl die fetch()-Aufrufe
// aus office-api.js als auch den EventSource-Stream (F13) blockieren. Alle Endpunkte
// liefern ohnehin nur oeffentliche GET-Daten bzw. verlangen fuer POST/PATCH einen
// gueltigen API-Key, ein offener Origin ist daher unkritisch.
header('Access-Control-Allow-Origin: *');
// Schreibende Aufrufe schicken X-Admin-Key/X-Api-Key und Content-Type: application/json.
// Beides macht aus dem fetch() einen "preflighted request": Der Browser fragt vorher per
// OPTIONS nach und bricht mit einem reinen Netzwerkfehler ab, wenn die erlaubten Methoden
// und Header fehlen - ohne dass der eigentliche POST je gesendet wird.
header('Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-Admin-Key, X-Api-Key');
header('Access-Control-Max-Age: 86400');
if ($this->streamWriter !== null) {
http_response_code($this->status);
header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
// Verhindert, dass ein vorgeschalteter nginx die Antwort puffert und SSE-Events
// erst nach Verbindungsende ausliefert.
header('X-Accel-Buffering: no');
if (function_exists('ob_implicit_flush')) {
ob_implicit_flush(true);
}
while (ob_get_level() > 0) {
ob_end_flush();
}
($this->streamWriter)();
return;
}
http_response_code($this->status);
if ($this->status === 204) {
return;
}
if ($this->contentType !== null) {
header('Content-Type: ' . $this->contentType);
foreach ($this->extraHeaders as $name => $value) {
header($name . ': ' . $value);
}
echo (string) $this->data;
return;
}
header('Content-Type: application/json; charset=utf-8');
echo json_encode($this->data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
public function body(): mixed
{
return $this->data;
}
}