contacts: caller names on desk phones while they ring (C4)
Spec 11 §6.1: sp-sub-dial-user asks /api/internal/caller-name (func_curl, 2 s at most; this machine only, with an APP_SECRET-derived key, not logged) for the ringing user's name for the caller, and sets it on each ringing leg (pre-dial handler, CONNECTEDLINE), never on the call, so forwards and other members don't see someone else's contacts. Ring group/queue/number prefixes kept. Contrack lookups never wait (cache, contactsWarm every 5 min). Proven on real Asterisk with baresip: apps/api/test/asterisk/cnam-test.ts.
This commit is contained in:
1 parent
692dd59552
commit
b05361e840
51 files changed
+1040
-31
No files matched your search
@@ -177,6 +177,7 @@ export async function contactNames(
|
||||
db: Db,
|
||||
userId: string | null,
|
||||
numbers: (string | null)[],
|
||||
opts: { wait?: boolean } = {},
|
||||
): Promise<Map<string, string>> {
|
||||
const want = [...new Set(numbers.filter((n): n is string => !!n))];
|
||||
const out = new Map<string, string>();
|
||||
@@ -184,8 +185,8 @@ export async function contactNames(
|
||||
const c = await contrackSettings(db);
|
||||
if (c) {
|
||||
const books = [
|
||||
userId ? await contrackBook(db, c, userId) : null,
|
||||
await contrackBook(db, c, null),
|
||||
userId ? await contrackBook(db, c, userId, opts) : null,
|
||||
await contrackBook(db, c, null, opts),
|
||||
];
|
||||
for (const b of books)
|
||||
for (const ct of b?.contacts ?? [])
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Caller names on desk phones while they ring (11 §6 C4): before ringing a user's phones, the
|
||||
// dialplan asks GET /api/internal/caller-name (func_curl, 2 s at most) for that user's name for
|
||||
// the caller, and the phones show it.
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
/** The dialplan's key for the route, derived from APP_SECRET (stable across restarts). */
|
||||
export const callerNameToken = (appSecret: string) =>
|
||||
createHmac('sha256', appSecret).update('selfphoneng caller-name').digest('hex');
|
||||
|
||||
export const sameToken = (a: string, b: string) =>
|
||||
a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
||||
|
||||
/**
|
||||
* What a phone can show and the dialplan can carry: letters, digits, spaces and . ' & @ -, at
|
||||
* most 40 characters. Nothing that Dial's options (, ^ ( ) |) or a SIP header (" < > \ ;) could
|
||||
* read as syntax.
|
||||
*/
|
||||
export const phoneSafeName = (name: string) =>
|
||||
name
|
||||
.normalize('NFC')
|
||||
.replace(/[^\p{L}\p{M}\p{N} .'&@-]+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 40)
|
||||
.trim();
|
||||
@@ -119,21 +119,42 @@ const TTL = 5 * 60_000;
|
||||
/** Forget a book (after we add to it, or a token changes). */
|
||||
export const forgetBook = (owner: string | null) => cache.delete(owner ?? '');
|
||||
|
||||
const inflight = new Map<string, Promise<Book>>();
|
||||
|
||||
/**
|
||||
* A Contrack book, from the cache when it's under 5 minutes old. Null: no token. A failing
|
||||
* Contrack keeps the last good list and says why.
|
||||
* Contrack keeps the last good list and says why. `wait: false` (a phone ringing, 11 §6 C4)
|
||||
* never waits on Contrack: it answers what the cache has and refreshes in the background.
|
||||
*/
|
||||
export async function contrackBook(
|
||||
db: Db,
|
||||
c: Contrack,
|
||||
owner: string | null,
|
||||
now = Date.now(),
|
||||
{ now = Date.now(), wait = true }: { now?: number; wait?: boolean } = {},
|
||||
): Promise<Book | null> {
|
||||
const token = await tokenFor(db, c, owner);
|
||||
if (!token) return null;
|
||||
const key = owner ?? '';
|
||||
const hit = cache.get(key);
|
||||
if (hit && hit.token === token && now - hit.at < TTL) return hit;
|
||||
const usable = hit && hit.token === token ? hit : undefined;
|
||||
if (usable && now - usable.at < TTL) return usable;
|
||||
if (!inflight.has(key))
|
||||
inflight.set(
|
||||
key,
|
||||
fetchBook(c, owner, token, usable, now).finally(() => inflight.delete(key)),
|
||||
);
|
||||
if (!wait) return usable ?? null;
|
||||
return inflight.get(key)!;
|
||||
}
|
||||
|
||||
async function fetchBook(
|
||||
c: Contrack,
|
||||
owner: string | null,
|
||||
token: string,
|
||||
hit: Book | undefined,
|
||||
now: number,
|
||||
): Promise<Book> {
|
||||
const key = owner ?? '';
|
||||
const book = owner === null ? 'company' : 'mine';
|
||||
try {
|
||||
const rows = await ctk<Slim[]>(c.url, token, 'api/contacts?view=slim');
|
||||
@@ -142,7 +163,7 @@ export async function contrackBook(
|
||||
return fresh;
|
||||
} catch (err) {
|
||||
const stale = {
|
||||
contacts: hit?.token === token ? hit.contacts : [],
|
||||
contacts: hit?.contacts ?? [],
|
||||
at: now, // tried now: the next try is in 5 minutes, not on every lookup
|
||||
error: (err as Error).message,
|
||||
token,
|
||||
|
||||
+2
@@ -3,4 +3,6 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
@@ -3,3 +3,5 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
+2
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
SP_TCADMIN_101=1
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
+2
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_103=2
|
||||
SP_CLASS_104=2
|
||||
SP_CLASS_105=2
|
||||
+23
-1
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
+2
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
SP_CLASS_103=2
|
||||
|
||||
+23
-1
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
+2
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=13125551234
|
||||
SP_DEFAULT_E911_CID=13125551234
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
SP_TCADMIN_101=1
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_99=2
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
+2
@@ -3,4 +3,6 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
SP_CLASS_103=2
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
@@ -260,6 +282,7 @@ exten => 551,1,Goto(sp-queue-44444444-4444-4444-8444-444444444551,s,1)
|
||||
exten => s,1,Set(__SP_TARGET=550)
|
||||
same => n,Answer()
|
||||
same => n,Set(CALLERID(name)=Support: ${CALLERID(name)})
|
||||
same => n,Set(__SP_CNAM_PREFIX=Support: ${SP_CNAM_PREFIX})
|
||||
same => n,Gosub(sp-sub-record,s,1)
|
||||
same => n,Queue(550,t,,,300)
|
||||
same => n,GotoIf($["${QUEUESTATUS}" = "TIMEOUT" | "${QUEUESTATUS}" = "FULL" | "${QUEUESTATUS}" = "JOINEMPTY" | "${QUEUESTATUS}" = "LEAVEEMPTY" | "${QUEUESTATUS}" = "JOINUNAVAIL" | "${QUEUESTATUS}" = "LEAVEUNAVAIL"]?fail)
|
||||
|
||||
+2
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
SP_CLASS_103=2
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
@@ -260,6 +282,7 @@ exten => 502,1,Goto(sp-rg-22222222-2222-4222-8222-222222222502,s,1)
|
||||
[sp-rg-22222222-2222-4222-8222-222222222500]
|
||||
exten => s,1,Set(__SP_TARGET=500)
|
||||
same => n,Set(CALLERID(name)=Sales: ${CALLERID(name)})
|
||||
same => n,Set(__SP_CNAM_PREFIX=Sales: ${SP_CNAM_PREFIX})
|
||||
same => n,Dial(Local/101@sp-rg-member&Local/102@sp-rg-member&Local/103@sp-rg-member,25,tm)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
|
||||
same => n,Goto(sp-vm-direct,101,1)
|
||||
|
||||
+2
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_1=2
|
||||
SP_CLASS_12=2
|
||||
+23
-1
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
+2
@@ -3,3 +3,5 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
+23
-1
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
+2
@@ -3,4 +3,6 @@ SP_DEFAULT_CID=13125551234
|
||||
SP_DEFAULT_E911_CID=13125551234
|
||||
SP_REC_ANNOUNCE=custom/44444444-4444-4444-8444-444444444448
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=http://127.0.0.1:8080/api/internal/caller-name
|
||||
SP_CNAM_TOKEN=0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f
|
||||
SP_CLASS_101=2
|
||||
+24
-1
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
@@ -284,6 +306,7 @@ exten => 13125551234,1,Set(__SP_DID=13125551234)
|
||||
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(__SP_CNAM_PREFIX=SALES: ${SP_CNAM_PREFIX})
|
||||
same => n,Goto(sp-ext-forward,13125550000,1)
|
||||
; anything else
|
||||
exten => _[0-9a-z].,1,Hangup()
|
||||
|
||||
@@ -117,5 +117,9 @@
|
||||
"trunkIds": ["7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37"]
|
||||
}
|
||||
],
|
||||
"recordingAnnouncement": "custom/44444444-4444-4444-8444-444444444448"
|
||||
"recordingAnnouncement": "custom/44444444-4444-4444-8444-444444444448",
|
||||
"callerNames": {
|
||||
"url": "http://127.0.0.1:8080/api/internal/caller-name",
|
||||
"token": "0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f"
|
||||
}
|
||||
}
|
||||
+2
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_99=0
|
||||
SP_CLASS_102=1
|
||||
+23
-1
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
@@ -95,7 +95,13 @@ export function renderDids(snap: Snapshot): string {
|
||||
`; DID ${num}${d.label ? ` "${s.name(d.label)}"` : ''}`,
|
||||
`exten => ${num},1,Set(__SP_DID=${num})`,
|
||||
` same => n,Set(CDR(SP_DID)=${num})`,
|
||||
...(prefix ? [` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`] : []),
|
||||
...(prefix
|
||||
? [
|
||||
` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`,
|
||||
// …and before a contact's name on ringing phones (sp-sub-dial-user, 11 §6 C4).
|
||||
` same => n,Set(__SP_CNAM_PREFIX=${prefix} \${SP_CNAM_PREFIX})`,
|
||||
]
|
||||
: []),
|
||||
// Recorded number: the caller hears the notice (if set) before the recording starts.
|
||||
...(d.recordInbound && snap.recordingAnnouncement
|
||||
? [' same => n,Answer()', ` same => n,Playback(${s.sound(snap.recordingAnnouncement)})`]
|
||||
|
||||
@@ -75,6 +75,9 @@ export function renderGlobals(snap: Snapshot): string {
|
||||
`SP_REC_ANNOUNCE=${snap.recordingAnnouncement ? s.sound(snap.recordingAnnouncement) : ''}`,
|
||||
// Spec 10 §3: also record each side on its own, for the transcript job.
|
||||
`SP_REC_SIDES=${st['ai.callTranscripts'] && st['ai.stt'] ? '1' : ''}`,
|
||||
// 11 §6 C4: where sp-sub-cnam asks for a caller's name in the ringing user's contacts.
|
||||
`SP_CNAM_URL=${snap.callerNames ? s.localUrl(snap.callerNames.url) : ''}`,
|
||||
`SP_CNAM_TOKEN=${snap.callerNames ? s.secret(snap.callerNames.token) : ''}`,
|
||||
];
|
||||
const exts = [...snap.extensions].sort(byNumber);
|
||||
for (const e of exts) lines.push(`SP_CLASS_${s.digits(e.number)}=${CLASS_RANK[e.outboundClass]}`);
|
||||
|
||||
@@ -18,7 +18,13 @@ export function renderQueueDialplan(snap: Snapshot): string {
|
||||
`[sp-queue-${s.ident(q.id)}]`,
|
||||
`exten => s,1,Set(__SP_TARGET=${num})`,
|
||||
' same => n,Answer()',
|
||||
...(prefix ? [` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`] : []),
|
||||
...(prefix
|
||||
? [
|
||||
` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`,
|
||||
// …and before a contact's name on ringing phones (sp-sub-dial-user, 11 §6 C4).
|
||||
` same => n,Set(__SP_CNAM_PREFIX=${prefix} \${SP_CNAM_PREFIX})`,
|
||||
]
|
||||
: []),
|
||||
...(q.record ? [' same => n,Gosub(sp-sub-record,s,1)'] : []),
|
||||
` same => n,Queue(${num},t,,,${s.digits(String(q.maxWaitSeconds))})`,
|
||||
` same => n,GotoIf($[${FAILED.map((f) => `"\${QUEUESTATUS}" = "${f}"`).join(' | ')}]?fail)`,
|
||||
|
||||
@@ -22,7 +22,13 @@ export function renderRingGroups(snap: Snapshot): string {
|
||||
`; ─── Ring group ${num} "${s.name(g.name)}" (${g.strategy})`,
|
||||
`[sp-rg-${s.ident(g.id)}]`,
|
||||
`exten => s,1,Set(__SP_TARGET=${num})`,
|
||||
...(prefix ? [` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`] : []),
|
||||
...(prefix
|
||||
? [
|
||||
` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`,
|
||||
// …and before a contact's name on ringing phones (sp-sub-dial-user, 11 §6 C4).
|
||||
` same => n,Set(__SP_CNAM_PREFIX=${prefix} \${SP_CNAM_PREFIX})`,
|
||||
]
|
||||
: []),
|
||||
...(g.members.length === 0
|
||||
? []
|
||||
: g.strategy === 'ringall'
|
||||
|
||||
@@ -12,16 +12,38 @@ exten => s,1,Set(N=\${FILTER(0123456789+,\${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(\${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(\${ARG1}-web))
|
||||
same => n,GotoIf($["\${DS}" = ""]?unavail)
|
||||
same => n,Dial(\${DS},\${ARG2},tx)
|
||||
same => n,GotoIf($["\${SP_DIRECTION}" != "inbound" | "\${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(\${ARG1}))
|
||||
same => n,GotoIf($["\${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=\${SP_CNAM_PREFIX}\${GOSUB_RETVAL})
|
||||
same => n,Dial(\${DS},\${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(\${DS},\${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(\${CURL(\${SP_CNAM_URL}?ext=\${FILTER(0-9,\${ARG1})}&number=\${FILTER(0-9,\${CALLERID(num)})}&token=\${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=\${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
@@ -26,6 +26,7 @@ export function loadCase(name: string): Snapshot {
|
||||
holidays?: Snapshot['holidays'];
|
||||
announcements?: Snapshot['announcements'];
|
||||
mohClasses?: { name: string; shuffle?: boolean; files: string[] }[];
|
||||
callerNames?: Snapshot['callerNames'];
|
||||
};
|
||||
const settings = Object.fromEntries(
|
||||
(Object.keys(SettingsSchemas) as SettingKey[])
|
||||
@@ -34,6 +35,7 @@ export function loadCase(name: string): Snapshot {
|
||||
) as Snapshot['settings'];
|
||||
return {
|
||||
today: raw.today ?? '2026-10-17',
|
||||
...(raw.callerNames ? { callerNames: raw.callerNames } : {}),
|
||||
settings,
|
||||
dids: (raw.dids ?? []).map((d) => ({
|
||||
trunkId: '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37',
|
||||
|
||||
@@ -103,6 +103,8 @@ export type GeneratorDeps = {
|
||||
debounceMs?: number;
|
||||
/** RTP_PORT_START/END (config.ts). */
|
||||
rtpPorts?: { start: number; end: number };
|
||||
/** The caller-name lookup (11 §6 C4). */
|
||||
callerNames?: { url: string; token: string };
|
||||
};
|
||||
|
||||
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
|
||||
@@ -167,7 +169,11 @@ export function createGenerator(deps: GeneratorDeps) {
|
||||
restartRequired: false,
|
||||
};
|
||||
try {
|
||||
const snap = { ...(await loadSnapshot(db)), rtpPorts: deps.rtpPorts };
|
||||
const snap = {
|
||||
...(await loadSnapshot(db)),
|
||||
rtpPorts: deps.rtpPorts,
|
||||
callerNames: deps.callerNames,
|
||||
};
|
||||
const files = render(snap);
|
||||
await mkdir(join(dir, '.prev'), { recursive: true });
|
||||
const current = new Map<GeneratedFileName, string | null>();
|
||||
|
||||
@@ -205,6 +205,8 @@ export type Snapshot = {
|
||||
recordingAnnouncement: string | null;
|
||||
/** Call audio ports from the environment (RTP_PORT_START/END), not the database. */
|
||||
rtpPorts?: { start: number; end: number };
|
||||
/** Caller names on ringing phones (11 §6 C4): the api's lookup URL and its key. */
|
||||
callerNames?: { url: string; token: string };
|
||||
};
|
||||
|
||||
export const DEFAULT_RTP_PORTS = { start: 10000, end: 20000 };
|
||||
|
||||
@@ -29,6 +29,12 @@ export const sound = check(/^[a-z0-9/_-]+$/, 'sound', 100);
|
||||
/** Context-name parts (uuids), MOH class names. */
|
||||
export const ident = check(/^[a-z0-9-]+$/, 'ident', 64);
|
||||
/** Generated SIP passwords. */
|
||||
/** The api on this machine, e.g. http://127.0.0.1:8080/api/internal/caller-name. */
|
||||
export const localUrl = check(
|
||||
/^http:\/\/(?:127\.0\.0\.1|localhost):[0-9]{1,5}\/[a-z0-9/_-]+$/,
|
||||
'local url',
|
||||
100,
|
||||
);
|
||||
export const secret = check(/^[A-Za-z0-9]+$/, 'secret', 64);
|
||||
/** Trunk usernames (voip.ms sub-accounts like 123456_pbx). */
|
||||
export const sipUser = check(/^[A-Za-z0-9_.-]+$/, 'sip user', 64);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { watchPinChanges } from './services/vmpin.ts';
|
||||
import { startJobs } from './jobs.ts';
|
||||
import { watchWebhooks } from './services/webhooks.ts';
|
||||
import { watchEvents } from './services/events.ts';
|
||||
import { callerNameToken } from './contacts/callername.ts';
|
||||
|
||||
let config;
|
||||
try {
|
||||
@@ -47,6 +48,10 @@ const generator = createGenerator({
|
||||
ami,
|
||||
dir: resolve(config.ASTERISK_CONFIG_DIR),
|
||||
rtpPorts: { start: config.RTP_PORT_START, end: config.RTP_PORT_END },
|
||||
callerNames: {
|
||||
url: `http://127.0.0.1:${config.API_PORT}/api/internal/caller-name`,
|
||||
token: callerNameToken(config.APP_SECRET),
|
||||
},
|
||||
log: app.log,
|
||||
onApplied: (r) => {
|
||||
live.emit('message', {
|
||||
|
||||
+15
-2
@@ -3,7 +3,7 @@
|
||||
// ponytail: setInterval instead of croner: every schedule in 04 §8 is a whole number of minutes.
|
||||
import { statfs } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { and, eq, isNull, lt, sql } from 'drizzle-orm';
|
||||
import { and, eq, isNotNull, isNull, lt, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import type { Config } from './config.ts';
|
||||
import type { Db } from './db/client.ts';
|
||||
@@ -25,6 +25,7 @@ import { transcribeCalls } from './ai/calls.ts';
|
||||
import { audit } from './services/audit.ts';
|
||||
import { backupDue, runBackup } from './services/backup.ts';
|
||||
import { sendCallReports } from './services/callreport.ts';
|
||||
import { contrackBook, contrackSettings } from './contacts/contrack.ts';
|
||||
import { logEvent } from './services/events.ts';
|
||||
import { deliverDue } from './services/webhooks.ts';
|
||||
import type { SendMail } from './services/mail.ts';
|
||||
@@ -240,11 +241,22 @@ export function createJobs(d: JobDeps) {
|
||||
);
|
||||
};
|
||||
|
||||
/** Contrack books warmed every 5 minutes, so a ringing phone's lookup finds them (11 §6 C4). */
|
||||
const contactsWarm = async () => {
|
||||
const c = await contrackSettings(d.db);
|
||||
if (!c) return;
|
||||
const owners = await d.db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(eq(users.isActive, true), isNotNull(users.contrackTokenEnc)));
|
||||
for (const owner of [null, ...owners.map((o) => o.id)]) await contrackBook(d.db, c, owner);
|
||||
};
|
||||
|
||||
const callReports = async () => {
|
||||
await sendCallReports({ db: d.db, send: d.send, log: d.log, now: now() });
|
||||
};
|
||||
|
||||
return { daily, backup, publicIpWatch, trunkWatch, diskWatch, callReports };
|
||||
return { daily, backup, publicIpWatch, trunkWatch, diskWatch, callReports, contactsWarm };
|
||||
}
|
||||
|
||||
/** Starts the jobs; returns a stop function. */
|
||||
@@ -267,6 +279,7 @@ export function startJobs(d: JobDeps): () => void {
|
||||
if (minute % 5 === 0) run('publicIpWatch', j.publicIpWatch);
|
||||
if (minute % 60 === 0) run('diskWatch', j.diskWatch);
|
||||
run('callReports', j.callReports);
|
||||
if (minute % 5 === 0) run('contactsWarm', j.contactsWarm);
|
||||
run('callTranscripts', async () => {
|
||||
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
addToBook,
|
||||
assertBuiltin,
|
||||
cleanNumbers,
|
||||
contactNames,
|
||||
createContact,
|
||||
readBooks,
|
||||
toDto,
|
||||
@@ -33,6 +34,7 @@ import { audit } from '../services/audit.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import { contrackCheck } from '../contacts/contrack.ts';
|
||||
import { callerNameToken, phoneSafeName, sameToken } from '../contacts/callername.ts';
|
||||
|
||||
const IdParams = z.object({ id: z.uuid() });
|
||||
const CommitQuery = z.object({ commit: z.enum(['0', '1']).default('0') });
|
||||
@@ -154,6 +156,38 @@ export async function contactRoutes(app: FastifyInstance) {
|
||||
);
|
||||
book('/api/contacts', requireAdmin, () => null);
|
||||
|
||||
/**
|
||||
* The dialplan's caller-name lookup (11 §6 C4): only Asterisk on this machine (no proxy in
|
||||
* between) with the token. Never waits on Contrack. Not logged: the token is in the query.
|
||||
*/
|
||||
app.get('/api/internal/caller-name', { logLevel: 'silent' }, async (req, reply) => {
|
||||
const q = z
|
||||
.object({
|
||||
ext: z.string().regex(/^\d{1,10}$/),
|
||||
number: z.string().regex(/^\d{3,20}$/),
|
||||
token: z.string().max(100),
|
||||
})
|
||||
.safeParse(req.query);
|
||||
const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress ?? '');
|
||||
if (
|
||||
!local ||
|
||||
req.headers['x-forwarded-for'] ||
|
||||
!q.success ||
|
||||
!sameToken(q.data.token, callerNameToken(app.config.APP_SECRET))
|
||||
)
|
||||
return reply.code(404).send('');
|
||||
const [e] = await db
|
||||
.select({ userId: extensions.userId })
|
||||
.from(extensions)
|
||||
.where(eq(extensions.number, q.data.ext));
|
||||
const names = await contactNames(db, e?.userId ?? null, [q.data.number], { wait: false }).catch(
|
||||
() => new Map<string, string>(),
|
||||
);
|
||||
return reply
|
||||
.type('text/plain; charset=utf-8')
|
||||
.send(phoneSafeName(names.get(q.data.number) ?? ''));
|
||||
});
|
||||
|
||||
/** Settings → Contacts → Test (11 §4.3): the form's address, and its token or the saved one. */
|
||||
app.post('/api/contacts/contrack/test', { preHandler: requireAdmin }, async (req) => {
|
||||
const b = z
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 11 §6 C4 against real Asterisk: a call from outside rings 101's phone with the caller's name
|
||||
// from 101's contacts. A second Asterisk plays the provider, a baresip phone is 101, and a tiny
|
||||
// stand-in for the api's /api/internal/caller-name runs in the PBX's network namespace (so it is
|
||||
// 127.0.0.1 to Asterisk, as in production). What the phone sees is read from the INVITE the PBX
|
||||
// sends it (pjsip logger).
|
||||
// Checks: a contact's name; no contact keeps the provider's name; a ring group's "Sales:" prefix
|
||||
// stays in front; the api not answering still rings with the provider's name; 101's contact name
|
||||
// does not follow a forward to 102.
|
||||
// Usage: node --conditions=source apps/api/test/asterisk/cnam-test.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 { SettingsSchemas, type SettingKey } from '@selfphoneng/shared';
|
||||
import { AmiClient } from '../../src/ami/client.ts';
|
||||
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
||||
import type { Snapshot } from '../../src/generator/load.ts';
|
||||
import { providerPjsip } from './provider.ts';
|
||||
|
||||
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
||||
const NET = 'sp-cnam';
|
||||
const PBX = 'sp-cnam-pbx';
|
||||
const PROVIDER = 'sp-cnam-provider';
|
||||
const PHONES = 'sp-cnam-phones';
|
||||
const API = 'sp-cnam-api';
|
||||
const AMI_PORT = 15041;
|
||||
const AMI_SECRET = 'cnam-test-secret';
|
||||
const TRUNK = '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37';
|
||||
const SUB = { user: '123456_pbx', pass: 'ProviderSecret42' };
|
||||
const TOKEN = 'ab'.repeat(32);
|
||||
const EXT = {
|
||||
'101': '00000000-0000-4000-8000-000000000101',
|
||||
'102': '00000000-0000-4000-8000-000000000102',
|
||||
};
|
||||
const PASS = { '101': 'CnamTest101xxxxxxxxxxxxx', '102': 'CnamTest102xxxxxxxxxxxxx' };
|
||||
const DID_USER = '13125551234'; // rings 101
|
||||
const DID_GROUP = '13125551235'; // "Sales:" ring group 600 → 101
|
||||
const KNOWN = '3125559999'; // Jane Doe in 101's contacts (10 digits, as voip.ms sends)
|
||||
const UNKNOWN = '3125550001';
|
||||
|
||||
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('[cnam-test]', ...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'];
|
||||
const ext = (number: '101' | '102', name: string) => ({
|
||||
id: EXT[number],
|
||||
number,
|
||||
name,
|
||||
sipPassword: PASS[number],
|
||||
webrtcEnabled: false,
|
||||
maxContacts: 5,
|
||||
ringSeconds: 6, // the forward check waits for 101's no-answer
|
||||
voicemailEnabled: false,
|
||||
voicemailPin: '4826',
|
||||
outboundClass: 'national' as const,
|
||||
outboundCallerId: null,
|
||||
emergencyCallerId: null,
|
||||
recordMode: 'never' as const,
|
||||
pickupGroup: null,
|
||||
language: 'en',
|
||||
mohClass: 'default',
|
||||
isAdmin: false,
|
||||
});
|
||||
return {
|
||||
today: '2026-10-23',
|
||||
settings,
|
||||
callerNames: { url: 'http://127.0.0.1:8080/api/internal/caller-name', token: TOKEN },
|
||||
dids: [
|
||||
{
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
number: DID_USER,
|
||||
trunkId: TRUNK,
|
||||
label: null,
|
||||
destination: { type: 'user', id: EXT['101'] },
|
||||
callerIdNamePrefix: null,
|
||||
recordInbound: false,
|
||||
},
|
||||
{
|
||||
id: '11111111-1111-4111-8111-111111111112',
|
||||
number: DID_GROUP,
|
||||
trunkId: TRUNK,
|
||||
label: null,
|
||||
destination: { type: 'ring_group', id: '22222222-2222-4222-8222-222222222222' },
|
||||
callerIdNamePrefix: null,
|
||||
recordInbound: false,
|
||||
},
|
||||
],
|
||||
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
|
||||
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: '22222222-2222-4222-8222-222222222222',
|
||||
number: '600',
|
||||
name: 'Sales',
|
||||
strategy: 'ringall',
|
||||
ringSeconds: 20,
|
||||
callerIdNamePrefix: 'Sales:',
|
||||
musicInsteadOfRing: false,
|
||||
members: ['101'],
|
||||
noAnswerDestination: { type: 'hangup' },
|
||||
},
|
||||
],
|
||||
queues: [],
|
||||
conferences: [],
|
||||
pagingGroups: [],
|
||||
mohClasses: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
announcements: [],
|
||||
recordingAnnouncement: null,
|
||||
routes: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** The provider: "Mobile" <caller> calls one of our DIDs; ${CALLER} picks who calls. */
|
||||
const providerDialplan = `[provider-out]
|
||||
exten => _X.,1,Set(CALLERID(all)="Mobile" <\${CALLER}>)
|
||||
same => n,Dial(PJSIP/\${EXTEN}@${SUB.user},30)
|
||||
|
||||
[provider-wait]
|
||||
exten => s,1,Answer()
|
||||
same => n,Wait(30)
|
||||
|
||||
[provider-in]
|
||||
exten => _X.,1,Answer()
|
||||
same => n,Wait(30)
|
||||
`;
|
||||
|
||||
/** baresip phones as 101 and 102: they ring, nobody answers (the INVITE is what we read). */
|
||||
function phoneFiles(dir: string) {
|
||||
for (const n of ['101', '102'] as const) {
|
||||
const d = join(dir, `phone-${n}`);
|
||||
mkdirSync(d, { recursive: true });
|
||||
writeFileSync(
|
||||
join(d, 'config'),
|
||||
[
|
||||
'poll_method epoll',
|
||||
`sip_listen 0.0.0.0:${n === '101' ? 5070 : 5072}`,
|
||||
'module_path /usr/lib/baresip/modules',
|
||||
'module g711.so',
|
||||
'module ausine.so',
|
||||
'module aufile.so',
|
||||
'module menu.so',
|
||||
'module account.so',
|
||||
'audio_source ausine,440',
|
||||
'audio_player aufile,/dev/null',
|
||||
'audio_alert aufile,/dev/null',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
writeFileSync(
|
||||
join(d, 'accounts'),
|
||||
`<sip:${n}@${PBX};transport=udp>;auth_pass=${PASS[n]};answermode=manual;audio_codecs=PCMU;regint=60\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The api's caller-name route, faked: Jane Doe for KNOWN in 101's book, with the right key. */
|
||||
const FAKE_API = `
|
||||
const http = require('http');
|
||||
http.createServer((req, res) => {
|
||||
const u = new URL(req.url, 'http://x');
|
||||
const ok = u.pathname === '/api/internal/caller-name' && u.searchParams.get('token') === '${TOKEN}';
|
||||
const name = ok && u.searchParams.get('ext') === '101' && u.searchParams.get('number') === '1${KNOWN}' ? 'Jane Doe' : '';
|
||||
console.log('lookup ' + u.searchParams.get('ext') + ' ' + u.searchParams.get('number') + ' ' + (ok ? 'key-ok' : 'key-bad') + ' -> ' + JSON.stringify(name));
|
||||
if (process.env.SLOW) return setTimeout(() => res.end(name), 5000);
|
||||
res.end(name);
|
||||
}).listen(8080, '127.0.0.1');
|
||||
`;
|
||||
|
||||
async function waitFor(what: string, fn: () => Promise<boolean>, seconds = 60) {
|
||||
for (let i = 0; i < seconds; i++) {
|
||||
if (await fn()) return;
|
||||
await sleep(1000);
|
||||
}
|
||||
throw new Error(`timed out waiting for ${what}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sp-cnam-'));
|
||||
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
|
||||
: '',
|
||||
);
|
||||
}
|
||||
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 problems: string[] = [];
|
||||
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 booted = (name: string) =>
|
||||
waitFor(
|
||||
`${name} to boot`,
|
||||
async () => docker('exec', name, 'asterisk', '-rx', 'core waitfullybooted').ok,
|
||||
);
|
||||
const startApi = (slow: boolean) => {
|
||||
docker('rm', '-f', API);
|
||||
const r = docker(
|
||||
'run',
|
||||
'-d',
|
||||
'--rm',
|
||||
'--name',
|
||||
API,
|
||||
'--network',
|
||||
`container:${PBX}`,
|
||||
...(slow ? ['-e', 'SLOW=1'] : []),
|
||||
'node:22-alpine',
|
||||
'node',
|
||||
'-e',
|
||||
FAKE_API,
|
||||
);
|
||||
if (!r.ok) throw new Error(`fake api: ${r.out}`);
|
||||
};
|
||||
|
||||
/** The provider calls `did` as `caller`; returns the From display name of the INVITE to `ext`. */
|
||||
async function ring(did: string, caller: string, ext: '101' | '102'): Promise<string | null> {
|
||||
// channel originate can't set a variable: the provider's dialplan reads a global.
|
||||
docker('exec', PROVIDER, 'asterisk', '-rx', `dialplan set global CALLER ${caller}`);
|
||||
const since = new Date().toISOString();
|
||||
await ami.command('pjsip set logger on');
|
||||
const o = docker(
|
||||
'exec',
|
||||
PROVIDER,
|
||||
'asterisk',
|
||||
'-rx',
|
||||
`channel originate Local/${did.slice(1)}@provider-out extension s@provider-wait`,
|
||||
);
|
||||
if (!o.ok) throw new Error(`originate: ${o.out}`);
|
||||
let from: string | null = null;
|
||||
await waitFor(
|
||||
`the INVITE to ${ext}`,
|
||||
async () => {
|
||||
const logs = docker('logs', '--since', since, PBX).out;
|
||||
const invites = logs
|
||||
.split(/<--- Transmitting SIP request/)
|
||||
.slice(1)
|
||||
.filter((b) => new RegExp(`^INVITE sip:${ext}[-@]`, 'm').test(b));
|
||||
const m = invites.map((b) => /^From: (?:"([^"]*)" )?</m.exec(b)).find(Boolean);
|
||||
if (m) from = m[1] ?? '';
|
||||
return m !== undefined;
|
||||
},
|
||||
20,
|
||||
);
|
||||
await ami.command('pjsip set logger off');
|
||||
await ami.command('channel request hangup all');
|
||||
docker('exec', PROVIDER, 'asterisk', '-rx', 'channel request hangup all');
|
||||
await sleep(2000);
|
||||
return from;
|
||||
}
|
||||
|
||||
try {
|
||||
docker('rm', '-f', PBX, PROVIDER, PHONES, API);
|
||||
docker('network', 'create', NET);
|
||||
if (!asterisk(PROVIDER, provConf, []).ok) throw new Error('provider did not start');
|
||||
await booted(PROVIDER);
|
||||
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 booted(PBX);
|
||||
startApi(false);
|
||||
ami.start();
|
||||
await new Promise((r) => ami.once('connected', r));
|
||||
await waitFor('the trunk to register', async () =>
|
||||
(await ami.actionList({ Action: 'PJSIPShowRegistrationsOutbound' })).some(
|
||||
(m) => m.Status === 'Registered',
|
||||
),
|
||||
);
|
||||
|
||||
log('starting baresip phones 101 and 102 (installs baresip on first run)…');
|
||||
const phones = 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 && ' +
|
||||
'(baresip -f /phones/phone-101 > /phones/phone-101/log 2>&1 &) && (baresip -f /phones/phone-102 > /phones/phone-102/log 2>&1 &) && sleep 900',
|
||||
);
|
||||
if (!phones.ok) throw new Error(`phones: ${phones.out}`);
|
||||
await waitFor(
|
||||
'101 and 102 to register',
|
||||
async () => {
|
||||
const c = await ami.command('pjsip show contacts');
|
||||
return /101\/sip:.*Avail/.test(c) && /102\/sip:.*Avail/.test(c);
|
||||
},
|
||||
240,
|
||||
);
|
||||
|
||||
const expect = (what: string, got: string | null, want: string) => {
|
||||
log(`${what}: the phone shows ${JSON.stringify(got)}`);
|
||||
if (got !== want)
|
||||
problems.push(
|
||||
`${what}: phone showed ${JSON.stringify(got)}, expected ${JSON.stringify(want)}`,
|
||||
);
|
||||
};
|
||||
expect('a contact calls 101', await ring(DID_USER, KNOWN, '101'), 'Jane Doe');
|
||||
expect('a stranger calls 101', await ring(DID_USER, UNKNOWN, '101'), 'Mobile');
|
||||
expect(
|
||||
'a contact calls the Sales group',
|
||||
await ring(DID_GROUP, KNOWN, '101'),
|
||||
'Sales: Jane Doe',
|
||||
);
|
||||
|
||||
// 101 doesn't answer and forwards to 102 (AstDB SP/CFNA/101, as My settings writes it): 101's
|
||||
// phone showed Jane Doe, and 102 must not see 101's private contact name.
|
||||
await ami.command('database put SP CFNA/101 102');
|
||||
expect('101 unanswered, forwarded to 102', await ring(DID_USER, KNOWN, '102'), 'Mobile');
|
||||
await ami.command('database del SP CFNA/101');
|
||||
|
||||
const lookups = docker('logs', API).out;
|
||||
if (!/lookup 101 13125559999 key-ok -> "Jane Doe"/.test(lookups))
|
||||
problems.push(`no keyed lookup for 101:\n${lookups}`);
|
||||
if (/key-bad/.test(lookups)) problems.push(`a lookup without the key:\n${lookups}`);
|
||||
|
||||
// The api too slow (5 s, the dialplan waits 2): the phone still rings, with the provider's name.
|
||||
startApi(true);
|
||||
await sleep(1000);
|
||||
const t0 = Date.now();
|
||||
expect('the api does not answer in time', await ring(DID_USER, KNOWN, '101'), 'Mobile');
|
||||
log(`rang after ${Math.round((Date.now() - t0) / 100) / 10} s with the api hanging`);
|
||||
} finally {
|
||||
ami.stop();
|
||||
if (process.env.KEEP) log(`KEEP: containers left running, config in ${dir}`);
|
||||
else {
|
||||
docker('rm', '-f', PBX, PROVIDER, PHONES, API);
|
||||
docker('network', 'rm', NET);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
if (problems.length) {
|
||||
console.error(`[cnam-test] FAILED:\n- ${problems.join('\n- ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
log('PASS');
|
||||
}
|
||||
|
||||
main().catch((e: unknown) => {
|
||||
console.error('[cnam-test]', e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { contactNames } from '../src/contacts/book.ts';
|
||||
import { callerNameToken, phoneSafeName } from '../src/contacts/callername.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
@@ -201,4 +202,27 @@ describe.skipIf(!hasTestDb)('contacts', () => {
|
||||
const company = await contactNames(t.db, null, ['13125551234']);
|
||||
expect(company.get('13125551234')).toBe('Front desk');
|
||||
});
|
||||
|
||||
it('the dialplan lookup (C4): only with the key, never through the proxy, phone-safe names', async () => {
|
||||
const token = callerNameToken(t.app.config.APP_SECRET);
|
||||
const look = (q: string, headers: Record<string, string> = {}) =>
|
||||
t.app.inject({ method: 'GET', url: `/api/internal/caller-name?${q}`, headers });
|
||||
const mine = await look(`ext=101&number=13125551234&token=${token}`);
|
||||
expect([mine.statusCode, mine.body]).toEqual([200, 'My Jane']);
|
||||
expect((await look(`ext=102&number=13125551234&token=${token}`)).body).toBe('Jane Doe'); // Sue's own copy, from the accepted share
|
||||
expect((await look(`ext=999&number=13125551234&token=${token}`)).body).toBe('Front desk'); // no user
|
||||
expect((await look(`ext=101&number=19995550000&token=${token}`)).body).toBe('');
|
||||
expect((await look('ext=101&number=13125551234&token=wrong')).statusCode).toBe(404);
|
||||
expect(
|
||||
(
|
||||
await look(`ext=101&number=13125551234&token=${token}`, {
|
||||
'x-forwarded-for': '203.0.113.9',
|
||||
})
|
||||
).statusCode,
|
||||
).toBe(404);
|
||||
expect(phoneSafeName(String.raw`Jane "J" <Doe>, Inc.; (x)^|\ Ünïcode-Smith`)).toBe(
|
||||
'Jane J Doe Inc. x Ünïcode-Smith',
|
||||
);
|
||||
expect(phoneSafeName('A'.repeat(60))).toHaveLength(40);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user