dial plan: made from routes and number lengths; Yealink dial-now, the ATA plan on the Credentials tab
This commit is contained in:
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
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user