diff --git a/apps/api/src/generator/__golden__/empty/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/empty/expected/extensions_selfphoneng.conf index bd645f7..e62fd35 100644 --- a/apps/api/src/generator/__golden__/empty/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/empty/expected/extensions_selfphoneng.conf @@ -186,4 +186,5 @@ exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E9 same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911) same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID})) same => n,Set(__SP_DIRECTION=outbound) + same => n,Set(CDR(SP_DIRECTION)=outbound) same => n,Hangup() diff --git a/apps/api/src/generator/__golden__/holidays/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/holidays/expected/extensions_selfphoneng.conf index d098db0..33ce7ae 100644 --- a/apps/api/src/generator/__golden__/holidays/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/holidays/expected/extensions_selfphoneng.conf @@ -220,6 +220,7 @@ exten => _*3[123]X,1,Playback(ss-noservice) [sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37] exten => s,1,Goto(sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,unknown,1) exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound) + same => n,Set(CDR(SP_DIRECTION)=inbound) same => n,Gosub(sp-sub-normalize,s,1(${EXTEN})) same => n,Set(SP_IN=${GOSUB_RETVAL}) same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller) @@ -239,6 +240,7 @@ exten => 13125551234,1,NoOp() [sp-inbound] ; DID 13125551234 exten => 13125551234,1,Set(__SP_DID=13125551234) + same => n,Set(CDR(SP_DID)=13125551234) same => n,Goto(sp-tc-55555555-5555-4555-8555-555555555001,s,1) ; anything else exten => _[0-9a-z].,1,Hangup() @@ -255,5 +257,6 @@ exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E9 same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911) same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID})) same => n,Set(__SP_DIRECTION=outbound) + same => n,Set(CDR(SP_DIRECTION)=outbound) same => n,Dial(PJSIP/911@trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37) same => n,Hangup() diff --git a/apps/api/src/generator/__golden__/hostile-names/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/hostile-names/expected/extensions_selfphoneng.conf index 15e0191..37b42ba 100644 --- a/apps/api/src/generator/__golden__/hostile-names/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/hostile-names/expected/extensions_selfphoneng.conf @@ -195,4 +195,5 @@ exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E9 same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911) same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID})) same => n,Set(__SP_DIRECTION=outbound) + same => n,Set(CDR(SP_DIRECTION)=outbound) same => n,Hangup() diff --git a/apps/api/src/generator/__golden__/ivr-business-hours/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/ivr-business-hours/expected/extensions_selfphoneng.conf index 77e8960..267fcb9 100644 --- a/apps/api/src/generator/__golden__/ivr-business-hours/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/ivr-business-hours/expected/extensions_selfphoneng.conf @@ -241,6 +241,7 @@ exten => _*3[123]X,1,Playback(ss-noservice) [sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37] exten => s,1,Goto(sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,unknown,1) exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound) + same => n,Set(CDR(SP_DIRECTION)=inbound) same => n,Gosub(sp-sub-normalize,s,1(${EXTEN})) same => n,Set(SP_IN=${GOSUB_RETVAL}) same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller) @@ -260,6 +261,7 @@ exten => 13125551234,1,NoOp() [sp-inbound] ; DID 13125551234 exten => 13125551234,1,Set(__SP_DID=13125551234) + same => n,Set(CDR(SP_DID)=13125551234) same => n,Goto(sp-tc-55555555-5555-4555-8555-555555555001,s,1) ; anything else exten => _[0-9a-z].,1,Hangup() @@ -276,5 +278,6 @@ exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E9 same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911) same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID})) same => n,Set(__SP_DIRECTION=outbound) + same => n,Set(CDR(SP_DIRECTION)=outbound) same => n,Dial(PJSIP/911@trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37) same => n,Hangup() diff --git a/apps/api/src/generator/__golden__/ivr/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/ivr/expected/extensions_selfphoneng.conf index 10385a4..7a8f646 100644 --- a/apps/api/src/generator/__golden__/ivr/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/ivr/expected/extensions_selfphoneng.conf @@ -242,6 +242,7 @@ exten => s,1,Answer() [sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37] exten => s,1,Goto(sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,unknown,1) exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound) + same => n,Set(CDR(SP_DIRECTION)=inbound) same => n,Gosub(sp-sub-normalize,s,1(${EXTEN})) same => n,Set(SP_IN=${GOSUB_RETVAL}) same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller) @@ -261,6 +262,7 @@ exten => 13125551234,1,NoOp() [sp-inbound] ; DID 13125551234 exten => 13125551234,1,Set(__SP_DID=13125551234) + same => n,Set(CDR(SP_DID)=13125551234) same => n,Goto(sp-ivr-33333333-3333-4333-8333-333333333600,s,1) ; anything else exten => _[0-9a-z].,1,Hangup() @@ -277,5 +279,6 @@ exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E9 same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911) same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID})) same => n,Set(__SP_DIRECTION=outbound) + same => n,Set(CDR(SP_DIRECTION)=outbound) same => n,Dial(PJSIP/911@trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37) same => n,Hangup() diff --git a/apps/api/src/generator/__golden__/one-user/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/one-user/expected/extensions_selfphoneng.conf index ebaa123..f7c00c2 100644 --- a/apps/api/src/generator/__golden__/one-user/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/one-user/expected/extensions_selfphoneng.conf @@ -179,6 +179,7 @@ exten => 13125551234,1,NoOp() [sp-inbound] ; DID 13125551234 exten => 13125551234,1,Set(__SP_DID=13125551234) + same => n,Set(CDR(SP_DID)=13125551234) same => n,Hangup() ; anything else exten => _[0-9a-z].,1,Hangup() @@ -195,4 +196,5 @@ exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E9 same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911) same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID})) same => n,Set(__SP_DIRECTION=outbound) + same => n,Set(CDR(SP_DIRECTION)=outbound) same => n,Hangup() diff --git a/apps/api/src/generator/__golden__/ringgroups/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/ringgroups/expected/extensions_selfphoneng.conf index a8ef421..f5dd32f 100644 --- a/apps/api/src/generator/__golden__/ringgroups/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/ringgroups/expected/extensions_selfphoneng.conf @@ -213,6 +213,7 @@ exten => _*3[123]X,1,Playback(ss-noservice) [sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37] exten => s,1,Goto(sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,unknown,1) exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound) + same => n,Set(CDR(SP_DIRECTION)=inbound) same => n,Gosub(sp-sub-normalize,s,1(${EXTEN})) same => n,Set(SP_IN=${GOSUB_RETVAL}) same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller) @@ -232,6 +233,7 @@ exten => 13125551234,1,NoOp() [sp-inbound] ; DID 13125551234 exten => 13125551234,1,Set(__SP_DID=13125551234) + same => n,Set(CDR(SP_DID)=13125551234) same => n,Goto(sp-rg-22222222-2222-4222-8222-222222222500,s,1) ; anything else exten => _[0-9a-z].,1,Hangup() @@ -248,5 +250,6 @@ exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E9 same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911) same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID})) same => n,Set(__SP_DIRECTION=outbound) + same => n,Set(CDR(SP_DIRECTION)=outbound) same => n,Dial(PJSIP/911@trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37) same => n,Hangup() diff --git a/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_selfphoneng.conf index fa49b33..cd651ba 100644 --- a/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_selfphoneng.conf @@ -174,6 +174,7 @@ exten => _*3[123]X,1,Playback(ss-noservice) [sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000001] exten => s,1,Goto(sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000001,unknown,1) exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound) + same => n,Set(CDR(SP_DIRECTION)=inbound) same => n,Gosub(sp-sub-normalize,s,1(${EXTEN})) same => n,Set(SP_IN=${GOSUB_RETVAL}) same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller) @@ -191,6 +192,7 @@ exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound) [sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000002] exten => s,1,Goto(sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000002,unknown,1) exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound) + same => n,Set(CDR(SP_DIRECTION)=inbound) same => n,Gosub(sp-sub-normalize,s,1(${EXTEN})) same => n,Set(SP_IN=${GOSUB_RETVAL}) same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller) @@ -217,6 +219,7 @@ exten => _1[2-9]XXNXXXXXX,1,Goto(sp-route-bbbbbbbb-0000-4000-8000-000000000001,$ [sp-route-bbbbbbbb-0000-4000-8000-000000000001] exten => _X.,1,Set(__SP_DIRECTION=outbound) + same => n,Set(CDR(SP_DIRECTION)=outbound) same => n,GotoIf($[${IF($["${SP_CLASS}" = ""]?0:${SP_CLASS})} < 2]?denied) same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1)) ; caller ID: route override > user > the DID a forwarded call came in on > default @@ -235,6 +238,7 @@ exten => _9NXXNXXXXXX,1,Goto(sp-route-bbbbbbbb-0000-4000-8000-000000000002,1${EX [sp-route-bbbbbbbb-0000-4000-8000-000000000002] exten => _X.,1,Set(__SP_DIRECTION=outbound) + same => n,Set(CDR(SP_DIRECTION)=outbound) same => n,GotoIf($[${IF($["${SP_CLASS}" = ""]?0:${SP_CLASS})} < 1]?denied) same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1)) ; caller ID: route override > user > the DID a forwarded call came in on > default @@ -264,6 +268,7 @@ exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E9 same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911) same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID})) same => n,Set(__SP_DIRECTION=outbound) + same => n,Set(CDR(SP_DIRECTION)=outbound) same => n,Dial(PJSIP/911@trunk-a1b2c3d4-0000-4000-8000-000000000001) same => n,Dial(PJSIP/911@trunk-a1b2c3d4-0000-4000-8000-000000000002) same => n,Hangup() diff --git a/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_selfphoneng.conf index 2e6ae6e..3190c58 100644 --- a/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_selfphoneng.conf @@ -177,6 +177,7 @@ exten => _*3[123]X,1,Playback(ss-noservice) [sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37] exten => s,1,Goto(sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,unknown,1) exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound) + same => n,Set(CDR(SP_DIRECTION)=inbound) same => n,Gosub(sp-sub-normalize,s,1(${EXTEN})) same => n,Set(SP_IN=${GOSUB_RETVAL}) same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller) @@ -197,9 +198,11 @@ exten => 13125559876,1,NoOp() [sp-inbound] ; DID 13125551234 "Main line" exten => 13125551234,1,Set(__SP_DID=13125551234) + same => n,Set(CDR(SP_DID)=13125551234) same => n,Goto(sp-local-numbers,101,1) ; DID 13125559876 "Sales line" exten => 13125559876,1,Set(__SP_DID=13125559876) + same => n,Set(CDR(SP_DID)=13125559876) same => n,Set(CALLERID(name)=SALES: ${CALLERID(name)}) same => n,Goto(sp-ext-forward,13125550000,1) ; anything else @@ -220,6 +223,7 @@ exten => _1833NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXT [sp-route-aaaaaaaa-0000-4000-8000-000000000002] exten => _X.,1,Set(__SP_DIRECTION=outbound) + same => n,Set(CDR(SP_DIRECTION)=outbound) same => n,GotoIf($[${IF($["${SP_CLASS}" = ""]?0:${SP_CLASS})} < 1]?denied) same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1)) ; caller ID: route override > user > the DID a forwarded call came in on > default @@ -243,6 +247,7 @@ exten => _1NXXNXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000001,${EXT [sp-route-aaaaaaaa-0000-4000-8000-000000000001] exten => _X.,1,Set(__SP_DIRECTION=outbound) + same => n,Set(CDR(SP_DIRECTION)=outbound) same => n,GotoIf($[${IF($["${SP_CLASS}" = ""]?0:${SP_CLASS})} < 2]?denied) same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1)) ; caller ID: route override > user > the DID a forwarded call came in on > default @@ -269,5 +274,6 @@ exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E9 same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911) same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID})) same => n,Set(__SP_DIRECTION=outbound) + same => n,Set(CDR(SP_DIRECTION)=outbound) same => n,Dial(PJSIP/911@trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37) same => n,Hangup() diff --git a/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_selfphoneng.conf index e08961a..19f0a8b 100644 --- a/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_selfphoneng.conf @@ -192,4 +192,5 @@ exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E9 same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911) same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID})) same => n,Set(__SP_DIRECTION=outbound) + same => n,Set(CDR(SP_DIRECTION)=outbound) same => n,Hangup() diff --git a/apps/api/src/generator/dialplan/emergency.ts b/apps/api/src/generator/dialplan/emergency.ts index 7ff20ca..5831f1e 100644 --- a/apps/api/src/generator/dialplan/emergency.ts +++ b/apps/api/src/generator/dialplan/emergency.ts @@ -19,6 +19,7 @@ export function renderEmergency(snap: Snapshot): string { ' same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)', ' same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))', ' same => n,Set(__SP_DIRECTION=outbound)', + ' same => n,Set(CDR(SP_DIRECTION)=outbound)', ...trunks.map((t) => ` same => n,Dial(PJSIP/911@trunk-${s.ident(t.id)})`), ' same => n,Hangup()', ].join('\n') + '\n' diff --git a/apps/api/src/generator/dialplan/inbound.ts b/apps/api/src/generator/dialplan/inbound.ts index a9092d0..5cca22a 100644 --- a/apps/api/src/generator/dialplan/inbound.ts +++ b/apps/api/src/generator/dialplan/inbound.ts @@ -52,6 +52,8 @@ export function renderTrunkEntries(snap: Snapshot): string { `[${ctx}]`, `exten => s,1,Goto(${ctx},unknown,1)`, 'exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound)', + // cdr_manager [mappings] read CDR variables, not channel ones (04 §5.4). + ' same => n,Set(CDR(SP_DIRECTION)=inbound)', ' same => n,Gosub(sp-sub-normalize,s,1(${EXTEN}))', ' same => n,Set(SP_IN=${GOSUB_RETVAL})', ' same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)', @@ -85,6 +87,7 @@ export function renderDids(snap: Snapshot): string { inbound.push( `; DID ${num}${d.label ? ` "${s.name(d.label)}"` : ''}`, `exten => ${num},1,Set(__SP_DID=${num})`, + ` same => n,Set(CDR(SP_DID)=${num})`, ...(prefix ? [` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`] : []), // TODO(M7): record_inbound (announcement + sp-sub-record). ` same => n,${destApp(d.destination, snap)}`, diff --git a/apps/api/src/generator/dialplan/outbound.ts b/apps/api/src/generator/dialplan/outbound.ts index ee20f12..5334ba3 100644 --- a/apps/api/src/generator/dialplan/outbound.ts +++ b/apps/api/src/generator/dialplan/outbound.ts @@ -33,6 +33,7 @@ export function renderOutbound(snap: Snapshot): string { const route = [ `[sp-route-${id}]`, 'exten => _X.,1,Set(__SP_DIRECTION=outbound)', + ' same => n,Set(CDR(SP_DIRECTION)=outbound)', ` same => n,GotoIf($[\${IF($["\${SP_CLASS}" = ""]?0:\${SP_CLASS})} < ${CLASS_RANK[r.requiredClass]}]?denied)`, ' same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1))', ' ; caller ID: route override > user > the DID a forwarded call came in on > default', diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 9238366..0994cce 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -14,6 +14,7 @@ import { watchEmergencies } from './services/emergency.ts'; import { watchVoicemail } from './services/voicemail.ts'; import { syncAstDb } from './services/astdb.ts'; import { watchToggles } from './services/toggles.ts'; +import { watchCdr } from './services/cdr.ts'; import { getSetting } from './services/settings.ts'; import { todayIn } from './generator/load.ts'; @@ -56,6 +57,7 @@ const sendMail = smtpMailer(db); app.decorate('sendMail', sendMail); watchEmergencies({ db, ami, live, send: sendMail, log: app.log }, ami); watchToggles({ db, ami, live, log: app.log }, ami); +const history = watchCdr({ db, log: app.log }, ami); const voicemail = watchVoicemail( { db, @@ -93,6 +95,7 @@ ami.on('connected', () => { await generator.apply({ trigger: 'startup', force: true }); await live.loadFromAsterisk(); await voicemail.scanAll(); + await history.catchUp(); })().catch((err) => app.log.error({ err }, 'startup sync failed')); }); ami.start(); @@ -116,6 +119,7 @@ for (const sig of ['SIGTERM', 'SIGINT'] as const) { process.once(sig, () => { generator.cancelPending(); clearInterval(dayTimer); + history.stop(); ami.stop(); void app .close() diff --git a/apps/api/src/services/cdr.ts b/apps/api/src/services/cdr.ts new file mode 100644 index 0000000..6a0d3c6 --- /dev/null +++ b/apps/api/src/services/cdr.ts @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// CDR ingestion and call aggregation (04 §5.4). Every AMI Cdr event is stored raw in `cdr`; a few +// seconds after a call's last one, its rows (one linkedid) become one `calls` row plus who was +// involved. The rules are heuristics, pinned by fixtures recorded from real calls +// (test/fixtures/cdr, made by test/asterisk/cdr-record.ts). +import type { EventEmitter } from 'node:events'; +import { and, asc, eq, inArray, isNull, lt } from 'drizzle-orm'; +import { field, type AmiMessage } from '../ami/events.ts'; +import type { Db } from '../db/client.ts'; +import { callExtensions, calls, cdr, dids, extensions } from '../db/schema.ts'; + +type CdrRow = typeof cdr.$inferSelect; + +/** cdr_manager.conf has usegmtime = yes: "2026-10-18 19:28:37" is UTC. */ +const utc = (v: string | undefined) => (v ? new Date(`${v.replace(' ', 'T')}Z`) : null); +const num = (v: string | undefined) => (v === undefined || v === '' ? null : Number(v)); +const str = (v: string | undefined) => v || null; + +export function toCdrRow(m: AmiMessage): typeof cdr.$inferInsert { + const f = (k: string) => field(m, k); + return { + uniqueid: f('UniqueID') ?? '', + linkedid: f('LinkedID') || f('UniqueID') || '', + startAt: utc(f('StartTime')), + answerAt: utc(f('AnswerTime')), + endAt: utc(f('EndTime')), + duration: num(f('Duration')), + billsec: num(f('BillableSeconds')), + src: str(f('Source')), + dst: str(f('Destination')), + dcontext: str(f('DestinationContext')), + clid: str(f('CallerID')), + channel: str(f('Channel')), + dstchannel: str(f('DestinationChannel')), + lastapp: str(f('LastApplication')), + lastdata: str(f('LastData')), + disposition: str(f('Disposition')), + accountcode: str(f('AccountCode')), + userfield: str(f('UserField')), + spDid: str(f('sp_did')), + spDirection: str(f('sp_direction')), + spRecording: str(f('sp_recording')), + raw: m, + }; +} + +const isTrunk = (ch: string | null) => /^PJSIP\/trunk-/.test(ch ?? ''); +/** "PJSIP/101-0000001" or "PJSIP/101-web-0000002" → "101"; Local channels don't count. */ +export const extOf = (ch: string | null) => + /^PJSIP\/(\d+)(?:-web)?-[0-9a-f]+$/.exec(ch ?? '')?.[1] ?? null; +/** `"Mobile" <13125559999>` → Mobile. */ +const nameOf = (clid: string | null) => /^"([^"]*)"/.exec(clid ?? '')?.[1] || null; + +export type CallSummary = { + direction: 'inbound' | 'outbound' | 'internal'; + status: 'answered' | 'missed' | 'voicemail' | 'abandoned' | 'busy' | 'failed'; + startedAt: Date; + answeredAt: Date | null; + endedAt: Date; + fromNumber: string | null; + fromName: string | null; + toNumber: string | null; + did: string | null; + caller: string | null; + rang: string[]; + answeredBy: string | null; + talkSeconds: number; +}; + +/** The pure part of aggregateCall: rows of one linkedid → what the call was. */ +export function summarize(rows: CdrRow[]): CallSummary { + const first = rows[0]!; + const direction = + (rows.find((r) => r.spDirection)?.spDirection as CallSummary['direction'] | undefined) ?? + (rows.some((r) => isTrunk(r.channel)) + ? 'inbound' + : rows.some((r) => isTrunk(r.dstchannel)) + ? 'outbound' + : 'internal'); + const did = rows.find((r) => r.spDid)?.spDid ?? null; + const caller = extOf(first.channel); + const rang = [...new Set(rows.map((r) => extOf(r.dstchannel)).filter((x): x is string => !!x))]; + const answeredRow = rows.find((r) => r.disposition === 'ANSWERED' && extOf(r.dstchannel)); + const trunkAnswered = rows.find((r) => r.disposition === 'ANSWERED' && isTrunk(r.dstchannel)); + const answeredBy = answeredRow ? extOf(answeredRow.dstchannel) : null; + const answered = answeredBy !== null || (direction === 'outbound' && !!trunkAnswered); + const status: CallSummary['status'] = answered + ? 'answered' + : rows.some((r) => r.lastapp === 'VoiceMail' && (r.billsec ?? 0) > 0) + ? 'voicemail' + : rows.some((r) => r.lastapp === 'Queue') + ? 'abandoned' + : rows.some((r) => r.disposition === 'BUSY') + ? 'busy' + : rows.some((r) => r.disposition === 'FAILED' || r.disposition === 'CONGESTION') + ? 'failed' + : 'missed'; + const talkRows = rows.filter( + (r) => r.disposition === 'ANSWERED' && (extOf(r.dstchannel) || isTrunk(r.dstchannel)), + ); + const times = (k: 'startAt' | 'endAt') => + rows.map((r) => r[k]?.getTime()).filter((x): x is number => x !== undefined); + return { + direction, + status, + startedAt: new Date(Math.min(...times('startAt'))), + answeredAt: (answeredRow ?? (answered ? trunkAnswered : undefined))?.answerAt ?? null, + endedAt: new Date(Math.max(...times('endAt'), ...times('startAt'))), + // A phone calling shows as its extension, not the caller ID it sends outside. + fromNumber: caller ?? first.src, + fromName: nameOf(first.clid), + toNumber: direction === 'inbound' ? (did ?? first.dst) : first.dst, + did, + caller, + rang, + answeredBy, + talkSeconds: Math.max(0, ...talkRows.map((r) => r.billsec ?? 0)), + }; +} + +/** Rebuilds the calls row for one linkedid (idempotent). */ +export async function aggregateCall(db: Db, linkedid: string): Promise { + const rows = await db + .select() + .from(cdr) + .where(eq(cdr.linkedid, linkedid)) + .orderBy(asc(cdr.startAt), asc(cdr.id)); + if (!rows.length) return; + const s = summarize(rows); + const numbers = [...new Set([s.caller, s.answeredBy, ...s.rang].filter((x): x is string => !!x))]; + const exts = numbers.length + ? await db + .select({ id: extensions.id, number: extensions.number }) + .from(extensions) + .where(inArray(extensions.number, numbers)) + : []; + const idOf = (n: string | null) => exts.find((e) => e.number === n)?.id ?? null; + const [d] = s.did + ? await db.select({ id: dids.id }).from(dids).where(eq(dids.number, s.did)) + : []; + await db.transaction(async (tx) => { + await tx.delete(calls).where(eq(calls.linkedid, linkedid)); + const [call] = await tx + .insert(calls) + .values({ + linkedid, + direction: s.direction, + status: s.status, + startedAt: s.startedAt, + answeredAt: s.answeredAt, + endedAt: s.endedAt, + fromNumber: s.fromNumber, + fromName: s.fromName, + toNumber: s.toNumber, + didId: d?.id ?? null, + answeredByExtensionId: idOf(s.answeredBy), + talkSeconds: s.talkSeconds, + }) + .returning({ id: calls.id }); + const involved = [ + ...(idOf(s.caller) ? [{ id: idOf(s.caller)!, role: 'caller' }] : []), + ...s.rang.flatMap((n) => (idOf(n) ? [{ id: idOf(n)!, role: 'rang' }] : [])), + ...(idOf(s.answeredBy) ? [{ id: idOf(s.answeredBy)!, role: 'answered' }] : []), + ]; + if (involved.length) + await tx + .insert(callExtensions) + .values(involved.map((x) => ({ callId: call!.id, extensionId: x.id, role: x.role }))) + .onConflictDoNothing(); + }); + // TODO(M7-T3): link the recording (sp_recording). TODO(M11-T5): webhook call.ended. +} + +export type CdrDeps = { + db: Db; + log: { error(o: object, msg: string): void }; + /** How long after a call's last Cdr event to aggregate it (04 §5.4: 5 s). */ + settleMs?: number; +}; + +export function watchCdr(d: CdrDeps, ami: Pick) { + const timers = new Map>(); + const schedule = (linkedid: string) => { + clearTimeout(timers.get(linkedid)); + timers.set( + linkedid, + setTimeout(() => { + timers.delete(linkedid); + void aggregateCall(d.db, linkedid).catch((err: unknown) => + d.log.error({ err, linkedid }, 'call aggregation failed'), + ); + }, d.settleMs ?? 5000), + ); + }; + ami.on('event', (m: AmiMessage) => { + if (field(m, 'Event') !== 'Cdr') return; + const row = toCdrRow(m); + void d.db + .insert(cdr) + .values(row) + .then(() => schedule(row.linkedid)) + .catch((err: unknown) => d.log.error({ err }, 'cdr insert failed')); + }); + return { + /** Calls whose aggregation never ran (api down at the time): on AMI connect. */ + async catchUp() { + const orphans = await d.db + .selectDistinct({ linkedid: cdr.linkedid }) + .from(cdr) + .leftJoin(calls, eq(calls.linkedid, cdr.linkedid)) + .where(and(isNull(calls.id), lt(cdr.createdAt, new Date(Date.now() - 60_000)))); + for (const o of orphans) await aggregateCall(d.db, o.linkedid); + }, + stop: () => timers.forEach(clearTimeout), + }; +} diff --git a/apps/api/test/asterisk/cdr-record.ts b/apps/api/test/asterisk/cdr-record.ts new file mode 100644 index 0000000..b6f577f --- /dev/null +++ b/apps/api/test/asterisk/cdr-record.ts @@ -0,0 +1,409 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Records real AMI Cdr events for the call history fixtures (04 §5.4, 09 M7-T1). Our generated +// config is the PBX, a second Asterisk plays voip.ms (provider.ts), baresip phones answer: +// 101 Alice auto-answer 102 Bob never answers, voicemail on, rings 5 s +// 103 Carol auto-answer 104 Dave no phone, no voicemail +// Each scenario's Cdr events go to apps/api/test/fixtures/cdr/.json. +// Queue abandon and park arrive with M8 (queues, parking). +// Usage: node --conditions=source apps/api/test/asterisk/cdr-record.ts +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared'; +import { AmiClient } from '../../src/ami/client.ts'; +import type { AmiMessage } from '../../src/ami/events.ts'; +import { GENERATED_FILES, render } from '../../src/generator/index.ts'; +import type { Snapshot, SnapExtension } from '../../src/generator/load.ts'; +import { providerDialplan, providerPjsip } from './provider.ts'; + +const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk'; +const OUT = fileURLToPath(new URL('../fixtures/cdr/', import.meta.url)); +const NET = 'sp-cdr'; +const PBX = 'sp-cdr-pbx'; +const PROVIDER = 'sp-cdr-provider'; +const PHONES = 'sp-cdr-phones'; +const AMI_PORT = 15040; +const AMI_SECRET = 'cdr-record-secret'; +const SUB = { user: '123456_pbx', pass: 'ProviderSecret42' }; +const TRUNK = '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37'; +const MOBILE = '3125559999'; +export const CDR_IDS = { + ext: (n: string) => `00000000-0000-4000-8000-000000000${n}`, + did: (n: string) => `11111111-1111-4111-8111-11111111${n}`, // 1234 / 1235 / 1236 + ringGroup: '22222222-2222-4222-8222-222222222500', + ivr: '33333333-3333-4333-8333-333333333600', +}; +const PASS = (n: string) => `CdrRecord${n}xxxxxxxxxxxxx`; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const docker = (...args: string[]) => { + const r = spawnSync('docker', args, { encoding: 'utf8' }); + return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}` }; +}; +const log = (...a: unknown[]) => console.log('[cdr-record]', ...a); + +function snapshot(): Snapshot { + const settings = Object.fromEntries( + (Object.keys(SettingsSchemas) as SettingKey[]) + .filter((k) => k !== 'sms.webhookToken') + .map((k) => [k, SettingsSchemas[k].parse(undefined)]), + ) as Snapshot['settings']; + settings['routing.defaultCallerIdDidId'] = CDR_IDS.did('1234'); + const ext = (number: string, name: string, extra: Partial = {}) => ({ + id: CDR_IDS.ext(number), + number, + name, + sipPassword: PASS(number), + webrtcEnabled: false, + maxContacts: 5, + ringSeconds: 25, + voicemailEnabled: false, + voicemailPin: '4826', + outboundClass: 'national' as const, + outboundCallerId: null, + emergencyCallerId: null, + recordMode: 'never' as const, + pickupGroup: null, + language: 'en', + mohClass: 'default', + isAdmin: false, + ...extra, + }); + const did = (n: string, destination: Snapshot['dids'][number]['destination']) => ({ + id: CDR_IDS.did(n), + number: `1312555${n}`, + trunkId: TRUNK, + label: null, + destination, + callerIdNamePrefix: null, + }); + return { + today: '2026-10-18', + settings, + extensions: [ + ext('101', 'Alice'), + ext('102', 'Bob', { voicemailEnabled: true, ringSeconds: 5 }), + ext('103', 'Carol'), + ext('104', 'Dave'), + ], + dids: [ + did('1234', { type: 'user', id: CDR_IDS.ext('101') }), + did('1235', { type: 'ring_group', id: CDR_IDS.ringGroup }), + did('1236', { type: 'ivr', id: CDR_IDS.ivr }), + ], + trunks: [ + { + id: TRUNK, + name: 'voip.ms', + enabled: true, + serverHost: PROVIDER, + serverPort: 5060, + transport: 'udp', + authMode: 'registration', + username: SUB.user, + password: SUB.pass, + fromUser: null, + fromDomain: null, + codecs: ['ulaw'], + srtp: false, + maxChannels: null, + }, + ], + ringGroups: [ + { + id: CDR_IDS.ringGroup, + number: '500', + name: 'Sales', + strategy: 'hunt', + ringSeconds: 5, + callerIdNamePrefix: null, + musicInsteadOfRing: false, + members: ['102', '103'], + noAnswerDestination: { type: 'hangup' }, + }, + ], + ivrs: [ + { + id: CDR_IDS.ivr, + number: '600', + name: 'Main menu', + greeting: 'beep', + invalidSound: null, + timeoutSeconds: 2, + maxRetries: 1, + allowDirectDial: false, + options: [], + timeoutDestination: { type: 'voicemail', id: CDR_IDS.ext('102') }, + invalidDestination: { type: 'hangup' }, + }, + ], + timeConditions: [], + holidays: [], + announcements: [], + routes: [ + { + id: 'aaaaaaaa-0000-4000-8000-000000000001', + name: 'Local and national', + position: 0, + enabled: true, + patterns: [{ match: 'NXXNXXXXXX', strip: 0, prepend: '1' }], + requiredClass: 'national', + trunkIds: [TRUNK], + callerId: null, + }, + ], + }; +} + +function phoneFiles(dir: string) { + const phones: [string, 'auto' | 'manual'][] = [ + ['101', 'auto'], + ['102', 'manual'], + ['103', 'auto'], + ]; + phones.forEach(([n, answer], i) => { + const d = join(dir, `phone-${n}`); + mkdirSync(d, { recursive: true }); + writeFileSync( + join(d, 'config'), + [ + `sip_listen 0.0.0.0:${5070 + 2 * i}`, + 'module_path /usr/lib/baresip/modules', + ...['g711', 'ausine', 'aufile', 'menu', 'account'].map((m) => `module ${m}.so`), + `audio_source ausine,${440 + 110 * i}`, + 'ausrc_srate 48000', + 'auplay_srate 48000', + 'audio_player aufile,/dev/null', + 'audio_alert aufile,/dev/null', + `rtp_ports ${30000 + 100 * i}-${30099 + 100 * i}`, + '', + ].join('\n'), + ); + writeFileSync( + join(d, 'accounts'), + `;auth_pass=${PASS(n)};answermode=${answer};audio_codecs=PCMU\n`, + ); + }); + return phones.map(([n]) => `(baresip -f /phones/phone-${n} > /phones/phone-${n}/log 2>&1 &)`); +} + +async function main() { + const dir = mkdtempSync(join(tmpdir(), 'sp-cdr-')); + const pbxConf = join(dir, 'pbx'); + const provConf = join(dir, 'provider'); + mkdirSync(pbxConf); + mkdirSync(provConf); + const files = render(snapshot()); + for (const f of GENERATED_FILES) { + writeFileSync(join(pbxConf, f), files[f]); + writeFileSync( + join(provConf, f), + f === 'pjsip_selfphoneng.conf' + ? providerPjsip(SUB) + : f === 'extensions_selfphoneng.conf' + ? providerDialplan(MOBILE, SUB) + : '', + ); + } + const runPhones = phoneFiles(dir); + writeFileSync( + join(dir, 'manager.conf.template'), + '[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n', + ); + const ami = new AmiClient({ + host: '127.0.0.1', + port: AMI_PORT, + username: 'selfphoneng', + secret: AMI_SECRET, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + backoffMs: [500], + }); + const asterisk = (name: string, conf: string, extra: string[]) => + docker( + 'run', + '-d', + '--rm', + '--name', + name, + '--network', + NET, + ...extra, + '-e', + `AMI_SECRET=${AMI_SECRET}`, + '-v', + `${conf}:/etc/asterisk/selfphoneng`, + IMAGE, + ); + const waitFor = async (what: string, fn: () => Promise | boolean, s = 60) => { + for (let i = 0; i < s * 4; i++) { + if (await fn()) return; + await sleep(250); + } + throw new Error(`timed out waiting for ${what}`); + }; + const cli = (box: string, c: string) => docker('exec', box, 'asterisk', '-rx', c).out; + const quiet = async () => { + cli(PBX, 'channel request hangup all'); + cli(PROVIDER, 'channel request hangup all'); + await waitFor( + 'calls to end', + () => !/(PJSIP|Local)\//.test(cli(PBX, 'core show channels concise')), + ); + await sleep(3000); // Cdr events follow the hangups + }; + + const events: AmiMessage[] = []; + ami.on('event', (m: AmiMessage) => { + if (m.Event === 'Cdr') events.push(m); + }); + const recorded: Record = {}; + async function scenario(name: string, run: () => Promise) { + events.length = 0; + await run(); + await quiet(); + recorded[name] = [...events]; + log( + `${name}: ${events.length} Cdr events, linkedids ${[...new Set(events.map((e) => e.LinkedID))].join(' ')}`, + ); + } + const fromPhone = (phone: string, exten: string, name: string) => + ami.action({ + Action: 'Originate', + Channel: `PJSIP/${phone}`, + Context: 'sp-from-ext', + Exten: exten, + Priority: '1', + CallerID: `"${name}" <${phone}>`, + Async: 'true', + }); + const fromMobile = (did10: string) => + cli(PROVIDER, `channel originate Local/${did10}@provider-out application Wait 30`); + const pbxChannel = (prefix: string) => + cli(PBX, 'core show channels concise') + .split('\n') + .find((l) => l.startsWith(prefix)) + ?.split('!')[0]; + + try { + docker('rm', '-f', PBX, PROVIDER, PHONES); + docker('network', 'create', NET); + if (!asterisk(PROVIDER, provConf, []).ok) throw new Error('provider did not start'); + await waitFor( + 'provider', + () => docker('exec', PROVIDER, 'asterisk', '-rx', 'core waitfullybooted').ok, + ); + const pbx = asterisk(PBX, pbxConf, [ + '-p', + `127.0.0.1:${AMI_PORT}:5038`, + '-v', + `${join(dir, 'manager.conf.template')}:/etc/asterisk/manager.conf.template:ro`, + ]); + if (!pbx.ok) throw new Error(`pbx: ${pbx.out}`); + await waitFor('pbx', () => docker('exec', PBX, 'asterisk', '-rx', 'core waitfullybooted').ok); + ami.start(); + await new Promise((r) => ami.once('connected', r)); + docker( + 'run', + '-d', + '--rm', + '--name', + PHONES, + '--network', + NET, + '-v', + `${dir}:/phones`, + 'debian:trixie-slim', + 'sh', + '-c', + `apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends baresip-core >/dev/null 2>&1 && ${runPhones.join(' && ')} && sleep 3600`, + ); + await waitFor( + 'phones and trunk', + () => { + const c = cli(PBX, 'pjsip show contacts'); + return ( + ['101', '102', '103'].every((n) => new RegExp(`${n}/sip:.*Avail`).test(c)) && + /Registered/.test(cli(PBX, 'pjsip show registrations')) + ); + }, + 240, + ); + log('phones registered, trunk registered'); + + await scenario('internal-answered', async () => { + await fromPhone('103', '101', 'Carol'); + await waitFor('101 answers', () => !!pbxChannel('PJSIP/101-')); + await sleep(5000); + }); + await scenario('internal-voicemail', async () => { + await fromPhone('103', '102', 'Carol'); + await waitFor( + 'voicemail', + () => /VoiceMail/.test(cli(PBX, 'core show channels concise')), + 20, + ); + await sleep(6000); + }); + await scenario('internal-unavailable', async () => { + await fromPhone('103', '104', 'Carol'); + await sleep(4000); + }); + await scenario('inbound-user', async () => { + fromMobile('3125551234'); + await waitFor('101 answers', () => !!pbxChannel('PJSIP/101-')); + await sleep(5000); + }); + await scenario('inbound-ringgroup', async () => { + fromMobile('3125551235'); + await waitFor('103 answers', () => !!pbxChannel('PJSIP/103-'), 20); + await sleep(4000); + }); + await scenario('inbound-ivr-voicemail', async () => { + fromMobile('3125551236'); + await waitFor( + 'voicemail', + () => /VoiceMail/.test(cli(PBX, 'core show channels concise')), + 30, + ); + await sleep(6000); + }); + await scenario('outbound-answered', async () => { + await fromPhone('101', '3125550000', 'Alice'); + await waitFor( + 'provider answers', + () => /Echo/.test(cli(PROVIDER, 'core show channels concise')), + 20, + ); + await sleep(5000); + }); + await scenario('transfer', async () => { + fromMobile('3125551234'); + await waitFor('101 answers', () => !!pbxChannel('PJSIP/101-')); + await sleep(3000); + await ami.action({ + Action: 'BlindTransfer', + Channel: pbxChannel('PJSIP/101-')!, + Context: 'sp-from-ext', + Exten: '103', + }); + await waitFor('103 answers', () => !!pbxChannel('PJSIP/103-'), 20); + await sleep(4000); + }); + + mkdirSync(OUT, { recursive: true }); + for (const [name, evs] of Object.entries(recorded)) { + if (!evs.length) throw new Error(`${name}: no Cdr events`); + writeFileSync(join(OUT, `${name}.json`), JSON.stringify(evs, null, 2) + '\n'); + } + log(`wrote ${Object.keys(recorded).length} fixtures to ${OUT}`); + } finally { + ami.stop(); + docker('rm', '-f', PHONES, PBX, PROVIDER); + docker('network', 'rm', NET); + rmSync(dir, { recursive: true, force: true }); + } +} + +await main(); diff --git a/apps/api/test/asterisk/provider.ts b/apps/api/test/asterisk/provider.ts new file mode 100644 index 0000000..fd2a511 --- /dev/null +++ b/apps/api/test/asterisk/provider.ts @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// The fake voip.ms used by trunk-test.ts and cdr-record.ts: a second Asterisk on the test network. + +/** The fake voip.ms: one sub-account that may register. */ +export const providerPjsip = (SUB: { user: string; pass: string }) => `[transport-udp] +type = transport +protocol = udp +bind = 0.0.0.0:5060 + +[${SUB.user}] +type = endpoint +context = provider-in +disallow = all +allow = ulaw +auth = ${SUB.user}-auth +aors = ${SUB.user} + +[${SUB.user}-auth] +type = auth +auth_type = userpass +username = ${SUB.user} +password = ${SUB.pass} + +[${SUB.user}] +type = aor +max_contacts = 1 +remove_existing = yes +`; + +/** + * provider-out: a mobile phone calls one of our DIDs. Like voip.ms, the Request-URI user is the + * 10-digit DID (06 §3), sent to the sub-account's registered contact. Echo() returns the audio. + */ +export const providerDialplan = (MOBILE: string, SUB: { user: string }) => `[provider-out] +exten => _X.,1,Set(CALLERID(all)="Mobile" <${MOBILE}>) + same => n,Dial(PJSIP/\${EXTEN}@${SUB.user},30) + +[provider-echo] +exten => s,1,Answer() + same => n,Echo() + +; Our outbound calls arrive here: remember what the provider saw, then echo the audio. +[provider-in] +exten => _X.,1,Set(GLOBAL(LAST_IN)=to=\${EXTEN} from=\${CALLERID(num)} rpid=\${PJSIP_HEADER(read,Remote-Party-ID)}) + same => n,Answer() + same => n,Echo() +`; diff --git a/apps/api/test/asterisk/trunk-test.ts b/apps/api/test/asterisk/trunk-test.ts index 717b2b8..43dd1cd 100644 --- a/apps/api/test/asterisk/trunk-test.ts +++ b/apps/api/test/asterisk/trunk-test.ts @@ -16,6 +16,8 @@ import { AmiClient } from '../../src/ami/client.ts'; import { GENERATED_FILES, render } from '../../src/generator/index.ts'; import type { Snapshot } from '../../src/generator/load.ts'; +import { providerDialplan, providerPjsip } from './provider.ts'; + const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk'; const RECORD = process.argv.includes('--record') ? process.argv[process.argv.indexOf('--record') + 1] @@ -152,51 +154,6 @@ function phoneFiles(dir: string) { ); } -/** The fake voip.ms: one sub-account that may register. */ -const PROVIDER_PJSIP = `[transport-udp] -type = transport -protocol = udp -bind = 0.0.0.0:5060 - -[${SUB.user}] -type = endpoint -context = provider-in -disallow = all -allow = ulaw -auth = ${SUB.user}-auth -aors = ${SUB.user} - -[${SUB.user}-auth] -type = auth -auth_type = userpass -username = ${SUB.user} -password = ${SUB.pass} - -[${SUB.user}] -type = aor -max_contacts = 1 -remove_existing = yes -`; - -/** - * provider-out: a mobile phone calls one of our DIDs. Like voip.ms, the Request-URI user is the - * 10-digit DID (06 §3), sent to the sub-account's registered contact. Echo() returns the audio. - */ -const PROVIDER_DIALPLAN = `[provider-out] -exten => _X.,1,Set(CALLERID(all)="Mobile" <${MOBILE}>) - same => n,Dial(PJSIP/\${EXTEN}@${SUB.user},30) - -[provider-echo] -exten => s,1,Answer() - same => n,Echo() - -; Our outbound calls arrive here: remember what the provider saw, then echo the audio. -[provider-in] -exten => _X.,1,Set(GLOBAL(LAST_IN)=to=\${EXTEN} from=\${CALLERID(num)} rpid=\${PJSIP_HEADER(read,Remote-Party-ID)}) - same => n,Answer() - same => n,Echo() -`; - async function waitFor(what: string, fn: () => Promise, seconds = 60) { for (let i = 0; i < seconds; i++) { if (await fn()) return; @@ -217,9 +174,9 @@ async function main() { writeFileSync( join(provConf, f), f === 'pjsip_selfphoneng.conf' - ? PROVIDER_PJSIP + ? providerPjsip(SUB) : f === 'extensions_selfphoneng.conf' - ? PROVIDER_DIALPLAN + ? providerDialplan(MOBILE, SUB) : '', ); } diff --git a/apps/api/test/cdr.test.ts b/apps/api/test/cdr.test.ts new file mode 100644 index 0000000..2555f98 --- /dev/null +++ b/apps/api/test/cdr.test.ts @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// M7-T1: each recorded scenario (test/fixtures/cdr, from real calls) produces the expected calls row. +import { readFileSync } from 'node:fs'; +import { eq } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { AmiMessage } from '../src/ami/events.ts'; +import { callExtensions, calls, cdr, dids, extensions, trunks } from '../src/db/schema.ts'; +import { aggregateCall, toCdrRow } from '../src/services/cdr.ts'; +import { encrypt } from '../src/crypto/secrets.ts'; +import { hasTestDb } from './db.ts'; +import { createTestApp } from './app.ts'; + +const fixture = (name: string): AmiMessage[] => + JSON.parse(readFileSync(new URL(`./fixtures/cdr/${name}.json`, import.meta.url), 'utf8')); + +type Expected = { + direction: string; + status: string; + fromNumber: string; + fromName: string | null; + toNumber: string; + did?: string; + answeredBy: string | null; + rang: string[]; + talkSeconds: number; +}; + +const SCENARIOS: Record = { + 'internal-answered': { + direction: 'internal', + status: 'answered', + fromNumber: '103', + fromName: 'Carol', + toNumber: '101', + answeredBy: '101', + rang: ['101'], + talkSeconds: 5, + }, + 'internal-voicemail': { + direction: 'internal', + status: 'voicemail', + fromNumber: '103', + fromName: 'Carol', + toNumber: '102', + answeredBy: null, + rang: ['102'], + talkSeconds: 0, + }, + 'internal-unavailable': { + direction: 'internal', + status: 'missed', + fromNumber: '103', + fromName: 'Carol', + toNumber: '104', + answeredBy: null, + rang: [], + talkSeconds: 0, + }, + 'inbound-user': { + direction: 'inbound', + status: 'answered', + fromNumber: '13125559999', + fromName: 'Mobile', + toNumber: '13125551234', + did: '13125551234', + answeredBy: '101', + rang: ['101'], + talkSeconds: 5, + }, + 'inbound-ringgroup': { + direction: 'inbound', + status: 'answered', + fromNumber: '13125559999', + fromName: 'Mobile', + toNumber: '13125551235', + did: '13125551235', + answeredBy: '103', + rang: ['102', '103'], + talkSeconds: 4, + }, + 'inbound-ivr-voicemail': { + direction: 'inbound', + status: 'voicemail', + fromNumber: '13125559999', + fromName: 'Mobile', + toNumber: '13125551236', + did: '13125551236', + answeredBy: null, + rang: [], + talkSeconds: 0, + }, + 'outbound-answered': { + direction: 'outbound', + status: 'answered', + fromNumber: '101', + fromName: 'Alice', + toNumber: '13125550000', + answeredBy: null, + rang: [], + talkSeconds: 5, + }, + transfer: { + direction: 'inbound', + status: 'answered', + fromNumber: '13125559999', + fromName: 'Mobile', + toNumber: '13125551234', + did: '13125551234', + answeredBy: '101', + rang: ['101', '103'], + talkSeconds: 4, + }, +}; + +describe.skipIf(!hasTestDb)('call aggregation from recorded CDRs', () => { + let t: Awaited>; + const extId: Record = {}; + + beforeAll(async () => { + t = await createTestApp(); + for (const [n, name] of [ + ['101', 'Alice'], + ['102', 'Bob'], + ['103', 'Carol'], + ['104', 'Dave'], + ] as const) { + const [e] = await t.db + .insert(extensions) + .values({ number: n, name, sipPasswordEnc: encrypt('x'), voicemailPinEnc: encrypt('4826') }) + .returning(); + extId[n] = e!.id; + } + const [trunk] = await t.db + .insert(trunks) + .values({ + name: 'voip.ms', + serverHost: 'chicago1.voip.ms', + username: 'u', + passwordEnc: encrypt('p'), + }) + .returning(); + for (const n of ['1234', '1235', '1236']) + await t.db.insert(dids).values({ + number: `1312555${n}`, + trunkId: trunk!.id, + destination: { type: 'hangup' }, + }); + }); + afterAll(() => t?.close()); + + it.each(Object.entries(SCENARIOS))('%s', async (name, want) => { + const events = fixture(name); + for (const e of events) await t.db.insert(cdr).values(toCdrRow(e)); + const linkedid = String(events[0]!.LinkedID); + await aggregateCall(t.db, linkedid); + await aggregateCall(t.db, linkedid); // idempotent + const rows = await t.db.select().from(calls).where(eq(calls.linkedid, linkedid)); + expect(rows).toHaveLength(1); + const c = rows[0]!; + expect({ + direction: c.direction, + status: c.status, + fromNumber: c.fromNumber, + fromName: c.fromName, + toNumber: c.toNumber, + talkSeconds: c.talkSeconds, + }).toEqual({ + direction: want.direction, + status: want.status, + fromNumber: want.fromNumber, + fromName: want.fromName, + toNumber: want.toNumber, + talkSeconds: want.talkSeconds, + }); + expect(c.answeredByExtensionId).toBe(want.answeredBy ? extId[want.answeredBy] : null); + expect(c.didId !== null).toBe(Boolean(want.did)); + expect(c.startedAt.getTime()).toBeLessThanOrEqual(c.endedAt.getTime()); + expect(c.answeredAt !== null).toBe(want.status === 'answered'); + const involved = await t.db + .select({ ext: callExtensions.extensionId, role: callExtensions.role }) + .from(callExtensions) + .where(eq(callExtensions.callId, c.id)); + expect( + involved + .filter((i) => i.role === 'rang') + .map((i) => i.ext) + .sort(), + ).toEqual(want.rang.map((n) => extId[n]).sort()); + const caller = /^\d{3}$/.test(want.fromNumber) ? [extId[want.fromNumber]] : []; + expect(involved.filter((i) => i.role === 'caller').map((i) => i.ext)).toEqual(caller); + }); +}); diff --git a/apps/api/test/dids.test.ts b/apps/api/test/dids.test.ts index 825d455..06b7f54 100644 --- a/apps/api/test/dids.test.ts +++ b/apps/api/test/dids.test.ts @@ -88,7 +88,7 @@ describe.skipIf(!hasTestDb)('DIDs API', () => { const conf = dialplan(); expect(conf).toContain('[sp-did-exists]\nexten => 13125551234,1,NoOp()'); expect(conf).toContain( - 'exten => 13125551234,1,Set(__SP_DID=13125551234)\n same => n,Goto(sp-local-numbers,101,1)', + 'exten => 13125551234,1,Set(__SP_DID=13125551234)\n same => n,Set(CDR(SP_DID)=13125551234)\n same => n,Goto(sp-local-numbers,101,1)', ); expect(conf).toContain(`[sp-from-trunk-${trunkId}]`); }); diff --git a/apps/api/test/fixtures/cdr/inbound-ivr-voicemail.json b/apps/api/test/fixtures/cdr/inbound-ivr-voicemail.json new file mode 100644 index 0000000..6fecd22 --- /dev/null +++ b/apps/api/test/fixtures/cdr/inbound-ivr-voicemail.json @@ -0,0 +1,28 @@ +[ + { + "Event": "Cdr", + "Privilege": "cdr,all", + "AccountCode": "", + "Source": "13125559999", + "Destination": "102", + "DestinationContext": "sp-vm-direct", + "CallerID": "\"Mobile\" <13125559999>", + "Channel": "PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-0000000a", + "DestinationChannel": "", + "LastApplication": "VoiceMail", + "LastData": "102@default,u", + "StartTime": "2026-10-18 19:33:48", + "AnswerTime": "2026-10-18 19:33:48", + "EndTime": "2026-10-18 19:33:57", + "Duration": "9", + "BillableSeconds": "9", + "Disposition": "ANSWERED", + "AMAFlags": "DOCUMENTATION", + "UniqueID": "1792352028.25", + "UserField": "", + "LinkedID": "1792352028.25", + "sp_did": "13125551236", + "sp_direction": "inbound", + "sp_recording": "" + } +] diff --git a/apps/api/test/fixtures/cdr/inbound-ringgroup.json b/apps/api/test/fixtures/cdr/inbound-ringgroup.json new file mode 100644 index 0000000..fb833e3 --- /dev/null +++ b/apps/api/test/fixtures/cdr/inbound-ringgroup.json @@ -0,0 +1,158 @@ +[ + { + "Event": "Cdr", + "Privilege": "cdr,all", + "AccountCode": "", + "Source": "13125559999", + "Destination": "102", + "DestinationContext": "sp-rg-member", + "CallerID": "\"Mobile\" <13125559999>", + "Channel": "Local/102@sp-rg-member-00000000;2", + "DestinationChannel": "PJSIP/102-00000008", + "LastApplication": "Dial", + "LastData": "PJSIP/102/sip:102-0x59ff2dbd50b0@172.21.0.4:5072,600,tx", + "StartTime": "2026-10-18 19:33:35", + "AnswerTime": "", + "EndTime": "2026-10-18 19:33:40", + "Duration": "5", + "BillableSeconds": "0", + "Disposition": "NO ANSWER", + "AMAFlags": "DOCUMENTATION", + "UniqueID": "1792352015.14", + "UserField": "", + "LinkedID": "1792352015.12", + "sp_did": "", + "sp_direction": "", + "sp_recording": "" + }, + { + "Event": "Cdr", + "Privilege": "cdr,all", + "AccountCode": "", + "Source": "13125559999", + "Destination": "103", + "DestinationContext": "sp-rg-member", + "CallerID": "\"Mobile\" <13125559999>", + "Channel": "Local/103@sp-rg-member-00000001;2", + "DestinationChannel": "PJSIP/103-00000009", + "LastApplication": "Dial", + "LastData": "PJSIP/103/sip:103-0x5fa815eee0b0@172.21.0.4:5074,600,tx", + "StartTime": "2026-10-18 19:33:40", + "AnswerTime": "2026-10-18 19:33:40", + "EndTime": "2026-10-18 19:33:40", + "Duration": "0", + "BillableSeconds": "0", + "Disposition": "ANSWERED", + "AMAFlags": "DOCUMENTATION", + "UniqueID": "1792352020.17", + "UserField": "", + "LinkedID": "1792352015.12", + "sp_did": "", + "sp_direction": "", + "sp_recording": "" + }, + { + "Event": "Cdr", + "Privilege": "cdr,all", + "AccountCode": "", + "Source": "103", + "Destination": "", + "DestinationContext": "sp-from-ext", + "CallerID": "\"Carol\" <103>", + "Channel": "PJSIP/103-00000009", + "DestinationChannel": "Local/103@sp-rg-member-00000001;1", + "LastApplication": "AppDial", + "LastData": "(Outgoing Line)", + "StartTime": "2026-10-18 19:33:40", + "AnswerTime": "2026-10-18 19:33:40", + "EndTime": "2026-10-18 19:33:40", + "Duration": "0", + "BillableSeconds": "0", + "Disposition": "ANSWERED", + "AMAFlags": "DOCUMENTATION", + "UniqueID": "1792352020.19", + "UserField": "", + "LinkedID": "1792352015.12", + "sp_did": "", + "sp_direction": "", + "sp_recording": "" + }, + { + "Event": "Cdr", + "Privilege": "cdr,all", + "AccountCode": "", + "Source": "13125559999", + "Destination": "s", + "DestinationContext": "sp-rg-22222222-2222-4222-8222-222222222500", + "CallerID": "\"Mobile\" <13125559999>", + "Channel": "PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-00000007", + "DestinationChannel": "Local/102@sp-rg-member-00000000;1", + "LastApplication": "Dial", + "LastData": "Local/102@sp-rg-member,5,t", + "StartTime": "2026-10-18 19:33:35", + "AnswerTime": "", + "EndTime": "2026-10-18 19:33:40", + "Duration": "5", + "BillableSeconds": "0", + "Disposition": "NO ANSWER", + "AMAFlags": "DOCUMENTATION", + "UniqueID": "1792352015.12", + "UserField": "", + "LinkedID": "1792352015.12", + "sp_did": "13125551235", + "sp_direction": "inbound", + "sp_recording": "" + }, + { + "Event": "Cdr", + "Privilege": "cdr,all", + "AccountCode": "", + "Source": "13125559999", + "Destination": "s", + "DestinationContext": "sp-rg-22222222-2222-4222-8222-222222222500", + "CallerID": "\"Mobile\" <13125559999>", + "Channel": "PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-00000007", + "DestinationChannel": "Local/103@sp-rg-member-00000001;1", + "LastApplication": "Dial", + "LastData": "Local/103@sp-rg-member,5,t", + "StartTime": "2026-10-18 19:33:40", + "AnswerTime": "2026-10-18 19:33:40", + "EndTime": "2026-10-18 19:33:40", + "Duration": "0", + "BillableSeconds": "0", + "Disposition": "ANSWERED", + "AMAFlags": "DOCUMENTATION", + "UniqueID": "1792352015.12", + "UserField": "", + "LinkedID": "1792352015.12", + "sp_did": "13125551235", + "sp_direction": "inbound", + "sp_recording": "" + }, + { + "Event": "Cdr", + "Privilege": "cdr,all", + "AccountCode": "", + "Source": "13125559999", + "Destination": "s", + "DestinationContext": "sp-rg-22222222-2222-4222-8222-222222222500", + "CallerID": "\"Mobile\" <13125559999>", + "Channel": "PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-00000007", + "DestinationChannel": "PJSIP/103-00000009", + "LastApplication": "Dial", + "LastData": "Local/103@sp-rg-member,5,t", + "StartTime": "2026-10-18 19:33:40", + "AnswerTime": "2026-10-18 19:33:40", + "EndTime": "2026-10-18 19:33:45", + "Duration": "4", + "BillableSeconds": "4", + "Disposition": "ANSWERED", + "AMAFlags": "DOCUMENTATION", + "UniqueID": "1792352015.12", + "UserField": "", + "LinkedID": "1792352015.12", + "sp_did": "13125551235", + "sp_direction": "inbound", + "sp_recording": "" + } +] diff --git a/apps/api/test/fixtures/cdr/inbound-user.json b/apps/api/test/fixtures/cdr/inbound-user.json new file mode 100644 index 0000000..60fbfcc --- /dev/null +++ b/apps/api/test/fixtures/cdr/inbound-user.json @@ -0,0 +1,28 @@ +[ + { + "Event": "Cdr", + "Privilege": "cdr,all", + "AccountCode": "", + "Source": "13125559999", + "Destination": "101", + "DestinationContext": "sp-local-numbers", + "CallerID": "\"Mobile\" <13125559999>", + "Channel": "PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-00000005", + "DestinationChannel": "PJSIP/101-00000006", + "LastApplication": "Dial", + "LastData": "PJSIP/101/sip:101-0x5f231650d0b0@172.21.0.4:5070,25,tx", + "StartTime": "2026-10-18 19:33:26", + "AnswerTime": "2026-10-18 19:33:26", + "EndTime": "2026-10-18 19:33:32", + "Duration": "5", + "BillableSeconds": "5", + "Disposition": "ANSWERED", + "AMAFlags": "DOCUMENTATION", + "UniqueID": "1792352006.9", + "UserField": "", + "LinkedID": "1792352006.9", + "sp_did": "13125551234", + "sp_direction": "inbound", + "sp_recording": "" + } +] diff --git a/apps/api/test/fixtures/cdr/internal-answered.json b/apps/api/test/fixtures/cdr/internal-answered.json new file mode 100644 index 0000000..cf5034d --- /dev/null +++ b/apps/api/test/fixtures/cdr/internal-answered.json @@ -0,0 +1,28 @@ +[ + { + "Event": "Cdr", + "Privilege": "cdr,all", + "AccountCode": "", + "Source": "103", + "Destination": "101", + "DestinationContext": "sp-from-ext", + "CallerID": "\"Carol\" <103>", + "Channel": "PJSIP/103-00000000", + "DestinationChannel": "PJSIP/101-00000001", + "LastApplication": "Dial", + "LastData": "PJSIP/101/sip:101-0x5f231650d0b0@172.21.0.4:5070,25,tx", + "StartTime": "2026-10-18 19:32:55", + "AnswerTime": "2026-10-18 19:32:55", + "EndTime": "2026-10-18 19:33:01", + "Duration": "5", + "BillableSeconds": "5", + "Disposition": "ANSWERED", + "AMAFlags": "DOCUMENTATION", + "UniqueID": "1792351975.0", + "UserField": "", + "LinkedID": "1792351975.0", + "sp_did": "", + "sp_direction": "", + "sp_recording": "" + } +] diff --git a/apps/api/test/fixtures/cdr/internal-unavailable.json b/apps/api/test/fixtures/cdr/internal-unavailable.json new file mode 100644 index 0000000..7fc5c53 --- /dev/null +++ b/apps/api/test/fixtures/cdr/internal-unavailable.json @@ -0,0 +1,28 @@ +[ + { + "Event": "Cdr", + "Privilege": "cdr,all", + "AccountCode": "", + "Source": "103", + "Destination": "104", + "DestinationContext": "sp-from-ext", + "CallerID": "\"Carol\" <103>", + "Channel": "PJSIP/103-00000004", + "DestinationChannel": "", + "LastApplication": "Hangup", + "LastData": "", + "StartTime": "2026-10-18 19:33:19", + "AnswerTime": "2026-10-18 19:33:19", + "EndTime": "2026-10-18 19:33:19", + "Duration": "0", + "BillableSeconds": "0", + "Disposition": "ANSWERED", + "AMAFlags": "DOCUMENTATION", + "UniqueID": "1792351999.7", + "UserField": "", + "LinkedID": "1792351999.7", + "sp_did": "", + "sp_direction": "", + "sp_recording": "" + } +] diff --git a/apps/api/test/fixtures/cdr/internal-voicemail.json b/apps/api/test/fixtures/cdr/internal-voicemail.json new file mode 100644 index 0000000..6e866bd --- /dev/null +++ b/apps/api/test/fixtures/cdr/internal-voicemail.json @@ -0,0 +1,54 @@ +[ + { + "Event": "Cdr", + "Privilege": "cdr,all", + "AccountCode": "", + "Source": "103", + "Destination": "102", + "DestinationContext": "sp-from-ext", + "CallerID": "\"Carol\" <103>", + "Channel": "PJSIP/103-00000002", + "DestinationChannel": "PJSIP/102-00000003", + "LastApplication": "Dial", + "LastData": "PJSIP/102/sip:102-0x59ff2dbd50b0@172.21.0.4:5072,5,tx", + "StartTime": "2026-10-18 19:33:04", + "AnswerTime": "2026-10-18 19:33:04", + "EndTime": "2026-10-18 19:33:09", + "Duration": "5", + "BillableSeconds": "5", + "Disposition": "NO ANSWER", + "AMAFlags": "DOCUMENTATION", + "UniqueID": "1792351984.3", + "UserField": "", + "LinkedID": "1792351984.3", + "sp_did": "", + "sp_direction": "", + "sp_recording": "" + }, + { + "Event": "Cdr", + "Privilege": "cdr,all", + "AccountCode": "", + "Source": "103", + "Destination": "102", + "DestinationContext": "sp-from-ext", + "CallerID": "\"Carol\" <103>", + "Channel": "PJSIP/103-00000002", + "DestinationChannel": "", + "LastApplication": "VoiceMail", + "LastData": "102@default,u", + "StartTime": "2026-10-18 19:33:09", + "AnswerTime": "2026-10-18 19:33:09", + "EndTime": "2026-10-18 19:33:15", + "Duration": "6", + "BillableSeconds": "6", + "Disposition": "ANSWERED", + "AMAFlags": "DOCUMENTATION", + "UniqueID": "1792351984.3", + "UserField": "", + "LinkedID": "1792351984.3", + "sp_did": "", + "sp_direction": "", + "sp_recording": "" + } +] diff --git a/apps/api/test/fixtures/cdr/outbound-answered.json b/apps/api/test/fixtures/cdr/outbound-answered.json new file mode 100644 index 0000000..9daf474 --- /dev/null +++ b/apps/api/test/fixtures/cdr/outbound-answered.json @@ -0,0 +1,28 @@ +[ + { + "Event": "Cdr", + "Privilege": "cdr,all", + "AccountCode": "", + "Source": "13125551234", + "Destination": "13125550000", + "DestinationContext": "sp-route-aaaaaaaa-0000-4000-8000-000000000001", + "CallerID": "\"Alice\" <13125551234>", + "Channel": "PJSIP/101-0000000b", + "DestinationChannel": "PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-0000000c", + "LastApplication": "Dial", + "LastData": "PJSIP/13125550000@trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,120", + "StartTime": "2026-10-18 19:34:01", + "AnswerTime": "2026-10-18 19:34:01", + "EndTime": "2026-10-18 19:34:06", + "Duration": "5", + "BillableSeconds": "5", + "Disposition": "ANSWERED", + "AMAFlags": "DOCUMENTATION", + "UniqueID": "1792352041.27", + "UserField": "", + "LinkedID": "1792352041.27", + "sp_did": "", + "sp_direction": "outbound", + "sp_recording": "" + } +] diff --git a/apps/api/test/fixtures/cdr/transfer.json b/apps/api/test/fixtures/cdr/transfer.json new file mode 100644 index 0000000..b844d1a --- /dev/null +++ b/apps/api/test/fixtures/cdr/transfer.json @@ -0,0 +1,54 @@ +[ + { + "Event": "Cdr", + "Privilege": "cdr,all", + "AccountCode": "", + "Source": "13125559999", + "Destination": "101", + "DestinationContext": "sp-local-numbers", + "CallerID": "\"Mobile\" <13125559999>", + "Channel": "PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-0000000d", + "DestinationChannel": "PJSIP/101-0000000e", + "LastApplication": "Dial", + "LastData": "PJSIP/101/sip:101-0x5f231650d0b0@172.21.0.4:5070,25,tx", + "StartTime": "2026-10-18 19:34:10", + "AnswerTime": "2026-10-18 19:34:10", + "EndTime": "2026-10-18 19:34:13", + "Duration": "3", + "BillableSeconds": "3", + "Disposition": "ANSWERED", + "AMAFlags": "DOCUMENTATION", + "UniqueID": "1792352050.30", + "UserField": "", + "LinkedID": "1792352050.30", + "sp_did": "13125551234", + "sp_direction": "inbound", + "sp_recording": "" + }, + { + "Event": "Cdr", + "Privilege": "cdr,all", + "AccountCode": "", + "Source": "13125559999", + "Destination": "101", + "DestinationContext": "sp-local-numbers", + "CallerID": "\"Mobile\" <13125559999>", + "Channel": "PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-0000000d", + "DestinationChannel": "PJSIP/103-0000000f", + "LastApplication": "Dial", + "LastData": "PJSIP/103/sip:103-0x5fa815eee0b0@172.21.0.4:5074,25,tx", + "StartTime": "2026-10-18 19:34:13", + "AnswerTime": "2026-10-18 19:34:13", + "EndTime": "2026-10-18 19:34:18", + "Duration": "4", + "BillableSeconds": "4", + "Disposition": "ANSWERED", + "AMAFlags": "DOCUMENTATION", + "UniqueID": "1792352050.30", + "UserField": "", + "LinkedID": "1792352050.30", + "sp_did": "13125551234", + "sp_direction": "inbound", + "sp_recording": "" + } +] diff --git a/asterisk/conf/cdr_manager.conf b/asterisk/conf/cdr_manager.conf index 6b08b19..09e8ea8 100644 --- a/asterisk/conf/cdr_manager.conf +++ b/asterisk/conf/cdr_manager.conf @@ -1,6 +1,9 @@ [general] enabled = yes +usegmtime = yes ; times in UTC: the api parses them without guessing a time zone + [mappings] +linkedid => LinkedID ; groups every leg of one call (04 §5.4); not in the event by default SP_DID => sp_did SP_DIRECTION => sp_direction SP_REC => sp_recording diff --git a/docs/spec/01-infrastructure.md b/docs/spec/01-infrastructure.md index 478ae0f..c6c43a5 100644 --- a/docs/spec/01-infrastructure.md +++ b/docs/spec/01-infrastructure.md @@ -502,12 +502,16 @@ endbeforehexten = yes ; cdr_manager.conf [general] enabled = yes +usegmtime = yes ; times in UTC: the api parses them without guessing a time zone [mappings] +linkedid => LinkedID ; groups every leg of one call; not in the event by default SP_DID => sp_did SP_DIRECTION => sp_direction SP_REC => sp_recording ``` The api turns `Cdr` AMI events into rows in `cdr` (see `04-backend-api.md` §5.4). +Without the `linkedid` mapping, Asterisk 24's `Cdr` event has `UniqueID` but no `LinkedID`, and +its times have no zone (checked on 24.0.0-rc2, M7-T1). ## 4. Networking and NAT diff --git a/docs/spec/04-backend-api.md b/docs/spec/04-backend-api.md index d9da2a6..25240ac 100644 --- a/docs/spec/04-backend-api.md +++ b/docs/spec/04-backend-api.md @@ -223,6 +223,19 @@ Trunk channel: `^PJSIP/trunk-([0-9a-f-]{36})-`. (inbound to user, inbound to ring group, IVR → voicemail, queue abandon, outbound, transfer, park) into `test/fixtures/cdr/*.json` and write a test per scenario. +As built (M7-T1): +- `apps/api/test/asterisk/cdr-record.ts` records 8 scenarios against our config, the fake + provider and baresip phones: internal answered / to voicemail / to an unavailable phone, inbound + to a user / ring group / IVR timeout → voicemail, outbound answered, blind transfer. Queue + abandon and park come with M8. `test/cdr.test.ts` checks each one. +- `cdr_manager.conf` [mappings] read **CDR variables**, not channel variables: the dialplan sets + `CDR(SP_DID)` in `sp-inbound` and `CDR(SP_DIRECTION)` at the trunk entry, in outbound routes and + in emergency calls. `linkedid` must be mapped too (01 §3.15); times are UTC (`usegmtime`). +- **from** is the caller's extension when the first leg is a phone (not the caller ID it sends + outside); otherwise `src`. An outbound call has no `answered_by` (no extension answered). +- Aggregation runs 5 s after a linkedid's last Cdr event; on every AMI connect, linkedids older + than a minute without a `calls` row are aggregated (instead of the nightly job). + ### 5.5 AstDB sync (`services/astdb.ts`) On every AMI connect, PostgreSQL wins: