dial plan: made from routes and number lengths; Yealink dial-now, the ATA plan on the Credentials tab

This commit is contained in:
iamdoubz committed 2026-10-21 17:14:13 -05:00
1 parent eacc571093
commit 0deff2a637
18 files changed
+284 -21

No files matched your search

@@ -58,6 +58,27 @@ linekey.30.type = 0
linekey.31.type = 0
linekey.32.type = 0
linekey.33.type = 0
## dial at once when the number is complete (911, full outside numbers); others wait for Send
dialplan.dialnow.rule.1 = 911
dialplan.dialnow.rule.2 = 9911
dialplan.dialnow.rule.3 = [2-9]xx[2-9]xxxxxx
dialplan.dialnow.rule.4 = 1[2-9]xx[2-9]xxxxxx
dialplan.dialnow.rule.5 =
dialplan.dialnow.rule.6 =
dialplan.dialnow.rule.7 =
dialplan.dialnow.rule.8 =
dialplan.dialnow.rule.9 =
dialplan.dialnow.rule.10 =
dialplan.dialnow.rule.11 =
dialplan.dialnow.rule.12 =
dialplan.dialnow.rule.13 =
dialplan.dialnow.rule.14 =
dialplan.dialnow.rule.15 =
dialplan.dialnow.rule.16 =
dialplan.dialnow.rule.17 =
dialplan.dialnow.rule.18 =
dialplan.dialnow.rule.19 =
dialplan.dialnow.rule.20 =
## time
local_time.time_zone = -6
local_time.time_zone_name = United States-Central Time
+4
View File
@@ -7,6 +7,7 @@ import { decrypt } from '../crypto/secrets.ts';
import type { Db } from '../db/client.ts';
import { contacts, devices, extensions } from '../db/schema.ts';
import { getSetting } from '../services/settings.ts';
import { loadDialRules, type DialRule } from './dialplan.ts';
export type ProvisionContext = {
mac: string;
@@ -26,6 +27,8 @@ export type ProvisionContext = {
/** The phonebook with this phone's credentials in it (phones fetch it like the config). */
phonebookUrl: string;
ntpServer: string;
/** What's dialable (dialplan.ts), so phones can send complete numbers at once. */
dialRules: DialRule[];
};
export const NTP_SERVER = 'pool.ntp.org';
@@ -77,6 +80,7 @@ export async function loadContext(
provPassword,
phonebookUrl: `https://${d.mac}:${provPassword}@${config.PUBLIC_HOSTNAME}/prov/phonebook/${d.mac}.xml`,
ntpServer: NTP_SERVER,
dialRules: await loadDialRules(db),
},
};
}
@@ -0,0 +1,59 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { describe, expect, it } from 'vitest';
import {
dialNowRules,
dialRules,
fromRoutePattern,
grandstreamDialPlan,
ruleText,
} from './dialplan.ts';
/** The routes createDefaultRoutes makes, international off. */
const DEFAULT_ROUTES = [
...['800', '888', '877', '866', '855', '844', '833'].map((p) => `1${p}NXXXXXX`),
'NXXNXXXXXX',
'1NXXNXXXXXX',
];
describe('dial plan', () => {
it('reads Asterisk patterns', () => {
expect(ruleText(fromRoutePattern('NXXNXXXXXX'))).toBe('[2-9]xx[2-9]xxxxxx');
expect(ruleText(fromRoutePattern('011.'))).toBe('011x+');
expect(ruleText(fromRoutePattern('[2-46]Z'))).toBe('[2-46][1-9]');
});
it('default routes, three-digit numbers: toll-free folds into 1NXX…, * codes wait', () => {
const rules = dialRules(DEFAULT_ROUTES, [3]);
expect(grandstreamDialPlan(rules)).toBe(
'{ 911 | 9911 | [2-9]xx[2-9]xxxxxx | 1[2-9]xx[2-9]xxxxxx | xxx | *x+ | **x+ }',
);
// 911 always; 10 and 11 digits can't grow; 101 could be the start of 1012345678? no (N), but
// 212 could start 2125551234, so three-digit numbers wait
expect(dialNowRules(rules).map(ruleText)).toEqual([
'911',
'9911',
'[2-9]xx[2-9]xxxxxx',
'1[2-9]xx[2-9]xxxxxx',
]);
});
it('one-digit and six-digit numbers, international on', () => {
const rules = dialRules([...DEFAULT_ROUTES, '011.'], [1, 3, 6, 3]);
expect(grandstreamDialPlan(rules)).toBe(
'{ 911 | 9911 | [2-9]xx[2-9]xxxxxx | 1[2-9]xx[2-9]xxxxxx | 011x+ | x | xxx | xxxxxx | *x+ | **x+ }',
);
// 0 can start 011…, and every short number can start a longer one
expect(dialNowRules(rules).map(ruleText)).toEqual([
'911',
'9911',
'[2-9]xx[2-9]xxxxxx',
'1[2-9]xx[2-9]xxxxxx',
]);
});
it('a route that only a longer one could continue', () => {
// 4-digit internal numbers start 1-9 at the PBX, but the phone can't know: xxxx waits for 5…
const rules = dialRules(['NXXXXX'], [4]);
expect(dialNowRules(rules).map(ruleText)).toEqual(['911', '9911', '[2-9]xxxxx']);
});
});
+111
View File
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The phone-side dial plan: which dialed strings are complete, so phones and analog adapters
// send them at once instead of waiting for # or a timeout. Made from what is dialable: 911/9911,
// the enabled outbound routes, the lengths internal numbers use, and * codes.
import { eq } from 'drizzle-orm';
import type { RoutePattern } from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { outboundRoutes } from '../db/schema.ts';
import { internalNumberLengths } from '../services/numbering.ts';
/** One rule: the characters allowed at each position; `more` = one or more digits follow (x+). */
export type DialRule = { pos: string[]; more: boolean };
const ANY = '0123456789';
const lit = (s: string): DialRule => ({ pos: [...s], more: false });
/** An Asterisk route pattern (X, Z, N, [..], trailing . or !) as a rule. */
export function fromRoutePattern(match: string): DialRule {
const pos: string[] = [];
let more = false;
for (let i = 0; i < match.length; i++) {
const c = match[i]!;
if (c === 'X') pos.push(ANY);
else if (c === 'Z') pos.push('123456789');
else if (c === 'N') pos.push('23456789');
else if (c === '.' || c === '!')
more = true; // ponytail: ! (zero or more) read as . (one or more)
else if (c === '[') {
const end = match.indexOf(']', i);
pos.push(
match.slice(i + 1, end).replace(/(\d)-(\d)/g, (_, a: string, b: string) => {
let r = '';
for (let d = Number(a); d <= Number(b); d++) r += d;
return r;
}),
);
i = end;
} else pos.push(c);
}
return { pos, more };
}
export function dialRules(routePatterns: string[], internalLengths: number[]): DialRule[] {
const rules = [lit('911'), lit('9911'), ...routePatterns.map(fromRoutePattern)];
for (const n of [...new Set(internalLengths)].sort((a, b) => a - b))
rules.push({ pos: Array<string>(n).fill(ANY), more: false });
rules.push({ pos: ['*'], more: true }, { pos: ['*', '*'], more: true });
// A rule another one already covers says nothing new (toll-free inside 1NXXNXXXXXX); of two
// equal ones, keep the first. 911 and 9911 always stay: dialNowRules sends them at once.
return rules.filter(
(r, i) => i < 2 || !rules.some((o, j) => j !== i && covers(o, r) && (j < i || !covers(r, o))),
);
}
/** Does every string `r` matches also match `o`? */
const covers = (o: DialRule, r: DialRule) =>
(o.more
? r.pos.length > o.pos.length || (r.more && r.pos.length === o.pos.length)
: !r.more && r.pos.length === o.pos.length) &&
r.pos.every((set, i) => [...set].every((c) => (o.pos[i] ?? ANY).includes(c)));
/** Can some string `other` matches be longer than `r` and start with a string `r` matches? */
const extends_ = (r: DialRule, other: DialRule) =>
(other.more || other.pos.length > r.pos.length) &&
r.pos.every((set, i) =>
[...set].some((c) => (other.pos[i] ?? (other.more ? ANY : '')).includes(c)),
);
/**
* Rules a phone may dial the moment they match: no other number can continue them. 911 and 9911
* always: no outside number starts with 911, and no internal one may (isReservedNumber).
*/
export const dialNowRules = (rules: DialRule[]): DialRule[] =>
rules.filter(
(r) =>
!r.more &&
((r.pos.every((s) => s.length === 1) && ['911', '9911'].includes(r.pos.join(''))) ||
!rules.some((o) => o !== r && extends_(r, o))),
);
/** A position in the x / [2-9] / digit syntax both Grandstream and Yealink read. */
function set(s: string): string {
if (s === ANY) return 'x';
if (s.length === 1) return s;
const d = [...s].map(Number).sort((a, b) => a - b);
const runs: string[] = [];
for (let i = 0; i < d.length;) {
let j = i;
while (j + 1 < d.length && d[j + 1] === d[j]! + 1) j++;
runs.push(j - i >= 2 ? `${d[i]}-${d[j]}` : d.slice(i, j + 1).join(''));
i = j + 1;
}
return `[${runs.join('')}]`;
}
export const ruleText = (r: DialRule) => r.pos.map(set).join('') + (r.more ? 'x+' : '');
/** Grandstream's Dial Plan: `{ 911 | 9911 | [2-9]xx[2-9]xxxxxx | … }`. */
export const grandstreamDialPlan = (rules: DialRule[]) => `{ ${rules.map(ruleText).join(' | ')} }`;
/** The rules for this install (enabled outbound routes, internal number lengths). */
export async function loadDialRules(db: Db): Promise<DialRule[]> {
const routes = await db
.select({ patterns: outboundRoutes.patterns })
.from(outboundRoutes)
.where(eq(outboundRoutes.enabled, true));
return dialRules(
routes.flatMap((r) => (r.patterns as RoutePattern[]).map((p) => p.match)),
await internalNumberLengths(db),
);
}
@@ -4,6 +4,7 @@
import { describe, expect, it } from 'vitest';
import { phoneTimezone } from '@selfphoneng/shared';
import type { PhonebookEntry, ProvisionContext } from './context.ts';
import { dialRules } from './dialplan.ts';
import { grandstream, grandstreamPhonebook } from './templates/grandstream.ts';
import { yealink, yealinkPhonebook } from './templates/yealink.ts';
@@ -31,6 +32,7 @@ const ctx = (over: Partial<ProvisionContext> = {}): ProvisionContext => ({
phonebookUrl:
'https://001565abcdef:Pr0vPassw0rdXyZ12345@pbx.example.com/prov/phonebook/001565abcdef.xml',
ntpServer: 'pool.ntp.org',
dialRules: dialRules(['NXXNXXXXXX', '1NXXNXXXXXX'], [3]),
...over,
});
const book: PhonebookEntry[] = [
@@ -53,6 +55,8 @@ describe('phone templates', () => {
expect(out).toContain('linekey.4.type = 0');
expect(out).toContain('linekey.33.type = 0');
expect(out).not.toContain('linekey.34');
expect(out).toMatch(/^dialplan\.dialnow\.rule\.1 = 911$/m);
expect(out).toMatch(/^dialplan\.dialnow\.rule\.20 =$/m);
});
it('Grandstream config: XML-escaped values', async () => {
@@ -1,13 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Yealink T3x/T4x/T5x <mac>.cfg (07 §3.1). Key names from Yealink's docs, (verify per model).
import { oneLine, xml, type PhonebookEntry, type ProvisionContext } from '../context.ts';
import { dialNowRules, ruleText } from '../dialplan.ts';
const TRANSPORT = { udp: 0, tcp: 1, tls: 2 } as const;
/** Line key 1 is the line; BLF keys are 2…; keys past the last BLF up to here are cleared. */
const LAST_KEY = 33;
/** Dial-now rules past the ones in use, up to here, are cleared. */
const LAST_DIALNOW = 20;
export function yealink(c: ProvisionContext): string {
const v = oneLine;
const now = dialNowRules(c.dialRules).slice(0, LAST_DIALNOW);
return [
'#!version:1.0.0.1',
'## account 1',
@@ -40,6 +44,12 @@ export function yealink(c: ProvisionContext): string {
{ length: LAST_KEY - c.blf.length - 1 },
(_, i) => `linekey.${i + c.blf.length + 2}.type = 0`,
),
'## dial at once when the number is complete (911, full outside numbers); others wait for Send',
...now.map((d, i) => `dialplan.dialnow.rule.${i + 1} = ${ruleText(d)}`),
...Array.from(
{ length: LAST_DIALNOW - now.length },
(_, i) => `dialplan.dialnow.rule.${i + now.length + 1} =`,
),
'## time',
`local_time.time_zone = ${c.timezone.yealinkOffset}`,
`local_time.time_zone_name = ${c.timezone.yealinkName}`,
+2
View File
@@ -29,6 +29,7 @@ import { assertMohClass } from './moh.ts';
import { softphoneQr } from '../provisioning/linphone.ts';
import { findReferences } from '../services/destinations.ts';
import { getSetting } from '../services/settings.ts';
import { grandstreamDialPlan, loadDialRules } from '../provisioning/dialplan.ts';
type ExtRow = typeof extensions.$inferSelect;
@@ -243,6 +244,7 @@ export async function extensionRoutes(app: FastifyInstance) {
username: e.number,
password: decrypt(e.sipPasswordEnc),
webUsername: e.webrtcEnabled ? `${e.number}-web` : null,
dialPlan: grandstreamDialPlan(await loadDialRules(db)),
};
});
+13
View File
@@ -98,6 +98,19 @@ export async function assertNumberAvailable(
);
}
/** Every length an internal number has or may get (the ranges, numbers in use, parking). */
export async function internalNumberLengths(db: Db): Promise<number[]> {
const lengths = new Set([String(PARKING.from).length]);
for (const key of Object.values(RANGE_KEY)) {
const [from, to] = await getSetting(db, key);
for (let n = String(from).length; n <= String(to).length; n++) lengths.add(n);
}
for (const { table } of OWNERS)
for (const r of await db.select({ number: table.number }).from(table))
lengths.add(r.number.length);
return [...lengths];
}
/** Lowest free number in the kind's range, or null when the range is full. */
export async function nextFreeNumber(db: Db, kind: NumberKind): Promise<string | null> {
const [from, to] = await getSetting(db, RANGE_KEY[kind]);
+1
View File
@@ -102,6 +102,7 @@ describe.skipIf(!hasTestDb)('extensions API', () => {
port: 5060,
username: '101',
webUsername: '101-web',
dialPlan: '{ 911 | 9911 | xxx | *x+ | **x+ }', // no trunk yet, so no outbound routes
});
expect(pjsip()).toContain(`password = ${r.json().password}`);
const [a] = await t.db