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
@@ -1,4 +1,4 @@
|
||||
import { describeCall, type CallEvent, type CallEventKind } from '../swyx/protocol'
|
||||
import { canStartRecording, describeCall, type CallEvent, type CallEventKind } from '../swyx/protocol'
|
||||
|
||||
const EVENT_LABELS: Record<CallEventKind, string> = {
|
||||
incoming: 'klingelt',
|
||||
@@ -11,11 +11,13 @@ interface Props {
|
||||
calls: CallEvent[]
|
||||
busy: boolean
|
||||
onAnswer: (line: number) => void
|
||||
onHangup: (line: number) => void
|
||||
onHangup: (line: number, callId?: number) => void
|
||||
onStartRecording: (line: number, callId: number) => void
|
||||
onStopRecording: (line: number, callId: number) => void
|
||||
}
|
||||
|
||||
/** Belegte Leitungen mit Aktionen. */
|
||||
export function ActiveCallList({ calls, busy, onAnswer, onHangup }: Props) {
|
||||
export function ActiveCallList({ calls, busy, onAnswer, onHangup, onStartRecording, onStopRecording }: Props) {
|
||||
if (calls.length === 0) {
|
||||
return <p className="note">Zurzeit keine belegte Leitung.</p>
|
||||
}
|
||||
@@ -31,8 +33,18 @@ export function ActiveCallList({ calls, busy, onAnswer, onHangup }: Props) {
|
||||
<span className="calls__number">{describeCall(call)}</span>
|
||||
{/* Klartext der App bevorzugen – er ist genauer als unsere vier Zustände. */}
|
||||
<span className="calls__state">{call.stateText ?? EVENT_LABELS[call.event]}</span>
|
||||
{call.recordingRequested && <span className="calls__state">● Aufnahme angefordert</span>}
|
||||
</div>
|
||||
<div className="calls__actions">
|
||||
{call.callId !== undefined && call.event === 'connected' && (
|
||||
<>
|
||||
<button type="button" className="button button--small"
|
||||
disabled={busy || !canStartRecording(call) || call.recordingRequested === true}
|
||||
onClick={() => onStartRecording(call.line, call.callId!)}>Aufnahme starten</button>
|
||||
<button type="button" className="button button--small" disabled={busy}
|
||||
onClick={() => onStopRecording(call.line, call.callId!)}>Aufnahme stoppen</button>
|
||||
</>
|
||||
)}
|
||||
{call.event === 'incoming' && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -46,8 +58,8 @@ export function ActiveCallList({ calls, busy, onAnswer, onHangup }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
className="button button--reject button--small"
|
||||
disabled={busy}
|
||||
onClick={() => onHangup(call.line)}
|
||||
disabled={busy || call.event === 'ended'}
|
||||
onClick={() => onHangup(call.line, call.callId)}
|
||||
>
|
||||
Auflegen
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { useRecordingHandoffs } from '../hooks/useRecordingHandoffs'
|
||||
|
||||
export default function RecordingHandoffs({ handoffs }: { handoffs: ReturnType<typeof useRecordingHandoffs> }) {
|
||||
if (!handoffs.recordings.length) return null
|
||||
return <section className="card recording-handoffs" aria-labelledby="recordings-heading">
|
||||
<h2 id="recordings-heading">Aufnahmen · Transkript & Zusammenfassung</h2>
|
||||
{handoffs.configError && <p className="note note--error" role="alert">{handoffs.configError}</p>}
|
||||
{handoffs.enabled === false && <p className="note">Die Übergabe ist vorbereitet. Die Transkription muss noch eingerichtet werden.</p>}
|
||||
{handoffs.recordings.map((entry) => <article className="recording-handoff" key={entry.recordingId}>
|
||||
<h3>{entry.peerName || entry.peerNumber || `Anruf ${entry.callId}`} · Leitung {entry.line}</h3>
|
||||
<p role="status">{entry.state === 'uploading' ? 'Aufnahme wird zur Transkription übergeben …'
|
||||
: entry.job ? entry.job.stage
|
||||
: entry.jobId ? 'Transkript wird geladen …'
|
||||
: !entry.callEnded ? 'Aufnahme gestoppt. Die WAV wird automatisch nach Gesprächsende übertragen – unabhängig davon, wer auflegt.'
|
||||
: entry.ready ? `WAV-Datei bereit: ${entry.ready.filename}`
|
||||
: 'Aufnahme beendet. Übergabe wartet auf die fertige WAV-Datei.'}</p>
|
||||
{(entry.error || entry.job?.error) && <p className="note note--error" role="alert">{entry.error || entry.job?.error}</p>}
|
||||
{entry.callEnded && entry.automatic && !entry.jobId && (entry.state !== 'error' || entry.retryAt !== undefined)
|
||||
&& <p className="note">Die Übertragung erfolgt automatisch über die offene Verbindung. Bei einer Unterbrechung wird sie erneut versucht.</p>}
|
||||
{entry.callEnded && !entry.jobId && (!entry.automatic || entry.state === 'error') && <>
|
||||
{!entry.ready && <p className="note">Bei Bedarf kann die WAV-Datei aus SwyxIt! hier manuell zugeordnet werden.</p>}
|
||||
<label className="recording-handoff__file">WAV-Datei übergeben
|
||||
<input type="file" accept=".wav,audio/wav,audio/x-wav" disabled={!handoffs.enabled || entry.state === 'uploading'}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) void handoffs.upload(entry.recordingId, file)
|
||||
event.target.value = ''
|
||||
}} />
|
||||
</label>
|
||||
{entry.ready && entry.state === 'error' && <button type="button" className="button button--small"
|
||||
disabled={!handoffs.enabled} onClick={() => void handoffs.upload(entry.recordingId)}>Übergabe erneut versuchen</button>}
|
||||
</>}
|
||||
{entry.job?.status === 'failed' && <button type="button" className="button button--small"
|
||||
disabled={entry.state === 'uploading'} onClick={() => void handoffs.retry(entry.recordingId)}>Verarbeitung erneut versuchen</button>}
|
||||
{(entry.job?.formatted_transcript || entry.job?.transcript) && <details open>
|
||||
<summary>Gesprächstranskript</summary>
|
||||
<p className="recording-handoff__text">{entry.job.formatted_transcript || entry.job.transcript}</p>
|
||||
</details>}
|
||||
{entry.job?.transcript && entry.job.status === 'summarizing'
|
||||
&& <p className="note">Das Transkript ist fertig. Die Zusammenfassung wird noch erstellt.</p>}
|
||||
{entry.job?.summary && <details open><summary>Zusammenfassung</summary><p className="recording-handoff__text">{entry.job.summary}</p></details>}
|
||||
</article>)}
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { downloadRecording, fetchTranscription, uploadRecording, RecordingUploadError, type RecordingHandoff, type StoppedRecording } from '../recordings'
|
||||
import type { SwyxTrayClient } from '../swyx/SwyxTrayClient'
|
||||
|
||||
/** Tray keeps the WAV until SwyxWeb has received the upstream job and acknowledged it. */
|
||||
export function useRecordingHandoffs(client: SwyxTrayClient) {
|
||||
const entries = useRef(new Map<string, RecordingHandoff>())
|
||||
const uploading = useRef(new Set<string>())
|
||||
const [recordings, setRecordings] = useState<RecordingHandoff[]>([])
|
||||
const [enabled, setEnabled] = useState<boolean | null>(null)
|
||||
const [configError, setConfigError] = useState<string>()
|
||||
|
||||
const update = useCallback((entry: RecordingHandoff) => {
|
||||
entries.current.set(entry.recordingId, entry)
|
||||
setRecordings([...entries.current.values()].reverse())
|
||||
}, [])
|
||||
|
||||
const prepare = useCallback((recording: StoppedRecording) => {
|
||||
const callEnded = client.canTransferRecording(recording)
|
||||
const current = entries.current.get(recording.recordingId)
|
||||
if (!current) update({ ...recording, callEnded, state: callEnded ? 'waiting' : 'waiting_call_end' })
|
||||
else if (callEnded && !current.callEnded) update({ ...current, callEnded, state: 'waiting' })
|
||||
}, [client, update])
|
||||
|
||||
const upload = useCallback(async (recordingId: string, selectedFile?: File) => {
|
||||
const entry = entries.current.get(recordingId)
|
||||
if (!entry || entry.jobId || uploading.current.size || !client.canTransferRecording(entry)) return
|
||||
uploading.current.add(recordingId)
|
||||
update({ ...entry, state: 'uploading', error: undefined })
|
||||
try {
|
||||
const file = selectedFile ?? (entry.ready?.transport === 'websocket'
|
||||
? await client.downloadRecording(entry.ready)
|
||||
: entry.ready ? await downloadRecording(entry.ready) : null)
|
||||
if (!file) throw new Error('Bitte die fertige WAV-Datei auswählen.')
|
||||
const job = await uploadRecording(recordingId, file)
|
||||
update({ ...entries.current.get(recordingId)!, state: 'submitted', job, jobId: job.id, error: undefined })
|
||||
} catch (error) {
|
||||
const current = entries.current.get(recordingId)!
|
||||
// Another tab may have completed the same idempotent upload while this attempt failed.
|
||||
if (!current.jobId) update({ ...current, state: 'error',
|
||||
retryAt: error instanceof RecordingUploadError && !error.retryable ? undefined : Date.now() + 15_000,
|
||||
error: error instanceof Error ? error.message : String(error) })
|
||||
} finally { uploading.current.delete(recordingId) }
|
||||
}, [client, update])
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const load = () => {
|
||||
fetch('/api/transcriptions/config', { signal: controller.signal })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error('Transkriptionskonfiguration konnte nicht geladen werden.')
|
||||
const config = await response.json()
|
||||
if (typeof config.enabled !== 'boolean') throw new Error('Ungültige Transkriptionskonfiguration.')
|
||||
setEnabled(config.enabled); setConfigError(undefined)
|
||||
}).catch((error: unknown) => {
|
||||
if (!controller.signal.aborted) setConfigError(error instanceof Error ? error.message : String(error))
|
||||
})
|
||||
}
|
||||
load()
|
||||
const timer = setInterval(load, 30_000)
|
||||
return () => { controller.abort(); clearInterval(timer) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const offStopped = client.on('recordingStopped', prepare)
|
||||
const offReady = client.on('recordingReady', (recording) => {
|
||||
prepare(recording)
|
||||
const existing = entries.current.get(recording.recordingId)!
|
||||
if (existing.line !== recording.line || existing.callId !== recording.callId) return
|
||||
const callEnded = client.canTransferRecording(recording)
|
||||
update({ ...existing, ...recording, ready: recording, automatic: recording.transport === 'websocket',
|
||||
callEnded,
|
||||
acknowledged: false, retryAt: undefined,
|
||||
state: existing.jobId ? 'submitted' : existing.state === 'uploading' ? 'uploading' : callEnded ? 'waiting' : 'waiting_call_end' })
|
||||
})
|
||||
const offStatus = client.on('recordingStatus', (recording) => {
|
||||
prepare(recording)
|
||||
const existing = entries.current.get(recording.recordingId)!
|
||||
if (existing.line !== recording.line || existing.callId !== recording.callId) return
|
||||
if (recording.state === 'submitted') {
|
||||
update({ ...existing, ...recording, acknowledged: true, automatic: true, state: 'submitted' })
|
||||
} else if (!existing.jobId && existing.state !== 'uploading') {
|
||||
const callEnded = client.canTransferRecording(recording)
|
||||
update({ ...existing, ...recording, callEnded, automatic: true,
|
||||
state: callEnded ? recording.state === 'error' ? 'error' : 'waiting' : 'waiting_call_end' })
|
||||
}
|
||||
})
|
||||
const offEnd = client.on('callEnded', call => {
|
||||
for (const entry of entries.current.values()) {
|
||||
if (entry.line === call.line && entry.callId === call.callId && !entry.jobId) {
|
||||
update({ ...entry, callEnded: true, state: entry.state === 'waiting_call_end' ? 'waiting' : entry.state })
|
||||
}
|
||||
}
|
||||
})
|
||||
const offSnapshot = client.on('snapshot', () => {
|
||||
for (const entry of entries.current.values()) {
|
||||
if (!entry.callEnded && !entry.jobId && client.canTransferRecording(entry)) {
|
||||
update({ ...entry, callEnded: true, state: entry.state === 'waiting_call_end' ? 'waiting' : entry.state })
|
||||
}
|
||||
}
|
||||
})
|
||||
return () => { offStopped(); offReady(); offStatus(); offEnd(); offSnapshot() }
|
||||
}, [client, prepare, update])
|
||||
|
||||
// One transfer at a time per browser. Native WAVs retry automatically while the page is open.
|
||||
useEffect(() => {
|
||||
if (!enabled || !client.isConnected() || uploading.current.size) return
|
||||
const next = recordings.filter(e => e.ready && !e.jobId && client.canTransferRecording(e) &&
|
||||
(e.state === 'waiting' || (e.state === 'error' && e.automatic && e.retryAt !== undefined)))
|
||||
.sort((a, b) => (a.retryAt ?? 0) - (b.retryAt ?? 0))[0]
|
||||
if (!next) return
|
||||
const timer = setTimeout(() => void upload(next.recordingId), Math.max(0, (next.retryAt ?? 0) - Date.now()))
|
||||
return () => clearTimeout(timer)
|
||||
}, [enabled, recordings, upload, client])
|
||||
|
||||
useEffect(() => {
|
||||
const pending = recordings.filter(e => e.jobId && (!e.job || !['done', 'failed'].includes(e.job.status)
|
||||
|| (e.automatic && !e.acknowledged)))
|
||||
if (!pending.length) return
|
||||
let cancelled = false
|
||||
const timer = setTimeout(() => {
|
||||
void Promise.all(pending.map(async (entry) => {
|
||||
let job = entry.job
|
||||
let acknowledged = entry.acknowledged
|
||||
let error: string | undefined
|
||||
try {
|
||||
if (!job || !['done', 'failed'].includes(job.status)) job = await fetchTranscription(entry.jobId!)
|
||||
if (entry.automatic && !acknowledged && client.isConnected()) {
|
||||
await client.acknowledgeRecording(entry.recordingId, entry.jobId!)
|
||||
acknowledged = true
|
||||
}
|
||||
} catch (e) { error = e instanceof Error ? e.message : String(e) }
|
||||
if (!cancelled) {
|
||||
const current = entries.current.get(entry.recordingId)!
|
||||
update({ ...current, job, acknowledged: current.acknowledged || acknowledged, error })
|
||||
}
|
||||
}))
|
||||
}, 3000)
|
||||
return () => { cancelled = true; clearTimeout(timer) }
|
||||
}, [recordings, client, update])
|
||||
|
||||
const retry = useCallback(async (recordingId: string) => {
|
||||
const entry = entries.current.get(recordingId)
|
||||
if (!entry?.jobId || uploading.current.size) return
|
||||
uploading.current.add(recordingId)
|
||||
update({ ...entry, state: 'uploading', error: undefined })
|
||||
try {
|
||||
update({ ...entry, state: 'submitted', job: await fetchTranscription(entry.jobId, true), error: undefined })
|
||||
} catch (error) {
|
||||
update({ ...entry, state: 'submitted', error: error instanceof Error ? error.message : String(error) })
|
||||
} finally { uploading.current.delete(recordingId) }
|
||||
}, [update])
|
||||
|
||||
return { recordings, enabled, configError, upload, retry }
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { SwyxTrayClient, type ConnectionStatus, type RawMessage } from '../swyx/SwyxTrayClient'
|
||||
import { mergeSnapshot, type CallEvent, type Contact, type SnapshotMessage } from '../swyx/protocol'
|
||||
import { mergeEndedEvent, mergeSnapshot, type CallEvent, type Contact, type SnapshotMessage } from '../swyx/protocol'
|
||||
import { loadDirectory as loadDirectoryFrom, type SweepOptions } from '../swyx/directory'
|
||||
import { reportCallEvents } from '../calls'
|
||||
import { reportAddresses } from '../addresses'
|
||||
import { useRecordingHandoffs } from './useRecordingHandoffs'
|
||||
|
||||
export interface LogEntry extends RawMessage {
|
||||
id: number
|
||||
@@ -31,12 +32,14 @@ export function useSwyxTray() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [log, setLog] = useState<LogEntry[]>([])
|
||||
const [history, setHistory] = useState<CallEvent[]>([])
|
||||
const [endedCall, setEndedCall] = useState<CallEvent | null>(null)
|
||||
// Belegte Leitungen, nach Leitungsnummer; jeder Snapshot schreibt sie neu.
|
||||
const [lines, setLines] = useState<Record<number, CallEvent>>({})
|
||||
const [tray, setTray] = useState<TrayState>({})
|
||||
// Adress-Cache der App; null heißt „noch keine Cache-Nachricht erhalten".
|
||||
const [addressCache, setAddressCache] = useState<Contact[] | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const pendingActions = useRef(0)
|
||||
|
||||
const clientRef = useRef<SwyxTrayClient | null>(null)
|
||||
const logIdRef = useRef(0)
|
||||
@@ -47,8 +50,16 @@ export function useSwyxTray() {
|
||||
clientRef.current = new SwyxTrayClient()
|
||||
}
|
||||
const client = clientRef.current
|
||||
const handoffs = useRecordingHandoffs(client)
|
||||
|
||||
useEffect(() => {
|
||||
const remember = (events: CallEvent[]) => {
|
||||
if (!events.length) return
|
||||
setHistory((entries) => [...events].reverse().concat(entries).slice(0, MAX_HISTORY))
|
||||
const ended = events.find((event) => event.event === 'ended')
|
||||
if (ended) setEndedCall(ended)
|
||||
reportCallEvents(events)
|
||||
}
|
||||
const unsubscribe = [
|
||||
client.on('status', (next) => {
|
||||
setStatus(next)
|
||||
@@ -77,6 +88,15 @@ export function useSwyxTray() {
|
||||
return next.length > MAX_LOG_ENTRIES ? next.slice(next.length - MAX_LOG_ENTRIES) : next
|
||||
})
|
||||
}),
|
||||
client.on('call', (event) => {
|
||||
// Der Server schickt normalerweise zuerst den Snapshot: dann ist das Ende
|
||||
// bereits verarbeitet. Auch die umgekehrte Reihenfolge bleibt eindeutig.
|
||||
const { lines: next, events } = mergeEndedEvent(linesRef.current, event)
|
||||
if (!events.length) return
|
||||
linesRef.current = next
|
||||
setLines(next)
|
||||
remember(events)
|
||||
}),
|
||||
client.on('snapshot', (snapshot: SnapshotMessage) => {
|
||||
setTray({
|
||||
connected: snapshot.connected,
|
||||
@@ -90,14 +110,16 @@ export function useSwyxTray() {
|
||||
// Verlauf und Benachrichtigung entstehen aus dem Vergleich mit ihm.
|
||||
// Der Vergleich läuft über die Ref, nicht im State-Updater – der wird
|
||||
// im StrictMode doppelt ausgeführt und würde den Verlauf verdoppeln.
|
||||
// Ein Verbindungsabbruch beweist kein Gesprächsende.
|
||||
if (snapshot.connected === false || snapshot.serverUp === false) {
|
||||
linesRef.current = {}
|
||||
setLines({})
|
||||
return
|
||||
}
|
||||
const { lines: next, events } = mergeSnapshot(linesRef.current, snapshot.lines)
|
||||
linesRef.current = next
|
||||
setLines(next)
|
||||
if (events.length > 0) {
|
||||
setHistory((entries) => [...[...events].reverse(), ...entries].slice(0, MAX_HISTORY))
|
||||
// Dauerhaft in die MongoDB, über das Backend – die Anzeige wartet nicht darauf.
|
||||
reportCallEvents(events)
|
||||
}
|
||||
remember(events)
|
||||
}),
|
||||
]
|
||||
return () => {
|
||||
@@ -113,6 +135,7 @@ export function useSwyxTray() {
|
||||
|
||||
/** Führt ein Kommando aus und übernimmt Fehleranzeige und Busy-Zustand. */
|
||||
const run = useCallback(async <T,>(action: () => Promise<T>): Promise<T | null> => {
|
||||
pendingActions.current += 1
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -121,13 +144,17 @@ export function useSwyxTray() {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
return null
|
||||
} finally {
|
||||
setBusy(false)
|
||||
pendingActions.current -= 1
|
||||
setBusy(pendingActions.current > 0)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const dial = useCallback((number: string) => run(() => client.dial(number)), [client, run])
|
||||
const answer = useCallback((line: number) => run(() => client.answer(line)), [client, run])
|
||||
const hangup = useCallback((line: number) => run(() => client.hangup(line)), [client, run])
|
||||
const answer = useCallback((line: number) => run(() => client.answerAndRecord(line)), [client, run])
|
||||
const hangup = useCallback((line: number, callId?: number) => run(() => client.hangup(line, callId)), [client, run])
|
||||
const startRecording = useCallback((line: number, callId: number) => run(() => client.startRecording(line, callId)), [client, run])
|
||||
const stopRecording = useCallback((line: number, callId: number) => run(() => client.stopRecording(line, callId)), [client, run])
|
||||
const dismissEndedCall = useCallback(() => setEndedCall(null), [])
|
||||
const refresh = useCallback(() => run(() => client.requestStatus()), [client, run])
|
||||
const focusWindow = useCallback(
|
||||
(title: string, url?: string) => run(() => client.focusWindow(title, url)),
|
||||
@@ -190,10 +217,15 @@ export function useSwyxTray() {
|
||||
)
|
||||
|
||||
return {
|
||||
handoffs,
|
||||
status,
|
||||
error,
|
||||
log,
|
||||
history,
|
||||
endedCall,
|
||||
dismissEndedCall,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
calls,
|
||||
ringingCall,
|
||||
tray,
|
||||
|
||||
@@ -1087,3 +1087,9 @@ a.button {
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* Übergabe fertiggestellter Gesprächsaufnahmen. */
|
||||
.recording-handoff { padding: 1rem 0; border-top: 1px solid var(--border, #ddd); }
|
||||
.recording-handoff h3 { margin: 0 0 .5rem; font-size: 1rem; }
|
||||
.recording-handoff__file { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; margin: .75rem 0; }
|
||||
.recording-handoff__text { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.recording-handoff details { margin-top: .75rem; }
|
||||
@@ -18,6 +18,7 @@ import WebhookPanel from '../components/WebhookPanel'
|
||||
import Tabs, { panelId, tabId, type Tab } from '../components/Tabs'
|
||||
import { ActiveCallList, CallHistory } from '../components/CallList'
|
||||
import CallDashboard from '../components/CallDashboard'
|
||||
import RecordingHandoffs from '../components/RecordingHandoffs'
|
||||
|
||||
type TabName = 'calls' | 'contacts' | 'webhook' | 'browsertabs' | 'connection'
|
||||
|
||||
@@ -202,6 +203,19 @@ export default function HomePage() {
|
||||
</header>
|
||||
|
||||
<main className="page__main">
|
||||
{error && <p role="alert" className="note note--error">{error}</p>}
|
||||
<RecordingHandoffs handoffs={tray.handoffs} />
|
||||
{tray.endedCall && <div role="status" className="card">
|
||||
Gespräch auf Leitung {tray.endedCall.line} beendet.
|
||||
<button type="button" className="button button--small" onClick={tray.dismissEndedCall}>Schließen</button>
|
||||
</div>}
|
||||
{!dev && calls.length > 0 && <section className="card">
|
||||
<h2>Aktuelle Gespräche</h2>
|
||||
<ActiveCallList calls={calls} busy={busy}
|
||||
onAnswer={(line) => void tray.answer(line)} onHangup={(line, callId) => void tray.hangup(line, callId)}
|
||||
onStartRecording={(line, callId) => void tray.startRecording(line, callId)}
|
||||
onStopRecording={(line, callId) => void tray.stopRecording(line, callId)} />
|
||||
</section>}
|
||||
<div hidden={dev}>
|
||||
<CallDashboard active={!dev} />
|
||||
</div>
|
||||
@@ -226,7 +240,9 @@ export default function HomePage() {
|
||||
calls={calls}
|
||||
busy={busy}
|
||||
onAnswer={(line) => void tray.answer(line)}
|
||||
onHangup={(line) => void tray.hangup(line)}
|
||||
onHangup={(line, callId) => void tray.hangup(line, callId)}
|
||||
onStartRecording={(line, callId) => void tray.startRecording(line, callId)}
|
||||
onStopRecording={(line, callId) => void tray.stopRecording(line, callId)}
|
||||
/>
|
||||
{isConnected && (appState.statusText || appState.user) && (
|
||||
<p className={`note${appState.connected === false ? ' note--error' : ''}`}>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createRecordingId, downloadRecording, fetchTranscription, MAX_RECORDING_BYTES, parseReadyRecording, uploadRecording } from './recordings'
|
||||
|
||||
const recordingId = '12345678-1234-1234-1234-123456789abc'
|
||||
const message = { type: 'recording.ready', recordingId, line: 1, callId: 42,
|
||||
filename: 'Gespräch.wav', bytes: 12, downloadPath: `/recordings/${recordingId}` }
|
||||
const job = { id: 'a'.repeat(32), status: 'queued', stage: 'Wartet' }
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('WAV-Übergabe', () => {
|
||||
it('erzeugt UUIDs auch auf HTTP-LAN-Seiten ohne randomUUID', () => {
|
||||
vi.stubGlobal('crypto', { getRandomValues: (bytes: Uint8Array) => bytes.fill(0) })
|
||||
expect(createRecordingId()).toBe('00000000-0000-4000-8000-000000000000')
|
||||
})
|
||||
it('bindet den Download an den verbundenen Tray-Server', () => {
|
||||
expect(parseReadyRecording(message, 'wss://tray.local:17654/ws')?.downloadUrl)
|
||||
.toBe(`https://tray.local:17654/recordings/${recordingId}`)
|
||||
})
|
||||
it.each([
|
||||
{ downloadPath: 'https://example.org/private' }, { downloadPath: '/recordings/../secret' },
|
||||
{ downloadPath: `//example.org/recordings/${recordingId}` }, { filename: '..\\call.wav' },
|
||||
{ bytes: MAX_RECORDING_BYTES + 1 }, { callId: 0 }, { recordingId: 'bad' }, { filename: 'call.mp3' },
|
||||
])('verwirft ungültige Dateimeldungen: %o', (change) => {
|
||||
expect(parseReadyRecording({ ...message, ...change }, 'ws://localhost/ws')).toBeNull()
|
||||
})
|
||||
it('sendet WAV, Aufnahme-Kennung und Pipeline-Einstellungen als Multipart', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(job)))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
const file = new File(['RIFFxxxxWAVE'], 'call.wav', { type: 'audio/wav' })
|
||||
expect(await uploadRecording(recordingId, file)).toEqual(job)
|
||||
const [url, options] = fetch.mock.calls[0]
|
||||
expect(url).toBe('/api/transcriptions')
|
||||
expect(options.body.get('recordingId')).toBe(recordingId)
|
||||
expect(options.body.get('file').name).toBe('call.wav')
|
||||
expect(options.body.get('language')).toBe('de')
|
||||
expect(options.body.get('style')).toBe('meeting')
|
||||
})
|
||||
it('meldet fehlende Konfiguration und ungültige Antworten', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ detail: 'Nicht konfiguriert' }), { status: 503 }))
|
||||
.mockResolvedValueOnce(new Response('{}')))
|
||||
await expect(uploadRecording(recordingId, new File(['RIFFxxxxWAVE'], 'call.wav'))).rejects.toThrow('Nicht konfiguriert')
|
||||
await expect(fetchTranscription(job.id)).rejects.toThrow('keinen gültigen Auftrag')
|
||||
})
|
||||
it('lädt nur vollständige Dateien ohne Weiterleitungen', async () => {
|
||||
const fetch = vi.fn().mockResolvedValueOnce(new Response('RIFFxxxxWAVE'))
|
||||
.mockResolvedValueOnce(new Response('short')).mockResolvedValueOnce(new Response('RIFFxxxxWAVEtoo long'))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
const recording = parseReadyRecording(message, 'ws://localhost:17654/ws')!
|
||||
expect((await downloadRecording(recording)).size).toBe(12)
|
||||
expect(fetch.mock.calls[0][1]).toMatchObject({ credentials: 'omit', redirect: 'error' })
|
||||
await expect(downloadRecording(recording)).rejects.toThrow('nicht vollständig')
|
||||
await expect(downloadRecording(recording)).rejects.toThrow('größer')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { CallEvent } from './swyx/protocol'
|
||||
|
||||
export const MAX_RECORDING_BYTES = 500 * 1024 * 1024
|
||||
export interface StoppedRecording {
|
||||
recordingId: string
|
||||
line: number
|
||||
callId: number
|
||||
peerNumber?: string
|
||||
peerName?: string
|
||||
callEnded?: boolean
|
||||
}
|
||||
export interface ReadyRecording extends StoppedRecording {
|
||||
filename: string
|
||||
bytes: number
|
||||
transport?: 'websocket'
|
||||
downloadUrl?: string
|
||||
}
|
||||
export interface RecordingStatus extends StoppedRecording {
|
||||
state: 'waiting_call_end' | 'waiting' | 'error' | 'submitted'
|
||||
jobId?: string
|
||||
error?: string
|
||||
}
|
||||
export interface TranscriptionJob {
|
||||
id: string
|
||||
filename: string
|
||||
status: 'queued' | 'transcribing' | 'diarizing' | 'summarizing' | 'done' | 'failed'
|
||||
stage: string
|
||||
error?: string
|
||||
transcript?: string
|
||||
formatted_transcript?: string
|
||||
summary?: string
|
||||
}
|
||||
export interface RecordingHandoff extends StoppedRecording {
|
||||
state: 'waiting_call_end' | 'waiting' | 'uploading' | 'submitted' | 'error'
|
||||
ready?: ReadyRecording
|
||||
job?: TranscriptionJob
|
||||
jobId?: string
|
||||
error?: string
|
||||
retryAt?: number
|
||||
automatic?: boolean
|
||||
acknowledged?: boolean
|
||||
}
|
||||
|
||||
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i
|
||||
|
||||
export class RecordingUploadError extends Error {
|
||||
constructor(message: string, readonly retryable: boolean) { super(message) }
|
||||
}
|
||||
|
||||
/** randomUUID fehlt auf HTTP-LAN-Adressen; getRandomValues funktioniert auch dort. */
|
||||
export function createRecordingId(): string {
|
||||
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID()
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16))
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
}
|
||||
|
||||
/** Vereinbarte Erweiterung für Protokoll 11; niemals einen beliebigen Dateipfad abrufen. */
|
||||
export function parseReadyRecording(raw: unknown, websocketUrl: string): ReadyRecording | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const value = raw as Record<string, unknown>
|
||||
if (value.type !== 'recording.ready' || typeof value.recordingId !== 'string' || !UUID.test(value.recordingId)
|
||||
|| !Number.isSafeInteger(value.line) || Number(value.line) < 1
|
||||
|| !Number.isSafeInteger(value.callId) || Number(value.callId) < 1
|
||||
|| typeof value.filename !== 'string' || !/^[^/\\\r\n]+\.wav$/i.test(value.filename)
|
||||
|| !Number.isSafeInteger(value.bytes) || Number(value.bytes) < 12 || Number(value.bytes) > MAX_RECORDING_BYTES) return null
|
||||
const common = { recordingId: value.recordingId, line: Number(value.line), callId: Number(value.callId),
|
||||
peerName: typeof value.peerName === 'string' ? value.peerName : undefined,
|
||||
peerNumber: typeof value.peerNumber === 'string' ? value.peerNumber : undefined,
|
||||
callEnded: value.callEnded === true,
|
||||
filename: value.filename, bytes: Number(value.bytes) }
|
||||
if (value.transport === 'websocket') return { ...common, transport: 'websocket' }
|
||||
if (value.downloadPath !== `/recordings/${value.recordingId}`) return null
|
||||
const url = new URL(websocketUrl)
|
||||
url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:'
|
||||
url.pathname = value.downloadPath as string
|
||||
url.search = ''; url.hash = ''; url.username = ''; url.password = ''
|
||||
return { ...common, downloadUrl: url.href }
|
||||
}
|
||||
|
||||
export function parseRecordingStatus(raw: unknown): RecordingStatus | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const v = raw as Record<string, unknown>
|
||||
if (v.type !== 'recording.status' || typeof v.recordingId !== 'string' || !UUID.test(v.recordingId)
|
||||
|| !Number.isSafeInteger(v.line) || Number(v.line) < 1 || !Number.isSafeInteger(v.callId) || Number(v.callId) < 1
|
||||
|| !['waiting_call_end', 'waiting', 'error', 'submitted'].includes(String(v.state))
|
||||
|| (v.state === 'submitted' && (typeof v.jobId !== 'string' || !/^[a-f0-9]{32}$/.test(v.jobId)))) return null
|
||||
return { recordingId: v.recordingId, line: Number(v.line), callId: Number(v.callId),
|
||||
state: v.state as RecordingStatus['state'], jobId: typeof v.jobId === 'string' ? v.jobId : undefined,
|
||||
callEnded: v.callEnded === true || v.state === 'submitted',
|
||||
error: typeof v.error === 'string' ? v.error : undefined,
|
||||
peerName: typeof v.peerName === 'string' ? v.peerName : undefined,
|
||||
peerNumber: typeof v.peerNumber === 'string' ? v.peerNumber : undefined }
|
||||
}
|
||||
|
||||
export function stoppedRecording(recordingId: string, call: Pick<CallEvent, 'line' | 'callId' | 'peerName' | 'peerNumber'>,
|
||||
callEnded = false): StoppedRecording {
|
||||
return { recordingId, line: call.line, callId: call.callId!, peerName: call.peerName, peerNumber: call.peerNumber, callEnded }
|
||||
}
|
||||
|
||||
async function readJob(response: Response): Promise<TranscriptionJob> {
|
||||
const body = await response.json().catch(() => null)
|
||||
if (!response.ok) throw new RecordingUploadError(
|
||||
typeof body?.detail === 'string' ? body.detail : `Übergabe zur Transkription fehlgeschlagen (HTTP ${response.status}).`,
|
||||
response.status >= 500 || [408, 429].includes(response.status))
|
||||
if (!body || typeof body.id !== 'string' || !/^[a-f0-9]{32}$/.test(body.id)
|
||||
|| !['queued', 'transcribing', 'diarizing', 'summarizing', 'done', 'failed'].includes(body.status)) {
|
||||
throw new Error('Der Transkriptionsdienst lieferte keinen gültigen Auftrag.')
|
||||
}
|
||||
return body as TranscriptionJob
|
||||
}
|
||||
|
||||
export async function uploadRecording(recordingId: string, file: File): Promise<TranscriptionJob> {
|
||||
if (!/\.wav$/i.test(file.name) || file.size < 12 || file.size > MAX_RECORDING_BYTES) {
|
||||
throw new Error('Bitte eine nicht leere WAV-Datei mit höchstens 500 MB auswählen.')
|
||||
}
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
form.append('recordingId', recordingId)
|
||||
form.append('language', 'de')
|
||||
form.append('style', 'meeting')
|
||||
return readJob(await fetch('/api/transcriptions', { method: 'POST', body: form, signal: AbortSignal.timeout(150_000) }))
|
||||
}
|
||||
|
||||
export async function downloadRecording(recording: ReadyRecording): Promise<File> {
|
||||
if (!recording.downloadUrl) throw new Error('Diese Aufnahme wird über WebSocket übertragen.')
|
||||
const response = await fetch(recording.downloadUrl, { credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(120_000) })
|
||||
if (!response.ok || !response.body) throw new Error('Die fertige WAV-Datei konnte nicht von SwyxTray geladen werden.')
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Uint8Array<ArrayBuffer>[] = []
|
||||
let size = 0
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
size += value.byteLength
|
||||
if (size > recording.bytes || size > MAX_RECORDING_BYTES) throw new Error('Die WAV-Datei ist größer als von SwyxTray angekündigt.')
|
||||
chunks.push(new Uint8Array(value))
|
||||
}
|
||||
} finally { await reader.cancel().catch(() => undefined) }
|
||||
if (size !== recording.bytes) throw new Error('Die WAV-Datei ist noch nicht vollständig verfügbar.')
|
||||
return new File(chunks, recording.filename, { type: 'audio/wav' })
|
||||
}
|
||||
|
||||
export async function fetchTranscription(jobId: string, retry = false): Promise<TranscriptionJob> {
|
||||
return readJob(await fetch(`/api/transcriptions/${encodeURIComponent(jobId)}${retry ? '/retry' : ''}`,
|
||||
{ method: retry ? 'POST' : 'GET', signal: AbortSignal.timeout(15_000) }))
|
||||
}
|
||||
@@ -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