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,
|
||||
|
||||
Reference in new issue
Block a user