Gesprächsaufnahmen mit lokalem Whisper und LLM verarbeiten

This commit is contained in:
Sven committed 2026-11-02 10:30:43 +01:00
1 parent 373081d33d
commit 55e5d34e83
39 files changed
+2931 -74

No files matched your search

@@ -2,6 +2,7 @@ package de.appcreation.swyxweb;
import de.appcreation.swyxweb.config.WebSocketProperties;
import de.appcreation.swyxweb.config.WebhookProperties;
import de.appcreation.swyxweb.transcription.TranscriptionProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -10,7 +11,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
// Zeitpläne wie die nächtliche Job-Bereinigung (JobCleanupService).
@EnableScheduling
@EnableConfigurationProperties({ WebSocketProperties.class, WebhookProperties.class })
@EnableConfigurationProperties({ WebSocketProperties.class, WebhookProperties.class, TranscriptionProperties.class })
public class BackendApplication {
public static void main(String[] args) {
@@ -0,0 +1,40 @@
package de.appcreation.swyxweb.transcription;
import java.util.Map;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/transcriptions")
public class TranscriptionController {
private final TranscriptionService service;
private final TranscriptionProperties properties;
public TranscriptionController(TranscriptionService service, TranscriptionProperties properties) {
this.service = service;
this.properties = properties;
}
@GetMapping("/config")
public Map<String, Object> config() {
return Map.of("enabled", properties.enabled(), "maxBytes", TranscriptionService.MAX_BYTES);
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@ResponseStatus(HttpStatus.ACCEPTED)
public TranscriptionJob upload(@RequestParam MultipartFile file, @RequestParam UUID recordingId,
@RequestParam(defaultValue = "de") String language, @RequestParam(defaultValue = "meeting") String style) {
return service.upload(file, recordingId, language, style);
}
@GetMapping("/{jobId}")
public TranscriptionJob get(@PathVariable String jobId) { return service.get(jobId); }
@PostMapping("/{jobId}/retry")
@ResponseStatus(HttpStatus.ACCEPTED)
public TranscriptionJob retry(@PathVariable String jobId) { return service.retry(jobId); }
}
@@ -0,0 +1,6 @@
package de.appcreation.swyxweb.transcription;
/** Ergebnis der lokalen Whisper-Verarbeitung mit optionaler LLM-Zusammenfassung. */
public record TranscriptionJob(String id, String filename, String status, String stage,
String error, String transcript, String formatted_transcript, String summary) {
}
@@ -0,0 +1,23 @@
package de.appcreation.swyxweb.transcription;
import java.net.URI;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
@ConfigurationProperties("app.transcription")
public record TranscriptionProperties(@DefaultValue("") String baseUrl) {
public TranscriptionProperties {
baseUrl = baseUrl == null ? "" : baseUrl.strip().replaceAll("/+$", "");
if (!baseUrl.isEmpty()) {
URI uri = URI.create(baseUrl);
if (!("http".equals(uri.getScheme()) || "https".equals(uri.getScheme()))
|| uri.getHost() == null || uri.getUserInfo() != null
|| uri.getQuery() != null || uri.getFragment() != null) {
throw new IllegalArgumentException("app.transcription.base-url muss eine HTTP(S)-Adresse ohne Zugangsdaten sein.");
}
}
}
public boolean enabled() { return !baseUrl.isEmpty(); }
}
@@ -0,0 +1,117 @@
package de.appcreation.swyxweb.transcription;
import java.io.IOException;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import java.util.function.Supplier;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
@Service
public class TranscriptionService {
public static final long MAX_BYTES = 500L * 1024 * 1024;
private final TranscriptionProperties properties;
private final RestClient client;
public TranscriptionService(TranscriptionProperties properties) {
this.properties = properties;
var factory = new JdkClientHttpRequestFactory(HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(5)).followRedirects(HttpClient.Redirect.NEVER).build());
factory.setReadTimeout(Duration.ofMinutes(2));
client = RestClient.builder().requestFactory(factory).build();
}
public TranscriptionJob upload(MultipartFile file, UUID recordingId, String language, String style) {
requireConfigured();
if (!Set.of("auto", "de", "en", "fr", "es", "it", "nl", "pl").contains(language)
|| !Set.of("standard", "short", "meeting").contains(style)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Ungültige Sprache oder Zusammenfassungsart.");
}
validateWav(file);
var headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType("audio/wav"));
var body = new LinkedMultiValueMap<String, Object>();
body.add("file", new HttpEntity<>(file.getResource(), headers));
body.add("language", language);
body.add("style", style);
return invoke(() -> client.post().uri(properties.baseUrl() + "/api/jobs")
.header("Idempotency-Key", "swyxweb:" + recordingId)
.contentType(MediaType.MULTIPART_FORM_DATA).body(body)
.retrieve().body(TranscriptionJob.class));
}
public TranscriptionJob get(String jobId) {
requireConfigured();
validateJobId(jobId);
return invoke(() -> client.get().uri(properties.baseUrl() + "/api/jobs/" + jobId)
.retrieve().body(TranscriptionJob.class));
}
public TranscriptionJob retry(String jobId) {
requireConfigured();
validateJobId(jobId);
return invoke(() -> client.post().uri(properties.baseUrl() + "/api/jobs/" + jobId + "/retry")
.retrieve().body(TranscriptionJob.class));
}
private void requireConfigured() {
if (!properties.enabled()) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Die Transkription ist noch nicht konfiguriert (APP_TRANSCRIPTION_BASE_URL).");
}
private void validateJobId(String jobId) {
if (!jobId.matches("[a-f0-9]{32}")) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Ungültige Auftragskennung.");
}
private void validateWav(MultipartFile file) {
String filename = file.getOriginalFilename();
if (filename == null || !filename.toLowerCase(Locale.ROOT).endsWith(".wav") || file.getSize() < 12) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Bitte eine nicht leere WAV-Datei auswählen.");
}
if (file.getSize() > MAX_BYTES) throw new ResponseStatusException(HttpStatus.CONTENT_TOO_LARGE, "Maximal 500 MB pro Aufnahme.");
try (var input = file.getInputStream()) {
byte[] header = input.readNBytes(12);
String kind = new String(header, 0, Math.min(4, header.length), StandardCharsets.US_ASCII);
if (header.length != 12 || !(kind.equals("RIFF") || kind.equals("RF64"))
|| !new String(header, 8, 4, StandardCharsets.US_ASCII).equals("WAVE")) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Die Datei hat keinen WAV-Dateikopf.");
}
} catch (IOException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Die WAV-Datei konnte nicht gelesen werden.", e);
}
}
private TranscriptionJob invoke(Supplier<TranscriptionJob> action) {
try {
TranscriptionJob job = action.get();
if (job == null || job.id() == null || !job.id().matches("[a-f0-9]{32}") || job.status() == null) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Der Transkriptionsdienst lieferte keinen gültigen Auftrag.");
}
return job;
} catch (RestClientResponseException e) {
HttpStatusCode status = e.getStatusCode().is4xxClientError()
? e.getStatusCode() : HttpStatus.BAD_GATEWAY;
throw new ResponseStatusException(status, "Der Transkriptionsdienst hat die Anfrage abgelehnt (HTTP " + e.getStatusCode().value() + ").", e);
} catch (RestClientException e) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY,
"Der Transkriptionsdienst ist nicht erreichbar oder hat nicht rechtzeitig geantwortet. Die Übergabe kann erneut versucht werden.", e);
}
}
}
@@ -21,7 +21,9 @@ public record LineState(
String peerNumber,
String peerName,
boolean busy,
boolean selected) {
boolean selected,
Integer callId,
boolean recordingRequested) {
public static final String FREE = "LSInactive";
public static final String RINGING = "LSRinging";
@@ -32,7 +34,7 @@ public record LineState(
private static final String NO_PEER = "unbekannt";
public static LineState free(int line) {
return new LineState(line, FREE, 0, "frei", NO_PEER, "", "", false, false);
return new LineState(line, FREE, 0, "frei", NO_PEER, "", "", false, false, null, false);
}
public static LineState ringing(int line, String number, String name) {
@@ -49,7 +51,12 @@ public record LineState(
}
public LineState connected() {
return occupied(line, ACTIVE, 6, "verbunden", peerNumber, peerName);
return occupied(line, ACTIVE, 6, "verbunden", peerNumber, peerName).withCall(callId, recordingRequested);
}
public LineState withCall(Integer id, boolean recording) {
return new LineState(line, state, stateCode, stateText, peer, peerNumber, peerName,
busy, selected, id, recording);
}
/** Nur für den Mock – die App kennt dieses Feld nicht, also nicht serialisieren. */
@@ -65,7 +72,7 @@ public record LineState(
peerOf(number, name),
number == null ? "" : number,
name == null ? "" : name,
true, true);
true, true, null, false);
}
/**
@@ -33,8 +33,8 @@ import org.springframework.web.socket.handler.TextWebSocketHandler;
* <li>Beim Verbinden: {@code {"app":"SwyxTray",…,"type":"hello"}}, danach ein Snapshot.</li>
* <li>Kommando: {@code {"id":7,"cmd":"call","number":"…"}}
* → {@code {"id":7,"ok":true,"line":2,"type":"result"}}</li>
* <li><b>Keine Ereignisnachrichten.</b> Jede Änderung geht als vollständiger
* {@code snapshot} über alle Leitungen raus – auch ein eingehender Anruf.</li>
* <li>Änderungen gehen als {@code snapshot} und {@code call}-Ereignisse raus.
* Aufnahmebefehle verwenden Leitung und Anrufkennung wie Protokoll 10.</li>
* </ul>
*
* Ein eingehender Anruf lässt sich über {@code POST /api/mock/incoming-call} auslösen.
@@ -73,7 +73,7 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
"SwyxIt!", "SwyxWeb – Google Chrome", "Kundenakte Muster GmbH");
/** Protokollstand der echten App seit dem Adress-Cache. */
private static final int PROTOCOL_VERSION = 8;
private static final int PROTOCOL_VERSION = 10;
/** So viele Treffer meldet die echte App höchstens; gekürzt wird ohne Hinweis. */
private static final int CONTACT_RESULT_LIMIT = 100;
@@ -90,11 +90,13 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
// Vorgetäuschte Tabs des Firefox-Plugins, in der Reihenfolge des Öffnens.
private final Map<Integer, MockTab> tabs = Collections.synchronizedMap(new LinkedHashMap<>());
private final AtomicInteger nextTabId = new AtomicInteger(41);
private final AtomicInteger nextCallId = new AtomicInteger(100);
private final AtomicInteger nextSession = new AtomicInteger();
// Ist der Adress-Cache gefüllt? Zum Umschalten auf die Rückfallebene.
private volatile boolean addressCacheFilled = true;
// Serialisiert Begrüßung und Snapshot, damit die Reihenfolge garantiert ist.
private final Object sendLock = new Object();
private Map<Integer, LineState> publishedLines = Map.of();
public SwyxTrayMockHandler(ObjectMapper mapper) {
this.mapper = mapper;
@@ -125,7 +127,7 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
protected synchronized void handleTextMessage(WebSocketSession session, TextMessage message) {
JsonNode request;
try {
request = mapper.readTree(message.getPayload());
@@ -182,12 +184,18 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
Integer line = switch (cmd) {
case "call" -> dial(request.path("number").asString(null));
case "answer" -> answer(requiredLine(request));
case "answer" -> answer(requiredLine(request), request.path("callId"));
case "hangup" -> hangup(requiredLine(request));
case "recording.start", "recording.stop" -> recording(requiredLine(request), request.path("callId"), "recording.start".equals(cmd));
case "ping", "status" -> null;
default -> throw new IllegalArgumentException("Unbekanntes Kommando '" + cmd + "'.");
};
sendText(session, toJson(result(id, true, line, null)));
var response = result(id, true, line, null);
if (cmd.startsWith("recording.")) {
response.put("callId", lines.get(line).callId());
response.put("recordingRequested", lines.get(line).recordingRequested());
}
sendText(session, toJson(response));
broadcastSnapshot();
} catch (IllegalArgumentException | IllegalStateException e) {
sendText(session, toJson(result(id, false, null, e.getMessage())));
@@ -205,13 +213,25 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
throw new IllegalArgumentException("Parameter 'number' fehlt.");
}
int line = freeLine();
lines.put(line, LineState.dialing(line, number.trim(), null));
lines.put(line, LineState.dialing(line, number.trim(), null).withCall(nextCallId.incrementAndGet(), false));
log.info("SwyxTray-Mock: Wähle {} auf Leitung {}", number, line);
return line;
}
private int answer(int line) {
lines.put(line, occupied(line).connected());
private int answer(int line, JsonNode callId) {
var current = occupied(line);
if (!current.isRinging()) throw new IllegalStateException("Die Leitung klingelt nicht mehr.");
if (!callId.isMissingNode() && current.callId() != callId.asInt()) throw new IllegalStateException("Anruf hat sich geändert.");
lines.put(line, current.connected());
return line;
}
private int recording(int line, JsonNode callId, boolean start) {
var current = occupied(line);
if (!callId.isIntegralNumber() || current.callId() != callId.asInt())
throw new IllegalArgumentException("Ungültige oder veraltete Anrufkennung.");
if (!LineState.ACTIVE.equals(current.state())) throw new IllegalStateException("Keine Sprechverbindung.");
lines.put(line, current.withCall(current.callId(), start));
return line;
}
@@ -390,12 +410,12 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
}
/** Löst einen eingehenden Anruf aus: Leitung belegen, Snapshot verschicken. */
public LineState simulateIncomingCall(String number, String name) {
public synchronized LineState simulateIncomingCall(String number, String name) {
int line = freeLine();
LineState state = LineState.ringing(
line,
number == null || number.isBlank() ? "+493012345" : number.trim(),
name == null || name.isBlank() ? null : name.trim());
name == null || name.isBlank() ? null : name.trim()).withCall(nextCallId.incrementAndGet(), false);
lines.put(line, state);
log.info("SwyxTray-Mock: Eingehender Anruf von {} auf Leitung {}", state.peer(), line);
broadcastSnapshot();
@@ -535,9 +555,24 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
private void broadcastSnapshot() {
String json = toJson(snapshot());
synchronized (sendLock) {
sessions.values().forEach(session -> sendText(session, json));
var messages = new ArrayList<String>();
messages.add(json);
for (var current : lines.values()) {
var previous = publishedLines.get(current.line());
if (previous != null && !previous.isFree() && (current.isFree() || !previous.callId().equals(current.callId())))
messages.add(toJson(callEvent("ended", previous)));
if (!current.isFree() && (previous == null || !current.state().equals(previous.state())))
messages.add(toJson(callEvent(current.isRinging() ? "incoming" : LineState.ACTIVE.equals(current.state()) ? "connected" : "outgoing", current)));
}
publishedLines = Map.copyOf(lines);
synchronized (sendLock) {
sessions.values().forEach(session -> messages.forEach(message -> sendText(session, message)));
}
}
private Map<String, Object> callEvent(String event, LineState state) {
return Map.of("type", "call", "event", event, "line", state.line(), "callId", state.callId(),
"peer", state.peer(), "peerNumber", state.peerNumber(), "peerName", state.peerName());
}
private String toJson(Object value) {
@@ -39,3 +39,11 @@ app.webhook.history=50
app.webhook.max-size=262144
logging.level.de.appcreation.swyxweb=DEBUG
# Ziel aus Sicht des Backends. Docker setzt den lokalen Whisper-Worker auf
# http://127.0.0.1:8765. Ohne konfigurierte Worker-Adresse bleibt die Übergabe deaktiviert.
app.transcription.base-url=
spring.servlet.multipart.max-file-size=500MB
spring.servlet.multipart.max-request-size=501MB
spring.servlet.multipart.file-size-threshold=0
spring.mvc.problemdetails.enabled=true
@@ -0,0 +1,25 @@
package de.appcreation.swyxweb.transcription;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
class TranscriptionPropertiesTests {
@Test
void bindsLocalWorkerUnderTranscriptionPrefix() {
new ApplicationContextRunner().withUserConfiguration(PropertiesConfig.class)
.withPropertyValues("app.transcription.base-url=http://127.0.0.1:8765/")
.run(context -> {
var properties = context.getBean(TranscriptionProperties.class);
assertThat(properties.baseUrl()).isEqualTo("http://127.0.0.1:8765");
assertThat(properties.enabled()).isTrue();
});
}
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(TranscriptionProperties.class)
static class PropertiesConfig {}
}
@@ -0,0 +1,82 @@
package de.appcreation.swyxweb.transcription;
import static org.assertj.core.api.Assertions.*;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.server.ResponseStatusException;
class TranscriptionServiceTests {
private static final String JOB_ID = "0123456789abcdef0123456789abcdef";
private static final byte[] WAV = "RIFFxxxxWAVEfmt data".getBytes(StandardCharsets.US_ASCII);
@Test
void streamsMultipartAndPreservesIdempotencyKeyAndResults() throws Exception {
var received = new AtomicReference<String>();
var key = new AtomicReference<String>();
var method = new AtomicReference<String>();
var requests = new AtomicInteger();
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/jobs", exchange -> {
requests.incrementAndGet();
received.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
key.set(exchange.getRequestHeaders().getFirst("Idempotency-Key"));
method.set(exchange.getRequestMethod());
byte[] body = ("{\"id\":\"" + JOB_ID + "\",\"filename\":\"call.wav\",\"status\":\"done\",\"stage\":\"Fertig\","
+ "\"summary\":\"Zusammenfassung\",\"transcript\":\"Gespräch\",\"segments\":[],\"audio_ext\":\".wav\"}").getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(202, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
try {
var service = new TranscriptionService(new TranscriptionProperties("http://127.0.0.1:" + server.getAddress().getPort()));
var id = UUID.randomUUID();
var job = service.upload(new MockMultipartFile("file", "call.wav", "audio/wav", WAV), id, "de", "meeting");
assertThat(job.id()).isEqualTo(JOB_ID);
assertThat(job.summary()).isEqualTo("Zusammenfassung");
assertThat(received.get()).contains("name=\"file\"; filename=\"call.wav\"", "audio/wav", "RIFFxxxxWAVE", "meeting", "de");
assertThat(key.get()).isEqualTo("swyxweb:" + id);
assertThat(service.get(JOB_ID).transcript()).isEqualTo("Gespräch");
assertThat(method.get()).isEqualTo("GET");
service.retry(JOB_ID);
assertThat(method.get()).isEqualTo("POST");
assertThat(requests).hasValue(3);
} finally { server.stop(0); }
}
@Test
void disabledIntegrationFailsClearly() {
var service = new TranscriptionService(new TranscriptionProperties(""));
assertThatThrownBy(() -> service.upload(new MockMultipartFile("file", WAV), UUID.randomUUID(), "de", "meeting"))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("503").hasMessageContaining("APP_TRANSCRIPTION_BASE_URL");
}
@Test
void rejectsInvalidFilesOptionsAndPathsBeforeNetworkAccess() {
var service = new TranscriptionService(new TranscriptionProperties("http://127.0.0.1:1"));
for (var file : new MockMultipartFile[] {
new MockMultipartFile("file", "call.mp3", "audio/wav", WAV),
new MockMultipartFile("file", "call.wav", "audio/wav", "not an audio file".getBytes()),
new MockMultipartFile("file", "empty.wav", "audio/wav", new byte[0]) }) {
assertThatThrownBy(() -> service.upload(file, UUID.randomUUID(), "de", "meeting")).hasMessageContaining("400");
}
assertThatThrownBy(() -> service.upload(new MockMultipartFile("file", "call.wav", "audio/wav", WAV), UUID.randomUUID(), "de", "invalid"))
.hasMessageContaining("400");
assertThatThrownBy(() -> service.get("../health")).hasMessageContaining("400");
}
@Test
void unavailableServerIsNotReportedAsSuccessfulUpload() {
var service = new TranscriptionService(new TranscriptionProperties("http://127.0.0.1:1"));
assertThatThrownBy(() -> service.get(JOB_ID)).hasMessageContaining("502").hasMessageContaining("nicht erreichbar");
}
}
@@ -0,0 +1,51 @@
package de.appcreation.swyxweb.websocket;
import org.junit.jupiter.api.Test;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import tools.jackson.databind.ObjectMapper;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class RecordingProtocolTests {
@Test
void answerRecordStopEndAndRejectStaleCall() throws Exception {
var mapper = new ObjectMapper();
var handler = new SwyxTrayMockHandler(mapper);
var session = mock(WebSocketSession.class);
when(session.getId()).thenReturn("recording-test");
when(session.isOpen()).thenReturn(true);
List<String> messages = new ArrayList<>();
doAnswer(invocation -> { messages.add(((TextMessage) invocation.getArgument(0)).getPayload()); return null; })
.when(session).sendMessage(any());
handler.afterConnectionEstablished(session);
assertThat(mapper.readTree(messages.getFirst()).path("protocol").asInt()).isEqualTo(10);
var incoming = handler.simulateIncomingCall("123", "Test");
int line = incoming.line();
int callId = incoming.callId();
handler.handleTextMessage(session, command(1, "recording.start", line, callId));
assertThat(mapper.readTree(messages.getLast()).path("ok").asBoolean()).isFalse();
handler.handleTextMessage(session, command(2, "answer", line, callId));
handler.handleTextMessage(session, command(3, "recording.start", line, callId));
var result = messages.stream().map(mapper::readTree).filter(n -> n.path("id").asInt() == 3).findFirst().orElseThrow();
assertThat(result.path("ok").asBoolean()).isTrue();
assertThat(result.path("recordingRequested").asBoolean()).isTrue();
handler.handleTextMessage(session, command(4, "recording.stop", line, callId));
result = messages.stream().map(mapper::readTree).filter(n -> n.path("id").asInt() == 4).findFirst().orElseThrow();
assertThat(result.path("recordingRequested").asBoolean()).isFalse();
handler.handleTextMessage(session, command(5, "hangup", line, callId));
var ended = mapper.readTree(messages.getLast());
assertThat(ended.path("event").asString()).isEqualTo("ended");
assertThat(ended.path("callId").asInt()).isEqualTo(callId);
var next = handler.simulateIncomingCall("123", "Test");
assertThat(next.callId()).isNotEqualTo(callId);
handler.handleTextMessage(session, command(6, "recording.start", line, callId));
assertThat(mapper.readTree(messages.getLast()).path("ok").asBoolean()).isFalse();
}
private TextMessage command(int id, String command, int line, int callId) {
return new TextMessage("{\"id\":" + id + ",\"cmd\":\"" + command + "\",\"line\":" + line + ",\"callId\":" + callId + "}");
}
}