cdr: store Cdr events, aggregate calls, fixtures from real calls
services/cdr.ts stores every AMI Cdr event in cdr and, 5 s after a call's last event, builds its calls row (direction, status, from, to, DID, answered by, talk time) and call_extensions (caller, rang, answered) per 04 §5.4; linkedids without a calls row are caught up on AMI connect. Aggregation is idempotent. test/asterisk/cdr-record.ts records 8 scenarios with our config, the fake voip.ms (now shared in provider.ts) and baresip phones; each has a fixture and a test with the expected calls row. Queue abandon and park come with M8. Found while recording: Asterisk 24's Cdr event has no LinkedID unless cdr_manager maps it, its times carry no zone (now usegmtime), and the mappings read CDR variables, so the dialplan sets CDR(SP_DID) and CDR(SP_DIRECTION) next to the channel variables. The Asterisk image needs a rebuild for the cdr_manager.conf change. Task: M7-T1
This commit is contained in:
1 parent
8a763ff130
commit
71440ac85d
31 files changed
+1332
-48
No files matched your search
@@ -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,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
|
||||||
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
||||||
same => n,Set(__SP_DIRECTION=outbound)
|
same => n,Set(__SP_DIRECTION=outbound)
|
||||||
|
same => n,Set(CDR(SP_DIRECTION)=outbound)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
@@ -220,6 +220,7 @@ exten => _*3[123]X,1,Playback(ss-noservice)
|
|||||||
[sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
|
[sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
|
||||||
exten => s,1,Goto(sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,unknown,1)
|
exten => s,1,Goto(sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,unknown,1)
|
||||||
exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound)
|
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,Gosub(sp-sub-normalize,s,1(${EXTEN}))
|
||||||
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
||||||
same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)
|
same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)
|
||||||
@@ -239,6 +240,7 @@ exten => 13125551234,1,NoOp()
|
|||||||
[sp-inbound]
|
[sp-inbound]
|
||||||
; DID 13125551234
|
; DID 13125551234
|
||||||
exten => 13125551234,1,Set(__SP_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)
|
same => n,Goto(sp-tc-55555555-5555-4555-8555-555555555001,s,1)
|
||||||
; anything else
|
; anything else
|
||||||
exten => _[0-9a-z].,1,Hangup()
|
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,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
|
||||||
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
||||||
same => n,Set(__SP_DIRECTION=outbound)
|
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,Dial(PJSIP/911@trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
@@ -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,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
|
||||||
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
||||||
same => n,Set(__SP_DIRECTION=outbound)
|
same => n,Set(__SP_DIRECTION=outbound)
|
||||||
|
same => n,Set(CDR(SP_DIRECTION)=outbound)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
+3
@@ -241,6 +241,7 @@ exten => _*3[123]X,1,Playback(ss-noservice)
|
|||||||
[sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
|
[sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
|
||||||
exten => s,1,Goto(sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,unknown,1)
|
exten => s,1,Goto(sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,unknown,1)
|
||||||
exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound)
|
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,Gosub(sp-sub-normalize,s,1(${EXTEN}))
|
||||||
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
||||||
same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)
|
same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)
|
||||||
@@ -260,6 +261,7 @@ exten => 13125551234,1,NoOp()
|
|||||||
[sp-inbound]
|
[sp-inbound]
|
||||||
; DID 13125551234
|
; DID 13125551234
|
||||||
exten => 13125551234,1,Set(__SP_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)
|
same => n,Goto(sp-tc-55555555-5555-4555-8555-555555555001,s,1)
|
||||||
; anything else
|
; anything else
|
||||||
exten => _[0-9a-z].,1,Hangup()
|
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,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
|
||||||
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
||||||
same => n,Set(__SP_DIRECTION=outbound)
|
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,Dial(PJSIP/911@trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
@@ -242,6 +242,7 @@ exten => s,1,Answer()
|
|||||||
[sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
|
[sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
|
||||||
exten => s,1,Goto(sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,unknown,1)
|
exten => s,1,Goto(sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,unknown,1)
|
||||||
exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound)
|
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,Gosub(sp-sub-normalize,s,1(${EXTEN}))
|
||||||
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
||||||
same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)
|
same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)
|
||||||
@@ -261,6 +262,7 @@ exten => 13125551234,1,NoOp()
|
|||||||
[sp-inbound]
|
[sp-inbound]
|
||||||
; DID 13125551234
|
; DID 13125551234
|
||||||
exten => 13125551234,1,Set(__SP_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)
|
same => n,Goto(sp-ivr-33333333-3333-4333-8333-333333333600,s,1)
|
||||||
; anything else
|
; anything else
|
||||||
exten => _[0-9a-z].,1,Hangup()
|
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,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
|
||||||
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
||||||
same => n,Set(__SP_DIRECTION=outbound)
|
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,Dial(PJSIP/911@trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
@@ -179,6 +179,7 @@ exten => 13125551234,1,NoOp()
|
|||||||
[sp-inbound]
|
[sp-inbound]
|
||||||
; DID 13125551234
|
; DID 13125551234
|
||||||
exten => 13125551234,1,Set(__SP_DID=13125551234)
|
exten => 13125551234,1,Set(__SP_DID=13125551234)
|
||||||
|
same => n,Set(CDR(SP_DID)=13125551234)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
; anything else
|
; anything else
|
||||||
exten => _[0-9a-z].,1,Hangup()
|
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,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
|
||||||
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
||||||
same => n,Set(__SP_DIRECTION=outbound)
|
same => n,Set(__SP_DIRECTION=outbound)
|
||||||
|
same => n,Set(CDR(SP_DIRECTION)=outbound)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
@@ -213,6 +213,7 @@ exten => _*3[123]X,1,Playback(ss-noservice)
|
|||||||
[sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
|
[sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
|
||||||
exten => s,1,Goto(sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,unknown,1)
|
exten => s,1,Goto(sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,unknown,1)
|
||||||
exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound)
|
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,Gosub(sp-sub-normalize,s,1(${EXTEN}))
|
||||||
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
||||||
same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)
|
same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)
|
||||||
@@ -232,6 +233,7 @@ exten => 13125551234,1,NoOp()
|
|||||||
[sp-inbound]
|
[sp-inbound]
|
||||||
; DID 13125551234
|
; DID 13125551234
|
||||||
exten => 13125551234,1,Set(__SP_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)
|
same => n,Goto(sp-rg-22222222-2222-4222-8222-222222222500,s,1)
|
||||||
; anything else
|
; anything else
|
||||||
exten => _[0-9a-z].,1,Hangup()
|
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,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
|
||||||
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
||||||
same => n,Set(__SP_DIRECTION=outbound)
|
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,Dial(PJSIP/911@trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
@@ -174,6 +174,7 @@ exten => _*3[123]X,1,Playback(ss-noservice)
|
|||||||
[sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000001]
|
[sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000001]
|
||||||
exten => s,1,Goto(sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000001,unknown,1)
|
exten => s,1,Goto(sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000001,unknown,1)
|
||||||
exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound)
|
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,Gosub(sp-sub-normalize,s,1(${EXTEN}))
|
||||||
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
||||||
same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)
|
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]
|
[sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000002]
|
||||||
exten => s,1,Goto(sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000002,unknown,1)
|
exten => s,1,Goto(sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000002,unknown,1)
|
||||||
exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound)
|
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,Gosub(sp-sub-normalize,s,1(${EXTEN}))
|
||||||
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
||||||
same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)
|
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]
|
[sp-route-bbbbbbbb-0000-4000-8000-000000000001]
|
||||||
exten => _X.,1,Set(__SP_DIRECTION=outbound)
|
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,GotoIf($[${IF($["${SP_CLASS}" = ""]?0:${SP_CLASS})} < 2]?denied)
|
||||||
same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1))
|
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
|
; 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]
|
[sp-route-bbbbbbbb-0000-4000-8000-000000000002]
|
||||||
exten => _X.,1,Set(__SP_DIRECTION=outbound)
|
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,GotoIf($[${IF($["${SP_CLASS}" = ""]?0:${SP_CLASS})} < 1]?denied)
|
||||||
same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1))
|
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
|
; 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,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
|
||||||
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
||||||
same => n,Set(__SP_DIRECTION=outbound)
|
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-000000000001)
|
||||||
same => n,Dial(PJSIP/911@trunk-a1b2c3d4-0000-4000-8000-000000000002)
|
same => n,Dial(PJSIP/911@trunk-a1b2c3d4-0000-4000-8000-000000000002)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
@@ -177,6 +177,7 @@ exten => _*3[123]X,1,Playback(ss-noservice)
|
|||||||
[sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
|
[sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
|
||||||
exten => s,1,Goto(sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,unknown,1)
|
exten => s,1,Goto(sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,unknown,1)
|
||||||
exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound)
|
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,Gosub(sp-sub-normalize,s,1(${EXTEN}))
|
||||||
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
||||||
same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)
|
same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)
|
||||||
@@ -197,9 +198,11 @@ exten => 13125559876,1,NoOp()
|
|||||||
[sp-inbound]
|
[sp-inbound]
|
||||||
; DID 13125551234 "Main line"
|
; DID 13125551234 "Main line"
|
||||||
exten => 13125551234,1,Set(__SP_DID=13125551234)
|
exten => 13125551234,1,Set(__SP_DID=13125551234)
|
||||||
|
same => n,Set(CDR(SP_DID)=13125551234)
|
||||||
same => n,Goto(sp-local-numbers,101,1)
|
same => n,Goto(sp-local-numbers,101,1)
|
||||||
; DID 13125559876 "Sales line"
|
; DID 13125559876 "Sales line"
|
||||||
exten => 13125559876,1,Set(__SP_DID=13125559876)
|
exten => 13125559876,1,Set(__SP_DID=13125559876)
|
||||||
|
same => n,Set(CDR(SP_DID)=13125559876)
|
||||||
same => n,Set(CALLERID(name)=SALES: ${CALLERID(name)})
|
same => n,Set(CALLERID(name)=SALES: ${CALLERID(name)})
|
||||||
same => n,Goto(sp-ext-forward,13125550000,1)
|
same => n,Goto(sp-ext-forward,13125550000,1)
|
||||||
; anything else
|
; 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]
|
[sp-route-aaaaaaaa-0000-4000-8000-000000000002]
|
||||||
exten => _X.,1,Set(__SP_DIRECTION=outbound)
|
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,GotoIf($[${IF($["${SP_CLASS}" = ""]?0:${SP_CLASS})} < 1]?denied)
|
||||||
same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1))
|
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
|
; 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]
|
[sp-route-aaaaaaaa-0000-4000-8000-000000000001]
|
||||||
exten => _X.,1,Set(__SP_DIRECTION=outbound)
|
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,GotoIf($[${IF($["${SP_CLASS}" = ""]?0:${SP_CLASS})} < 2]?denied)
|
||||||
same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1))
|
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
|
; 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,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
|
||||||
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
||||||
same => n,Set(__SP_DIRECTION=outbound)
|
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,Dial(PJSIP/911@trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
@@ -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,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
|
||||||
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
||||||
same => n,Set(__SP_DIRECTION=outbound)
|
same => n,Set(__SP_DIRECTION=outbound)
|
||||||
|
same => n,Set(CDR(SP_DIRECTION)=outbound)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
@@ -19,6 +19,7 @@ export function renderEmergency(snap: Snapshot): string {
|
|||||||
' same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)',
|
' same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)',
|
||||||
' same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))',
|
' same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))',
|
||||||
' same => n,Set(__SP_DIRECTION=outbound)',
|
' 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)})`),
|
...trunks.map((t) => ` same => n,Dial(PJSIP/911@trunk-${s.ident(t.id)})`),
|
||||||
' same => n,Hangup()',
|
' same => n,Hangup()',
|
||||||
].join('\n') + '\n'
|
].join('\n') + '\n'
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export function renderTrunkEntries(snap: Snapshot): string {
|
|||||||
`[${ctx}]`,
|
`[${ctx}]`,
|
||||||
`exten => s,1,Goto(${ctx},unknown,1)`,
|
`exten => s,1,Goto(${ctx},unknown,1)`,
|
||||||
'exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound)',
|
'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,Gosub(sp-sub-normalize,s,1(${EXTEN}))',
|
||||||
' same => n,Set(SP_IN=${GOSUB_RETVAL})',
|
' same => n,Set(SP_IN=${GOSUB_RETVAL})',
|
||||||
' same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)',
|
' 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(
|
inbound.push(
|
||||||
`; DID ${num}${d.label ? ` "${s.name(d.label)}"` : ''}`,
|
`; DID ${num}${d.label ? ` "${s.name(d.label)}"` : ''}`,
|
||||||
`exten => ${num},1,Set(__SP_DID=${num})`,
|
`exten => ${num},1,Set(__SP_DID=${num})`,
|
||||||
|
` same => n,Set(CDR(SP_DID)=${num})`,
|
||||||
...(prefix ? [` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`] : []),
|
...(prefix ? [` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`] : []),
|
||||||
// TODO(M7): record_inbound (announcement + sp-sub-record).
|
// TODO(M7): record_inbound (announcement + sp-sub-record).
|
||||||
` same => n,${destApp(d.destination, snap)}`,
|
` same => n,${destApp(d.destination, snap)}`,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function renderOutbound(snap: Snapshot): string {
|
|||||||
const route = [
|
const route = [
|
||||||
`[sp-route-${id}]`,
|
`[sp-route-${id}]`,
|
||||||
'exten => _X.,1,Set(__SP_DIRECTION=outbound)',
|
'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,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))',
|
' 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',
|
' ; caller ID: route override > user > the DID a forwarded call came in on > default',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { watchEmergencies } from './services/emergency.ts';
|
|||||||
import { watchVoicemail } from './services/voicemail.ts';
|
import { watchVoicemail } from './services/voicemail.ts';
|
||||||
import { syncAstDb } from './services/astdb.ts';
|
import { syncAstDb } from './services/astdb.ts';
|
||||||
import { watchToggles } from './services/toggles.ts';
|
import { watchToggles } from './services/toggles.ts';
|
||||||
|
import { watchCdr } from './services/cdr.ts';
|
||||||
import { getSetting } from './services/settings.ts';
|
import { getSetting } from './services/settings.ts';
|
||||||
import { todayIn } from './generator/load.ts';
|
import { todayIn } from './generator/load.ts';
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ const sendMail = smtpMailer(db);
|
|||||||
app.decorate('sendMail', sendMail);
|
app.decorate('sendMail', sendMail);
|
||||||
watchEmergencies({ db, ami, live, send: sendMail, log: app.log }, ami);
|
watchEmergencies({ db, ami, live, send: sendMail, log: app.log }, ami);
|
||||||
watchToggles({ db, ami, live, log: app.log }, ami);
|
watchToggles({ db, ami, live, log: app.log }, ami);
|
||||||
|
const history = watchCdr({ db, log: app.log }, ami);
|
||||||
const voicemail = watchVoicemail(
|
const voicemail = watchVoicemail(
|
||||||
{
|
{
|
||||||
db,
|
db,
|
||||||
@@ -93,6 +95,7 @@ ami.on('connected', () => {
|
|||||||
await generator.apply({ trigger: 'startup', force: true });
|
await generator.apply({ trigger: 'startup', force: true });
|
||||||
await live.loadFromAsterisk();
|
await live.loadFromAsterisk();
|
||||||
await voicemail.scanAll();
|
await voicemail.scanAll();
|
||||||
|
await history.catchUp();
|
||||||
})().catch((err) => app.log.error({ err }, 'startup sync failed'));
|
})().catch((err) => app.log.error({ err }, 'startup sync failed'));
|
||||||
});
|
});
|
||||||
ami.start();
|
ami.start();
|
||||||
@@ -116,6 +119,7 @@ for (const sig of ['SIGTERM', 'SIGINT'] as const) {
|
|||||||
process.once(sig, () => {
|
process.once(sig, () => {
|
||||||
generator.cancelPending();
|
generator.cancelPending();
|
||||||
clearInterval(dayTimer);
|
clearInterval(dayTimer);
|
||||||
|
history.stop();
|
||||||
ami.stop();
|
ami.stop();
|
||||||
void app
|
void app
|
||||||
.close()
|
.close()
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<EventEmitter, 'on'>) {
|
||||||
|
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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/<name>.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<SnapExtension> = {}) => ({
|
||||||
|
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'),
|
||||||
|
`<sip:${n}@${PBX};transport=udp>;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> | 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<string, AmiMessage[]> = {};
|
||||||
|
async function scenario(name: string, run: () => Promise<void>) {
|
||||||
|
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();
|
||||||
@@ -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()
|
||||||
|
`;
|
||||||
@@ -16,6 +16,8 @@ import { AmiClient } from '../../src/ami/client.ts';
|
|||||||
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
||||||
import type { Snapshot } from '../../src/generator/load.ts';
|
import type { Snapshot } from '../../src/generator/load.ts';
|
||||||
|
|
||||||
|
import { providerDialplan, providerPjsip } from './provider.ts';
|
||||||
|
|
||||||
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
||||||
const RECORD = process.argv.includes('--record')
|
const RECORD = process.argv.includes('--record')
|
||||||
? process.argv[process.argv.indexOf('--record') + 1]
|
? 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<boolean>, seconds = 60) {
|
async function waitFor(what: string, fn: () => Promise<boolean>, seconds = 60) {
|
||||||
for (let i = 0; i < seconds; i++) {
|
for (let i = 0; i < seconds; i++) {
|
||||||
if (await fn()) return;
|
if (await fn()) return;
|
||||||
@@ -217,9 +174,9 @@ async function main() {
|
|||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(provConf, f),
|
join(provConf, f),
|
||||||
f === 'pjsip_selfphoneng.conf'
|
f === 'pjsip_selfphoneng.conf'
|
||||||
? PROVIDER_PJSIP
|
? providerPjsip(SUB)
|
||||||
: f === 'extensions_selfphoneng.conf'
|
: f === 'extensions_selfphoneng.conf'
|
||||||
? PROVIDER_DIALPLAN
|
? providerDialplan(MOBILE, SUB)
|
||||||
: '',
|
: '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, Expected> = {
|
||||||
|
'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<ReturnType<typeof createTestApp>>;
|
||||||
|
const extId: Record<string, string> = {};
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -88,7 +88,7 @@ describe.skipIf(!hasTestDb)('DIDs API', () => {
|
|||||||
const conf = dialplan();
|
const conf = dialplan();
|
||||||
expect(conf).toContain('[sp-did-exists]\nexten => 13125551234,1,NoOp()');
|
expect(conf).toContain('[sp-did-exists]\nexten => 13125551234,1,NoOp()');
|
||||||
expect(conf).toContain(
|
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}]`);
|
expect(conf).toContain(`[sp-from-trunk-${trunkId}]`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
+158
@@ -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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
+28
@@ -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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
+54
@@ -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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
[general]
|
[general]
|
||||||
enabled = yes
|
enabled = yes
|
||||||
|
usegmtime = yes ; times in UTC: the api parses them without guessing a time zone
|
||||||
|
|
||||||
[mappings]
|
[mappings]
|
||||||
|
linkedid => LinkedID ; groups every leg of one call (04 §5.4); not in the event by default
|
||||||
SP_DID => sp_did
|
SP_DID => sp_did
|
||||||
SP_DIRECTION => sp_direction
|
SP_DIRECTION => sp_direction
|
||||||
SP_REC => sp_recording
|
SP_REC => sp_recording
|
||||||
@@ -502,12 +502,16 @@ endbeforehexten = yes
|
|||||||
; cdr_manager.conf
|
; cdr_manager.conf
|
||||||
[general]
|
[general]
|
||||||
enabled = yes
|
enabled = yes
|
||||||
|
usegmtime = yes ; times in UTC: the api parses them without guessing a time zone
|
||||||
[mappings]
|
[mappings]
|
||||||
|
linkedid => LinkedID ; groups every leg of one call; not in the event by default
|
||||||
SP_DID => sp_did
|
SP_DID => sp_did
|
||||||
SP_DIRECTION => sp_direction
|
SP_DIRECTION => sp_direction
|
||||||
SP_REC => sp_recording
|
SP_REC => sp_recording
|
||||||
```
|
```
|
||||||
The api turns `Cdr` AMI events into rows in `cdr` (see `04-backend-api.md` §5.4).
|
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
|
## 4. Networking and NAT
|
||||||
|
|
||||||
|
|||||||
@@ -223,6 +223,19 @@ Trunk channel: `^PJSIP/trunk-([0-9a-f-]{36})-`.
|
|||||||
(inbound to user, inbound to ring group, IVR → voicemail, queue abandon,
|
(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.
|
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`)
|
### 5.5 AstDB sync (`services/astdb.ts`)
|
||||||
|
|
||||||
On every AMI connect, PostgreSQL wins:
|
On every AMI connect, PostgreSQL wins:
|
||||||
|
|||||||
Reference in new issue
Block a user