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
This commit is contained in:
parent
72095095c5
commit
9426406ee5
24 changed files with 1440 additions and 16 deletions
69
API.md
69
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`.
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
135
api/src/Controller/VoiceRecordingController.php
Normal file
135
api/src/Controller/VoiceRecordingController.php
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Monitor\Api\Controller;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Monitor\Api\Http\Request;
|
||||
use Monitor\Api\Http\Response;
|
||||
use Monitor\Api\Repository\AgentRepository;
|
||||
use Monitor\Api\Repository\VoiceRecordingRepository;
|
||||
|
||||
/**
|
||||
* Issue #85: Sprachaufnahme-Warteschlange. Zwei Zugriffsarten mit unterschiedlicher
|
||||
* Berechtigung, analog zu HostConfigController:
|
||||
*
|
||||
* - Upload aus record.html (Handy-Browser eines Menschen, kein registrierter Agent) -
|
||||
* unauthentifiziert wie die uebrigen von der UI ausgeloesten Schreibvorgaenge auf oeffentlich
|
||||
* verlinkten Seiten; die Agenten-Id kommt als URL-Segment mit.
|
||||
* - Abholen/Markieren durch den Agenten selbst - hinter dem bestehenden Agenten-API-Key,
|
||||
* mit Ownership-Check wie bei AgentController::activity() (ein Agent sieht/holt nur seine
|
||||
* eigene Warteschlange).
|
||||
*/
|
||||
final class VoiceRecordingController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly VoiceRecordingRepository $recordings,
|
||||
private readonly AgentRepository $agents,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @param array<string, string> $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<string, string> $params
|
||||
* @param array<string, mixed> $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<string, string> $params
|
||||
* @param array<string, mixed> $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<string, string> $params
|
||||
* @param array<string, mixed> $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]);
|
||||
}
|
||||
}
|
||||
21
api/src/Db/migrations/006_voice_recordings.sql
Normal file
21
api/src/Db/migrations/006_voice_recordings.sql
Normal file
|
|
@ -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);
|
||||
|
|
@ -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<string, mixed> $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<string, mixed> $body
|
||||
* @param string|null $rawBody - nur angeben, wenn der Test genau diese Bytes braucht
|
||||
* (Signaturpruefung). Sonst wird $body als JSON serialisiert.
|
||||
* @param array<string, mixed> $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
|
||||
|
|
|
|||
|
|
@ -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<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().
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
207
api/src/Repository/VoiceRecordingRepository.php
Normal file
207
api/src/Repository/VoiceRecordingRepository.php
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Monitor\Api\Repository;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Monitor\Api\Db\Database;
|
||||
|
||||
/**
|
||||
* Warteschlange fuer Sprachaufnahmen (Issue #85): record.html laedt eine Aufnahme fuer einen
|
||||
* Agenten hoch, der Agent fragt regelmaessig ab, ob Aufnahmen vorliegen, holt sie ab und
|
||||
* markiert sie danach als erledigt (siehe fds2610/Agent#17 fuer die Agenten-Seite). Die
|
||||
* eigentlichen Audiodateien liegen nicht in SQLite, sondern unter $storageDir - hier steht
|
||||
* nur ihr Dateiname (storage_name) plus Metadaten.
|
||||
*/
|
||||
final class VoiceRecordingRepository
|
||||
{
|
||||
/**
|
||||
* Whitelist erlaubter Audio-MIME-Typen -> 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<string, string>
|
||||
*/
|
||||
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<string, mixed> 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<int, array<string, mixed>> */
|
||||
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<string, mixed> */
|
||||
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<int, int> 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<string, mixed> */
|
||||
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<string, mixed> $row @return array<string, mixed>
|
||||
*/
|
||||
private function toPublic(array $row): array
|
||||
{
|
||||
unset($row['storage_name']);
|
||||
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
223
api/tests/Unit/Controller/VoiceRecordingControllerTest.php
Normal file
223
api/tests/Unit/Controller/VoiceRecordingControllerTest.php
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Monitor\Api\Tests\Controller;
|
||||
|
||||
use Monitor\Api\Controller\VoiceRecordingController;
|
||||
use Monitor\Api\Db\Database;
|
||||
use Monitor\Api\Http\Request;
|
||||
use Monitor\Api\Repository\AgentRepository;
|
||||
use Monitor\Api\Repository\VoiceRecordingRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class VoiceRecordingControllerTest extends TestCase
|
||||
{
|
||||
private VoiceRecordingController $controller;
|
||||
private AgentRepository $agents;
|
||||
private VoiceRecordingRepository $recordings;
|
||||
private string $storageDir;
|
||||
private string $tmpUploadFile;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$db = Database::inMemory();
|
||||
$this->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<string, mixed> 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']);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
127
api/tests/Unit/Repository/VoiceRecordingRepositoryTest.php
Normal file
127
api/tests/Unit/Repository/VoiceRecordingRepositoryTest.php
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Monitor\Api\Tests\Repository;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Monitor\Api\Db\Database;
|
||||
use Monitor\Api\Repository\AgentRepository;
|
||||
use Monitor\Api\Repository\VoiceRecordingRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class VoiceRecordingRepositoryTest extends TestCase
|
||||
{
|
||||
private VoiceRecordingRepository $recordings;
|
||||
private string $storageDir;
|
||||
private int $agentId;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$db = Database::inMemory();
|
||||
$agents = new AgentRepository($db);
|
||||
$this->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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
91
html/css/record.css
Normal file
91
html/css/record.css
Normal file
|
|
@ -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%;
|
||||
}
|
||||
|
|
@ -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*()-
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
151
html/js/record.js
Normal file
151
html/js/record.js
Normal file
|
|
@ -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=<id> 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();
|
||||
38
html/record.html
Normal file
38
html/record.html
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<title>Sprachaufnahme</title>
|
||||
<link rel="stylesheet" href="css/record.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="record-header">
|
||||
<h1>Sprachaufnahme</h1>
|
||||
<p>Für: <strong id="agent-name">…</strong></p>
|
||||
</header>
|
||||
|
||||
<main class="record-main">
|
||||
<p id="status" class="record-status" role="status"></p>
|
||||
|
||||
<div class="record-controls">
|
||||
<button id="start-button" type="button" class="record-button record-button-start">Aufnahme starten</button>
|
||||
<button id="stop-button" type="button" class="record-button record-button-stop" hidden>Aufnahme beenden</button>
|
||||
</div>
|
||||
|
||||
<audio id="audio-preview" class="record-preview" controls hidden></audio>
|
||||
|
||||
<div class="record-controls">
|
||||
<button id="upload-button" type="button" class="record-button record-button-upload" hidden>Hochladen</button>
|
||||
<button id="discard-button" type="button" class="record-button record-button-discard" hidden>Verwerfen</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Laufzeit-Konfiguration (API-Basis-URL): wird vom Webserver aus MONITOR_API_BASE
|
||||
erzeugt, siehe docker/nginx/default.conf.template - dieselbe Datei wie office.html
|
||||
nutzt, damit record.js ueber office-api.js dieselbe API erreicht. -->
|
||||
<script src="/config.js"></script>
|
||||
<script type="module" src="js/record.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Reference in a new issue