Gesprächsaufnahmen mit lokalem Whisper und LLM verarbeiten
This commit is contained in:
1 parent
373081d33d
commit
55e5d34e83
39 files changed
+2931
-74
No files matched your search
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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). */
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user