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

+399
View File
@@ -0,0 +1,399 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SwyxTrayClient } from './SwyxTrayClient'
class FakeSocket {
static OPEN = 1
static CONNECTING = 0
static latest: FakeSocket
readyState = 1
sent: { id: number; cmd: string; line?: number; callId?: number }[] = []
onopen: (() => void) | null = null
onmessage: ((event: { data: string }) => void) | null = null
onclose: ((event: { code: number; reason: string }) => void) | null = null
onerror: (() => void) | null = null
constructor() { FakeSocket.latest = this }
send(text: string) { this.sent.push(JSON.parse(text)) }
close() { this.readyState = 3; this.onclose?.({ code: 1000, reason: '' }) }
receive(data: unknown) { this.onmessage?.({ data: JSON.stringify(data) }) }
result(ok = true) {
this.receive({ type: 'result', id: this.sent.at(-1)!.id, ok, error: ok ? undefined : 'SDK abgewiesen' })
}
snapshot(state: string, callId = 41) {
this.receive({ type: 'snapshot', connected: true, serverUp: true,
lines: [{ line: 1, state, callId, peerNumber: '123', recordingRequested: false }] })
}
}
const flush = async () => { await Promise.resolve(); await Promise.resolve(); await Promise.resolve() }
let client: SwyxTrayClient
let socket: FakeSocket
let errors: string[]
beforeEach(() => {
vi.useFakeTimers()
vi.stubGlobal('WebSocket', FakeSocket)
client = new SwyxTrayClient()
client.connect('ws://localhost:17654/ws')
socket = FakeSocket.latest
socket.onopen?.()
socket.receive({ type: 'hello', protocol: 10 })
socket.snapshot('LSRinging')
errors = []
client.on('error', (error) => errors.push(error))
})
afterEach(() => { client.dispose(); vi.unstubAllGlobals(); vi.useRealTimers() })
describe('Annehmen und Aufnehmen', () => {
it.each([true, false])('wartet auf Sprechverbindung; Snapshot vor Quittung: %s', async (beforeAck) => {
const operation = client.answerAndRecord(1)
expect(socket.sent).toEqual([{ id: 1, cmd: 'answer', line: 1, callId: 41 }])
if (beforeAck) socket.snapshot('LSActive')
socket.result()
await flush()
if (!beforeAck) {
expect(socket.sent).toHaveLength(1)
socket.snapshot('LSWaitingForMedia')
await flush()
expect(socket.sent).toHaveLength(1)
socket.snapshot('LSActive')
await flush()
}
expect(socket.sent.at(-1)).toEqual({ id: 2, cmd: 'recording.start', line: 1, callId: 41 })
socket.snapshot('LSActive')
socket.result()
await operation
expect(socket.sent).toHaveLength(2)
expect(errors).toEqual([])
})
it('startet bei fehlgeschlagener Annahme keine Aufnahme', async () => {
const operation = client.answerAndRecord(1)
socket.result(false)
await expect(operation).rejects.toThrow('SDK abgewiesen')
socket.snapshot('LSActive')
expect(socket.sent).toHaveLength(1)
})
it.each(['LSInactive', 'LSRinging'])('nimmt nach Ende/Wiederverwendung nicht auf: %s', async (state) => {
const operation = client.answerAndRecord(1)
socket.result()
await flush()
socket.snapshot(state, 42)
await operation
expect(socket.sent).toHaveLength(1)
expect(errors[0]).toContain('beendet oder ersetzt')
})
it('verarbeitet explizites Gesprächsende während der Annahme', async () => {
const ended = vi.fn()
client.on('call', ended)
const operation = client.answerAndRecord(1)
socket.result()
await flush()
socket.receive({ type: 'call', event: 'ended', line: 1, callId: 41 })
await operation
expect(ended).toHaveBeenCalledOnce()
expect(socket.sent).toHaveLength(1)
expect(errors[0]).toContain('beendet')
})
it('wartet bei gehaltenem Gespräch und meldet Zeitüberschreitung', async () => {
const operation = client.answerAndRecord(1)
socket.result()
socket.snapshot('LSOnHold')
await flush()
await vi.advanceTimersByTimeAsync(15001)
await operation
expect(socket.sent).toHaveLength(1)
expect(errors[0]).toContain('15 Sekunden')
})
it('startet nach Verbindungsabbruch nicht erneut', async () => {
const operation = client.answerAndRecord(1)
socket.result()
await flush()
client.disconnect()
await operation
expect(socket.sent).toHaveLength(1)
expect(errors[0]).toContain('unterbrochen')
})
it('meldet Aufnahmefehler bei erfolgreicher Annahme', async () => {
const operation = client.answerAndRecord(1)
socket.snapshot('LSActive')
socket.result()
await flush()
socket.result(false)
expect((await operation).ok).toBe(true)
expect(errors[0]).toContain('Aufnahme nicht gestartet: SDK abgewiesen')
})
it('nimmt mit älterer Tray-Version an, meldet fehlende Aufnahme', async () => {
socket.receive({ type: 'hello', protocol: 9 })
const operation = client.answerAndRecord(1)
socket.result()
await operation
expect(socket.sent).toHaveLength(1)
expect(errors[0]).toContain('Protokoll 10')
})
it('stellt Starten und Stoppen mit Anrufkennung bereit', async () => {
const start = client.startRecording(1, 41)
socket.result()
await start
const stop = client.stopRecording(1, 41)
expect(socket.sent.at(-1)).toMatchObject({ cmd: 'recording.stop', line: 1, callId: 41 })
socket.result()
await stop
})
it('bereitet die Übergabe erst nach erfolgreichem Stopp vor', async () => {
const stopped = vi.fn()
client.on('recordingStopped', stopped)
const failed = client.stopRecording(1, 41)
expect(stopped).not.toHaveBeenCalled()
socket.result(false)
await expect(failed).rejects.toThrow()
expect(stopped).not.toHaveBeenCalled()
const stop = client.stopRecording(1, 41)
socket.result()
await stop
expect(stopped).toHaveBeenCalledWith(expect.objectContaining({ line: 1, callId: 41, peerNumber: '123' }))
})
it('verwendet unter Protokoll 11 je Aufnahmeabschnitt eine stabile Kennung', async () => {
socket.receive({ type: 'hello', protocol: 11 })
const start = client.startRecording(1, 41)
const id = (socket.sent.at(-1) as unknown as { recordingId: string }).recordingId
expect(id).toBeTruthy()
socket.result(); await start
const stop = client.stopRecording(1, 41)
expect(socket.sent.at(-1)).toMatchObject({ recordingId: id })
socket.result(); await stop
const next = client.startRecording(1, 41)
expect(socket.sent.at(-1)).not.toMatchObject({ recordingId: id })
socket.result(); await next
})
it('bereitet auch beim Auflegen vor, aber nicht beim Verbindungsverlust', () => {
const stopped = vi.fn()
client.on('recordingStopped', stopped)
const active = { type: 'snapshot', connected: true, serverUp: true, lines: [
{ line: 1, state: 'LSActive', callId: 41, recordingRequested: true },
] }
socket.receive(active)
socket.receive({ ...active, connected: false, lines: [] })
expect(stopped).not.toHaveBeenCalled()
socket.receive(active)
socket.snapshot('LSInactive')
expect(stopped).toHaveBeenCalledWith(expect.objectContaining({ line: 1, callId: 41 }))
})
it('meldet fertige WAV-Dateien mit ihrem ursprünglichen Anruf', () => {
const ready = vi.fn()
client.on('recordingReady', ready)
const recordingId = '12345678-1234-1234-1234-123456789abc'
socket.snapshot('LSActive', 99)
socket.receive({ type: 'recording.ready', recordingId, line: 1, callId: 41,
filename: 'call.wav', bytes: 44, downloadPath: `/recordings/${recordingId}` })
expect(ready).toHaveBeenCalledWith(expect.objectContaining({ callId: 41,
downloadUrl: `http://localhost:17654/recordings/${recordingId}` }))
})
it('übernimmt die Aufnahme-Kennung aus dem Snapshot nach Neuverbindung', async () => {
const recordingId = '12345678-1234-1234-1234-123456789abc'
socket.receive({ type: 'hello', protocol: 11 })
socket.receive({ type: 'snapshot', lines: [{ line: 1, callId: 41, state: 'LSActive', recordingRequested: true, recordingId }] })
const stopped = vi.fn()
client.on('recordingStopped', stopped)
const stop = client.stopRecording(1, 41)
expect(socket.sent.at(-1)).toMatchObject({ recordingId })
socket.result(); await stop
expect(stopped).toHaveBeenCalledWith(expect.objectContaining({ recordingId }))
})
})
describe('WAV über die offene WebSocket-Verbindung', () => {
beforeEach(() => socket.snapshot('LSInactive'))
const recordingId = '12345678-1234-1234-1234-123456789abc'
const recording = { recordingId, line: 1, callId: 41, filename: 'call.wav', bytes: 12, transport: 'websocket' as const, callEnded: true }
function block(id: number, offset: number, bytes: string) {
const data = new ArrayBuffer(12 + bytes.length)
const view = new DataView(data)
view.setUint32(0, 0x53575958, false)
view.setInt32(4, id, true); view.setUint32(8, offset, true)
new Uint8Array(data, 12).set(new TextEncoder().encode(bytes))
socket.onmessage?.({ data } as unknown as { data: string })
}
it('empfängt Blöcke und verarbeitet dazwischen Telefonereignisse', async () => {
const ready = vi.fn(), ended = vi.fn()
client.on('recordingReady', ready); client.on('call', ended)
socket.receive({ type: 'recording.ready', ...recording })
expect(ready).toHaveBeenCalledWith(expect.objectContaining(recording))
const download = client.downloadRecording(recording)
const id = socket.sent.at(-1)!.id
block(id, 0, 'RIFFxx')
socket.receive({ type: 'call', event: 'ended', line: 1, callId: 41 })
block(id, 6, 'xxWAVE')
socket.result()
const file = await download
expect(file.size).toBe(12)
expect(await file.text()).toBe('RIFFxxxxWAVE')
expect(ended).toHaveBeenCalledOnce()
})
it.each(['gap', 'duplicate', 'oversize', 'truncated'])('verwirft beschädigte Übertragung: %s', async (problem) => {
const download = client.downloadRecording(recording)
const assertion = expect(download).rejects.toThrow()
const id = socket.sent.at(-1)!.id
block(id, 0, 'RIFF')
if (problem === 'gap') block(id, 6, 'xxxxWAVE')
if (problem === 'duplicate') block(id, 0, 'RIFF')
if (problem === 'oversize') block(id, 4, 'xxxxWAVEextra')
socket.result()
await assertion
})
it('verwirft Teilbytes bei Abbruch und startet die Übertragung vollständig neu', async () => {
const first = client.downloadRecording(recording)
const assertion = expect(first).rejects.toThrow('geschlossen')
block(socket.sent.at(-1)!.id, 0, 'RIFF')
socket.close()
await assertion
client.connect('ws://localhost:17654/ws'); socket = FakeSocket.latest; socket.onopen?.()
const again = client.downloadRecording(recording)
block(socket.sent.at(-1)!.id, 0, 'RIFFxxxxWAVE')
socket.result()
expect((await again).size).toBe(12)
})
it('bestätigt erst mit der SwyxWeb-Auftragskennung und rekonstruiert übergebene Aufnahmen', async () => {
const status = vi.fn()
client.on('recordingStatus', status)
const ack = client.acknowledgeRecording(recordingId, 'a'.repeat(32))
expect(socket.sent.at(-1)).toMatchObject({ cmd: 'recording.ack', recordingId, jobId: 'a'.repeat(32) })
socket.result(); await ack
socket.receive({ type: 'recording.status', recordingId, line: 1, callId: 41, state: 'submitted', jobId: 'a'.repeat(32) })
expect(status).toHaveBeenCalledWith(expect.objectContaining({ state: 'submitted', jobId: 'a'.repeat(32) }))
socket.receive({ type: 'recording.status', recordingId, line: 1, callId: 41, state: 'submitted', jobId: '../invalid' })
expect(status).toHaveBeenCalledOnce()
})
it('übernimmt bei doppeltem Start die vorhandene Aufnahme-UUID der Tray-App', async () => {
socket.receive({ type: 'hello', protocol: 11 })
const start = client.startRecording(1, 41)
socket.receive({ type: 'result', id: socket.sent.at(-1)!.id, ok: true, recordingId })
await start
const stop = client.stopRecording(1, 41)
expect(socket.sent.at(-1)).toMatchObject({ recordingId })
socket.result(); await stop
})
})
describe('Übergabe nach jedem bestätigten Gesprächsende', () => {
const recording = { recordingId: '12345678-1234-1234-1234-123456789abc',
line: 1, callId: 41, filename: 'call.wav', bytes: 12, transport: 'websocket' as const }
it.each(['LSTerminated', 'LSInactive', 'LSDisabled', 'LSNone', 'replaced', 'event'])
('überträgt nach externem Ende ohne Auflegen- oder Stopp-Befehl: %s', async mode => {
const stopped = vi.fn()
client.on('recordingStopped', stopped)
socket.receive({ type: 'snapshot', connected: true, serverUp: true,
lines: [{ line: 1, state: 'LSActive', callId: 41, recordingRequested: true, recordingId: recording.recordingId }] })
if (mode === 'event') socket.receive({ type: 'call', event: 'ended', line: 1, callId: 41 })
else if (mode === 'replaced') socket.snapshot('LSRinging', 42)
else socket.snapshot(mode)
expect(stopped).toHaveBeenCalledWith(expect.objectContaining({ recordingId: recording.recordingId, callEnded: true }))
expect(socket.sent).toHaveLength(0)
const transfer = client.downloadRecording(recording)
expect(socket.sent.at(-1)).toMatchObject({ cmd: 'recording.download', recordingId: recording.recordingId })
const bytes = new ArrayBuffer(24)
const header = new DataView(bytes)
header.setUint32(0, 0x53575958, false)
header.setInt32(4, socket.sent.at(-1)!.id, true)
header.setUint32(8, 0, true)
new Uint8Array(bytes, 12).set(new TextEncoder().encode('RIFFxxxxWAVE'))
socket.onmessage?.({ data: bytes as unknown as string })
socket.result()
expect(await (await transfer).text()).toBe('RIFFxxxxWAVE')
expect(socket.sent.map(c => c.cmd)).toEqual(['recording.download'])
await expect(client.hangup(1, 41)).rejects.toThrow('bereits beendet')
})
it('gibt die WAV nach einem während der Unterbrechung beendeten Gespräch frei', () => {
socket.snapshot('LSActive')
socket.close()
expect(client.canTransferRecording(recording)).toBe(false)
client.connect('ws://localhost:17654/ws'); socket = FakeSocket.latest; socket.onopen?.()
socket.snapshot('LSTerminated')
expect(client.canTransferRecording(recording)).toBe(true)
expect(socket.sent).toHaveLength(0)
})
it('ein Aufnahmestopp gibt die WAV bei weiterlaufendem Gespräch nicht frei', async () => {
socket.snapshot('LSActive')
const stopped = vi.fn()
client.on('recordingStopped', stopped)
const stop = client.stopRecording(1, 41)
socket.result(); await stop
expect(stopped).toHaveBeenCalledWith(expect.objectContaining({ callEnded: false }))
await expect(client.downloadRecording(recording)).rejects.toThrow('Gesprächsende')
// Even a premature ready notice cannot override a still active call.
await expect(client.downloadRecording({ ...recording, callEnded: true })).rejects.toThrow('Gesprächsende')
expect(socket.sent.map(c => c.cmd)).toEqual(['recording.stop'])
socket.snapshot('LSOnHold')
expect(client.canTransferRecording(recording)).toBe(false)
socket.snapshot('LSInactive')
expect(client.canTransferRecording(recording)).toBe(true)
})
it('ein Verbindungsabbruch bestätigt das Gesprächsende nicht', () => {
socket.snapshot('LSActive')
socket.receive({ type: 'snapshot', connected: false, serverUp: false, lines: [] })
expect(client.canTransferRecording(recording)).toBe(false)
})
it('prüft nach Neuverbindung die tatsächliche Leitung statt ein fehlendes Ereignis zu erraten', () => {
client.connect('ws://localhost:17654/ws'); socket = FakeSocket.latest; socket.onopen?.()
socket.receive({ type: 'snapshot', connected: true, serverUp: true, lines: [] })
expect(client.canTransferRecording(recording)).toBe(false)
socket.snapshot('LSActive')
expect(client.canTransferRecording(recording)).toBe(false)
client.connect('ws://localhost:17654/ws'); socket = FakeSocket.latest; socket.onopen?.()
socket.snapshot('LSInactive')
expect(client.canTransferRecording(recording)).toBe(true)
})
it('meldet das Ende auch für vorher gestoppte Aufnahmen genau einmal', () => {
const ended = vi.fn()
client.on('callEnded', ended)
socket.snapshot('LSActive') // recordingRequested is already false
socket.snapshot('LSInactive')
socket.receive({ type: 'call', event: 'ended', line: 1, callId: 41 })
expect(ended).toHaveBeenCalledOnce()
expect(ended).toHaveBeenCalledWith(expect.objectContaining({ line: 1, callId: 41 }))
})
it.each(['snapshot', 'event'])('Auflegen bleibt nach der SDK-Quittung bis zum Ende ausstehend: %s', async mode => {
socket.snapshot('LSActive')
let settled = false
const hangup = client.hangup(1, 41).then(result => { settled = true; return result })
expect(socket.sent.at(-1)).toMatchObject({ cmd: 'hangup', line: 1, callId: 41 })
socket.result(); await flush()
expect(settled).toBe(false)
expect(client.canTransferRecording(recording)).toBe(false)
if (mode === 'snapshot') socket.snapshot('LSInactive')
else socket.receive({ type: 'call', event: 'ended', line: 1, callId: 41 })
await hangup
expect(settled).toBe(true)
expect(client.canTransferRecording(recording)).toBe(true)
const count = socket.sent.length
await expect(client.hangup(1, 41)).rejects.toThrow('bereits beendet')
expect(socket.sent).toHaveLength(count)
})
it('verarbeitet Gesprächsende vor der Auflegen-Quittung', async () => {
const hangup = client.hangup(1, 41)
socket.snapshot('LSInactive')
socket.result()
await expect(hangup).resolves.toMatchObject({ ok: true })
})
it('sendet bei Doppelklick nur einen Auflegen-Befehl', async () => {
const first = client.hangup(1, 41)
const second = client.hangup(1, 41)
expect(first).toBe(second)
expect(socket.sent).toHaveLength(1)
socket.result(); socket.snapshot('LSInactive')
await first
})
it('ein verspätetes Auflegen trifft keinen Folgeanruf', async () => {
socket.snapshot('LSActive', 42)
await expect(client.hangup(1, 41)).rejects.toThrow('neu belegt')
expect(socket.sent).toHaveLength(0)
})
it('bei fehlender Ende-Bestätigung bleiben Datei und Wiederholungsmöglichkeit erhalten', async () => {
const first = client.hangup(1, 41)
const assertion = expect(first).rejects.toThrow('15 Sekunden')
socket.result(); await flush()
await vi.advanceTimersByTimeAsync(15_001)
await assertion
expect(client.canTransferRecording(recording)).toBe(false)
const retry = client.hangup(1, 41)
socket.result(); socket.snapshot('LSInactive')
await retry
expect(socket.sent).toHaveLength(2)
})
})
+288 -7
View File
@@ -2,14 +2,20 @@ import {
COMMANDS,
CONTACT_QUERY_MAX_LENGTH,
parseAddresses,
parseCallEvent,
canStartRecording,
type CallEvent,
parseHello,
parseResult,
parseSnapshot,
parseSnapshotLine,
type Contact,
type HelloMessage,
type ResultMessage,
type SnapshotMessage,
} from './protocol'
import { createRecordingId, parseReadyRecording, parseRecordingStatus, stoppedRecording,
MAX_RECORDING_BYTES, type ReadyRecording, type StoppedRecording, type RecordingStatus } from '../recordings'
export type ConnectionStatus = 'idle' | 'connecting' | 'open' | 'closing' | 'closed'
@@ -23,6 +29,11 @@ interface Listeners {
status: (status: ConnectionStatus) => void
hello: (hello: HelloMessage) => void
snapshot: (snapshot: SnapshotMessage) => void
call: (event: CallEvent) => void
callEnded: (event: CallEvent) => void
recordingStopped: (recording: StoppedRecording) => void
recordingReady: (recording: ReadyRecording) => void
recordingStatus: (recording: RecordingStatus) => void
/** Unaufgeforderter Adress-Cache der App; auch eine leere Liste zählt. */
addresses: (contacts: Contact[]) => void
raw: (message: RawMessage) => void
@@ -92,11 +103,23 @@ export class SwyxTrayClient {
private url: string | null = null
private socket: WebSocket | null = null
private nextId = 1
private latestSnapshot: SnapshotMessage | null = null
private protocolVersion = 0
private connectionGeneration = 0
private readonly recordingIds = new Map<string, string>()
private readonly endedCalls = new Set<string>()
private readonly hangups = new Map<string, Promise<ResultMessage>>()
private readonly pending = new Map<number, Pending>()
private readonly downloads = new Map<number, { recording: ReadyRecording; chunks: ArrayBuffer[]; size: number }>()
private readonly listeners: { [K in keyof Listeners]: Set<Listeners[K]> } = {
status: new Set(),
hello: new Set(),
snapshot: new Set(),
call: new Set(),
callEnded: new Set(),
recordingStopped: new Set(),
recordingReady: new Set(),
recordingStatus: new Set(),
addresses: new Set(),
raw: new Set(),
error: new Set(),
@@ -165,7 +188,12 @@ export class SwyxTrayClient {
this.pending.set(id, { resolve, reject, timer })
const message = JSON.stringify({ id, cmd, ...payload })
this.socket!.send(message)
try { this.socket!.send(message) } catch (error) {
clearTimeout(timer)
this.pending.delete(id)
reject(error)
return
}
this.emit('raw', { direction: 'out', text: message, at: new Date() })
})
}
@@ -187,12 +215,203 @@ export class SwyxTrayClient {
return this.send(COMMANDS.call, { number })
}
answer(line: number): Promise<ResultMessage> {
return this.send(COMMANDS.answer, { line })
answer(line: number, callId?: number): Promise<ResultMessage> {
return this.send(COMMANDS.answer, { line, ...(callId !== undefined ? { callId } : {}) })
}
hangup(line: number): Promise<ResultMessage> {
return this.send(COMMANDS.hangup, { line })
/** Annahme ist asynchron: erst nach Bestätigung desselben aktiven Anrufs aufnehmen. */
async answerAndRecord(line: number): Promise<ResultMessage> {
const call = this.latestSnapshot?.lines.find((entry) => entry.line === line)
if (!call || call.event !== 'incoming') throw new Error('Diese Leitung klingelt nicht mehr.')
const generation = this.connectionGeneration
const result = await this.answer(line, call.callId)
try {
if (this.protocolVersion < 10 || call.callId === undefined) {
throw new Error('Für die automatische Aufnahme muss SwyxTray auf Protokoll 10 aktualisiert werden.')
}
if (generation !== this.connectionGeneration) throw new Error('Verbindung wurde unterbrochen.')
await this.waitForConversation(line, call.callId, generation)
if (generation !== this.connectionGeneration) throw new Error('Verbindung wurde unterbrochen.')
return await this.startRecording(line, call.callId)
} catch (error) {
this.emit('error', `Anrufannahme bestätigt; Aufnahme nicht gestartet: ${error instanceof Error ? error.message : String(error)}`)
// Die Annahme war erfolgreich. Ein Aufnahmefehler darf keinen zweiten Annahmeversuch auslösen.
return result
}
}
async startRecording(line: number, callId: number): Promise<ResultMessage> {
const key = `${line}:${callId}`
const previous = this.recordingIds.get(key)
const recordingId = createRecordingId()
this.recordingIds.set(key, recordingId)
try {
const result = await this.send(COMMANDS.startRecording, { line, callId,
...(this.protocolVersion >= 11 ? { recordingId } : {}) })
this.recordingIds.set(key, result.recordingId ?? this.recordingIds.get(key) ?? recordingId)
return result
} catch (error) {
if (this.recordingIds.get(key) === recordingId) {
if (previous) this.recordingIds.set(key, previous); else this.recordingIds.delete(key)
}
throw error
}
}
async stopRecording(line: number, callId: number): Promise<ResultMessage> {
const call = this.latestSnapshot?.lines.find((entry) => entry.line === line && entry.callId === callId)
const recordingId = this.recordingId(line, callId)
const result = await this.send(COMMANDS.stopRecording, { line, callId,
...(this.protocolVersion >= 11 ? { recordingId } : {}) })
this.emit('recordingStopped', stoppedRecording(result.recordingId ?? recordingId, call ?? { line, callId }))
return result
}
/** Binary blocks use SWYX + request id + byte offset (little endian), then WAV bytes. */
async downloadRecording(recording: ReadyRecording): Promise<File> {
if (recording.transport !== 'websocket' || recording.bytes > MAX_RECORDING_BYTES || recording.bytes < 12) {
throw new Error('Ungültiger WebSocket-Aufnahmeauftrag.')
}
if (!this.canTransferRecording(recording)) throw new Error('Die WAV wird erst nach dem bestätigten Gesprächsende übertragen.')
const id = this.nextId
const transfer = { recording, chunks: [] as ArrayBuffer[], size: 0 }
this.downloads.set(id, transfer)
try {
await this.send('recording.download', { recordingId: recording.recordingId }, 300_000)
if (transfer.size !== recording.bytes) throw new Error('Die WAV-Datei wurde nicht vollständig übertragen.')
return new File(transfer.chunks, recording.filename, { type: 'audio/wav' })
} finally { this.downloads.delete(id) }
}
acknowledgeRecording(recordingId: string, jobId: string): Promise<ResultMessage> {
return this.send('recording.ack', { recordingId, jobId })
}
private receiveRecordingBlock(data: ArrayBuffer): void {
if (data.byteLength < 12) return
const header = new DataView(data)
if (header.getUint32(0, false) !== 0x53575958) return // SWYX
const id = header.getInt32(4, true)
const transfer = this.downloads.get(id)
if (!transfer) return
const size = data.byteLength - 12
if (header.getUint32(8, true) !== transfer.size || size === 0 || size > 64 * 1024
|| transfer.size + size > transfer.recording.bytes || transfer.size + size > MAX_RECORDING_BYTES) {
const pending = this.pending.get(id)
if (pending) {
clearTimeout(pending.timer)
this.pending.delete(id)
pending.reject(new Error('Ungültige Reihenfolge oder Größe der WAV-Datenblöcke.'))
}
this.downloads.delete(id)
return
}
transfer.chunks.push(data.slice(12))
transfer.size += size
}
private recordingId(line: number, callId: number): string {
const key = `${line}:${callId}`
const call = this.latestSnapshot?.lines.find((entry) => entry.line === line && entry.callId === callId)
const id = this.recordingIds.get(key) ?? call?.recordingId ?? createRecordingId()
this.recordingIds.set(key, id)
return id
}
private waitForConversation(line: number, callId: number, generation: number): Promise<void> {
return new Promise((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | undefined
const cleanup: (() => void)[] = []
const finish = (error?: Error) => {
if (timer) clearTimeout(timer)
cleanup.forEach((off) => off())
if (error) reject(error); else resolve()
}
const check = () => {
const snapshot = this.latestSnapshot
const current = snapshot?.lines.find((entry) => entry.line === line)
if (generation !== this.connectionGeneration || snapshot?.connected === false || snapshot?.serverUp === false) {
finish(new Error('Verbindung zu SwyxIt! verloren.'))
} else if (!current || current.callId !== callId) {
finish(new Error('Das Gespräch wurde bereits beendet oder ersetzt.'))
} else if (canStartRecording(current)) {
finish()
}
}
cleanup.push(this.on('snapshot', check), this.on('status', (status) => {
if (status !== 'open') finish(new Error('Verbindung unterbrochen.'))
}), this.on('call', (event) => {
if (event.event === 'ended' && event.line === line && event.callId === callId) {
finish(new Error('Das Gespräch wurde bereits beendet.'))
}
}))
timer = setTimeout(() => finish(new Error('Keine Sprechverbindung innerhalb von 15 Sekunden.')), 15000)
check()
})
}
hangup(line: number, callId?: number): Promise<ResultMessage> {
const call = this.latestSnapshot?.lines.find(c => c.line === line)
if (!call || call.event === 'ended' || this.latestSnapshot?.connected === false || this.latestSnapshot?.serverUp === false
|| (callId !== undefined && call.callId !== callId)) {
return Promise.reject(new Error('Das Gespräch ist bereits beendet oder die Leitung wurde neu belegt.'))
}
const key = this.callKey(call)
const pending = this.hangups.get(key)
if (pending) return pending
const generation = this.connectionGeneration
const operation = this.send(COMMANDS.hangup, { line, ...(call.callId !== undefined ? { callId: call.callId } : {}) })
.then(async result => {
await this.waitForCallEnd(call, generation)
return result
}).finally(() => { if (this.hangups.get(key) === operation) this.hangups.delete(key) })
this.hangups.set(key, operation)
return operation
}
canTransferRecording(recording: StoppedRecording): boolean {
if (this.latestSnapshot?.lines.some(c => c.line === recording.line && c.callId === recording.callId)) return false
if (recording.callEnded === true || this.endedCalls.has(this.callKey(recording))) return true
// After reconnect there may be no previous snapshot. Require an explicitly
// present, healthy SDK line; a missing line or disconnected server is not proof.
const snapshot = this.latestSnapshot
if (snapshot?.connected !== true || snapshot.serverUp !== true) return false
const raw = snapshot.raw as { lines?: unknown[] }
const line = Array.isArray(raw?.lines) ? raw.lines.find((entry): entry is Record<string, unknown> =>
!!entry && typeof entry === 'object' && Number((entry as Record<string, unknown>).line) === recording.line) : undefined
return !!line && (parseSnapshotLine(line) === null
|| (Number.isSafeInteger(line.callId) && line.callId !== recording.callId))
}
private callKey(call: { line: number; callId?: number }): string { return `${call.line}:${call.callId ?? ''}` }
private confirmCallEnd(call: CallEvent): void {
const key = this.callKey(call)
if (this.endedCalls.has(key)) return
this.endedCalls.add(key)
this.emit('callEnded', { ...call, event: 'ended' })
}
private waitForCallEnd(call: CallEvent, generation: number): Promise<void> {
return new Promise((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | undefined
const cleanup: (() => void)[] = []
const finish = (error?: Error) => {
if (timer) clearTimeout(timer)
cleanup.forEach(off => off())
if (error) reject(error); else resolve()
}
const check = () => {
if (generation !== this.connectionGeneration || this.latestSnapshot?.connected === false || this.latestSnapshot?.serverUp === false) {
finish(new Error('Verbindung unterbrochen; Gesprächsende konnte nicht bestätigt werden.'))
} else if (this.endedCalls.has(this.callKey(call))) finish()
}
cleanup.push(this.on('snapshot', check), this.on('callEnded', check), this.on('status', status => {
if (status !== 'open') finish(new Error('Verbindung unterbrochen; Gesprächsende konnte nicht bestätigt werden.'))
}))
timer = setTimeout(() => finish(new Error('Gesprächsende wurde innerhalb von 15 Sekunden nicht bestätigt.')), 15_000)
check()
})
}
/**
@@ -269,12 +488,19 @@ export class SwyxTrayClient {
const url = this.url
if (!url) return
this.connectionGeneration += 1
this.latestSnapshot = null
this.protocolVersion = 0
this.recordingIds.clear()
this.endedCalls.clear()
this.hangups.clear()
this.emit('status', 'connecting')
this.emit('raw', { direction: 'system', text: `Verbinde mit ${url} …`, at: new Date() })
let socket: WebSocket
try {
socket = new WebSocket(url)
socket.binaryType = 'arraybuffer'
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
this.emit('error', describeConnectFailure(url, message))
@@ -304,6 +530,8 @@ export class SwyxTrayClient {
socket.onclose = (event: CloseEvent) => {
if (this.socket !== socket) return
this.socket = null
this.latestSnapshot = null
this.connectionGeneration += 1
this.failAllPending(new Error('Verbindung zur SwyxTray-App geschlossen.'))
this.emit('status', 'closed')
this.emit('raw', {
@@ -319,6 +547,10 @@ export class SwyxTrayClient {
private handleMessage(data: unknown): void {
if (typeof data !== 'string') {
if (data instanceof ArrayBuffer) {
this.receiveRecordingBlock(data)
return
}
const size = data instanceof Blob ? data.size : data instanceof ArrayBuffer ? data.byteLength : 0
this.emit('raw', { direction: 'in', text: `[Binärdaten, ${size} Bytes]`, at: new Date() })
return
@@ -347,14 +579,58 @@ export class SwyxTrayClient {
return
}
// Die App schickt keine Anruf-Ereignisse: Jede Änderung – auch ein
// eingehender Anruf – kommt als vollständiger Snapshot.
const callEvent = parseCallEvent(parsed)
if (callEvent) {
const previous = this.latestSnapshot?.lines.find((entry) => entry.line === callEvent.line
&& (callEvent.callId === undefined || entry.callId === callEvent.callId))
if (callEvent.event === 'ended') {
if (previous && this.latestSnapshot) {
this.latestSnapshot = { ...this.latestSnapshot, lines: this.latestSnapshot.lines.filter(c => c !== previous) }
}
this.confirmCallEnd(previous ?? callEvent)
}
if (callEvent.event === 'ended' && previous?.recordingRequested && previous.callId !== undefined) {
this.emit('recordingStopped', stoppedRecording(this.recordingId(previous.line, previous.callId), previous, true))
}
this.emit('call', callEvent)
return
}
// Snapshots liefern zusätzlich den vollständigen Leitungszustand.
const snapshot = parseSnapshot(parsed)
if (snapshot) {
const previousSnapshot = this.latestSnapshot
this.latestSnapshot = snapshot
if (snapshot.connected !== false && snapshot.serverUp !== false) {
for (const previous of previousSnapshot?.lines ?? []) {
const current = snapshot.lines.find((entry) => entry.line === previous.line && entry.callId === previous.callId)
if (!current) this.confirmCallEnd(previous)
if (previous.recordingRequested && previous.callId !== undefined
&& (!current || current.recordingRequested === false)) {
this.emit('recordingStopped', stoppedRecording(this.recordingId(previous.line, previous.callId), previous, !current))
}
}
}
for (const call of snapshot.lines) {
this.endedCalls.delete(this.callKey(call))
if (call.callId !== undefined && call.recordingId) this.recordingIds.set(`${call.line}:${call.callId}`, call.recordingId)
}
this.emit('snapshot', snapshot)
return
}
const recording = this.url && parseReadyRecording(parsed, this.url)
if (recording) {
this.emit('recordingReady', recording)
return
}
const recordingStatus = parseRecordingStatus(parsed)
if (recordingStatus) {
this.emit('recordingStatus', recordingStatus)
return
}
// Der Adress-Cache dagegen kommt unaufgefordert – beim Verbinden und wenn
// die App ihn erneuert.
const addresses = parseAddresses(parsed)
@@ -365,6 +641,7 @@ export class SwyxTrayClient {
const hello = parseHello(parsed)
if (hello) {
this.protocolVersion = hello.protocol ?? 0
this.emit('hello', hello)
return
}
@@ -412,6 +689,9 @@ export class SwyxTrayClient {
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
socket.close(CLOSE_CODE_NORMAL, reason)
}
this.latestSnapshot = null
this.connectionGeneration += 1
this.emit('status', 'closed')
this.failAllPending(new Error(reason))
}
@@ -421,6 +701,7 @@ export class SwyxTrayClient {
request.reject(error)
}
this.pending.clear()
this.downloads.clear()
}
/** Beendet den Client endgültig (Seitenwechsel). */
+1 -1
View File
@@ -257,7 +257,7 @@ describe('mergeSnapshot', () => {
it('erkennt einen zweiten Anruf auf derselben Leitung', () => {
const { lines } = mergeSnapshot({}, [ringing])
const other = { ...ringing, peerNumber: '+499998888', peerName: 'Andere GmbH' }
expect(mergeSnapshot(lines, [other]).events).toEqual([other])
expect(mergeSnapshot(lines, [other]).events).toEqual([{ ...ringing, event: 'ended' }, other])
})
})
+59 -7
View File
@@ -54,10 +54,9 @@
* Beide tragen dieselben Einträge wie `contacts` – Name, Rufnummer, Beschreibung.
* Der Push ist die einzige Nachricht der App **ohne** `id` und ohne `ok`.
*
* **Sonst kennt die App keine Ereignisnachrichten.** Jede Zustandsänderung – auch ein
* eingehender Anruf – kommt als vollständiger `snapshot` über *alle* Leitungen.
* Die Anrufmeldung der Seite entsteht deshalb aus dem Vergleich zweier Snapshots
* (siehe {@link mergeSnapshot}).
* Protokoll 10 ergänzt recording.start/recording.stop mit line und callId.
* Die Tray-App sendet außerdem type:call mit incoming/outgoing/connected/ended.
* Snapshots bleiben maßgeblich; Push-Ereignisse werden ohne Dopplungen verarbeitet.
*/
export type CallEventKind = 'incoming' | 'outgoing' | 'connected' | 'ended'
@@ -67,6 +66,12 @@ export interface CallEvent {
/** 1-basierte Leitungsnummer, wie in SwyxIt! */
line: number
event: CallEventKind
/** SDK-Kennung schützt vor Wiederverwendung derselben Leitung. */
callId?: number
/** Letzter über die Tray-App bestätigter SDK-Auftrag, kein Dateinachweis. */
recordingRequested?: boolean
/** Vorbereiteter Vertrag ab Protokoll 11: UUID des aktuellen Aufnahmeabschnitts. */
recordingId?: string
/** Bei `connected` aus dem vorherigen Zustand derselben Leitung übernommen. */
direction?: CallDirection
/** Fertige Anzeigeform der App, z. B. "Muster GmbH (+493012345)". */
@@ -82,6 +87,9 @@ export interface CallEvent {
export interface ResultMessage {
id: number
ok: boolean
callId?: number
recordingRequested?: boolean
recordingId?: string
line?: number
/** Nur bei `focus`: true = Fenster geholt, false = Browser mit der URL gestartet. */
focused?: boolean
@@ -156,6 +164,8 @@ export const COMMANDS = {
call: 'call',
answer: 'answer',
hangup: 'hangup',
startRecording: 'recording.start',
stopRecording: 'recording.stop',
ping: 'ping',
status: 'status',
focus: 'focus',
@@ -204,7 +214,9 @@ export function callStateOf(state?: string, stateText?: string): CallEventKind |
const has = (...needles: string[]) => needles.some((n) => name.includes(n) || text.includes(n))
// Frei oder gerade freigeworden.
if (has('inactive', 'disabled', 'terminate', 'frei', 'beendet')) return null
if (name === 'lsnone' || has('inactive', 'disabled', 'terminate', 'frei', 'beendet')) return null
if (name === 'lsactivealerting' || name === 'lsactivealertingdc') return 'incoming'
if (name === 'lsalerting') return 'outgoing'
// Eingehend: die Leitung klingelt beim Benutzer.
if (has('ringing', 'knocking', 'klingelt', 'ruft an')) return 'incoming'
// Ausgehend: gewählt wird, die Gegenstelle wird gerufen.
@@ -229,6 +241,10 @@ export function parseSnapshotLine(raw: unknown): CallEvent | null {
return {
line,
event,
callId: asLine(raw.callId),
recordingRequested: asBoolean(raw.recordingRequested),
recordingId: typeof raw.recordingId === 'string' && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(raw.recordingId)
? raw.recordingId : undefined,
// Bei `connected` verrät der Zustand die Richtung nicht – sie kommt aus dem
// vorherigen Snapshot derselben Leitung (siehe mergeSnapshot).
direction: event === 'incoming' || event === 'outgoing' ? event : undefined,
@@ -240,6 +256,22 @@ export function parseSnapshotLine(raw: unknown): CallEvent | null {
}
}
/** Nur echte Sprechzustände lösen die Aufnahme aus, keine Schalt-/Haltezustände. */
export function canStartRecording(call: CallEvent): boolean {
return ['LSActive', 'LSConferenceActive', 'LSDirectCall', 'LSTransferring'].includes(call.state ?? '')
}
export function parseCallEvent(raw: unknown): CallEvent | null {
if (!isRecord(raw) || raw.type !== 'call') return null
const line = asLine(raw.line)
if (line === undefined || line < 1 || !['incoming', 'outgoing', 'connected', 'ended'].includes(String(raw.event))) return null
return {
line, event: raw.event as CallEventKind, callId: asLine(raw.callId),
peer: asString(raw.peer), peerNumber: asString(raw.peerNumber), peerName: asString(raw.peerName),
direction: raw.direction === 'incoming' || raw.direction === 'outgoing' ? raw.direction : undefined,
}
}
/** Liest die Quittung auf ein Kommando. */
export function parseResult(raw: unknown): ResultMessage | null {
if (!isRecord(raw)) return null
@@ -252,6 +284,10 @@ export function parseResult(raw: unknown): ResultMessage | null {
return {
id,
ok: raw.ok,
recordingId: typeof raw.recordingId === 'string' && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(raw.recordingId)
? raw.recordingId : undefined,
callId: asLine(raw.callId),
recordingRequested: asBoolean(raw.recordingRequested),
line: asLine(raw.line),
focused: asBoolean(raw.focused),
tabs: Array.isArray(raw.tabs) ? parseTabs(raw.tabs) : undefined,
@@ -355,7 +391,7 @@ export function parseSnapshot(raw: unknown): SnapshotMessage | null {
/**
* Führt einen Snapshot mit dem bisherigen Stand zusammen.
*
* Weil die App keine Ereignisse schickt, entstehen sie hier aus dem Vergleich:
* Für Snapshot-basierte Updates entstehen die Ereignisse hier aus dem Vergleich:
* neue oder veränderte Leitungen ergeben ein Ereignis, verschwundene ein
* `ended`. Die Richtung wird über den Wechsel hinweg mitgeführt, damit ein
* angenommener Anruf im Verlauf eingehend bleibt.
@@ -372,7 +408,9 @@ export function mergeSnapshot(
// Gleiche Gegenstelle ⇒ derselbe Anruf; nur dann darf etwas übernommen werden.
const sameCall =
before !== undefined &&
(entry.peerNumber ?? entry.peer) === (before.peerNumber ?? before.peer)
(entry.callId !== undefined && before.callId !== undefined
? entry.callId === before.callId
: (entry.peerNumber ?? entry.peer) === (before.peerNumber ?? before.peer))
const merged: CallEvent = {
...entry,
@@ -383,6 +421,7 @@ export function mergeSnapshot(
}
next[entry.line] = merged
if (before && !sameCall) events.push({ ...before, event: 'ended' })
if (!before || before.event !== merged.event || !sameCall) {
events.push(merged)
}
@@ -397,6 +436,19 @@ export function mergeSnapshot(
return { lines: next, events: events.sort((a, b) => a.line - b.line) }
}
/** Explizites Ende ergänzen, sofern der Snapshot es nicht schon verarbeitet hat. */
export function mergeEndedEvent(previous: Record<number, CallEvent>, event: CallEvent):
{ lines: Record<number, CallEvent>; events: CallEvent[] } {
const call = previous[event.line]
if (event.event !== 'ended' || !call || (event.callId !== undefined && event.callId !== call.callId)) {
return { lines: previous, events: [] }
}
const lines = { ...previous }
delete lines[event.line]
return { lines, events: [{ ...call, ...event, direction: event.direction ?? call.direction,
peer: event.peer ?? call.peer, peerNumber: event.peerNumber ?? call.peerNumber, peerName: event.peerName ?? call.peerName }] }
}
/** Anzeigename einer Leitung: fertige Anzeigeform, sonst Name oder Nummer. */
export function describeCall(event: CallEvent): string {
return event.peer ?? event.peerName ?? event.peerNumber ?? `Leitung ${event.line}`
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { canStartRecording, mergeEndedEvent, mergeSnapshot, parseCallEvent, parseSnapshotLine, type CallEvent } from './protocol'
describe('Aufnahmeprotokoll 10', () => {
it('liest Anrufkennung und den bestätigten Aufnahmeauftrag', () => {
const call = parseSnapshotLine({ line: 1, state: 'LSActive', callId: 42, recordingRequested: true })!
expect(call.callId).toBe(42)
expect(call.recordingRequested).toBe(true)
expect(canStartRecording(call)).toBe(true)
})
it.each(['LSWaitingForMedia', 'LSOnHold', 'LSRinging', 'unknown'])('startet nicht im Zustand %s', (state) => {
expect(canStartRecording({ line: 1, event: 'connected', state })).toBe(false)
})
it('unterscheidet dieselbe Rufnummer auf wiederverwendeter Leitung', () => {
const old: CallEvent = { line: 1, event: 'connected', peerNumber: '123', callId: 41, direction: 'incoming' }
const fresh: CallEvent = { line: 1, event: 'connected', peerNumber: '123', callId: 42 }
const result = mergeSnapshot({ 1: old }, [fresh])
expect(result.events.map((call) => [call.event, call.callId])).toEqual([['ended', 41], ['connected', 42]])
expect(result.lines[1].direction).toBeUndefined()
})
it('Aufnahmeänderung ist kein weiteres connected-Ereignis', () => {
const call: CallEvent = { line: 1, event: 'connected', callId: 41, recordingRequested: false }
const result = mergeSnapshot({ 1: call }, [{ ...call, recordingRequested: true }])
expect(result.events).toEqual([])
expect(result.lines[1].recordingRequested).toBe(true)
})
it('liest ended und weist ungültige Meldungen ab', () => {
expect(parseCallEvent({ type: 'call', event: 'ended', line: 1, callId: 41 })).toMatchObject({ event: 'ended', line: 1, callId: 41 })
expect(parseCallEvent({ type: 'call', event: 'unknown', line: 1 })).toBeNull()
expect(parseCallEvent({ type: 'call', event: 'ended', line: 0 })).toBeNull()
})
})
describe('Ende-Ereignisse ohne doppelte Benachrichtigungen', () => {
const call: CallEvent = { line: 1, callId: 41, event: 'connected', direction: 'incoming' }
const end = parseCallEvent({ type: 'call', line: 1, callId: 41, event: 'ended' })!
it('Snapshot vor Ereignis', () => {
const snapshot = mergeSnapshot({ 1: call }, [])
expect(snapshot.events).toHaveLength(1)
expect(mergeEndedEvent(snapshot.lines, end).events).toEqual([])
})
it('Ereignis vor Snapshot', () => {
const event = mergeEndedEvent({ 1: call }, end)
expect(event.events).toHaveLength(1)
expect(event.events[0].direction).toBe('incoming')
expect(mergeSnapshot(event.lines, []).events).toEqual([])
})
it('Verspätetes Ende trifft keinen neuen Anruf', () => {
const event = mergeEndedEvent({ 1: { ...call, callId: 42 } }, end)
expect(event.events).toEqual([])
expect(event.lines[1].callId).toBe(42)
})
})