; Ringnex toll-free inbound campaign routing reference.
; IMPORTANT: review and merge into the custom Ringnex Asterisk only:
; /opt/ringnex-webrtc/etc/asterisk/extensions_commio.conf
; Do NOT apply this to the separate MagnusBilling/system Asterisk — same
; rule as tenant-routing-snippet.conf in this same folder.
;
; STATUS: this exact dialplan (context names, tenant checks, everything
; below, including the "s" extension + RN_INBOUND_DID fixes) is what's
; actually deployed live, confirmed via `dialplan show <context>` for
; every context below. This file is a record of what's deployed, not a
; still-untested proposal — keep it that way: whenever the live file is
; patched via SSH, mirror the same change back here in the same sitting,
; don't let this drift (it already did once — this rewrite is the
; recovery from that).
;
; The voicemail-on-decline branch inside [from-commio-route]'s
; agent-route/ring-agent labels below (RN_CAN_VM / the "voicemail" label /
; RN_VOICEMAIL_FILE) IS applied and confirmed end-to-end live: a real
; recorded WAV landed in RN_VOICEMAIL_DIR and a matching `voicemails` row
; was created by callTracker.js. Getting there uncovered three real,
; non-obvious bugs in this minimal/autoload=no custom Asterisk build —
; recorded here since none of them are visible just from reading the
; dialplan text below:
;   1. Playback(beep) (the stock core sound) fails — it's beep.gsm, and
;      format_gsm/codec_gsm is never loaded on this build. Fixed by
;      deploying a self-made WAV (custom/rn-vm-beep, see the comment at
;      the Record() line below) instead of relying on the stock sound.
;   2. Record() itself was entirely unavailable — app_record.so isn't
;      loaded either (same autoload=no cause). Needs `module load
;      app_record.so` live PLUS a persistent `load => app_record.so` line
;      in modules.conf (added after app_read.so), or it silently
;      disappears again on the next Asterisk restart.
;   3. Record()'s OWN internal confirmation beep (separate from #1 above —
;      it plays automatically before recording starts unless the `q`
;      option is passed) hits the exact same missing-beep failure, and
;      critically that failure doesn't just skip silently — it tears the
;      whole call down with an immediate BYE to the caller (confirmed via
;      `pjsip set logger on`). This was killing every recording before it
;      could capture anything. Fixed by adding `q` to Record()'s options
;      (we already play our own cue via custom/rn-vm-beep right before it,
;      so Record()'s internal one is redundant anyway).
; A fourth, structural gap (not a bug to fix so much as how Record() works
; at all): when the CALLER hangs up DURING the recording — the normal way
; someone finishes leaving a message — Record() does NOT return to the
; next dialplan priority; the channel is gone immediately. The file itself
; still gets saved (that's what Record()'s `k` option is for), but
; Set(RN_VOICEMAIL_FILE=...) right after Record() never runs in that case,
; so callTracker.js never hears about a file that genuinely exists on
; disk. The `h` (hangup) extension below is what actually makes this
; reliable — it always runs regardless of how the call ended, and
; RN_VM_FILE (set BEFORE Record() starts, so it survives) is still
; readable there.
; Prerequisites, before any of this does anything useful:
;   - A `RN_VOICEMAIL_DIR` global in extensions.conf's [globals] pointing
;     at a spool directory separate from the recordings one, e.g.
;     /opt/ringnex-webrtc/var/spool/asterisk/voicemail-custom (must exist
;     and be writable by the Asterisk process).
;   - That same directory SSHFS-mounted onto the app server as
;     VOICEMAIL_ROOT (backend/.env) — same restricted-tunnel-user pattern
;     already used for RECORDING_ROOT, but a second, separate mount. NOT
;     yet done as of this writing — the call-flow (decline -> prompt ->
;     record -> DB row -> notification) is fully verified, but playback
;     from the Call Logs Voicemail tab needs this mount to actually reach
;     the file.
;   - The one-time static prompt custom/agent-busy-vm-prompt synthesized
;     via the same eSpeak NG + ffmpeg pipeline backend/src/tts.js already
;     uses for IVR prompts (fixed text: "Agent is busy right now, please
;     record your message after the beep"), scp'd to sounds/en/custom/
;     once via the existing ASTERISK_SOUNDS_* deploy mechanism.
;   - custom/rn-vm-beep (see bug #1 above) deployed the same way.
;   - app_record.so loaded (see bug #2 above).
;
; This is the inbound half tenant-routing-snippet.conf doesn't cover (that
; file is only the from-webrtc-saas / outbound-from-agent context). The
; context that actually receives calls arriving from Commio's trunk is
; [from-commio] in extensions_commio.conf (per pjsip_commio.conf's
; `context=from-commio` on the [commio] endpoint).
;
; Two things about how Commio actually sends INVITEs shaped this design,
; both confirmed via `pjsip set logger on` against real inbound calls:
;   - The Request-URI carries no user part at all (just host:port), so
;     Asterisk falls back to the "s" extension rather than a digit
;     extension. The actual dialed DID only exists in the To: header
;     (e.g. To: <sip:+18773658962@5.78.77.240>), extracted via
;     PJSIP_HEADER(read,To) + CUT + FILTER. `_X.` is kept as a fallback
;     for a hypothetical trunk config that DOES send R-URI digits, but
;     real Commio calls land on "s" today.
;   - Both entry points (s and _X.) immediately Goto a shared
;     [from-commio-route] context with the extracted DID, so the actual
;     campaign-vs-single-agent routing logic exists exactly once.
;
; -----------------------------------------------------------------------
; Prerequisites (must exist BEFORE this dialplan will do anything useful)
; -----------------------------------------------------------------------
;
; 1. Modules that must be explicitly loaded — this box's modules.conf has
;    autoload=no, so each of these needs its own `load =>` line (added
;    after the existing `load => func_cut.so`), not just the .so file
;    being present under .../modules/:
;      app_queue.so              — see queue notes below for a gotcha
;      res_pjsip_header_funcs.so — provides PJSIP_HEADER(), used to pull
;                                   the DID out of the To: header above
;      app_read.so                — provides Read(), used by the IVR
;                                   context; without it Asterisk logs
;                                   "No application 'Read'" and the call
;                                   just dies after Read()'s own timeout
;                                   with no audio ever having played
;    All three were live-loaded via `module load <name>.so` (no restart
;    needed for a module load, only for the extconfig.conf change below).
;
; 2. AstDB families the backend writes via AMI DBPut (see
;    backend/src/tollFreeRoutes.js) — nothing to set up, just what this
;    dialplan reads:
;      ringnex_campaign/<did>                 = <campaign-uuid>
;      ringnex_campaign_status/<campaign-id>  = ACTIVE|INACTIVE
;      ringnex_campaign_ivr/<campaign-id>     = <ivr-uuid> or empty
;      ringnex_campaign_timeout/<campaign-id> = <seconds, e.g. 300>
;      ringnex_campaign_tenant/<campaign-id>  = <tenant-uuid>
;      ringnex_ivr_greeting/<ivr-id>          = <audio filename, no extension> or empty
;      ringnex_ivr_option_<ivr-id>/<digit>    = "CAMPAIGN:<campaign-id>" or "HANGUP:"
;      ringnex_ivr_prompt_<ivr-id>/<digit>    = <audio filename, no extension> or empty
;    Plus the pre-existing single-agent families this same context also
;    relies on: ringnex_inbound_did, ringnex_tenant, ringnex_tenant_status,
;    ringnex_perm/<endpoint>/RECEIVE_CALLS and (voicemail, see the pending
;    EXCEPTION note above) ringnex_perm/<endpoint>/REDIRECT_TO_VOICEMAIL —
;    both written by the exact same generic ASTERISK_TELEPHONY_PERMISSIONS
;    loop in server.js, nothing extra to set up.
;
; 3. Realtime `queues` and `queue_members` tables, reachable through the
;    SAME ODBC connection Asterisk already uses for ps_endpoints/ps_auths/
;    ps_aors — on the live box this is the "ringnex" connection defined in
;    res_odbc.conf ([ringnex] stanza: dsn=RingnexRealtime), which points at
;    a MariaDB instance on 127.0.0.1:3307, database ringnex_webrtc. The DSN
;    itself is defined in /etc/odbc.ini ([RingnexRealtime], Driver=MariaDB
;    Unicode) + /etc/odbcinst.ini (the driver definition) — NOT
;    res_config_mysql; this box uses res_config_odbc throughout.
;
;    extconfig.conf mapping actually used:
;
;      [settings]
;      queues => odbc,ringnex
;      queue_members => odbc,ringnex
;
;    IMPORTANT: extconfig.conf's family->backend map is only read once, at
;    Asterisk process startup (main/config.c's config_maps init) — there is
;    no live-reload command for it (`config reload extconfig` is a silent
;    no-op). A `core restart gracefully` (waits for active calls to end,
;    then restarts in place — same PID, since Asterisk restarts via
;    execve()) is required after changing it.
;
;    Table shape — on the live box these tables ALREADY EXISTED (created by
;    whatever originally provisioned this Asterisk install) with Asterisk's
;    full standard realtime queue schema. The columns this app actually
;    writes are a subset and are universally present in the standard schema:
;
;      queues:        name, strategy, timeout, retry (+ many other nullable columns)
;      queue_members: queue_name, interface, membername, penalty, paused
;                      (+ state_interface, uniqueid auto_increment, wrapuptime,
;                        ringinuse, reason_paused — all nullable/defaulted)
;
;    `retry` is currently left NULL (Asterisk's own default, ~5s) — that's
;    the gap between one full round through the member list (everyone
;    tried once, nobody answered) and starting the next round, distinct
;    from `timeout` (how long ONE member rings before moving to the next
;    member within a round). With only 2 test agents both declining, this
;    is what makes the ringing cycle back to agent 1 — expected queue
;    behavior with a small roster, not a bug.
;
;    Also needs both queues.conf and queuerules.conf to exist on disk (see
;    app_queue.so note above) — even a minimal [general]-only file:
;
;      queues.conf:        queuerules.conf:
;        [general]           [general]
;        persistentmembers = yes
;        autofill = yes
;
; 4. IVR prompt audio (backend/src/tts.js): eSpeak NG generates a WAV,
;    ffmpeg resamples it to 8kHz mono (Asterisk's telephony rate — eSpeak's
;    raw output is 22050Hz and won't sound right otherwise), then it's
;    scp'd to Asterisk's sounds/en/custom/ directory (a dedicated
;    passwordless SSH key, ASTERISK_SOUNDS_* env vars in backend/.env).
;    Verified end-to-end live: synthesize -> resample -> scp -> file
;    present with correct permissions -> played back on a real call.
;    greeting_audio_path is "custom/<hash>" (relative to sounds/en/), and
;    is the GREETING TEXT PLUS EVERY OPTION'S PROMPT TEXT synthesized
;    together as one script (Read() only plays one file — see
;    buildFullMenuScript in tollFreeRoutes.js) — not just the bare
;    greeting, or a caller hears "Thanks for calling..." then silence.
;
; -----------------------------------------------------------------------
; Call flow
; -----------------------------------------------------------------------
; 1. [from-commio]'s "s" (real Commio calls) or _X. (fallback) extracts a
;    digits-only DID and Goto's [from-commio-route] with it.
; 2. [from-commio-route] sets RN_INBOUND_DID (see below) then checks
;    ringnex_campaign/<did>. Set -> campaign path. Otherwise -> the
;    single-agent path via ringnex_inbound_did/<did>.
; 3. Campaign path: tenant ACTIVE/TRIAL check (ringnex_campaign_tenant ->
;    ringnex_tenant_status) -> campaign ACTIVE/INACTIVE check -> IVR or
;    straight to queue.
; 4. Single-agent path: endpoint -> tenant -> tenant status ->
;    RECEIVE_CALLS permission -> Dial.
; 5. Active campaign with no IVR -> straight to that campaign's queue.
; 6. Active campaign with an IVR -> play the (combined) greeting+options
;    script, read one digit, look up that digit's routing, then either
;    queue or hang up.
; 7. Queue() rings members per the campaign's ring_strategy (ringall =
;    everyone at once; leastrecent = one at a time, longest-idle first,
;    ~20s per member before trying the next — see AGENT_RING_SECONDS in
;    tollFreeRoutes.js) for up to the campaign's no_answer_timeout_sec
;    (300s = 5 minutes default). If nobody answers in time, the caller
;    hears an apology message and the call ends — no voicemail, no
;    forwarding, per the approved design.
; 8. Unknown DID / blocked tenant -> Hangup(21). Inactive campaign or
;    unassigned toll-free number -> Busy().
;
; RN_INBOUND_DID: the ORIGINAL pre-existing single-agent dialplan (before
; any of this was rebuilt) set this variable, and callTracker.js's live
; call tracker specifically listens for it (VarSet Variable ===
; "RN_INBOUND_DID") to tag a call's direction/`to` DID for every
; live-calls feed in the app (Owner/Supervisor dashboards, the Toll-Free
; Live Dashboard). Rebuilding this context from scratch (the s-extension
; and UUID-pattern fixes) dropped it by accident — every inbound Commio
; call's `to` was silently stuck at "s" (whatever Newchannel saw before
; any Goto ran) until this was restored below. If you ever rebuild this
; context again, don't drop it a second time.

[from-commio]
exten => s,1,NoOp(Ringnex inbound Commio call, no R-URI digits - extracting DID from To header)
 same => n,Set(RN_TOHDR=${PJSIP_HEADER(read,To)})
 same => n,Set(RN_DID=${FILTER(0-9,${CUT(RN_TOHDR,@,1)})})
 same => n,Goto(from-commio-route,${RN_DID},1)

exten => _X.,1,NoOp(Ringnex inbound Commio call to ${EXTEN})
 same => n,Set(RN_DID=${FILTER(0-9,${EXTEN})})
 same => n,Goto(from-commio-route,${RN_DID},1)

; Shared routing logic for both entry points above — takes a DID (digits
; only) in ${EXTEN}.
[from-commio-route]
exten => _X.,1,NoOp(Routing DID ${EXTEN})
 same => n,Set(RN_DID=${EXTEN})
 same => n,Set(RN_INBOUND_DID=${RN_DID})
 same => n,Set(RN_CAMPAIGN=${DB(ringnex_campaign/${RN_DID})})
 same => n,GotoIf($["${RN_CAMPAIGN}"!=""]?campaign-route:agent-route)

 same => n(agent-route),Set(RN_ENDPOINT=${DB(ringnex_inbound_did/${RN_DID})})
 same => n,GotoIf($["${RN_ENDPOINT}"=""]?unknown-did)
 same => n,Set(RN_TENANT=${DB(ringnex_tenant/${RN_ENDPOINT})})
 same => n,GotoIf($["${RN_TENANT}"=""]?blocked)
 same => n,Set(RN_TENANT_STATUS=${DB(ringnex_tenant_status/${RN_TENANT})})
 same => n,GotoIf($["${RN_TENANT_STATUS}"="ACTIVE" | "${RN_TENANT_STATUS}"="TRIAL"]?agent-tenant-ok:blocked)
 same => n(agent-tenant-ok),Set(RN_CAN_RECEIVE=${DB(ringnex_perm/${RN_ENDPOINT}/RECEIVE_CALLS)})
 same => n,GotoIf($["${RN_CAN_RECEIVE}"="1"]?ring-agent:blocked)
 same => n(ring-agent),Dial(PJSIP/${RN_ENDPOINT},30)
 same => n,GotoIf($["${DIALSTATUS}"="ANSWER"]?call-done)
 ; Declined / no-answer / offline-unreachable all land here (anything
 ; short of ANSWER) - voicemail is opt-in per agent via the
 ; REDIRECT_TO_VOICEMAIL permission (permissions.js), synced to AstDB the
 ; same generic way every other ringnex_perm/<endpoint>/<KEY> entry is
 ; (server.js's ASTERISK_TELEPHONY_PERMISSIONS loop - no special-cased
 ; sync code needed). Never reached by the toll-free queue path below,
 ; which stays voicemail-free by design (see call-flow note #7 above).
 same => n,Set(RN_CAN_VM=${DB(ringnex_perm/${RN_ENDPOINT}/REDIRECT_TO_VOICEMAIL)})
 same => n,GotoIf($["${RN_CAN_VM}"="1"]?voicemail:call-done)

 same => n(voicemail),Answer()
 same => n,Playback(custom/agent-busy-vm-prompt)
 ; The stock "beep" sound is beep.gsm - this custom Asterisk build never
 ; loads format_gsm/codec_gsm (autoload=no, nothing explicitly loads it),
 ; so Playback(beep) fails outright. Deploy a self-made WAV instead (same
 ; 8kHz mono PCM as every other custom/ prompt - a short sine-tone works
 ; fine, e.g. `ffmpeg -f lavfi -i "sine=frequency=1000:duration=0.35" -ar
 ; 8000 -ac 1 -sample_fmt s16 rn-vm-beep.wav`, scp'd to sounds/en/custom/
 ; the same way TTS prompts are).
 same => n,Playback(custom/rn-vm-beep)
 same => n,Set(RN_VM_FILE=vm-${RN_TENANT}-${RN_ENDPOINT}-${STRFTIME(${EPOCH},,%Y%m%d-%H%M%S)}-${UNIQUEID})
 ; RN_VOICEMAIL_DIR is a [globals] entry (extensions.conf), e.g.
 ; /opt/ringnex-webrtc/var/spool/asterisk/voicemail-custom - a spool
 ; separate from Asterisk's own built-in voicemail app storage, since this
 ; uses raw Record(), not VoiceMail()/mailboxes. Must match VOICEMAIL_ROOT
 ; in backend/.env (mounted onto the app server via its own SSHFS mount,
 ; kept apart from the recordings mount).
 ;
 ; `q` is load-bearing, not cosmetic: Record() plays its OWN internal
 ; confirmation beep by default (separate from the custom/rn-vm-beep cue
 ; above), looking up a bare "beep" in ulaw - which fails the same way the
 ; stock GSM one does. Confirmed live via `pjsip set logger on`: that
 ; failed internal lookup doesn't just skip silently, it tears the whole
 ; call down (Asterisk sends the caller a BYE right after the "Unable to
 ; open beep (format (ulaw))" warning) - so this was actually killing
 ; every recording before it could capture anything, not merely cosmetic.
 same => n,Record(${RN_VOICEMAIL_DIR}/${RN_VM_FILE}.wav,3,120,kq)
 ; callTracker.js listens for exactly this VarSet to create the
 ; `voicemails` DB row and push the real-time "voicemail:new" notification.
 same => n,Set(RN_VOICEMAIL_FILE=${RN_VM_FILE}.wav)
 same => n(call-done),Hangup()

; Record() does not return to the next priority when the CALLER hangs up
; DURING the recording (the normal way someone finishes leaving a
; voicemail) - the channel is gone immediately, so the
; Set(RN_VOICEMAIL_FILE=...) line above never runs even though the file
; itself was saved (that's what Record()'s k option is for). The h
; extension always runs on hangup regardless of how the call ended, and
; RN_VM_FILE (set BEFORE Record() starts, so it survives this) is still
; readable here - this is what actually makes the RN_VOICEMAIL_FILE
; signal (which callTracker.js listens for) reliable. See the long
; comment near the top of this file for how this was discovered.
exten => h,1,GotoIf($["${RN_VM_FILE}"!=""]?vm-cleanup:h-done)
 same => n(vm-cleanup),Set(RN_VOICEMAIL_FILE=${RN_VM_FILE}.wav)
 same => n(h-done),NoOp()

 same => n(campaign-route),Set(RN_CAMP_TENANT=${DB(ringnex_campaign_tenant/${RN_CAMPAIGN})})
 same => n,Set(RN_CAMP_TENANT_STATUS=${DB(ringnex_tenant_status/${RN_CAMP_TENANT})})
 same => n,GotoIf($["${RN_CAMP_TENANT_STATUS}"="ACTIVE" | "${RN_CAMP_TENANT_STATUS}"="TRIAL"]?campaign-tenant-ok:blocked)
 same => n(campaign-tenant-ok),Set(RN_CAMPAIGN_STATUS=${DB(ringnex_campaign_status/${RN_CAMPAIGN})})
 same => n,GotoIf($["${RN_CAMPAIGN_STATUS}"="ACTIVE"]?campaign-active:not-in-service)
 same => n(campaign-active),Set(RN_IVR=${DB(ringnex_campaign_ivr/${RN_CAMPAIGN})})
 same => n,GotoIf($["${RN_IVR}"!=""]?play-ivr:queue-campaign)
 same => n(queue-campaign),Goto(from-commio-tollfree-queue,${RN_CAMPAIGN},1)
 same => n(play-ivr),Goto(from-commio-tollfree-ivr,${RN_IVR},1)

 same => n(unknown-did),Hangup(1)
 same => n(not-in-service),Busy()
 same => n,Wait(3)
 same => n,Hangup(21)
 same => n(blocked),Hangup(21)

; Shared by the direct-to-queue path above and the IVR's own
; "route to campaign" action below — takes a campaign id (a UUID, not a
; phone number) in ${EXTEN}. Pattern is bare `_.` (matches one-or-more of
; ANY character), not `_X.` — X only matches a leading DIGIT, so a UUID
; starting with a letter (a-f) would silently fail to match at all.
[from-commio-tollfree-queue]
exten => _.,1,NoOp(Queueing for campaign ${EXTEN})
 same => n,Set(RN_CID=${EXTEN})
 same => n,Set(RN_TIMEOUT=${DB(ringnex_campaign_timeout/${RN_CID})})
 same => n,GotoIf($["${RN_TIMEOUT}"=""]?default-timeout:has-timeout)
 same => n(default-timeout),Set(RN_TIMEOUT=300)
 same => n(has-timeout),Queue(ringnex-campaign-${RN_CID},t,,,${RN_TIMEOUT})
 ; Queue() only returns here if nobody answered within RN_TIMEOUT, the
 ; queue had no members, or a similar non-connect outcome — a caller who
 ; got bridged to an agent never reaches this line.
 same => n,Playback(im-sorry)
 same => n,Hangup()

; Dynamic, data-driven IVR — digits/options come from ivr_options via the
; ringnex_ivr_option_<id> AstDB family, not static dialplan extensions, so
; this uses Read() (one digit into a variable) rather than the usual
; Background()+WaitExten()+per-digit-exten pattern. Takes an ivr id (a
; UUID) in ${EXTEN} — same bare `_.` pattern reasoning as above.
[from-commio-tollfree-ivr]
exten => _.,1,NoOp(Playing IVR ${EXTEN})
 same => n,Set(RN_IVR_ID=${EXTEN})
 same => n,Set(RN_GREETING=${DB(ringnex_ivr_greeting/${RN_IVR_ID})})
 ; Read(variable, filename, max-digits, options, attempts, timeout) — plays
 ; `filename` (blank plays nothing, just waits) and reads up to 1 digit,
 ; 2 attempts, 7s timeout per attempt.
 same => n,Read(RN_DIGIT,${RN_GREETING},1,,2,7)
 same => n,GotoIf($["${RN_DIGIT}"=""]?no-input)

 same => n,Set(RN_OPTION=${DB(ringnex_ivr_option_${RN_IVR_ID}/${RN_DIGIT})})
 same => n,GotoIf($["${RN_OPTION}"=""]?invalid-digit)
 same => n,Set(RN_ACTION=${CUT(RN_OPTION,:,1)})
 same => n,Set(RN_TARGET=${CUT(RN_OPTION,:,2)})
 same => n,GotoIf($["${RN_ACTION}"="HANGUP"]?say-goodbye:route-to-campaign)

 same => n(route-to-campaign),Goto(from-commio-tollfree-queue,${RN_TARGET},1)

 same => n(say-goodbye),Playback(vm-goodbye)
 same => n,Hangup()

 same => n(invalid-digit),Playback(pbx-invalid)
 same => n,Hangup(1)

 same => n(no-input),Playback(pbx-invalid)
 same => n,Hangup(1)
