17 Commits
Author SHA1 Message Date
iamdoubz e54e8a1576 release: version 0.0.3
ci / check (push) Successful in 1m16s
ci / images (push) Successful in 12s
release / images (release) Successful in 2m22s
2026-10-21 13:44:40 -05:00
iamdoubz 338ab8d7f1 reports: total minutes, talk time, answered; outside minutes per day; voip.ms balance
Owner asked for total minutes, call time and missed calls in one place, and the money left on
voip.ms. Reports now totals answered calls, talk time and outside minutes (in from arrival, out
from the answer: roughly what the provider bills), charts outside minutes per day, and shows each
voip.ms trunk's balance (getBalance; verify with a real account).
2026-10-21 13:38:23 -05:00
iamdoubz 175d5a2c73 music on hold: play order set in the UI, and shuffle
Owner: the first upload always played first. A class is now an Asterisk playlist in the order
set on Sounds & music (move up/down), new uploads last, with a Shuffle option (sort = random).
moh-test.ts plays both in a real Asterisk and reads the file order from its debug log.
2026-10-21 13:27:02 -05:00
iamdoubz aa6681da03 numbers: any free internal number of 1 to 6 digits; ranges only suggest
Owner wanted the first user on 1. Numbering ranges now choose the Next free suggestion
(Settings → Numbering), the setup wizard takes a number per person, and a one-digit number
can't also be a menu key of a digital receptionist with direct dialing. Dialplan patterns that
take an internal number match one digit (_X!); golden case short-numbers proves it in Asterisk.
2026-10-21 13:17:25 -05:00
iamdoubz 4143f6d064 probe: downloadable executables for machines without Docker
ci / check (push) Successful in 59s
ci / images (push) Successful in 49s
Each release attaches selfphoneng-probe-linux-x64 and -arm64 (Node's single executable
applications: esbuild bundle + postject into the official Node binary, checked against
nodejs.org's SHASUMS256.txt), the bundle as selfphoneng-probe.cjs (Node 22+) and SHA256SUMS.
Built in binary.Dockerfile with BuildKit's local output, so Gitea's runner can build it too.
deploy/probe/selfphoneng-probe.service runs it under systemd, sandboxed.
2026-10-21 12:57:04 -05:00
iamdoubz 65a0438dde diagnostics: System → Diagnostics with a network test probe; RTP_PORT_START/END
The server checks itself (public IP, DNS, local networks, phone ACL, SIP transports, free audio
ports) and says how to fix each problem. A quick or complete network test asks the probe
(apps/probe, own image) to test this server from outside: HTTP/HTTPS, SIP over UDP/TCP/TLS, the
browser phone's /ws, sampled audio ports. The probe only tests the address that asks.
The audio port range comes from RTP_PORT_START/END (default 10000-20000).
2026-10-21 12:37:16 -05:00
iamdoubz 90b3081368 docs: update.sh tested on a throwaway compose install
ci / check (push) Successful in 57s
ci / images (push) Successful in 1m11s
2026-10-21 11:53:25 -05:00
iamdoubz 22b3ef9a8c backup before migrating: once per set of pending changes, not on every restart of a crash-looping api 2026-10-21 11:43:24 -05:00
iamdoubz e2518b03d8 backups: seconds in the name, so two in one minute don't overwrite; update.sh prints the api's log on a failed check 2026-10-21 11:34:42 -05:00
iamdoubz ff59bc7799 update.sh: executable 2026-10-21 11:26:45 -05:00
iamdoubz 4d9b5dab8a update: go back to the running release; docs for the first update from v0.0.2 2026-10-21 11:25:46 -05:00
iamdoubz 54e1e201a1 update: the api backs up before migrating; scripts/update.sh for compose installs
Docker only, never bare metal (owner, 2026-10-21). A new version with pending migrations backs
up first (database, voicemail, sounds, MOH) and stops if it can't (SKIP_BACKUP_BEFORE_MIGRATE
to override). update.sh: checks, changelog, backup (node dist/backup-cli.js), log compaction,
build while the old version runs, restart, self-check, and rollback with restore. Container
logs capped in compose. Admin guide: Updating, for compose and Portainer.
2026-10-21 11:15:40 -05:00
iamdoubz 8d5475cfbf security: fix the review leftovers (owner, 08 §6)
- Asterisk doesn't load app_system, func_shell, app_disa, res_agi, app_externalivr.
- Turning two-step sign-in on asks for the password again.
- *97/*98: one voicemail login at a time per phone, 10 an hour (sp-sub-vmlimit).
- /prov: an unknown MAC gets the same 401 as a known one.
- The login limiter sweeps keys past every window.
Also the v1 checklist with the owner's results from 2026-10-21.
2026-10-21 10:54:44 -05:00
iamdoubz 3c8828eedb docs: simpler README with the logo; ready-for-v1 checklist
ci / check (push) Successful in 58s
ci / images (push) Successful in 4s
release / images (release) Successful in 34s
2026-10-21 07:42:33 -05:00
iamdoubz 0a2264d8af release: version 0.0.2; note more SIP providers (Telnyx, Flowroute, Twilio) as next
ci / check (push) Successful in 1m26s
ci / images (push) Successful in 2m59s
2026-10-21 07:38:17 -05:00
iamdoubz 996685e1ab dialplan: build the dial string per contact; iOS Linphone's push params broke Dial
Its Contact holds pn-prid=...:voip&...:remote, and PJSIP_DIAL_CONTACTS passes that '&' through,
so Dial rejected the whole device list and the call dropped for every device of the user.
sp-sub-contacts walks the AOR's contacts, drops pn-* params and FILTERs the rest. call-test
registers a phone with such a Contact and checks it is rung.
2026-10-20 16:34:45 -05:00
iamdoubz 38f39c9028 docs: to do, unload Asterisk modules that can run commands
Task: none (owner request, 2026-10-20)
2026-10-20 15:00:07 -05:00
144 changed files with 9293 additions and 389 deletions

No files matched your search

+2
View File
@@ -14,3 +14,5 @@ APP_UID=1000
APP_GID=1000
LOG_LEVEL=info
TRUST_PROXY=true # true when behind Caddy/nginx
RTP_PORT_START=10000 # call audio (UDP), both ends included: open this range on the
RTP_PORT_END=20000 # firewall and router. Two ports per call; 200 is plenty for a small office
+33 -5
View File
@@ -1,17 +1,18 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Container images, built only when a release is published (08 §3.1). Runs on GitHub Actions and
# Gitea Actions (both read .github/workflows); each pushes to its own registry:
# GitHub: ghcr.io/<owner>/selfphoneng-{api,asterisk} (the job's GITHUB_TOKEN)
# Gitea: <gitea host>/<owner>/selfphoneng-{api,asterisk} (secrets REGISTRY_USER + REGISTRY_TOKEN:
# GitHub: ghcr.io/<owner>/selfphoneng-{api,asterisk,probe} (the job's GITHUB_TOKEN)
# Gitea: <gitea host>/<owner>/selfphoneng-{api,asterisk,probe} (secrets REGISTRY_USER + REGISTRY_TOKEN:
# a personal access token with write:package; Gitea's job token can't push packages)
# Tags: the version (0.0.1), plus <major>.<minor> and latest; a pre-release gets the version and dev.
# The release also gets the probe as downloads (selfphoneng-probe-linux-{x64,arm64}, .cjs, SHA256SUMS).
name: release
on:
release:
types: [published]
permissions:
contents: read
contents: write # attaching the probe downloads to the release
packages: write
jobs:
@@ -79,6 +80,7 @@ jobs:
)
docker build "${labels[@]}" -f apps/api/Dockerfile -t "$p-api:build" .
docker build "${labels[@]}" -t "$p-asterisk:build" asterisk
docker build "${labels[@]}" -f apps/probe/Dockerfile -t "$p-probe:build" .
- name: Check
shell: bash
run: |
@@ -88,15 +90,41 @@ jobs:
docker run --rm --entrypoint node "$p-api:build" --input-type=module -e "await import('/app/dist/app.js')"
test "$(docker run --rm --entrypoint cat "$p-api:build" /app/VERSION)" = "${{ steps.v.outputs.version }}"
docker run --rm --entrypoint asterisk "$p-asterisk:build" -V
docker run --rm "$p-probe:build" node --input-type=module -e "await import('/app/src/server.ts')"
- name: Push
shell: bash
run: |
set -euo pipefail
p="${{ steps.v.outputs.prefix }}"
for image in api asterisk; do
for image in api asterisk probe; do
for t in ${{ steps.v.outputs.tags }}; do
docker tag "$p-$image:build" "$p-$image:$t"
docker push "$p-$image:$t"
done
done
echo "pushed: $p-{api,asterisk}:{${{ steps.v.outputs.tags }}}" | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"
echo "pushed: $p-{api,asterisk,probe}:{${{ steps.v.outputs.tags }}}" | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"
- name: Probe downloads
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
# The probe without Docker (08 §2.6): a .cjs for Node 22+, and Linux executables with Node
# inside. Built in BuildKit and handed back as files, so it works on Gitea's runner too.
docker build -f apps/probe/binary.Dockerfile --output type=local,dest=probe-dist .
id="${{ github.event.release.id }}"
for f in probe-dist/*; do
name=$(basename "$f")
if [ "${{ steps.v.outputs.registry }}" = ghcr.io ]; then
curl -fsS -o /dev/null -X POST -H "Authorization: Bearer $GH_TOKEN" \
-H 'Content-Type: application/octet-stream' --data-binary "@$f" \
"https://uploads.github.com/repos/$GITHUB_REPOSITORY/releases/$id/assets?name=$name"
else
# The REGISTRY_TOKEN personal access token needs write:repository for this.
curl -fsS -o /dev/null -X POST -H "Authorization: token ${REGISTRY_TOKEN:-$GH_TOKEN}" \
-F "attachment=@$f" \
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/$id/assets?name=$name"
fi
echo "attached $name" | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"
done
+48 -1
View File
@@ -1,7 +1,54 @@
# Changelog
Versions follow [semver](https://semver.org). Each release's container images are built from its
tag (08 §3.1): `selfphoneng-api` and `selfphoneng-asterisk`, on ghcr.io and on the Gitea registry.
tag (08 §3.1): `selfphoneng-api`, `selfphoneng-asterisk` and `selfphoneng-probe`, on ghcr.io and on
the Gitea registry. From v0.0.3 the release also has the probe as downloads for machines without
Docker.
## v0.0.3 — 2026-10-21
**Before you update:** this version changes the database (music-on-hold order). The api backs up
first, by itself (see the admin guide, Updating). Compose installs from v0.0.2 get
`scripts/update.sh` with the release: `git fetch --tags && git checkout v0.0.3`, then
`sudo scripts/update.sh v0.0.3`. Portainer: change both tags to `0.0.3` and redeploy. Optional:
`RTP_PORT_START`/`RTP_PORT_END` on the api, then open that range instead of 10000–20000.
- **Any internal number:** users, ring groups, queues, digital receptionists, conference rooms and
paging groups take any free number of 1 to 6 digits, for example **1** for the first user (the
setup wizard asks for it too). Settings → **Numbering** sets where **Next free** looks. A
one-digit number and a menu key of a digital receptionist that lets callers dial numbers can't
be the same. Ring groups, queues, voicemail, pickup (`**`), queue login (`*45`), forwarding
(`*72`) and intercom (`*80`) now work with one-digit numbers.
- **Music on hold order:** a set plays in the order shown on Sounds & music (move files up and
down; new uploads go last), or **Shuffle** for random order. Before, the order was Asterisk's.
- **Reports:** totals now include answered calls, talk time and **outside minutes** (in and out,
roughly what voip.ms bills), with outside minutes per day, and the **voip.ms balance** of each
trunk that has the API login.
- **System → Diagnostics:** checks what usually keeps phones off the network (public IP, DNS,
local networks, the phone network limit, SIP listening, free audio ports), with how to fix each.
A **Quick** or **Complete network test** checks the ports from the internet through a network
test probe you run outside your network; set its URL in Settings → Network. The probe comes as
a Docker image (`selfphoneng-probe`), and, for a machine without Docker, as a single Linux
executable (x64 and arm64) attached to each release, with a systemd unit.
- The call audio port range is configurable: `RTP_PORT_START` and `RTP_PORT_END` (default
10000–20000).
- Updating (Docker only): a new version backs up before it changes the database, and stops
instead if the backup fails. `scripts/update.sh` updates a compose install to a release, checks
the result and goes back by itself if the check fails. The admin guide's "Updating" section
covers compose and Portainer.
- Container logs are capped (3 × 10 MB) in `docker-compose.yml`.
- Security leftovers from the review (08 §6), all fixed:
- Asterisk no longer loads the modules that can run host commands (`app_system`, `func_shell`,
`app_disa`, `res_agi`, `app_externalivr`).
- Turning two-step sign-in on asks for your password again.
- `*97`/`*98` voicemail logins: one at a time per phone, 10 an hour.
- Phone setup files: an unknown MAC gets the same answer as a known one.
- The login limiter no longer keeps every email ever tried until a restart.
## v0.0.2 — 2026-10-21
- Fix: calls to a user with Linphone on an iPhone registered dropped at once, for every device of
that user. Its push parameters hold `&`, which split the dial string.
## v0.0.1 — 2026-10-20
+37 -9
View File
@@ -1,15 +1,43 @@
<p align="center">
<img src="apps/web/public/logo-512.png" alt="SelfPhoneNG" width="220">
</p>
# SelfPhoneNG
A fully open source, self-hosted business phone system: extensions, desk phones and a
browser softphone, voicemail to email, IVRs, ring groups, queues, office hours, call
recording, live dashboard, SMS and phone provisioning. Bring your own SIP trunk and
numbers (reference provider: voip.ms). Built on Asterisk 24 LTS + TypeScript + PostgreSQL 18.
A fully open source, self-hosted business phone system. Bring your own SIP trunk and numbers
(reference provider: voip.ms). Built on Asterisk 24 + TypeScript + PostgreSQL 18, runs in Docker.
**Status:** pre-1.0, not yet tested by an outside installer.
**Status:** pre-1.0 (v0.0.3), for testing. See [what's left for v1](docs/v1-checklist.md).
- Installing and running it: the [admin guide](docs/admin-guide/README.md)
- Start here if you are writing code (human or AI): [`AGENTS.md`](AGENTS.md)
- The full spec: [`docs/spec/`](docs/spec/00-overview.md)
- Build order: [`docs/spec/09-roadmap.md`](docs/spec/09-roadmap.md)
## What it does today
- **Phones:** users and extensions, desk phones (Yealink, Grandstream) set up automatically, a
browser phone, and Linphone set up by scanning a QR code.
- **Numbers:** voip.ms trunks (registration or IP auth, UDP/TCP/TLS + SRTP), phone numbers,
outbound rules with calling permissions, 911 with E911 caller ID and alerts.
- **Call flows:** digital receptionists, office hours and holidays, ring groups, queues, parking,
pickup, conference rooms, paging and intercom, music on hold.
- **Voicemail** with email, **call history**, **recordings**, a **live dashboard** (listen,
whisper, barge) and reports.
- **Running it:** updates (`scripts/update.sh` for compose installs; a backup before every
database change), backups and restore, background health checks, webhooks and API tokens,
two-step sign-in for admins, limits on outside calls at once.
## Wanted
- More SIP providers: **Telnyx, Flowroute and Twilio** first.
- SMS (built for voip.ms, parked until wanted).
- Push notifications, so phone apps ring in the background.
- Text-to-speech prompts, recording prompts by phone, a queue wallboard, Polycom phones.
## Docs
- [Admin guide](docs/admin-guide/README.md): install, [voip.ms setup](docs/admin-guide/voipms.md),
[network and firewall](docs/admin-guide/network.md), [E911 and recording law](docs/admin-guide/legal.md),
[troubleshooting](docs/admin-guide/troubleshooting.md), [updating and backups](docs/admin-guide/maintenance.md)
- [Changelog](CHANGELOG.md)
- [Ready for v1 checklist](docs/v1-checklist.md)
- Writing code (human or AI): start with [`AGENTS.md`](AGENTS.md), then the
[spec](docs/spec/00-overview.md) and the [roadmap](docs/spec/09-roadmap.md)
License: AGPL-3.0-or-later.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@selfphoneng/api",
"version": "0.0.1",
"version": "0.0.3",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+1 -1
View File
@@ -47,7 +47,7 @@ declare module 'fastify' {
/** Decorated by the caller (index.ts, tests) before ready(): it needs app.log. */
generator: Generator;
/** Decorated by the caller too (tests pass a fake). */
ami: Pick<AmiClient, 'action' | 'isConnected'>;
ami: Pick<AmiClient, 'action' | 'command' | 'isConnected'>;
}
}
+18
View File
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { expect, it } from 'vitest';
import {
isLoginBlocked,
loginLimiterSize,
recordLoginFailure,
resetLoginLimiter,
} from './login-limiter.ts';
it('forgets emails tried long ago instead of growing until restart', () => {
resetLoginLimiter();
const t0 = 1_000_000_000;
for (let i = 0; i < 100; i++) recordLoginFailure(`10.0.0.${i}`, `user${i}@x.test`, t0);
expect(loginLimiterSize()).toBe(200);
for (let i = 0; i < 5; i++) recordLoginFailure('10.9.9.9', 'kept@x.test', t0 + 61 * 60_000);
expect(loginLimiterSize()).toBe(2);
expect(isLoginBlocked('10.9.9.9', 'other@x.test', t0 + 61 * 60_000)).toBe(true);
});
+14
View File
@@ -3,8 +3,10 @@
// ponytail: in-process memory, resets on restart; fine for one api process (v1 has no HA).
const MIN = 60_000;
const RULES = { ip: { max: 5, windowMs: 15 * MIN }, email: { max: 10, windowMs: 60 * MIN } };
const LONGEST = Math.max(RULES.ip.windowMs, RULES.email.windowMs);
const failures = new Map<string, number[]>();
let lastSweep = 0;
function recent(key: string, windowMs: number, now: number): number[] {
const list = (failures.get(key) ?? []).filter((t) => now - t < windowMs);
@@ -21,12 +23,24 @@ export function isLoginBlocked(ip: string, email: string, now = Date.now()): boo
}
export function recordLoginFailure(ip: string, email: string, now = Date.now()): void {
// Forget keys past every window, at most once a minute: emails tried once and never again
// would otherwise stay until restart (08 §6).
if (now - lastSweep > MIN) {
lastSweep = now;
for (const [key, list] of failures) if (now - list.at(-1)! >= LONGEST) failures.delete(key);
}
for (const key of [`ip:${ip}`, `email:${email.toLowerCase()}`]) {
failures.set(key, [...(failures.get(key) ?? []), now]);
}
}
/** Tests only. */
export function loginLimiterSize(): number {
return failures.size;
}
/** Tests only. */
export function resetLoginLimiter(): void {
failures.clear();
lastSweep = 0;
}
+15
View File
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// `node dist/backup-cli.js` in the api container: one backup like the one before migrating
// (database, voicemail, custom sounds, music on hold; no recordings), its file name printed.
// scripts/update.sh runs it in the running (old) api before updating.
import { loadConfig } from './config.ts';
import { createDb } from './db/client.ts';
import { backupBeforeMigrate } from './services/backup.ts';
const config = loadConfig();
const { db, pool } = createDb(config.DATABASE_URL);
try {
console.log((await backupBeforeMigrate(config, db)).name);
} finally {
await pool.end();
}
+10
View File
@@ -19,6 +19,16 @@ describe('config', () => {
expect(() => loadConfig({ ...env, APP_SECRET: undefined })).toThrow(ConfigError);
expect(() => loadConfig({ ...env, APP_SECRET: 'c2hvcnQ=' })).toThrow(/APP_SECRET/);
});
it('call audio ports: 10000–20000 by default; the end above the start', () => {
expect(loadConfig(env)).toMatchObject({ RTP_PORT_START: 10000, RTP_PORT_END: 20000 });
expect(loadConfig({ ...env, RTP_PORT_START: '40000', RTP_PORT_END: '40199' })).toMatchObject({
RTP_PORT_START: 40000,
RTP_PORT_END: 40199,
});
expect(() => loadConfig({ ...env, RTP_PORT_START: '40000', RTP_PORT_END: '30000' })).toThrow(
/RTP_PORT_END/,
);
});
it('rejects a non-postgres DATABASE_URL', () => {
expect(() => loadConfig({ ...env, DATABASE_URL: 'mysql://x' })).toThrow(/DATABASE_URL/);
});
+45 -35
View File
@@ -1,41 +1,51 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { z } from 'zod';
const EnvSchema = z.object({
APP_SECRET: z
.string()
.refine((v) => Buffer.from(v, 'base64').length === 32, 'must be 32 random bytes, base64'),
PUBLIC_HOSTNAME: z.string().min(1),
DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, 'must be a postgres:// URL'),
INITIAL_ADMIN_EMAIL: z.email(),
INITIAL_ADMIN_PASSWORD: z.string().min(1),
AMI_HOST: z.string().default('127.0.0.1'),
AMI_PORT: z.coerce.number().int().min(1).max(65535).default(5038),
AMI_USERNAME: z.string().default('selfphoneng'),
AMI_SECRET: z.string().min(8),
/** Where generated Asterisk config goes (03 §3). Dev: ../../data/asterisk-generated */
ASTERISK_CONFIG_DIR: z.string().default('/etc/asterisk/selfphoneng'),
/** Asterisk's voicemail spool, shared with the api (04 §5.6). Dev: ../../data/voicemail */
VOICEMAIL_DIR: z.string().default('/var/spool/asterisk/voicemail'),
/** Uploaded prompts, played as custom/<id> (04 §6). Dev: ../../data/sounds-custom */
SOUNDS_DIR: z.string().default('/var/lib/asterisk/sounds/custom'),
/** Music-on-hold classes, one folder each (04 §6, 03 §8). Dev: ../../data/moh */
MOH_DIR: z.string().default('/var/lib/asterisk/moh'),
/** Backups (04 §8.1). Dev: ../../data/backups */
BACKUPS_DIR: z.string().default('/var/lib/selfphoneng/backups'),
/** SIP TLS certificate, fullchain.pem + privkey.pem (M11-T2). Dev: ../../data/tls */
TLS_DIR: z.string().default('/var/lib/selfphoneng/tls'),
/** voip.ms REST endpoint (06 §5.1); tests and e2e point it at a fake. */
VOIPMS_API_URL: z.url().default('https://voip.ms/api/v1/rest.php'),
/** MixMonitor's directory; recordings are YYYY/MM/DD/<uniqueid>.wav under it (03 sp-sub-record). */
RECORDINGS_DIR: z.string().default('/var/spool/asterisk/monitor'),
/** The browser softphone's SIP WebSocket. Default wss://PUBLIC_HOSTNAME/ws (the reverse proxy). */
WEBRTC_WS_URL: z.url().optional(),
API_PORT: z.coerce.number().int().min(1).max(65535).default(8080),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
TRUST_PROXY: z.stringbool().default(true),
TZ: z.string().default('America/Chicago'),
});
const EnvSchema = z
.object({
APP_SECRET: z
.string()
.refine((v) => Buffer.from(v, 'base64').length === 32, 'must be 32 random bytes, base64'),
PUBLIC_HOSTNAME: z.string().min(1),
DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, 'must be a postgres:// URL'),
INITIAL_ADMIN_EMAIL: z.email(),
INITIAL_ADMIN_PASSWORD: z.string().min(1),
AMI_HOST: z.string().default('127.0.0.1'),
AMI_PORT: z.coerce.number().int().min(1).max(65535).default(5038),
AMI_USERNAME: z.string().default('selfphoneng'),
AMI_SECRET: z.string().min(8),
/** Where generated Asterisk config goes (03 §3). Dev: ../../data/asterisk-generated */
ASTERISK_CONFIG_DIR: z.string().default('/etc/asterisk/selfphoneng'),
/** Asterisk's voicemail spool, shared with the api (04 §5.6). Dev: ../../data/voicemail */
VOICEMAIL_DIR: z.string().default('/var/spool/asterisk/voicemail'),
/** Uploaded prompts, played as custom/<id> (04 §6). Dev: ../../data/sounds-custom */
SOUNDS_DIR: z.string().default('/var/lib/asterisk/sounds/custom'),
/** Music-on-hold classes, one folder each (04 §6, 03 §8). Dev: ../../data/moh */
MOH_DIR: z.string().default('/var/lib/asterisk/moh'),
/** Backups (04 §8.1). Dev: ../../data/backups */
BACKUPS_DIR: z.string().default('/var/lib/selfphoneng/backups'),
/** SIP TLS certificate, fullchain.pem + privkey.pem (M11-T2). Dev: ../../data/tls */
TLS_DIR: z.string().default('/var/lib/selfphoneng/tls'),
/** voip.ms REST endpoint (06 §5.1); tests and e2e point it at a fake. */
VOIPMS_API_URL: z.url().default('https://voip.ms/api/v1/rest.php'),
/** MixMonitor's directory; recordings are YYYY/MM/DD/<uniqueid>.wav under it (03 sp-sub-record). */
RECORDINGS_DIR: z.string().default('/var/spool/asterisk/monitor'),
/** The browser softphone's SIP WebSocket. Default wss://PUBLIC_HOSTNAME/ws (the reverse proxy). */
WEBRTC_WS_URL: z.url().optional(),
API_PORT: z.coerce.number().int().min(1).max(65535).default(8080),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
TRUST_PROXY: z.stringbool().default(true),
/** Call audio (RTP) ports, both ends included: the firewall opens this UDP range (01 §3.6). */
RTP_PORT_START: z.coerce.number().int().min(1024).max(65534).default(10000),
RTP_PORT_END: z.coerce.number().int().min(1025).max(65535).default(20000),
/** Migrate even when the backup before migrating fails (index.ts). For a fix-it-now moment only. */
SKIP_BACKUP_BEFORE_MIGRATE: z.stringbool().default(false),
TZ: z.string().default('America/Chicago'),
})
.refine((c) => c.RTP_PORT_END > c.RTP_PORT_START, {
path: ['RTP_PORT_END'],
message: 'must be above RTP_PORT_START',
});
export type Config = z.infer<typeof EnvSchema>;
+26
View File
@@ -1,5 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sql } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import pg from 'pg';
@@ -19,3 +22,26 @@ export type Db = ReturnType<typeof createDb>['db'];
export async function runMigrations(db: Db): Promise<void> {
await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
}
/**
* Migrations this image has and the database hasn't run yet: how many are applied, of how many.
* Null when none are, or for an empty database (a fresh install: nothing to lose). Reads drizzle's
* own bookkeeping, `drizzle.__drizzle_migrations`.
*/
export async function migrationsPending(
db: Db,
folder = MIGRATIONS_DIR,
): Promise<{ applied: number; total: number } | null> {
const journal = JSON.parse(await readFile(join(folder, 'meta', '_journal.json'), 'utf8')) as {
entries: unknown[];
};
const t = await db.execute<{ t: string | null }>(
sql`select to_regclass('drizzle.__drizzle_migrations')::text as t`,
);
if (!t.rows[0]?.t) return null;
const r = await db.execute<{ n: number }>(
sql`select count(*)::int as n from drizzle.__drizzle_migrations`,
);
const applied = r.rows[0]!.n;
return applied < journal.entries.length ? { applied, total: journal.entries.length } : null;
}
@@ -0,0 +1,7 @@
ALTER TABLE "moh_classes" ADD COLUMN "shuffle" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "sound_files" ADD COLUMN "position" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
-- Existing music on hold keeps the order it was uploaded in (owner, 2026-10-21).
UPDATE "sound_files" s SET "position" = o.n FROM (
SELECT "id", row_number() OVER (PARTITION BY "moh_class_id" ORDER BY "created_at", "id") - 1 AS n
FROM "sound_files" WHERE "moh_class_id" IS NOT NULL
) o WHERE s."id" = o."id";
File diff suppressed because it is too large. Load diff
@@ -36,6 +36,13 @@
"when": 1792512298128,
"tag": "0004_system_events",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1792606712441,
"tag": "0005_moh_order",
"breakpoints": true
}
]
}
+4
View File
@@ -334,6 +334,8 @@ export const queueCalls = pgTable(
export const mohClasses = pgTable('moh_classes', {
id: id(),
name: text().notNull().unique(),
/** Play the files in random order; otherwise in `sound_files.position` order (03 §8). */
shuffle: boolean().notNull().default(false),
...timestamps,
});
@@ -349,6 +351,8 @@ export const soundFiles = pgTable(
originalFilename: text(),
asteriskName: text().notNull().unique(),
durationMs: integer().notNull(),
/** Music on hold: where it plays in its class's list (0 first). Prompts don't use it. */
position: integer().notNull().default(0),
...timestamps,
},
(t) => [
@@ -10,16 +10,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).
[sp-sub-dial-user]
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
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,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
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
; in its Contact from reaching Dial's options (',') or another device ('&').
[sp-sub-contacts]
exten => s,1,Set(LOCAL(CL)=${PJSIP_AOR(${ARG1},contact)})
same => n(next),Set(LOCAL(C)=${POP(CL)})
same => n,GotoIf($["${C}" = ""]?done)
same => n,Set(LOCAL(ST)=${PJSIP_CONTACT(${C},status)})
same => n,GotoIf($["${ST}" = "Unavailable" | "${ST}" = "Unreachable"]?next)
same => n,Set(LOCAL(U)=${PJSIP_CONTACT(${C},uri)})
same => n,Set(LOCAL(D)=${CUT(U,\;,1)})
same => n,Set(LOCAL(I)=1)
same => n(param),Set(I=$[${I} + 1])
same => n,GotoIf($[${I} > ${FIELDQTY(U,\;)}]?add)
same => n,Set(LOCAL(P)=${CUT(U,\;,${I})})
same => n,GotoIf($["${P:0:3}" = "pn-"]?param)
same => n,Set(D=${D}\;${P})
same => n,Goto(param)
same => n(add),Set(DS=${DS}${IF($["${DS}" = ""]?:&)}PJSIP/${ARG1}/${FILTER(0-9a-zA-Z.:@\;=_+%[]\x2d,${D})})
same => n,Goto(next)
same => n(done),Return()
; ─── Call a user with all their personal rules.
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
[sp-sub-user]
@@ -87,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -105,7 +148,7 @@ exten => *79,1,Answer() ; DND off
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
exten => _*72X!,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
@@ -116,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
@@ -131,7 +174,7 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
@@ -149,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
@@ -165,7 +208,7 @@ exten => _*3[123]X,1,Answer()
same => n,Hangup()
[sp-vm-direct]
exten => _X.,1,Answer()
exten => _X!,1,Answer()
same => n,VoiceMail(${EXTEN}@default,u)
same => n,Hangup()
@@ -175,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
same => n,Goto(sp-outbound-routes,${EXTEN},1)
[sp-rg-member] ; one member of a ring group (Local channel)
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n(skip),Hangup()
[sp-queue-member] ; one queue agent (Local channel)
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n,Hangup()
[sp-from-ext]
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -10,16 +10,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).
[sp-sub-dial-user]
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
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,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
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
; in its Contact from reaching Dial's options (',') or another device ('&').
[sp-sub-contacts]
exten => s,1,Set(LOCAL(CL)=${PJSIP_AOR(${ARG1},contact)})
same => n(next),Set(LOCAL(C)=${POP(CL)})
same => n,GotoIf($["${C}" = ""]?done)
same => n,Set(LOCAL(ST)=${PJSIP_CONTACT(${C},status)})
same => n,GotoIf($["${ST}" = "Unavailable" | "${ST}" = "Unreachable"]?next)
same => n,Set(LOCAL(U)=${PJSIP_CONTACT(${C},uri)})
same => n,Set(LOCAL(D)=${CUT(U,\;,1)})
same => n,Set(LOCAL(I)=1)
same => n(param),Set(I=$[${I} + 1])
same => n,GotoIf($[${I} > ${FIELDQTY(U,\;)}]?add)
same => n,Set(LOCAL(P)=${CUT(U,\;,${I})})
same => n,GotoIf($["${P:0:3}" = "pn-"]?param)
same => n,Set(D=${D}\;${P})
same => n,Goto(param)
same => n(add),Set(DS=${DS}${IF($["${DS}" = ""]?:&)}PJSIP/${ARG1}/${FILTER(0-9a-zA-Z.:@\;=_+%[]\x2d,${D})})
same => n,Goto(next)
same => n(done),Return()
; ─── Call a user with all their personal rules.
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
[sp-sub-user]
@@ -87,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -105,7 +148,7 @@ exten => *79,1,Answer() ; DND off
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
exten => _*72X!,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
@@ -116,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
@@ -131,7 +174,7 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
@@ -149,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
@@ -165,7 +208,7 @@ exten => _*3[123]X,1,Answer()
same => n,Hangup()
[sp-vm-direct]
exten => _X.,1,Answer()
exten => _X!,1,Answer()
same => n,VoiceMail(${EXTEN}@default,u)
same => n,Hangup()
@@ -175,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
same => n,Goto(sp-outbound-routes,${EXTEN},1)
[sp-rg-member] ; one member of a ring group (Local channel)
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n(skip),Hangup()
[sp-queue-member] ; one queue agent (Local channel)
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n,Hangup()
[sp-from-ext]
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -10,16 +10,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).
[sp-sub-dial-user]
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
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,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
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
; in its Contact from reaching Dial's options (',') or another device ('&').
[sp-sub-contacts]
exten => s,1,Set(LOCAL(CL)=${PJSIP_AOR(${ARG1},contact)})
same => n(next),Set(LOCAL(C)=${POP(CL)})
same => n,GotoIf($["${C}" = ""]?done)
same => n,Set(LOCAL(ST)=${PJSIP_CONTACT(${C},status)})
same => n,GotoIf($["${ST}" = "Unavailable" | "${ST}" = "Unreachable"]?next)
same => n,Set(LOCAL(U)=${PJSIP_CONTACT(${C},uri)})
same => n,Set(LOCAL(D)=${CUT(U,\;,1)})
same => n,Set(LOCAL(I)=1)
same => n(param),Set(I=$[${I} + 1])
same => n,GotoIf($[${I} > ${FIELDQTY(U,\;)}]?add)
same => n,Set(LOCAL(P)=${CUT(U,\;,${I})})
same => n,GotoIf($["${P:0:3}" = "pn-"]?param)
same => n,Set(D=${D}\;${P})
same => n,Goto(param)
same => n(add),Set(DS=${DS}${IF($["${DS}" = ""]?:&)}PJSIP/${ARG1}/${FILTER(0-9a-zA-Z.:@\;=_+%[]\x2d,${D})})
same => n,Goto(next)
same => n(done),Return()
; ─── Call a user with all their personal rules.
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
[sp-sub-user]
@@ -87,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -105,7 +148,7 @@ exten => *79,1,Answer() ; DND off
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
exten => _*72X!,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
@@ -116,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
@@ -131,7 +174,7 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
@@ -149,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
@@ -165,7 +208,7 @@ exten => _*3[123]X,1,Answer()
same => n,Hangup()
[sp-vm-direct]
exten => _X.,1,Answer()
exten => _X!,1,Answer()
same => n,VoiceMail(${EXTEN}@default,u)
same => n,Hangup()
@@ -175,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
same => n,Goto(sp-outbound-routes,${EXTEN},1)
[sp-rg-member] ; one member of a ring group (Local channel)
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n(skip),Hangup()
[sp-queue-member] ; one queue agent (Local channel)
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n,Hangup()
[sp-from-ext]
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -10,16 +10,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).
[sp-sub-dial-user]
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
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,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
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
; in its Contact from reaching Dial's options (',') or another device ('&').
[sp-sub-contacts]
exten => s,1,Set(LOCAL(CL)=${PJSIP_AOR(${ARG1},contact)})
same => n(next),Set(LOCAL(C)=${POP(CL)})
same => n,GotoIf($["${C}" = ""]?done)
same => n,Set(LOCAL(ST)=${PJSIP_CONTACT(${C},status)})
same => n,GotoIf($["${ST}" = "Unavailable" | "${ST}" = "Unreachable"]?next)
same => n,Set(LOCAL(U)=${PJSIP_CONTACT(${C},uri)})
same => n,Set(LOCAL(D)=${CUT(U,\;,1)})
same => n,Set(LOCAL(I)=1)
same => n(param),Set(I=$[${I} + 1])
same => n,GotoIf($[${I} > ${FIELDQTY(U,\;)}]?add)
same => n,Set(LOCAL(P)=${CUT(U,\;,${I})})
same => n,GotoIf($["${P:0:3}" = "pn-"]?param)
same => n,Set(D=${D}\;${P})
same => n,Goto(param)
same => n(add),Set(DS=${DS}${IF($["${DS}" = ""]?:&)}PJSIP/${ARG1}/${FILTER(0-9a-zA-Z.:@\;=_+%[]\x2d,${D})})
same => n,Goto(next)
same => n(done),Return()
; ─── Call a user with all their personal rules.
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
[sp-sub-user]
@@ -87,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -105,7 +148,7 @@ exten => *79,1,Answer() ; DND off
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
exten => _*72X!,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
@@ -116,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
@@ -131,7 +174,7 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
@@ -149,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
@@ -165,7 +208,7 @@ exten => _*3[123]X,1,Answer()
same => n,Hangup()
[sp-vm-direct]
exten => _X.,1,Answer()
exten => _X!,1,Answer()
same => n,VoiceMail(${EXTEN}@default,u)
same => n,Hangup()
@@ -175,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
same => n,Goto(sp-outbound-routes,${EXTEN},1)
[sp-rg-member] ; one member of a ring group (Local channel)
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n(skip),Hangup()
[sp-queue-member] ; one queue agent (Local channel)
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n,Hangup()
[sp-from-ext]
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -10,16 +10,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).
[sp-sub-dial-user]
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
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,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
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
; in its Contact from reaching Dial's options (',') or another device ('&').
[sp-sub-contacts]
exten => s,1,Set(LOCAL(CL)=${PJSIP_AOR(${ARG1},contact)})
same => n(next),Set(LOCAL(C)=${POP(CL)})
same => n,GotoIf($["${C}" = ""]?done)
same => n,Set(LOCAL(ST)=${PJSIP_CONTACT(${C},status)})
same => n,GotoIf($["${ST}" = "Unavailable" | "${ST}" = "Unreachable"]?next)
same => n,Set(LOCAL(U)=${PJSIP_CONTACT(${C},uri)})
same => n,Set(LOCAL(D)=${CUT(U,\;,1)})
same => n,Set(LOCAL(I)=1)
same => n(param),Set(I=$[${I} + 1])
same => n,GotoIf($[${I} > ${FIELDQTY(U,\;)}]?add)
same => n,Set(LOCAL(P)=${CUT(U,\;,${I})})
same => n,GotoIf($["${P:0:3}" = "pn-"]?param)
same => n,Set(D=${D}\;${P})
same => n,Goto(param)
same => n(add),Set(DS=${DS}${IF($["${DS}" = ""]?:&)}PJSIP/${ARG1}/${FILTER(0-9a-zA-Z.:@\;=_+%[]\x2d,${D})})
same => n,Goto(next)
same => n(done),Return()
; ─── Call a user with all their personal rules.
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
[sp-sub-user]
@@ -87,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -105,7 +148,7 @@ exten => *79,1,Answer() ; DND off
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
exten => _*72X!,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
@@ -116,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
@@ -131,7 +174,7 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
@@ -149,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
@@ -165,7 +208,7 @@ exten => _*3[123]X,1,Answer()
same => n,Hangup()
[sp-vm-direct]
exten => _X.,1,Answer()
exten => _X!,1,Answer()
same => n,VoiceMail(${EXTEN}@default,u)
same => n,Hangup()
@@ -175,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
same => n,Goto(sp-outbound-routes,${EXTEN},1)
[sp-rg-member] ; one member of a ring group (Local channel)
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n(skip),Hangup()
[sp-queue-member] ; one queue agent (Local channel)
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n,Hangup()
[sp-from-ext]
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -10,16 +10,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).
[sp-sub-dial-user]
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
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,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
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
; in its Contact from reaching Dial's options (',') or another device ('&').
[sp-sub-contacts]
exten => s,1,Set(LOCAL(CL)=${PJSIP_AOR(${ARG1},contact)})
same => n(next),Set(LOCAL(C)=${POP(CL)})
same => n,GotoIf($["${C}" = ""]?done)
same => n,Set(LOCAL(ST)=${PJSIP_CONTACT(${C},status)})
same => n,GotoIf($["${ST}" = "Unavailable" | "${ST}" = "Unreachable"]?next)
same => n,Set(LOCAL(U)=${PJSIP_CONTACT(${C},uri)})
same => n,Set(LOCAL(D)=${CUT(U,\;,1)})
same => n,Set(LOCAL(I)=1)
same => n(param),Set(I=$[${I} + 1])
same => n,GotoIf($[${I} > ${FIELDQTY(U,\;)}]?add)
same => n,Set(LOCAL(P)=${CUT(U,\;,${I})})
same => n,GotoIf($["${P:0:3}" = "pn-"]?param)
same => n,Set(D=${D}\;${P})
same => n,Goto(param)
same => n(add),Set(DS=${DS}${IF($["${DS}" = ""]?:&)}PJSIP/${ARG1}/${FILTER(0-9a-zA-Z.:@\;=_+%[]\x2d,${D})})
same => n,Goto(next)
same => n(done),Return()
; ─── Call a user with all their personal rules.
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
[sp-sub-user]
@@ -87,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -105,7 +148,7 @@ exten => *79,1,Answer() ; DND off
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
exten => _*72X!,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
@@ -116,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
@@ -131,7 +174,7 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
@@ -149,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
@@ -165,7 +208,7 @@ exten => _*3[123]X,1,Answer()
same => n,Hangup()
[sp-vm-direct]
exten => _X.,1,Answer()
exten => _X!,1,Answer()
same => n,VoiceMail(${EXTEN}@default,u)
same => n,Hangup()
@@ -175,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
same => n,Goto(sp-outbound-routes,${EXTEN},1)
[sp-rg-member] ; one member of a ring group (Local channel)
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n(skip),Hangup()
[sp-queue-member] ; one queue agent (Local channel)
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n,Hangup()
[sp-from-ext]
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -10,16 +10,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).
[sp-sub-dial-user]
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
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,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
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
; in its Contact from reaching Dial's options (',') or another device ('&').
[sp-sub-contacts]
exten => s,1,Set(LOCAL(CL)=${PJSIP_AOR(${ARG1},contact)})
same => n(next),Set(LOCAL(C)=${POP(CL)})
same => n,GotoIf($["${C}" = ""]?done)
same => n,Set(LOCAL(ST)=${PJSIP_CONTACT(${C},status)})
same => n,GotoIf($["${ST}" = "Unavailable" | "${ST}" = "Unreachable"]?next)
same => n,Set(LOCAL(U)=${PJSIP_CONTACT(${C},uri)})
same => n,Set(LOCAL(D)=${CUT(U,\;,1)})
same => n,Set(LOCAL(I)=1)
same => n(param),Set(I=$[${I} + 1])
same => n,GotoIf($[${I} > ${FIELDQTY(U,\;)}]?add)
same => n,Set(LOCAL(P)=${CUT(U,\;,${I})})
same => n,GotoIf($["${P:0:3}" = "pn-"]?param)
same => n,Set(D=${D}\;${P})
same => n,Goto(param)
same => n(add),Set(DS=${DS}${IF($["${DS}" = ""]?:&)}PJSIP/${ARG1}/${FILTER(0-9a-zA-Z.:@\;=_+%[]\x2d,${D})})
same => n,Goto(next)
same => n(done),Return()
; ─── Call a user with all their personal rules.
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
[sp-sub-user]
@@ -87,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -105,7 +148,7 @@ exten => *79,1,Answer() ; DND off
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
exten => _*72X!,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
@@ -116,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
@@ -131,7 +174,7 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
@@ -149,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
@@ -165,7 +208,7 @@ exten => _*3[123]X,1,Answer()
same => n,Hangup()
[sp-vm-direct]
exten => _X.,1,Answer()
exten => _X!,1,Answer()
same => n,VoiceMail(${EXTEN}@default,u)
same => n,Hangup()
@@ -175,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
same => n,Goto(sp-outbound-routes,${EXTEN},1)
[sp-rg-member] ; one member of a ring group (Local channel)
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n(skip),Hangup()
[sp-queue-member] ; one queue agent (Local channel)
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n,Hangup()
[sp-from-ext]
@@ -1,4 +1,6 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
stunaddr = stun.example.org:3478
@@ -10,16 +10,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).
[sp-sub-dial-user]
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
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,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
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
; in its Contact from reaching Dial's options (',') or another device ('&').
[sp-sub-contacts]
exten => s,1,Set(LOCAL(CL)=${PJSIP_AOR(${ARG1},contact)})
same => n(next),Set(LOCAL(C)=${POP(CL)})
same => n,GotoIf($["${C}" = ""]?done)
same => n,Set(LOCAL(ST)=${PJSIP_CONTACT(${C},status)})
same => n,GotoIf($["${ST}" = "Unavailable" | "${ST}" = "Unreachable"]?next)
same => n,Set(LOCAL(U)=${PJSIP_CONTACT(${C},uri)})
same => n,Set(LOCAL(D)=${CUT(U,\;,1)})
same => n,Set(LOCAL(I)=1)
same => n(param),Set(I=$[${I} + 1])
same => n,GotoIf($[${I} > ${FIELDQTY(U,\;)}]?add)
same => n,Set(LOCAL(P)=${CUT(U,\;,${I})})
same => n,GotoIf($["${P:0:3}" = "pn-"]?param)
same => n,Set(D=${D}\;${P})
same => n,Goto(param)
same => n(add),Set(DS=${DS}${IF($["${DS}" = ""]?:&)}PJSIP/${ARG1}/${FILTER(0-9a-zA-Z.:@\;=_+%[]\x2d,${D})})
same => n,Goto(next)
same => n(done),Return()
; ─── Call a user with all their personal rules.
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
[sp-sub-user]
@@ -87,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -105,7 +148,7 @@ exten => *79,1,Answer() ; DND off
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
exten => _*72X!,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
@@ -116,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
@@ -131,7 +174,7 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
@@ -149,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
@@ -165,7 +208,7 @@ exten => _*3[123]X,1,Answer()
same => n,Hangup()
[sp-vm-direct]
exten => _X.,1,Answer()
exten => _X!,1,Answer()
same => n,VoiceMail(${EXTEN}@default,u)
same => n,Hangup()
@@ -175,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
same => n,Goto(sp-outbound-routes,${EXTEN},1)
[sp-rg-member] ; one member of a ring group (Local channel)
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n(skip),Hangup()
[sp-queue-member] ; one queue agent (Local channel)
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n,Hangup()
[sp-from-ext]
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -10,16 +10,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).
[sp-sub-dial-user]
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
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,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
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
; in its Contact from reaching Dial's options (',') or another device ('&').
[sp-sub-contacts]
exten => s,1,Set(LOCAL(CL)=${PJSIP_AOR(${ARG1},contact)})
same => n(next),Set(LOCAL(C)=${POP(CL)})
same => n,GotoIf($["${C}" = ""]?done)
same => n,Set(LOCAL(ST)=${PJSIP_CONTACT(${C},status)})
same => n,GotoIf($["${ST}" = "Unavailable" | "${ST}" = "Unreachable"]?next)
same => n,Set(LOCAL(U)=${PJSIP_CONTACT(${C},uri)})
same => n,Set(LOCAL(D)=${CUT(U,\;,1)})
same => n,Set(LOCAL(I)=1)
same => n(param),Set(I=$[${I} + 1])
same => n,GotoIf($[${I} > ${FIELDQTY(U,\;)}]?add)
same => n,Set(LOCAL(P)=${CUT(U,\;,${I})})
same => n,GotoIf($["${P:0:3}" = "pn-"]?param)
same => n,Set(D=${D}\;${P})
same => n,Goto(param)
same => n(add),Set(DS=${DS}${IF($["${DS}" = ""]?:&)}PJSIP/${ARG1}/${FILTER(0-9a-zA-Z.:@\;=_+%[]\x2d,${D})})
same => n,Goto(next)
same => n(done),Return()
; ─── Call a user with all their personal rules.
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
[sp-sub-user]
@@ -87,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -105,7 +148,7 @@ exten => *79,1,Answer() ; DND off
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
exten => _*72X!,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
@@ -116,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
@@ -131,7 +174,7 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
@@ -149,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
@@ -165,7 +208,7 @@ exten => _*3[123]X,1,Answer()
same => n,Hangup()
[sp-vm-direct]
exten => _X.,1,Answer()
exten => _X!,1,Answer()
same => n,VoiceMail(${EXTEN}@default,u)
same => n,Hangup()
@@ -175,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
same => n,Goto(sp-outbound-routes,${EXTEN},1)
[sp-rg-member] ; one member of a ring group (Local channel)
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n(skip),Hangup()
[sp-queue-member] ; one queue agent (Local channel)
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n,Hangup()
[sp-from-ext]
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -10,16 +10,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).
[sp-sub-dial-user]
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
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,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
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
; in its Contact from reaching Dial's options (',') or another device ('&').
[sp-sub-contacts]
exten => s,1,Set(LOCAL(CL)=${PJSIP_AOR(${ARG1},contact)})
same => n(next),Set(LOCAL(C)=${POP(CL)})
same => n,GotoIf($["${C}" = ""]?done)
same => n,Set(LOCAL(ST)=${PJSIP_CONTACT(${C},status)})
same => n,GotoIf($["${ST}" = "Unavailable" | "${ST}" = "Unreachable"]?next)
same => n,Set(LOCAL(U)=${PJSIP_CONTACT(${C},uri)})
same => n,Set(LOCAL(D)=${CUT(U,\;,1)})
same => n,Set(LOCAL(I)=1)
same => n(param),Set(I=$[${I} + 1])
same => n,GotoIf($[${I} > ${FIELDQTY(U,\;)}]?add)
same => n,Set(LOCAL(P)=${CUT(U,\;,${I})})
same => n,GotoIf($["${P:0:3}" = "pn-"]?param)
same => n,Set(D=${D}\;${P})
same => n,Goto(param)
same => n(add),Set(DS=${DS}${IF($["${DS}" = ""]?:&)}PJSIP/${ARG1}/${FILTER(0-9a-zA-Z.:@\;=_+%[]\x2d,${D})})
same => n,Goto(next)
same => n(done),Return()
; ─── Call a user with all their personal rules.
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
[sp-sub-user]
@@ -87,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -105,7 +148,7 @@ exten => *79,1,Answer() ; DND off
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
exten => _*72X!,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
@@ -116,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
@@ -131,7 +174,7 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
@@ -149,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
@@ -165,7 +208,7 @@ exten => _*3[123]X,1,Answer()
same => n,Hangup()
[sp-vm-direct]
exten => _X.,1,Answer()
exten => _X!,1,Answer()
same => n,VoiceMail(${EXTEN}@default,u)
same => n,Hangup()
@@ -175,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
same => n,Goto(sp-outbound-routes,${EXTEN},1)
[sp-rg-member] ; one member of a ring group (Local channel)
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n(skip),Hangup()
[sp-queue-member] ; one queue agent (Local channel)
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n,Hangup()
[sp-from-ext]
@@ -1,10 +1,10 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[after-hours]
mode = files
directory = /var/lib/asterisk/moh/after-hours
mode = playlist
entry = /var/lib/asterisk/moh/after-hours/5a1f0c3e-0000-4000-8000-000000000003
sort = random
[support-jazz]
mode = files
directory = /var/lib/asterisk/moh/support-jazz
sort = random
mode = playlist
entry = /var/lib/asterisk/moh/support-jazz/5a1f0c3e-0000-4000-8000-000000000002
entry = /var/lib/asterisk/moh/support-jazz/5a1f0c3e-0000-4000-8000-000000000001
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -1,5 +1,11 @@
{
"mohClasses": ["support-jazz", "after-hours"],
"mohClasses": [
{
"name": "support-jazz",
"files": ["5a1f0c3e-0000-4000-8000-000000000002", "5a1f0c3e-0000-4000-8000-000000000001"]
},
{ "name": "after-hours", "shuffle": true, "files": ["5a1f0c3e-0000-4000-8000-000000000003"] }
],
"extensions": [
{
"id": "00000000-0000-4000-8000-000000000101",
@@ -10,16 +10,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).
[sp-sub-dial-user]
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
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,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
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
; in its Contact from reaching Dial's options (',') or another device ('&').
[sp-sub-contacts]
exten => s,1,Set(LOCAL(CL)=${PJSIP_AOR(${ARG1},contact)})
same => n(next),Set(LOCAL(C)=${POP(CL)})
same => n,GotoIf($["${C}" = ""]?done)
same => n,Set(LOCAL(ST)=${PJSIP_CONTACT(${C},status)})
same => n,GotoIf($["${ST}" = "Unavailable" | "${ST}" = "Unreachable"]?next)
same => n,Set(LOCAL(U)=${PJSIP_CONTACT(${C},uri)})
same => n,Set(LOCAL(D)=${CUT(U,\;,1)})
same => n,Set(LOCAL(I)=1)
same => n(param),Set(I=$[${I} + 1])
same => n,GotoIf($[${I} > ${FIELDQTY(U,\;)}]?add)
same => n,Set(LOCAL(P)=${CUT(U,\;,${I})})
same => n,GotoIf($["${P:0:3}" = "pn-"]?param)
same => n,Set(D=${D}\;${P})
same => n,Goto(param)
same => n(add),Set(DS=${DS}${IF($["${DS}" = ""]?:&)}PJSIP/${ARG1}/${FILTER(0-9a-zA-Z.:@\;=_+%[]\x2d,${D})})
same => n,Goto(next)
same => n(done),Return()
; ─── Call a user with all their personal rules.
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
[sp-sub-user]
@@ -87,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -105,7 +148,7 @@ exten => *79,1,Answer() ; DND off
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
exten => _*72X!,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
@@ -116,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
@@ -131,7 +174,7 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
@@ -149,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
@@ -165,7 +208,7 @@ exten => _*3[123]X,1,Answer()
same => n,Hangup()
[sp-vm-direct]
exten => _X.,1,Answer()
exten => _X!,1,Answer()
same => n,VoiceMail(${EXTEN}@default,u)
same => n,Hangup()
@@ -175,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
same => n,Goto(sp-outbound-routes,${EXTEN},1)
[sp-rg-member] ; one member of a ring group (Local channel)
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n(skip),Hangup()
[sp-queue-member] ; one queue agent (Local channel)
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n,Hangup()
[sp-from-ext]
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -0,0 +1,6 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[sp_admin_user]
type = user
admin = yes
marked = yes
music_on_hold_when_empty = yes
@@ -0,0 +1,6 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_CLASS_1=2
SP_CLASS_12=2
@@ -0,0 +1,321 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
; ─── Normalize a number. ARG1 = raw. Returns normalized in GOSUB_RETVAL. (matches 00 §8)
[sp-sub-normalize]
exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
same => n,ExecIf($["${N:0:2}" = "+1"]?Set(N=${N:1}))
same => n,ExecIf($["${N:0:1}" = "+"]?Set(N=011${N:1}))
same => n,ExecIf($[${LEN(${N})} = 10]?Set(N=1${N}))
same => n,Return(${N})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
[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,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
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
; in its Contact from reaching Dial's options (',') or another device ('&').
[sp-sub-contacts]
exten => s,1,Set(LOCAL(CL)=${PJSIP_AOR(${ARG1},contact)})
same => n(next),Set(LOCAL(C)=${POP(CL)})
same => n,GotoIf($["${C}" = ""]?done)
same => n,Set(LOCAL(ST)=${PJSIP_CONTACT(${C},status)})
same => n,GotoIf($["${ST}" = "Unavailable" | "${ST}" = "Unreachable"]?next)
same => n,Set(LOCAL(U)=${PJSIP_CONTACT(${C},uri)})
same => n,Set(LOCAL(D)=${CUT(U,\;,1)})
same => n,Set(LOCAL(I)=1)
same => n(param),Set(I=$[${I} + 1])
same => n,GotoIf($[${I} > ${FIELDQTY(U,\;)}]?add)
same => n,Set(LOCAL(P)=${CUT(U,\;,${I})})
same => n,GotoIf($["${P:0:3}" = "pn-"]?param)
same => n,Set(D=${D}\;${P})
same => n,Goto(param)
same => n(add),Set(DS=${DS}${IF($["${DS}" = ""]?:&)}PJSIP/${ARG1}/${FILTER(0-9a-zA-Z.:@\;=_+%[]\x2d,${D})})
same => n,Goto(next)
same => n(done),Return()
; ─── Call a user with all their personal rules.
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
[sp-sub-user]
exten => s,1,Set(__SP_TARGET=${ARG1})
same => n,ExecIf($["${SP_DIRECTION}" = "inbound" & ("${ARG4}" = "inbound" | "${ARG4}" = "all")]?Gosub(sp-sub-record,s,1))
same => n,GotoIf($["${DB(SP/DND/${ARG1})}" = "1"]?busy)
same => n,Set(FWD=${DB(SP/CFU/${ARG1})})
same => n,GotoIf($["${FWD}" != ""]?forward)
same => n,Gosub(sp-sub-dial-user,s,1(${ARG1},${ARG2}))
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy)
same => n(noanswer),Set(FWD=${DB(SP/CFNA/${ARG1})})
same => n,GotoIf($["${FWD}" != ""]?forward)
same => n,GotoIf($["${ARG3}" = "1"]?vmu)
same => n,Return()
same => n(vmu),VoiceMail(${ARG1}@default,u)
same => n,Return()
same => n(busy),Set(FWD=${DB(SP/CFB/${ARG1})})
same => n,GotoIf($["${FWD}" != ""]?forward)
same => n,GotoIf($["${ARG3}" = "1"]?vmb)
same => n,Busy(5)
same => n,Return()
same => n(vmb),VoiceMail(${ARG1}@default,b)
same => n,Return()
same => n(forward),Gosub(sp-sub-forward,s,1(${FWD},${ARG1}))
same => n(done),Return()
; ─── Forward a call. ARG1 = target number, ARG2 = ext whose forward rule this is.
; Stops after 3 hops so 101→102→101 can't loop forever.
[sp-sub-forward]
exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} + 1])
same => n,GotoIf($[${SP_FWDHOPS} > 3]?stop)
same => n,GotoIf($[${DIALPLAN_EXISTS(sp-local-numbers,${ARG1},1)}]?internal)
same => n,Set(SP_CLASS=${GLOBAL(SP_CLASS_${ARG2})})
same => n,Set(SP_LIMITEXT=${ARG2}) ; counts toward that extension's call limit
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${ARG1},1)
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
same => n(stop),Return()
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
[sp-sub-record]
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
same => n,MixMonitor(${SP_REC},b)
same => n(done),Return()
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
[sp-sub-autoanswer]
exten => s,1,Set(PJSIP_HEADER(add,Call-Info)=<sip:${SP_EXT}>\;answer-after=0)
same => n,Set(PJSIP_HEADER(add,Alert-Info)=info=alert-autoanswer)
same => n,Return()
; ─── 911 alert ring-out. Originated by the api (04 §5.3): Channel PJSIP/<notify ext>,
; Exten = the extension that dialed 911.
[sp-emergency-alert]
exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,Wait(1)
same => n,Playback(beep&beep&beep)
same => n,Playback(extension)
same => n,SayDigits(${EXTEN})
same => n,Playback(beep&beep&beep)
same => n,Playback(extension)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
same => n,Playback(do-not-disturb&activated)
same => n,Hangup()
exten => *79,1,Answer() ; DND off
same => n,Set(X=${DB_DELETE(SP/DND/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X!,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
same => n,Playback(call-fwd-unconditional&activated)
same => n,Hangup()
exten => *73,1,Answer() ; cancel forward always
same => n,Set(X=${DB_DELETE(SP/CFU/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
exten => *8,1,Pickup()
same => n,Hangup()
exten => *43,1,Answer() ; echo test
same => n,Playback(demo-echotest)
same => n,Echo()
same => n,Playback(demo-echodone)
same => n,Hangup()
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${PQMSTATUS}" != "PAUSED"]?deny) ; not an agent of that queue
same => n,Set(DB(${K})=1)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 1)
same => n,Playback(agent-loggedoff)
same => n,Hangup()
same => n(login),Set(X=${DB_DELETE(${K})})
same => n,UnpauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${UPQMSTATUS}" != "UNPAUSED"]?deny)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 0)
same => n,Playback(agent-loginok)
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
same => n,Hangup()
same => n(deny),Answer()
same => n,Playback(ss-noservice)
same => n,Hangup()
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
same => n,Goto(sp-tc-override,${EXTEN},1)
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
[sp-vm-direct]
exten => _X!,1,Answer()
same => n,VoiceMail(${EXTEN}@default,u)
same => n,Hangup()
[sp-ext-forward]
exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external destination
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${EXTEN},1)
[sp-rg-member] ; one member of a ring group (Local channel)
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n(skip),Hangup()
[sp-queue-member] ; one queue agent (Local channel)
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n,Hangup()
[sp-from-ext]
include => sp-emergency-dial
include => sp-featurecodes
include => sp-local-numbers
include => sp-outbound-routes
[sp-local-numbers]
include => sp-parkedcalls
; users (sorted by number)
exten => 1,1,Gosub(sp-sub-user,s,1(1,25,1,never))
same => n,Hangup()
exten => 1,hint,PJSIP/1&PJSIP/1-web
exten => 12,1,Gosub(sp-sub-user,s,1(12,25,1,never))
same => n,Hangup()
exten => 12,hint,PJSIP/12&PJSIP/12-web
; ring groups
exten => 5,1,Goto(sp-rg-22222222-2222-4222-8222-222222222005,s,1)
; queues
exten => 6,1,Goto(sp-queue-44444444-4444-4444-8444-444444444006,s,1)
; digital receptionists
exten => 0,1,Goto(sp-ivr-33333333-3333-4333-8333-333333333000,s,1)
; ─── Ring group 5 "Sales" (ringall)
[sp-rg-22222222-2222-4222-8222-222222222005]
exten => s,1,Set(__SP_TARGET=5)
same => n,Dial(Local/1@sp-rg-member&Local/12@sp-rg-member,25,t)
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
same => n,Goto(sp-vm-direct,1,1)
same => n(done),Hangup()
; ─── Queue 6 "Support"
[sp-queue-44444444-4444-4444-8444-444444444006]
exten => s,1,Set(__SP_TARGET=6)
same => n,Answer()
same => n,Queue(6,t,,,300)
same => n,GotoIf($["${QUEUESTATUS}" = "TIMEOUT" | "${QUEUESTATUS}" = "FULL" | "${QUEUESTATUS}" = "JOINEMPTY" | "${QUEUESTATUS}" = "LEAVEEMPTY" | "${QUEUESTATUS}" = "JOINUNAVAIL" | "${QUEUESTATUS}" = "LEAVEUNAVAIL"]?fail)
same => n,Hangup()
same => n(fail),UserEvent(SpQueueExit,Queue: 6,Status: ${QUEUESTATUS})
same => n,Hangup()
; ─── Digital receptionist 0 "Main menu"
[sp-ivr-33333333-3333-4333-8333-333333333000]
include => sp-ivr-direct
exten => s,1,Answer()
same => n,Set(SP_IVR_TRIES=0)
same => n(menu),Set(TIMEOUT(digit)=3)
same => n,Background(custom/44444444-4444-4444-8444-444444444444)
same => n,WaitExten(5)
exten => 2,1,Goto(sp-rg-22222222-2222-4222-8222-222222222005,s,1)
exten => i,1,Set(SP_IVR_TRIES=$[${SP_IVR_TRIES} + 1])
same => n,GotoIf($[${SP_IVR_TRIES} >= 3]?giveup)
same => n,Playback(pbx-invalid)
same => n,Goto(s,menu)
same => n(giveup),Hangup()
exten => t,1,Set(SP_IVR_TRIES=$[${SP_IVR_TRIES} + 1])
same => n,GotoIf($[${SP_IVR_TRIES} >= 3]?giveup)
same => n,Goto(s,menu)
same => n(giveup),Hangup()
[sp-ivr-direct]
exten => 0,1,Goto(sp-local-numbers,0,1)
exten => 1,1,Goto(sp-local-numbers,1,1)
exten => 5,1,Goto(sp-local-numbers,5,1)
exten => 6,1,Goto(sp-local-numbers,6,1)
exten => 12,1,Goto(sp-local-numbers,12,1)
; ─── Office hours overrides (admins only, see sp-featurecodes)
[sp-tc-override]
exten => _*3[123]X,1,Playback(ss-noservice)
same => n,Hangup()
[sp-inbound]
; anything else
exten => _[0-9a-z].,1,Hangup()
[sp-outbound-routes]
exten => pending,1,Hangup() ; no routes yet
[sp-emergency-dial]
exten => 911,1,Goto(sp-emergency,s,1)
exten => 9911,1,Goto(sp-emergency,s,1) ; people who dial 9 first out of habit
[sp-emergency]
exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E911_CID})})
same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
same => n,Set(__SP_DIRECTION=outbound)
same => n,Set(CDR(SP_DIRECTION)=outbound)
same => n,Hangup()
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1,173 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[global]
type = global
user_agent = SelfPhoneNG
endpoint_identifier_order = ip,username,anonymous
keep_alive_interval = 90
max_initial_qualify_time = 4
[system]
type = system
timer_t1 = 500
timer_b = 32000
[transport-udp]
type = transport
protocol = udp
bind = 0.0.0.0:5060
[transport-tcp]
type = transport
protocol = tcp
bind = 0.0.0.0:5060
[transport-ws]
type = transport
protocol = ws
bind = 0.0.0.0
; ── extension 1 (Dan Doe) ──
[1]
type = endpoint
context = sp-from-ext
disallow = all
allow = g722,ulaw,alaw
auth = 1-auth
aors = 1
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_pai = yes
callerid = "Dan Doe" <1>
mailboxes = 1@default
mwi_subscribe_replaces_unsolicited = yes
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=1
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[1-auth]
type = auth
auth_type = userpass
username = 1
password = Ab3dEf9hIj2kLm4nOp6qRs8t
[1]
type = aor
max_contacts = 5
remove_existing = yes
qualify_frequency = 30
mailboxes = 1@default
[1-web]
type = endpoint
context = sp-from-ext
webrtc = yes
disallow = all
allow = g722,ulaw
auth = 1-web-auth
aors = 1-web
from_domain = selfphoneng.invalid
callerid = "Dan Doe" <1>
mailboxes = 1@default
mwi_subscribe_replaces_unsolicited = yes
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=1
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[1-web-auth]
type = auth
auth_type = userpass
username = 1-web
password = Ab3dEf9hIj2kLm4nOp6qRs8t
[1-web]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 0
; ── extension 12 (Sue Smith) ──
[12]
type = endpoint
context = sp-from-ext
disallow = all
allow = g722,ulaw,alaw
auth = 12-auth
aors = 12
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_pai = yes
callerid = "Sue Smith" <12>
mailboxes = 12@default
mwi_subscribe_replaces_unsolicited = yes
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=12
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[12-auth]
type = auth
auth_type = userpass
username = 12
password = Bb3dEf9hIj2kLm4nOp6qRs8t
[12]
type = aor
max_contacts = 5
remove_existing = yes
qualify_frequency = 30
mailboxes = 12@default
[12-web]
type = endpoint
context = sp-from-ext
webrtc = yes
disallow = all
allow = g722,ulaw
auth = 12-web-auth
aors = 12-web
from_domain = selfphoneng.invalid
callerid = "Sue Smith" <12>
mailboxes = 12@default
mwi_subscribe_replaces_unsolicited = yes
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=12
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[12-web-auth]
type = auth
auth_type = userpass
username = 12-web
password = Bb3dEf9hIj2kLm4nOp6qRs8t
[12-web]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 0
@@ -0,0 +1,16 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
; ─── Queue 6 "Support"
[6]
strategy = rrmemory
timeout = 15
retry = 5
wrapuptime = 10
maxlen = 0
musicclass = default
announce-frequency = 60
announce-position = yes
ringinuse = no
setinterfacevar = yes
joinempty = paused,unavailable,invalid,unknown
leavewhenempty = paused,unavailable,invalid,unknown
member => Local/1@sp-queue-member/n,0,Dan Doe,hint:1@sp-local-numbers
@@ -0,0 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -0,0 +1,28 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[general]
format = wav
attach = no ; the api sends emails itself
maxmsg = 100
maxsecs = 300
minsecs = 1
maxsilence = 10
silencethreshold = 128
maxlogins = 3
minpassword = 4
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
; stores them from VoicemailPasswordChange (services/vmpin.ts).
externpasscheck = /usr/local/bin/sp-vm-pincheck
envelope = yes
saycid = yes
review = no
nextaftercmd = yes
sendvoicemail = no
pollmailboxes = yes
pollfreq = 30
[zonemessages]
local = America/Chicago|'vm-received' Q 'digits/at' IMp
[default]
1 => 4826,Dan Doe,,,tz=local|attach=no
12 => 4826,Sue Smith,,,tz=local|attach=no
@@ -0,0 +1,62 @@
{
"extensions": [
{
"id": "00000000-0000-4000-8000-000000000001",
"number": "1",
"name": "Dan Doe",
"voicemailEnabled": true,
"sipPassword": "Ab3dEf9hIj2kLm4nOp6qRs8t"
},
{
"id": "00000000-0000-4000-8000-000000000012",
"number": "12",
"name": "Sue Smith",
"sipPassword": "Bb3dEf9hIj2kLm4nOp6qRs8t"
}
],
"ringGroups": [
{
"id": "22222222-2222-4222-8222-222222222005",
"number": "5",
"name": "Sales",
"members": ["1", "12"],
"noAnswerDestination": { "type": "voicemail", "id": "00000000-0000-4000-8000-000000000001" }
}
],
"queues": [
{
"id": "44444444-4444-4444-8444-444444444006",
"number": "6",
"name": "Support",
"members": [{ "number": "1", "name": "Dan Doe", "penalty": 0 }]
}
],
"ivrs": [
{
"id": "33333333-3333-4333-8333-333333333000",
"number": "0",
"name": "Main menu",
"greeting": "custom/44444444-4444-4444-8444-444444444444",
"allowDirectDial": true,
"options": [
{
"digit": "2",
"destination": { "type": "ring_group", "id": "22222222-2222-4222-8222-222222222005" }
}
]
}
],
"asteriskMatches": [
["1", "sp-local-numbers"],
["5", "sp-local-numbers"],
["0", "sp-local-numbers"],
["1", "sp-rg-member"],
["1", "sp-queue-member"],
["1", "sp-vm-direct"],
["1", "sp-ivr-direct"],
["**1", "sp-featurecodes"],
["*456", "sp-featurecodes"],
["*721", "sp-featurecodes"],
["*801", "sp-featurecodes"]
]
}
@@ -10,16 +10,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).
[sp-sub-dial-user]
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
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,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
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
; in its Contact from reaching Dial's options (',') or another device ('&').
[sp-sub-contacts]
exten => s,1,Set(LOCAL(CL)=${PJSIP_AOR(${ARG1},contact)})
same => n(next),Set(LOCAL(C)=${POP(CL)})
same => n,GotoIf($["${C}" = ""]?done)
same => n,Set(LOCAL(ST)=${PJSIP_CONTACT(${C},status)})
same => n,GotoIf($["${ST}" = "Unavailable" | "${ST}" = "Unreachable"]?next)
same => n,Set(LOCAL(U)=${PJSIP_CONTACT(${C},uri)})
same => n,Set(LOCAL(D)=${CUT(U,\;,1)})
same => n,Set(LOCAL(I)=1)
same => n(param),Set(I=$[${I} + 1])
same => n,GotoIf($[${I} > ${FIELDQTY(U,\;)}]?add)
same => n,Set(LOCAL(P)=${CUT(U,\;,${I})})
same => n,GotoIf($["${P:0:3}" = "pn-"]?param)
same => n,Set(D=${D}\;${P})
same => n,Goto(param)
same => n(add),Set(DS=${DS}${IF($["${DS}" = ""]?:&)}PJSIP/${ARG1}/${FILTER(0-9a-zA-Z.:@\;=_+%[]\x2d,${D})})
same => n,Goto(next)
same => n(done),Return()
; ─── Call a user with all their personal rules.
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
[sp-sub-user]
@@ -87,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -105,7 +148,7 @@ exten => *79,1,Answer() ; DND off
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
exten => _*72X!,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
@@ -116,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
@@ -131,7 +174,7 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
@@ -149,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
@@ -165,7 +208,7 @@ exten => _*3[123]X,1,Answer()
same => n,Hangup()
[sp-vm-direct]
exten => _X.,1,Answer()
exten => _X!,1,Answer()
same => n,VoiceMail(${EXTEN}@default,u)
same => n,Hangup()
@@ -175,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
same => n,Goto(sp-outbound-routes,${EXTEN},1)
[sp-rg-member] ; one member of a ring group (Local channel)
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n(skip),Hangup()
[sp-queue-member] ; one queue agent (Local channel)
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n,Hangup()
[sp-from-ext]
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -10,16 +10,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).
[sp-sub-dial-user]
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
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,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
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
; in its Contact from reaching Dial's options (',') or another device ('&').
[sp-sub-contacts]
exten => s,1,Set(LOCAL(CL)=${PJSIP_AOR(${ARG1},contact)})
same => n(next),Set(LOCAL(C)=${POP(CL)})
same => n,GotoIf($["${C}" = ""]?done)
same => n,Set(LOCAL(ST)=${PJSIP_CONTACT(${C},status)})
same => n,GotoIf($["${ST}" = "Unavailable" | "${ST}" = "Unreachable"]?next)
same => n,Set(LOCAL(U)=${PJSIP_CONTACT(${C},uri)})
same => n,Set(LOCAL(D)=${CUT(U,\;,1)})
same => n,Set(LOCAL(I)=1)
same => n(param),Set(I=$[${I} + 1])
same => n,GotoIf($[${I} > ${FIELDQTY(U,\;)}]?add)
same => n,Set(LOCAL(P)=${CUT(U,\;,${I})})
same => n,GotoIf($["${P:0:3}" = "pn-"]?param)
same => n,Set(D=${D}\;${P})
same => n,Goto(param)
same => n(add),Set(DS=${DS}${IF($["${DS}" = ""]?:&)}PJSIP/${ARG1}/${FILTER(0-9a-zA-Z.:@\;=_+%[]\x2d,${D})})
same => n,Goto(next)
same => n(done),Return()
; ─── Call a user with all their personal rules.
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
[sp-sub-user]
@@ -87,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -105,7 +148,7 @@ exten => *79,1,Answer() ; DND off
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
exten => _*72X!,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
@@ -116,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
@@ -131,7 +174,7 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
@@ -149,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
@@ -165,7 +208,7 @@ exten => _*3[123]X,1,Answer()
same => n,Hangup()
[sp-vm-direct]
exten => _X.,1,Answer()
exten => _X!,1,Answer()
same => n,VoiceMail(${EXTEN}@default,u)
same => n,Hangup()
@@ -175,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
same => n,Goto(sp-outbound-routes,${EXTEN},1)
[sp-rg-member] ; one member of a ring group (Local channel)
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n(skip),Hangup()
[sp-queue-member] ; one queue agent (Local channel)
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n,Hangup()
[sp-from-ext]
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
@@ -10,16 +10,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).
[sp-sub-dial-user]
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
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,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
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
; in its Contact from reaching Dial's options (',') or another device ('&').
[sp-sub-contacts]
exten => s,1,Set(LOCAL(CL)=${PJSIP_AOR(${ARG1},contact)})
same => n(next),Set(LOCAL(C)=${POP(CL)})
same => n,GotoIf($["${C}" = ""]?done)
same => n,Set(LOCAL(ST)=${PJSIP_CONTACT(${C},status)})
same => n,GotoIf($["${ST}" = "Unavailable" | "${ST}" = "Unreachable"]?next)
same => n,Set(LOCAL(U)=${PJSIP_CONTACT(${C},uri)})
same => n,Set(LOCAL(D)=${CUT(U,\;,1)})
same => n,Set(LOCAL(I)=1)
same => n(param),Set(I=$[${I} + 1])
same => n,GotoIf($[${I} > ${FIELDQTY(U,\;)}]?add)
same => n,Set(LOCAL(P)=${CUT(U,\;,${I})})
same => n,GotoIf($["${P:0:3}" = "pn-"]?param)
same => n,Set(D=${D}\;${P})
same => n,Goto(param)
same => n(add),Set(DS=${DS}${IF($["${DS}" = ""]?:&)}PJSIP/${ARG1}/${FILTER(0-9a-zA-Z.:@\;=_+%[]\x2d,${D})})
same => n,Goto(next)
same => n(done),Return()
; ─── Call a user with all their personal rules.
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
[sp-sub-user]
@@ -87,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
same => n,GotoIf($[${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
@@ -105,7 +148,7 @@ exten => *79,1,Answer() ; DND off
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
exten => _*72X!,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
@@ -116,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
@@ -131,7 +174,7 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
@@ -149,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
@@ -165,7 +208,7 @@ exten => _*3[123]X,1,Answer()
same => n,Hangup()
[sp-vm-direct]
exten => _X.,1,Answer()
exten => _X!,1,Answer()
same => n,VoiceMail(${EXTEN}@default,u)
same => n,Hangup()
@@ -175,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
same => n,Goto(sp-outbound-routes,${EXTEN},1)
[sp-rg-member] ; one member of a ring group (Local channel)
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n(skip),Hangup()
[sp-queue-member] ; one queue agent (Local channel)
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n,Hangup()
[sp-from-ext]
@@ -1,2 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
rtpstart = 10000
rtpend = 20000
icesupport = yes
+54 -11
View File
@@ -13,16 +13,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).
[sp-sub-dial-user]
exten => s,1,Set(DS=\${PJSIP_DIAL_CONTACTS(\${ARG1})})
same => n,Set(WS=\${PJSIP_DIAL_CONTACTS(\${ARG1}-web)})
same => n,ExecIf($["\${DS}" != "" & "\${WS}" != ""]?Set(DS=\${DS}&\${WS}))
same => n,ExecIf($["\${DS}" = ""]?Set(DS=\${WS}))
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,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
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
; in its Contact from reaching Dial's options (',') or another device ('&').
[sp-sub-contacts]
exten => s,1,Set(LOCAL(CL)=\${PJSIP_AOR(\${ARG1},contact)})
same => n(next),Set(LOCAL(C)=\${POP(CL)})
same => n,GotoIf($["\${C}" = ""]?done)
same => n,Set(LOCAL(ST)=\${PJSIP_CONTACT(\${C},status)})
same => n,GotoIf($["\${ST}" = "Unavailable" | "\${ST}" = "Unreachable"]?next)
same => n,Set(LOCAL(U)=\${PJSIP_CONTACT(\${C},uri)})
same => n,Set(LOCAL(D)=\${CUT(U,\\;,1)})
same => n,Set(LOCAL(I)=1)
same => n(param),Set(I=$[\${I} + 1])
same => n,GotoIf($[\${I} > \${FIELDQTY(U,\\;)}]?add)
same => n,Set(LOCAL(P)=\${CUT(U,\\;,\${I})})
same => n,GotoIf($["\${P:0:3}" = "pn-"]?param)
same => n,Set(D=\${D}\\;\${P})
same => n,Goto(param)
same => n(add),Set(DS=\${DS}\${IF($["\${DS}" = ""]?:&)}PJSIP/\${ARG1}/\${FILTER(0-9a-zA-Z.:@\\;=_+%[]\\x2d,\${D})})
same => n,Goto(next)
same => n(done),Return()
; ─── Call a user with all their personal rules.
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
[sp-sub-user]
@@ -90,14 +112,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,SayDigits(\${EXTEN})
same => n,Hangup()
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
[sp-sub-vmlimit]
exten => s,1,Set(GROUP(spvm)=\${SP_EXT})
same => n,GotoIf($[\${GROUP_COUNT(\${SP_EXT}@spvm)} > 1]?no)
same => n,Set(LOCAL(H)=\${STRFTIME(\${EPOCH},UTC,%Y%m%d%H)})
same => n,Set(LOCAL(V)=\${DB(SP/VMLOGIN/\${SP_EXT})})
same => n,Set(LOCAL(N)=\${IF($["\${CUT(V,:,1)}" = "\${H}"]?\${CUT(V,:,2)}:0)})
same => n,GotoIf($[\${N} >= 10]?no)
same => n,Set(DB(SP/VMLOGIN/\${SP_EXT})=\${H}:$[\${N} + 1])
same => n,Return(ok)
same => n(no),Return()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["\${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(\${SP_EXT}@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,Gosub(sp-sub-vmlimit,s,1)
same => n,GotoIf($["\${GOSUB_RETVAL}" != "ok"]?full)
same => n,VoiceMailMain(@default)
same => n,Hangup()
same => n(full),Playback(an-error-has-occurred)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/\${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: \${SP_EXT},Value: 1)
@@ -108,7 +151,7 @@ exten => *79,1,Answer() ; DND off
same => n,UserEvent(SpToggle,Key: DND,Ext: \${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
exten => _*72X!,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(\${EXTEN:3}))
same => n,Set(DB(SP/CFU/\${SP_EXT})=\${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: \${SP_EXT},Value: \${GOSUB_RETVAL})
@@ -119,7 +162,7 @@ exten => *73,1,Answer() ; cancel forward alw
same => n,UserEvent(SpToggle,Key: CFU,Ext: \${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,Set(T=\${FILTER(0-9,\${EXTEN:2})}) ; digits only: "&" or "," would add arguments
exten => _**X!,1,Set(T=\${FILTER(0-9,\${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/\${T}-&PJSIP/\${T}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
@@ -134,7 +177,7 @@ exten => *43,1,Answer() ; echo test
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(\${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=\${FILTER(0-9,\${EXTEN:3})})
same => n,Set(K=SP/QPAUSE/\${Q}/\${SP_EXT})
same => n,GotoIf($["\${DB(\${K})}" = "1"]?login)
@@ -152,7 +195,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=\${FILTER(0-9,\${EXTEN:3})}) ; *80<ext> intercom, both ways
exten => _*80X!,1,Set(T=\${FILTER(0-9,\${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["\${T}" != "\${EXTEN:3}"]?deny)
same => n,GotoIf($["\${PJSIP_ENDPOINT(\${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/\${T},db(sp-sub-autoanswer^s^1),30)
@@ -168,7 +211,7 @@ exten => _*3[123]X,1,Answer()
same => n,Hangup()
[sp-vm-direct]
exten => _X.,1,Answer()
exten => _X!,1,Answer()
same => n,VoiceMail(\${EXTEN}@default,u)
same => n,Hangup()
@@ -178,12 +221,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
same => n,Goto(sp-outbound-routes,\${EXTEN},1)
[sp-rg-member] ; one member of a ring group (Local channel)
exten => _X.,1,GotoIf($["\${DB(SP/DND/\${EXTEN})}" = "1"]?skip)
exten => _X!,1,GotoIf($["\${DB(SP/DND/\${EXTEN})}" = "1"]?skip)
same => n,Gosub(sp-sub-dial-user,s,1(\${EXTEN},600))
same => n(skip),Hangup()
[sp-queue-member] ; one queue agent (Local channel)
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(\${EXTEN},600))
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(\${EXTEN},600))
same => n,Hangup()
[sp-from-ext]
+2 -2
View File
@@ -25,7 +25,7 @@ export function loadCase(name: string): Snapshot {
timeConditions?: Partial<Snapshot['timeConditions'][number]>[];
holidays?: Snapshot['holidays'];
announcements?: Snapshot['announcements'];
mohClasses?: string[];
mohClasses?: { name: string; shuffle?: boolean; files: string[] }[];
};
const settings = Object.fromEntries(
(Object.keys(SettingsSchemas) as SettingKey[])
@@ -117,7 +117,7 @@ export function loadCase(name: string): Snapshot {
id: c.id!,
number: c.number!,
})),
mohClasses: raw.mohClasses ?? [],
mohClasses: (raw.mohClasses ?? []).map((c) => ({ shuffle: false, ...c })),
pagingGroups: (raw.pagingGroups ?? []).map((g, i) => ({
name: `Paging ${i + 1}`,
duplex: false,
+3 -1
View File
@@ -101,6 +101,8 @@ export type GeneratorDeps = {
log: Pick<FastifyBaseLogger, 'info' | 'error'>;
onApplied?: (r: ApplyResult) => void;
debounceMs?: number;
/** RTP_PORT_START/END (config.ts). */
rtpPorts?: { start: number; end: number };
};
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
@@ -165,7 +167,7 @@ export function createGenerator(deps: GeneratorDeps) {
restartRequired: false,
};
try {
const snap = await loadSnapshot(db);
const snap = { ...(await loadSnapshot(db)), rtpPorts: deps.rtpPorts };
const files = render(snap);
await mkdir(join(dir, '.prev'), { recursive: true });
const current = new Map<GeneratedFileName, string | null>();
+18 -4
View File
@@ -200,11 +200,18 @@ export type Snapshot = {
* Music-on-hold classes that have music ('default' is built in and not here). An empty class
* would play silence; left out, Asterisk falls back to default (seen in M8-T5).
*/
mohClasses: string[];
mohClasses: SnapMohClass[];
/** Played before recording a call that a phone number records (Settings → Recording). */
recordingAnnouncement: string | null;
/** Call audio ports from the environment (RTP_PORT_START/END), not the database. */
rtpPorts?: { start: number; end: number };
};
export const DEFAULT_RTP_PORTS = { start: 10000, end: 20000 };
/** A music-on-hold class: its files (sound_files ids, <MOH_DIR>/<name>/<id>.wav) in play order. */
export type SnapMohClass = { name: string; shuffle: boolean; files: string[] };
export function todayIn(timezone: string, now = new Date()): string {
// en-CA formats as YYYY-MM-DD.
return new Intl.DateTimeFormat('en-CA', { timeZone: timezone }).format(now);
@@ -261,9 +268,16 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise<Snapshot>
const holidayRows = await db.select().from(holidays);
const annRows = await db.select().from(announcements);
const mohRows = await db
.selectDistinct({ name: mohClasses.name })
.select({ name: mohClasses.name, shuffle: mohClasses.shuffle, file: soundFiles.id })
.from(mohClasses)
.innerJoin(soundFiles, eq(soundFiles.mohClassId, mohClasses.id));
.innerJoin(soundFiles, eq(soundFiles.mohClassId, mohClasses.id))
.orderBy(asc(mohClasses.name), asc(soundFiles.position), asc(soundFiles.createdAt));
const moh = new Map<string, SnapMohClass>();
for (const r of mohRows) {
const c = moh.get(r.name) ?? { name: r.name, shuffle: r.shuffle, files: [] };
c.files.push(r.file);
moh.set(r.name, c);
}
const optionRows = await db.select().from(ivrOptions);
const soundName = new Map(
(await db.select({ id: soundFiles.id, name: soundFiles.asteriskName }).from(soundFiles)).map(
@@ -362,7 +376,7 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise<Snapshot>
maxMembers: c.maxMembers,
record: c.record,
})),
mohClasses: mohRows.map((m) => m.name),
mohClasses: [...moh.values()],
pagingGroups: pageRows.map((g) => ({
id: g.id,
number: g.number,
+11 -8
View File
@@ -1,18 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// musiconhold_selfphoneng.conf (03 §8): one class per moh_classes row, a folder Asterisk plays in
// random order. [default] lives in the static musiconhold.conf (01 §3.11).
// musiconhold_selfphoneng.conf (03 §8): one class per moh_classes row that has music, as a
// playlist in the order the admin set (owner, 2026-10-21), or shuffled. [default] lives in the
// static musiconhold.conf (01 §3.11).
import type { Snapshot } from './load.ts';
import * as s from './sanitize.ts';
export function renderMoh(snap: Snapshot): string {
return [...snap.mohClasses]
.sort()
.map((name) =>
.sort((a, b) => a.name.localeCompare(b.name))
.map((c) =>
[
`[${s.ident(name)}]`,
'mode = files',
`directory = /var/lib/asterisk/moh/${s.ident(name)}`,
'sort = random',
`[${s.ident(c.name)}]`,
'mode = playlist',
// No extension: Asterisk picks the format, as it does for files mode.
...c.files.map((id) => `entry = /var/lib/asterisk/moh/${s.ident(c.name)}/${s.ident(id)}`),
// Random picks each next file at random (res_musiconhold MOH_RANDOMIZE, any mode).
...(c.shuffle ? ['sort = random'] : []),
'',
].join('\n'),
)
+14 -2
View File
@@ -1,7 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// pjsip_selfphoneng.conf (03 §5) and rtp_selfphoneng.conf (03 §4).
import { CLASS_RANK } from '@selfphoneng/shared';
import { byNumber, type SnapExtension, type SnapTrunk, type Snapshot } from './load.ts';
import {
byNumber,
DEFAULT_RTP_PORTS,
type SnapExtension,
type SnapTrunk,
type Snapshot,
} from './load.ts';
import * as s from './sanitize.ts';
const section = (title: string, lines: (string | false | null | undefined)[]) =>
@@ -275,7 +281,13 @@ export function renderPjsip(snap: Snapshot): string {
export function renderRtp(snap: Snapshot): string {
const st = snap.settings;
const lines = ['icesupport = yes'];
const rtp = snap.rtpPorts ?? DEFAULT_RTP_PORTS;
const lines = [
// After rtp.conf's own 10000–20000 (Asterisk takes a setting's last value): RTP_PORT_* (env).
`rtpstart = ${s.digits(String(rtp.start))}`,
`rtpend = ${s.digits(String(rtp.end))}`,
'icesupport = yes',
];
if (st['network.stunServer']) lines.push(`stunaddr = ${hostPort(st['network.stunServer'])}`);
if (st['network.lanIp'] && st['network.publicIp'])
lines.push(
+26 -1
View File
@@ -5,7 +5,8 @@ import { AmiClient } from './ami/client.ts';
import { buildApp } from './app.ts';
import { ConfigError, loadConfig } from './config.ts';
import { setSecretKey } from './crypto/secrets.ts';
import { createDb, runMigrations } from './db/client.ts';
import { createDb, migrationsPending, runMigrations } from './db/client.ts';
import { backupBeforeMigrate } from './services/backup.ts';
import { clearRestartIfRestarted, createGenerator } from './generator/index.ts';
import { ensureInitialAdmin } from './services/bootstrap.ts';
import { LiveState } from './live/state.ts';
@@ -45,6 +46,7 @@ const generator = createGenerator({
db,
ami,
dir: resolve(config.ASTERISK_CONFIG_DIR),
rtpPorts: { start: config.RTP_PORT_START, end: config.RTP_PORT_END },
log: app.log,
onApplied: (r) => {
live.emit('message', {
@@ -82,6 +84,29 @@ app.decorate('live', live);
watchWebhooks(db, live, app.log);
watchEvents(db, live, ami, app.log);
// A new version with database changes: back up first, so a bad update can be undone (08 §3.1).
const pending = await migrationsPending(db);
if (pending) {
if (config.SKIP_BACKUP_BEFORE_MIGRATE)
app.log.warn('database changes pending; SKIP_BACKUP_BEFORE_MIGRATE is set, so no backup first');
else {
app.log.info(pending, 'database changes pending: backing up first');
const backup = await backupBeforeMigrate(config, db, pending).catch((err: unknown) => {
app.log.fatal(
{ err, dir: config.BACKUPS_DIR },
'backup before migrating failed, so the database was left as it is. Fix the cause ' +
'(the backups folder must exist and be writable, with free space), or set ' +
'SKIP_BACKUP_BEFORE_MIGRATE=true to migrate without one',
);
process.exit(1);
});
// update.sh finds this line to restore from (the name, then the message).
app.log.info(
{ backup: backup.name },
backup.made ? 'backup before migrating made' : 'backup before migrating made earlier, kept',
);
}
}
await runMigrations(db);
app.log.info('database migrated');
const admin = await ensureInitialAdmin(
@@ -105,3 +105,16 @@ export async function getDids(baseUrl: string, creds: VoipmsCreds): Promise<Voip
];
});
}
/**
* The account's balance (getBalance → balance.current_balance, in the account's currency).
* (verify) against a real account: the shape is from voip.ms's API docs (06 §5.1).
*/
export async function getBalance(baseUrl: string, creds: VoipmsCreds): Promise<number> {
const r = await voipmsApi(baseUrl, creds, 'getBalance');
const b = r.balance as Record<string, unknown> | undefined;
const n = Number(str(b?.current_balance));
if (!b || str(b.current_balance) === '' || !Number.isFinite(n))
throw new VoipmsError('bad_response');
return n;
}
+1 -1
View File
@@ -37,7 +37,7 @@ function contactStatus(s: string | undefined): ContactLive['status'] {
/** "101" and "101-web" both belong to extension 101; trunks and others don't match. */
export const extOfEndpoint = (endpoint: string): string | null =>
/^(\d{3,6})(?:-web)?$/.exec(endpoint)?.[1] ?? null;
/^(\d{1,6})(?:-web)?$/.exec(endpoint)?.[1] ?? null;
/** "trunk-<uuid>", "trunk-<uuid>-reg" → the trunk id. */
export const trunkOfObject = (name: string): string | null =>
+6 -3
View File
@@ -114,8 +114,11 @@ export async function authRoutes(app: FastifyInstance) {
app.post('/api/auth/totp/enable', { preHandler: requireAuth }, async (req) => {
adminOnly(req.user!);
const { secret, code } = TotpEnableSchema.parse(req.body);
if ((await myRow(req.user!.id)).totpSecretEnc) throw conflict('Two-step sign-in is already on');
const { secret, code, password } = TotpEnableSchema.parse(req.body);
const u = await myRow(req.user!.id);
if (u.totpSecretEnc) throw conflict('Two-step sign-in is already on');
// Like turning it off: a stolen session alone can't lock the owner out with its own secret.
if (!(await verifyPassword(u.passwordHash, password))) throw badRequest('Wrong password');
const step = checkTotp(secret, code, null);
if (step === null)
throw badRequest('That code doesn’t match. Check the phone’s clock and try again.');
@@ -135,7 +138,7 @@ export async function authRoutes(app: FastifyInstance) {
return { recoveryCodes: codes };
});
/** Turning it off or making new recovery codes needs the password again. */
/** Turning it off or making new recovery codes needs the password again (on does too). */
async function confirmPassword(id: string, body: unknown) {
const { password } = PasswordConfirmSchema.parse(body);
const u = await myRow(id);
+27 -1
View File
@@ -21,7 +21,7 @@ import {
describeDestination,
findReferences,
} from '../services/destinations.ts';
import { assertNumberAvailable } from '../services/numbering.ts';
import { assertNumberAvailable, ownerOf } from '../services/numbering.ts';
type Row = typeof ivrs.$inferSelect;
type Tx = Parameters<Parameters<Db['transaction']>[0]>[0];
@@ -99,9 +99,26 @@ export async function ivrRoutes(app: FastifyInstance) {
app.get('/api/ivrs/:id', async (req) => toDto(db, await getIvr(IdParams.parse(req.params).id)));
/** With direct dialing on, a menu key can't also be a one-digit number: pressing it picks the option. */
async function assertMenuKeys(directDial: boolean, keys: string[]) {
if (!directDial) return;
for (const key of keys) {
const owner = /^\d$/.test(key) ? await ownerOf(db, key) : null;
if (owner)
throw conflict(
`Key ${key} is also the number of ${owner}: with "callers can dial numbers" on, pressing ${key} would always choose the menu option. Use another key, or turn that off`,
{ owner },
);
}
}
app.post('/api/ivrs', async (req, reply) => {
const { options = [], ...body } = IvrCreateSchema.parse(req.body);
await assertNumberAvailable(db, body.number, 'ivr');
await assertMenuKeys(
body.allowDirectDial ?? true,
options.map((o) => o.digit),
);
await assertPrompt(db, body.greetingSoundId);
await assertPrompt(db, body.invalidSoundId);
await assertDestinations(db, [
@@ -139,6 +156,15 @@ export async function ivrRoutes(app: FastifyInstance) {
const before = await getIvr(id);
if (body.number && body.number !== before.number)
await assertNumberAvailable(db, body.number, 'ivr', id);
if (options || body.allowDirectDial)
await assertMenuKeys(
body.allowDirectDial ?? before.allowDirectDial,
options
? options.map((o) => o.digit)
: (await db.select().from(ivrOptions).where(eq(ivrOptions.ivrId, id))).map(
(o) => o.digit,
),
);
await assertPrompt(db, body.greetingSoundId);
await assertPrompt(db, body.invalidSoundId);
// Pointing at itself is fine here: the IVR exists.
+51 -2
View File
@@ -7,11 +7,12 @@
import { randomUUID } from 'node:crypto';
import { mkdir, rm } from 'node:fs/promises';
import { resolve } from 'node:path';
import { and, asc, eq } from 'drizzle-orm';
import { and, asc, eq, sql } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
MohClassCreateSchema,
MohClassUpdateSchema,
SOUND_MAX_BYTES,
SoundName,
type ListResponse,
@@ -38,10 +39,11 @@ async function toDto(db: Db, c: ClassRow): Promise<MohClassDto> {
.select()
.from(soundFiles)
.where(eq(soundFiles.mohClassId, c.id))
.orderBy(asc(soundFiles.name));
.orderBy(asc(soundFiles.position), asc(soundFiles.createdAt));
return {
id: c.id,
name: c.name,
shuffle: c.shuffle,
files: files.map((f) => ({
id: f.id,
name: f.name,
@@ -79,6 +81,14 @@ export async function mohRoutes(app: FastifyInstance) {
.catch((e: unknown) => app.log.warn({ err: e }, 'moh reload'));
}
async function nextPosition(classId: string) {
const [r] = await db
.select({ n: sql<number>`coalesce(max(${soundFiles.position}) + 1, 0)::int` })
.from(soundFiles)
.where(eq(soundFiles.mohClassId, classId));
return r?.n ?? 0;
}
async function getClass(id: string) {
const [c] = await db.select().from(mohClasses).where(eq(mohClasses.id, id));
if (!c) throw notFound('Music on hold not found');
@@ -172,6 +182,8 @@ export async function mohRoutes(app: FastifyInstance) {
source: 'upload',
originalFilename: filename ?? null,
asteriskName: `moh/${c.name}/${id}`,
// Plays last, after the ones already there.
position: await nextPosition(c.id),
durationMs: (await durationMs(file)) ?? 0,
});
await audit(db, {
@@ -187,6 +199,43 @@ export async function mohRoutes(app: FastifyInstance) {
return reply.code(201).send(await toDto(db, c));
});
/** Shuffle on or off, and the play order (every file of the class, first to last). */
app.patch('/api/moh-classes/:id', async (req) => {
const c = await getClass(IdParams.parse(req.params).id);
const { shuffle, order } = MohClassUpdateSchema.parse(req.body);
if (order) {
const ids = (
await db
.select({ id: soundFiles.id })
.from(soundFiles)
.where(eq(soundFiles.mohClassId, c.id))
).map((f) => f.id);
if (
order.length !== ids.length ||
new Set(order).size !== order.length ||
!order.every((id) => ids.includes(id))
)
throw badRequest('The order must list every file of this music on hold once');
}
await db.transaction(async (tx) => {
if (shuffle !== undefined)
await tx.update(mohClasses).set({ shuffle }).where(eq(mohClasses.id, c.id));
for (const [position, id] of (order ?? []).entries())
await tx.update(soundFiles).set({ position }).where(eq(soundFiles.id, id));
});
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'update',
entityType: 'moh_class',
entityId: c.id,
summary: `Changed the play order of music on hold "${c.name}"`,
diff: { after: { shuffle, order } },
});
app.generator.requestApply('api-change');
return toDto(db, await getClass(c.id));
});
app.get('/api/moh-classes/:id/files/:fileId/audio', async (req, reply) => {
const { id, fileId } = FileParams.parse(req.params);
const c = await getClass(id);
+19 -15
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// GET /prov/... (07 §2): phones fetch their config with HTTP Basic auth (user = MAC, password =
// the device's provisioning password). No session. Unknown MAC → 404, bad auth → 401; both are
// logged with the IP and count toward an IP block (provisioning/limiter.ts).
// the device's provisioning password). No session. Unknown MAC or bad auth → 401, alike; wrong
// credentials are logged with the IP and count toward an IP block (provisioning/limiter.ts).
import { timingSafeEqual } from 'node:crypto';
import { BlockList, isIPv4 } from 'node:net';
import { eq, sql } from 'drizzle-orm';
@@ -113,24 +113,28 @@ export async function provisioningRoutes(app: FastifyInstance) {
if (!kind) return deny(reply, 404, 'Not found');
const mac = kind.m![1]!;
const found = await loadContext(db, app.config, mac);
if (
!found ||
found.ctx.vendor === 'generic' ||
(kind.f.vendor && found.ctx.vendor !== kind.f.vendor)
) {
recordProvFailure(req.ip);
req.log.warn({ ip: req.ip, mac }, 'provisioning: unknown MAC');
return deny(reply, 404, 'Not found');
}
const loaded = await loadContext(db, app.config, mac);
const found =
loaded &&
loaded.ctx.vendor !== 'generic' &&
(!kind.f.vendor || loaded.ctx.vendor === kind.f.vendor)
? loaded
: null;
const auth = basicAuth(req);
const ok =
auth !== null && auth.user.toLowerCase() === mac && same(auth.pass, found.ctx.provPassword);
found !== null &&
auth !== null &&
auth.user.toLowerCase() === mac &&
same(auth.pass, found.ctx.provPassword);
if (!ok) {
// A phone asks without credentials first and retries after the 401: not a failure.
// An unknown MAC gets the same 401 as a known one, so the answers don't tell which MACs
// exist (08 §6). A phone asks without credentials first and retries: not a failure.
if (auth) {
recordProvFailure(req.ip);
req.log.warn({ ip: req.ip, mac }, 'provisioning: wrong credentials');
req.log.warn(
{ ip: req.ip, mac },
found ? 'provisioning: wrong credentials' : 'provisioning: unknown MAC',
);
}
return reply
.code(401)
+58 -3
View File
@@ -1,17 +1,20 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/reports (04 §7.3, 05 §2 Reports): calls per day, busiest hours, per-queue stats. Admin only.
import { and, asc, sql } from 'drizzle-orm';
import { and, asc, eq, sql } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import {
CALL_DIRECTIONS,
ReportRangeSchema,
type ListResponse,
type QueueReportRow,
type ProviderBalanceDto,
type ReportSummary,
} from '@selfphoneng/shared';
import { requireAdmin } from '../auth/guards.ts';
import type { Db } from '../db/client.ts';
import { calls, queues } from '../db/schema.ts';
import { decrypt } from '../crypto/secrets.ts';
import { calls, queues, trunks } from '../db/schema.ts';
import { getBalance, VoipmsError } from '../integrations/voipms/client.ts';
import { badRequest } from '../errors.ts';
import { todayIn } from '../generator/load.ts';
import { queueStats } from '../services/queuestats.ts';
@@ -42,6 +45,10 @@ export async function reportRoutes(app: FastifyInstance) {
);
const local = sql`(${calls.startedAt} at time zone ${tz})`;
const missed = sql`${calls.status} in ('missed', 'voicemail', 'abandoned')`;
// Outside line time: inbound from arrival (a receptionist or voicemail answers too),
// outbound from the answer (talk time).
const lineIn = sql`coalesce(sum(extract(epoch from ${calls.endedAt} - ${calls.startedAt})) filter (where ${calls.direction} = 'inbound'), 0)`;
const lineOut = sql`coalesce(sum(${calls.talkSeconds}) filter (where ${calls.direction} = 'outbound'), 0)`;
const [perDay, perHour, [t]] = await Promise.all([
db
.select({
@@ -49,6 +56,7 @@ export async function reportRoutes(app: FastifyInstance) {
direction: calls.direction,
n: sql<number>`count(*)::int`,
missed: sql<number>`count(*) filter (where ${missed})::int`,
outside: sql<number>`round(${lineIn} + ${lineOut})::int`,
})
.from(calls)
.where(where)
@@ -65,7 +73,11 @@ export async function reportRoutes(app: FastifyInstance) {
db
.select({
calls: sql<number>`count(*)::int`,
answered: sql<number>`count(*) filter (where ${calls.status} = 'answered')::int`,
missed: sql<number>`count(*) filter (where ${missed})::int`,
talk: sql<number>`coalesce(sum(${calls.talkSeconds}), 0)::int`,
lineIn: sql<number>`round(${lineIn})::int`,
lineOut: sql<number>`${lineOut}::int`,
avgTalk: sql<
number | null
>`round(avg(${calls.talkSeconds}) filter (where ${calls.status} = 'answered'))::int`,
@@ -82,6 +94,7 @@ export async function reportRoutes(app: FastifyInstance) {
CALL_DIRECTIONS.map((dir) => [dir, rows.find((r) => r.direction === dir)?.n ?? 0]),
) as Record<(typeof CALL_DIRECTIONS)[number], number>),
missed: rows.reduce((s, r) => s + r.missed, 0),
outsideMinutes: Math.round(rows.reduce((s, r) => s + r.outside, 0) / 60),
});
}
const hours = Array.from({ length: 7 }, () => Array<number>(24).fill(0));
@@ -90,11 +103,53 @@ export async function reportRoutes(app: FastifyInstance) {
from,
to,
days,
totals: { calls: t!.calls, missed: t!.missed, avgTalkSeconds: t!.avgTalk },
totals: {
calls: t!.calls,
answered: t!.answered,
missed: t!.missed,
avgTalkSeconds: t!.avgTalk,
talkSeconds: t!.talk,
outsideSeconds: { inbound: t!.lineIn, outbound: t!.lineOut },
},
hours,
};
});
/** Each voip.ms trunk's account balance (owner, 2026-10-21); a trunk without API login says so. */
app.get('/api/reports/balance', async (): Promise<ListResponse<ProviderBalanceDto>> => {
const rows = await db
.select()
.from(trunks)
.where(eq(trunks.provider, 'voipms'))
.orderBy(asc(trunks.name));
const items = await Promise.all(
rows.map(async (t): Promise<ProviderBalanceDto> => {
const base = { trunkId: t.id, trunkName: t.name };
if (!t.voipmsApiUsername || !t.voipmsApiPasswordEnc)
return {
...base,
balance: null,
error: 'Add the voip.ms API username and password to this trunk (Trunks) to see it',
};
try {
const creds = {
username: t.voipmsApiUsername,
password: decrypt(t.voipmsApiPasswordEnc),
};
return {
...base,
balance: await getBalance(app.config.VOIPMS_API_URL, creds),
error: null,
};
} catch (e) {
if (e instanceof VoipmsError) return { ...base, balance: null, error: e.message };
throw e;
}
}),
);
return { items, nextCursor: null };
});
app.get('/api/reports/queues', async (req): Promise<ListResponse<QueueReportRow>> => {
const { from, to } = await range(db, req.query);
const qs = await db.select().from(queues).orderBy(asc(queues.number));
+24 -3
View File
@@ -5,7 +5,13 @@ import { networkInterfaces } from 'node:os';
import { join, resolve } from 'node:path';
import { desc, lt } from 'drizzle-orm';
import { z } from 'zod';
import type { ListResponse, SystemEventDto, SystemStatus } from '@selfphoneng/shared';
import type {
DiagnosticsDto,
ListResponse,
NetworkTestDto,
SystemEventDto,
SystemStatus,
} from '@selfphoneng/shared';
import type { FastifyInstance } from 'fastify';
import { requireAdmin } from '../auth/guards.ts';
import { audit } from '../services/audit.ts';
@@ -14,7 +20,8 @@ import { systemEvents } from '../db/schema.ts';
import { appVersion } from './about.ts';
import { csvCell } from './calls.ts';
import { BACKUP_NAME, listBackups, runBackup } from '../services/backup.ts';
import { HttpError, notFound } from '../errors.ts';
import { badRequest, HttpError, notFound } from '../errors.ts';
import { insideChecks, networkTest } from '../services/diagnostics.ts';
import { AmiDisconnectedError } from '../ami/client.ts';
import { GENERATED_FILES } from '../generator/index.ts';
@@ -67,7 +74,7 @@ export async function systemRoutes(app: FastifyInstance) {
sip: s['sip.udpPort'],
sipTcp: s['sip.tcpEnabled'],
sips: s['sip.tlsEnabled'] ? s['sip.tlsPort'] : null,
rtp: '10000–20000', // asterisk/conf/rtp.conf
rtp: `${app.config.RTP_PORT_START}–${app.config.RTP_PORT_END}`, // RTP_PORT_START/END
https: 443,
},
ami: { connected: app.ami.isConnected() },
@@ -163,6 +170,20 @@ export async function systemRoutes(app: FastifyInstance) {
return reply.sendFile(name, dir);
});
// Diagnostics (08 §2.6): the inside checks; the probe's outside test, quick or complete.
const diag = () => ({ db: app.db, config: app.config, ami: app.ami });
app.get('/api/system/diagnostics', async (): Promise<DiagnosticsDto> => ({
checks: await insideChecks(diag()),
}));
app.post('/api/system/diagnostics/network', async (req): Promise<NetworkTestDto> => {
const { mode } = z
.object({ mode: z.enum(['quick', 'complete']).default('quick') })
.parse(req.body ?? {});
if (!(await getAllSettings(app.db))['network.probeUrl'])
throw badRequest('No network test probe set (Settings → Network)');
return networkTest(diag(), mode);
});
app.get('/api/system/generated-files', async () => ({
files: await Promise.all(
GENERATED_FILES.map(async (name) => ({
+54 -5
View File
@@ -1,11 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Backups (04 §8.1): BACKUPS_DIR/selfphoneng-YYYYMMDD-HHMM.tar.gz with db.sql (pg_dump), the
// Backups (04 §8.1): BACKUPS_DIR/selfphoneng-YYYYMMDD-HHMMSS.tar.gz with db.sql (pg_dump), the
// voicemail, custom sounds and music-on-hold folders, optionally recordings, and manifest.json.
// No generated Asterisk config (plaintext SIP passwords; it's rebuilt from the DB on start).
// Restore is scripts/restore.sh only.
import { execFile } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdtemp, readdir, rename, rm, stat, writeFile, chmod } from 'node:fs/promises';
import { chmod, mkdtemp, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
import { promisify } from 'node:util';
@@ -17,7 +17,9 @@ import { getSetting } from './settings.ts';
const run = promisify(execFile);
/** manifest.json `version`: the backup format, which restore.sh checks. */
export const BACKUP_FORMAT = 1;
export const BACKUP_NAME = /^selfphoneng-\d{8}-\d{4}\.tar\.gz$/;
/** With seconds since 2026-10-21, so two backups in one minute (the update's and the api's own
* before migrating) don't share a name; older names have none. */
export const BACKUP_NAME = /^selfphoneng-\d{8}-\d{4}(\d{2})?\.tar\.gz$/;
/**
* Lets restore.sh tell whether .env has the APP_SECRET the backup's encrypted columns need,
@@ -70,7 +72,9 @@ export async function createBackup(
src: BackupSources,
opts: { keep: number; now?: Date },
): Promise<BackupDto> {
const name = `selfphoneng-${stamp(src.timezone, opts.now)}.tar.gz`;
const now = opts.now ?? new Date();
const seconds = String(now.getSeconds()).padStart(2, '0');
const name = `selfphoneng-${stamp(src.timezone, now)}${seconds}.tar.gz`;
const work = await mkdtemp(join(tmpdir(), 'sp-backup-'));
const tmp = join(src.outDir, `.${name}.tmp`);
try {
@@ -99,7 +103,7 @@ export async function createBackup(
}
const manifest = {
version: BACKUP_FORMAT,
createdAt: (opts.now ?? new Date()).toISOString(),
createdAt: now.toISOString(),
includesRecordings: included.includes('recordings'),
folders: included,
secretCheck: secretCheck(src.appSecret),
@@ -151,6 +155,51 @@ export function runBackup(config: Config, db: Db): Promise<BackupDto> {
return running;
}
/**
* Before a new version migrates the database (index.ts): the database and the folders the daily
* backup has, without recordings (big, and no migration touches them). Prunes nothing, so it
* doesn't push a daily backup out; the daily job's pruning removes it in time. Reads as little of
* the old schema as it can: the time zone only, falling back to TZ.
*
* With `step` (the migrations about to run), once per step: a crash-looping api would otherwise
* back up on every restart until the disk fills. `.before-migrate.json` in the backups folder
* remembers the step and its backup; `made: false` means that one is reused.
*/
export async function backupBeforeMigrate(
config: Config,
db: Db,
step?: { applied: number; total: number },
): Promise<BackupDto & { made: boolean }> {
const dir = resolve(config.BACKUPS_DIR);
const marker = join(dir, '.before-migrate.json');
const key = step && `${step.applied}-${step.total}`;
if (key) {
const last = await readFile(marker, 'utf8').then(
(s) => JSON.parse(s) as { key: string; name: string },
() => null,
);
const kept = last?.key === key && (await listBackups(dir)).find((b) => b.name === last.name);
if (kept) return { ...kept, made: false };
}
const timezone = await getSetting(db, 'general.timezone').catch(() => config.TZ);
const made = await createBackup(
{
databaseUrl: config.DATABASE_URL,
appSecret: config.APP_SECRET,
outDir: resolve(config.BACKUPS_DIR),
timezone,
dirs: {
voicemail: resolve(config.VOICEMAIL_DIR),
'sounds-custom': resolve(config.SOUNDS_DIR),
moh: resolve(config.MOH_DIR),
},
},
{ keep: Infinity },
);
if (key) await writeFile(marker, JSON.stringify({ key, name: made.name }));
return { ...made, made: true };
}
/** The daily backup is due: enabled, past `backup.time` here, and none made today yet. */
export async function backupDue(config: Config, db: Db, now = new Date()): Promise<boolean> {
if (!(await getSetting(db, 'backup.enabled'))) return false;
+483
View File
@@ -0,0 +1,483 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// System → Diagnostics (08 §2.6). insideChecks: what this server can tell about itself (settings,
// DNS, listening transports, the audio port range). networkTest: the probe (apps/probe) tests it
// from outside, the only way to know what a phone on mobile data or the SIP provider can reach.
import { randomBytes } from 'node:crypto';
import { createSocket, type Socket } from 'node:dgram';
import { resolve4 } from 'node:dns/promises';
import { BlockList, isIPv4 } from 'node:net';
import type { DiagnosticCheck, NetworkTestDto } from '@selfphoneng/shared';
import type { AmiClient } from '../ami/client.ts';
import type { Config } from '../config.ts';
import type { Db } from '../db/client.ts';
import { detectPublicIp } from '../jobs.ts';
import { getAllSettings } from './settings.ts';
export type DiagDeps = {
db: Db;
config: Pick<Config, 'PUBLIC_HOSTNAME' | 'RTP_PORT_START' | 'RTP_PORT_END'>;
ami: Pick<AmiClient, 'isConnected' | 'command'>;
/** Overridable in tests. */
lookupPublicIp?: () => Promise<string | null>;
resolveHost?: (host: string) => Promise<string[]>;
fetchProbe?: typeof fetch;
};
const PRIVATE = new BlockList();
for (const [a, b] of [
['10.0.0.0', 8],
['172.16.0.0', 12],
['192.168.0.0', 16],
['100.64.0.0', 10],
['127.0.0.0', 8],
] as const)
PRIVATE.addSubnet(a, b, 'ipv4');
const inNets = (ip: string, nets: string[]) => {
const list = new BlockList();
for (const n of nets) {
const [a, b] = n.split('/');
list.addSubnet(a!, Number(b), 'ipv4');
}
return list.check(ip, 'ipv4');
};
const check = (
id: string,
label: string,
status: DiagnosticCheck['status'],
detail: string,
fix?: string,
): DiagnosticCheck => ({ id, label, status, detail, ...(fix && { fix }) });
/** Even ports in the range that something else already holds (RTP takes even ones). */
async function busyRtpPorts(start: number, end: number): Promise<number> {
const ports: number[] = [];
for (let p = start + (start % 2); p <= end; p += 2) ports.push(p);
let busy = 0;
// ponytail: binds every even port once, 200 at a time (~1 s for 10 000 ports).
for (let i = 0; i < ports.length; i += 200)
await Promise.all(
ports.slice(i, i + 200).map(
(port) =>
new Promise<void>((done) => {
const s = createSocket('udp4');
s.once('error', () => (busy++, s.close(), done()));
s.bind(port, () => s.close(done));
}),
),
);
return busy;
}
export async function insideChecks(d: DiagDeps): Promise<DiagnosticCheck[]> {
const s = await getAllSettings(d.db);
const host = d.config.PUBLIC_HOSTNAME;
const out: DiagnosticCheck[] = [];
const setIp = s['network.publicIp'];
const seenIp = await (d.lookupPublicIp ?? detectPublicIp)();
const ip = setIp ?? seenIp;
if (!setIp)
out.push(
check(
'public-ip',
'Public IP',
'fail',
"Not set. Phones outside your network are given the server's private address, so their calls fail or have no sound.",
`Settings → Network → Public IP: ${seenIp ?? "this server's internet address"}, then restart telephony (System).`,
),
);
else if (seenIp && seenIp !== setIp)
out.push(
check(
'public-ip',
'Public IP',
'fail',
`Set to ${setIp}, but this server reaches the internet from ${seenIp}.`,
`Settings → Network → Public IP: ${seenIp}, then restart telephony. If it keeps changing, turn on "Re-check the public IP".`,
),
);
else
out.push(
check(
'public-ip',
'Public IP',
seenIp ? 'ok' : 'warn',
seenIp ? setIp : `${setIp} (couldn't look it up to compare: no internet?)`,
),
);
const names = await (d.resolveHost ?? resolve4)(host).catch(() => [] as string[]);
if (!names.length)
out.push(
check(
'dns',
`DNS for ${host}`,
'fail',
`${host} doesn't resolve. Phones and the browser find the server by this name.`,
`At your DNS provider, add an A record: ${host} → ${ip ?? 'your public IP'}.`,
),
);
else if (ip && !names.includes(ip))
out.push(
check(
'dns',
`DNS for ${host}`,
'fail',
`${host} points at ${names.join(', ')}, not ${ip}.`,
`Point the A record at ${ip}. With Cloudflare, set it to "DNS only" (grey cloud): its proxy carries web pages, not phone calls (SIP).`,
),
);
else out.push(check('dns', `DNS for ${host}`, 'ok', `${host} → ${names.join(', ')}`));
const nets = s['network.localNets'];
const lan = s['network.lanIp'];
if (lan && !inNets(lan, nets))
out.push(
check(
'local-nets',
'Local networks',
'fail',
`The server's LAN address ${lan} isn't in them (${nets.join(', ') || 'none'}).`,
`Settings → Network: add ${lan.replace(/\.\d+$/, '.0')}/24 (or your LAN's range) to the local networks.`,
),
);
else out.push(check('local-nets', 'Local networks', 'ok', nets.join(', ') || 'none'));
out.push(
s['sip.restrictPhonesToLocalNets']
? check(
'phone-acl',
'Where phones can sign in from',
'warn',
`Only from ${[...nets, ...s['sip.extraAllowedNets']].join(', ')}. Phone apps on mobile data, or phones at home, are refused.`,
'Settings → Security: untick "Only accept desk phones from the networks below", or add the networks they call from.',
)
: check('phone-acl', 'Where phones can sign in from', 'ok', 'Anywhere, with their password'),
);
if (s['provisioning.lanOnly'])
out.push(
check(
'prov-lan',
'Phone setup files',
'warn',
'Only phones on the local networks get them: the Linphone QR code fails on mobile data.',
'Settings → Security: untick "Only to phones on the local networks" while setting up phones elsewhere.',
),
);
const port = s['sip.udpPort'];
if (!d.ami.isConnected())
out.push(
check(
'sip',
'SIP',
'fail',
"Telephony (Asterisk) isn't reachable.",
'Check that the asterisk container runs: docker compose ps, then docker compose logs asterisk.',
),
);
else {
const t = await d.ami.command('pjsip show transports').catch(() => '');
const want = [
['transport-udp', `UDP ${port}`],
...(s['sip.tcpEnabled'] ? [['transport-tcp', `TCP ${port}`]] : []),
...(s['sip.tlsEnabled'] ? [['transport-tls', `TLS ${s['sip.tlsPort']}`]] : []),
];
const missing = want.filter(([name]) => !t.includes(name!)).map(([, what]) => what);
out.push(
missing.length
? check(
'sip',
'SIP',
'fail',
`Not listening on ${missing.join(', ')}.`,
'Restart telephony (System): ports and TLS changes only take effect then.',
)
: check('sip', 'SIP', 'ok', `Listening on ${want.map(([, w]) => w).join(', ')}`),
);
}
const { RTP_PORT_START: a, RTP_PORT_END: b } = d.config;
const total = Math.floor((b - a) / 2) + 1;
const busy = await busyRtpPorts(a, b);
const range = `UDP ${a}–${b}`;
out.push(
busy > total / 4
? check(
'rtp',
'Call audio ports',
'warn',
`${range}: ${busy} of ${total} are taken by other programs (or calls).`,
`Pick a free range with RTP_PORT_START and RTP_PORT_END (in .env, or the api's environment in your stack), restart, and open that range instead. Two ports per call: 200 ports is plenty for a small office.`,
)
: check(
'rtp',
'Call audio ports',
'ok',
`${range}, ${total} usable${busy ? `, ${busy} in use` : ''}`,
),
);
out.push(
s['network.probeUrl']
? check('probe', 'Network test probe', 'ok', s['network.probeUrl'])
: check(
'probe',
'Network test probe',
'skip',
'None set, so nothing is tested from outside your network.',
'Run apps/probe on a machine outside this network (deploy/probe), then put its URL in Settings → Network.',
),
);
return out;
}
type ProbeResult = { id: string; ok: boolean; detail: string };
/** The probe's reason in brackets, unless it only repeats "no answer". */
const why = (detail: string) => (detail === 'no answer' ? '' : ` (${detail})`);
/** The probe's outside tests; `complete` adds TLS, the browser phone's /ws and audio ports. */
export async function networkTest(
d: DiagDeps,
mode: 'quick' | 'complete',
): Promise<NetworkTestDto> {
const s = await getAllSettings(d.db);
const probe = s['network.probeUrl'];
if (!probe) throw new Error('No network test probe set (Settings → Network)');
const host = d.config.PUBLIC_HOSTNAME;
const port = s['sip.udpPort'];
const { RTP_PORT_START: a, RTP_PORT_END: b } = d.config;
const lan = s['network.lanIp'] ?? 'this server';
const tests: Record<string, unknown>[] = [
{ id: 'http', kind: 'http', port: 80, path: '/' },
{ id: 'https', kind: 'https', port: 443, path: '/healthz' },
{ id: 'sip-udp', kind: 'sip-udp', port },
...(s['sip.tcpEnabled'] ? [{ id: 'sip-tcp', kind: 'sip-tcp', port }] : []),
];
// Audio ports: a few, listened on here for the probe's datagrams.
const listeners: { port: number; sock: Socket; got: boolean }[] = [];
if (mode === 'complete') {
tests.push({ id: 'ws', kind: 'https', port: 443, path: '/ws', websocket: true });
if (s['sip.tlsEnabled']) tests.push({ id: 'sip-tls', kind: 'sip-tls', port: s['sip.tlsPort'] });
const even = (p: number) => p - (p % 2);
const sample = [
...new Set([
even(a + 1),
even(b),
even(a + Math.floor((b - a) / 2)),
even(Math.min(b, a + 2 + Math.floor(Math.random() * Math.max(0, b - a - 2)))),
]),
];
const token = randomBytes(8).toString('hex');
for (const p of sample) {
const sock = createSocket('udp4');
const ok = await new Promise<boolean>((r) => {
sock.once('error', () => r(false));
sock.bind(p, () => r(true));
});
if (!ok) continue; // a call or another program has it: try the others
const l = { port: p, sock, got: false };
sock.on('message', (m) => (l.got ||= m.toString().includes(token)));
listeners.push(l);
tests.push({ id: `rtp-${p}`, kind: 'udp', port: p, token });
}
}
let ip: string | null = null;
let results: ProbeResult[] = [];
let probeError: string | null = null;
try {
const r = await (d.fetchProbe ?? fetch)(new URL('/v1/check', probe), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ host, tests }),
signal: AbortSignal.timeout(30_000),
});
const body = (await r.json()) as { ip?: string; results?: ProbeResult[]; error?: string };
if (!r.ok) probeError = body.error ?? `HTTP ${r.status}`;
else ({ ip = null, results = [] } = body as { ip: string; results: ProbeResult[] });
// Datagrams sent last arrive just after the answer.
await new Promise((r) => setTimeout(r, 700));
} catch (e) {
probeError = (e as Error).message;
} finally {
for (const l of listeners) l.sock.close();
}
if (probeError)
return {
mode,
testedIp: null,
checks: [
check(
'probe',
'Network test probe',
'fail',
`${probe} didn't answer: ${probeError}`,
'Check the probe runs and is reachable from this server, then try again.',
),
],
};
const res = (id: string) => results.find((x) => x.id === id);
const out: DiagnosticCheck[] = [];
if (ip && isIPv4(ip) && PRIVATE.check(ip, 'ipv4'))
out.push(
check(
'probe-ip',
'Tested address',
'warn',
`The probe saw ${ip}, a private address: it's inside your network, so these results don't show what the internet sees.`,
'Run the probe on a machine outside this network.',
),
);
else if (ip && s['network.publicIp'] && ip !== s['network.publicIp'])
out.push(
check(
'probe-ip',
'Tested address',
'warn',
`The probe saw this server as ${ip}, but the public IP is set to ${s['network.publicIp']} (a VPN, or a changed address?).`,
`If ${ip} is right: Settings → Network → Public IP, then restart telephony.`,
),
);
else if (ip) out.push(check('probe-ip', 'Tested address', 'ok', ip));
const http = res('http');
if (http)
out.push(
http.ok
? check('http', 'HTTP (TCP 80)', 'ok', http.detail)
: check(
'http',
'HTTP (TCP 80)',
'warn',
`Not reachable${why(http.detail)}. Only needed to get the HTTPS certificate over HTTP (the bundled Caddy, or certbot).`,
'If you need it: forward TCP 80 to this server on the router, and sudo ufw allow 80/tcp.',
),
);
const https = res('https');
if (https) {
const status = Number(/HTTP (\d{3})/.exec(https.detail)?.[1]);
out.push(
!https.ok
? check(
'https',
'HTTPS (TCP 443)',
'fail',
`Not reachable${why(https.detail)}. The web app, the browser phone and phone setup all use it.`,
`Forward TCP 443 to ${lan} on the router, and sudo ufw allow 443/tcp.`,
)
: status !== 200
? check(
'https',
'HTTPS (TCP 443)',
'fail',
`Reachable, but answered ${https.detail.split('; ')[0]} instead of SelfPhoneNG.`,
"Your reverse proxy can't reach the api: it must send this host name to 127.0.0.1 on the api's port (API_PORT, 8080 by default).",
)
: /certificate isn't valid/.test(https.detail)
? check(
'https',
'HTTPS (TCP 443)',
'warn',
https.detail,
`Get a certificate for ${host}: the bundled Caddy does it by itself; with your own proxy, certbot.`,
)
: check('https', 'HTTPS (TCP 443)', 'ok', https.detail),
);
}
const udp = res('sip-udp');
if (udp)
out.push(
udp.ok
? check('sip-udp', `SIP (UDP ${port})`, 'ok', `Asterisk answered: ${udp.detail}`)
: check(
'sip-udp',
`SIP (UDP ${port})`,
'fail',
`No answer${why(udp.detail)}. Phones away from the office, phone apps on mobile data and the SIP provider's calls all need it.`,
`Forward UDP ${port} to ${lan} on the router (on both, behind two), sudo ufw allow ${port}/udp, and turn off SIP ALG on the router.`,
),
);
const tcp = res('sip-tcp');
if (tcp)
out.push(
tcp.ok
? check('sip-tcp', `SIP (TCP ${port})`, 'ok', `Asterisk answered: ${tcp.detail}`)
: check(
'sip-tcp',
`SIP (TCP ${port})`,
'warn',
`No answer${why(tcp.detail)}. Only phones set to TCP need it.`,
`Forward TCP ${port} to ${lan} and sudo ufw allow ${port}/tcp, or keep phones on UDP.`,
),
);
const tls = res('sip-tls');
if (tls)
out.push(
tls.ok
? check(
'sip-tls',
`SIP over TLS (TCP ${s['sip.tlsPort']})`,
'ok',
`Asterisk answered: ${tls.detail}`,
)
: check(
'sip-tls',
`SIP over TLS (TCP ${s['sip.tlsPort']})`,
'fail',
`No answer${why(tls.detail)}.`,
`Forward TCP ${s['sip.tlsPort']} to ${lan} and sudo ufw allow ${s['sip.tlsPort']}/tcp.`,
),
);
const ws = res('ws');
if (ws)
out.push(
ws.ok
? check('ws', 'Browser phone (/ws)', 'ok', ws.detail)
: check(
'ws',
'Browser phone (/ws)',
'fail',
`The WebSocket didn't open${why(ws.detail)}.`,
'Your reverse proxy must pass WebSocket upgrades on /ws to 127.0.0.1:8088 (see deploy/nginx/selfphoneng.conf.example).',
),
);
if (mode === 'complete') {
const got = listeners.filter((l) => l.got);
const range = `UDP ${a}–${b}`;
const fix = `Forward ${range} to ${lan} on the router, and sudo ufw allow ${a}:${b}/udp. Without it calls connect but have no sound, or only one way.`;
out.push(
!listeners.length
? check(
'rtp',
`Call audio (${range})`,
'skip',
'Every sampled port was in use here: try again.',
)
: got.length === listeners.length
? check(
'rtp',
`Call audio (${range})`,
'ok',
`${got.length} of ${listeners.length} sampled ports got through`,
)
: check(
'rtp',
`Call audio (${range})`,
got.length ? 'warn' : 'fail',
`${got.length} of ${listeners.length} sampled ports got through (missing: ${listeners
.filter((l) => !l.got)
.map((l) => l.port)
.join(', ')}).`,
got.length
? `The forward or firewall rule doesn't cover the whole range. ${fix}`
: fix,
),
);
}
return { mode, testedIp: ip, checks: out };
}
+1 -1
View File
@@ -20,7 +20,7 @@ export async function checkForward(
const digits = dialedDigits(input);
if (!digits) throw badRequest('Enter a number');
if (digits === ext.number) throw badRequest("You can't forward calls to yourself");
if (/^\d{3,6}$/.test(digits)) {
if (/^\d{1,6}$/.test(digits)) {
if (await ownerOf(db, digits)) return digits;
throw badRequest(`${digits} isn't an extension or group here`);
}
+42 -6
View File
@@ -3,7 +3,15 @@
import { and, eq, ne } from 'drizzle-orm';
import { isReservedNumber, type NumberKind } from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { conferences, extensions, ivrs, pagingGroups, queues, ringGroups } from '../db/schema.ts';
import {
conferences,
extensions,
ivrOptions,
ivrs,
pagingGroups,
queues,
ringGroups,
} from '../db/schema.ts';
import { badRequest, conflict } from '../errors.ts';
import { getSetting } from './settings.ts';
@@ -46,20 +54,48 @@ export async function ownerOf(db: Db, number: string, excludeId?: string): Promi
return null;
}
/**
* A digital receptionist that lets callers dial numbers and has `key` in its menu: callers pressing
* it get the menu option, so a one-digit number `key` can't be dialed from there.
*/
export async function menuKeyOwner(db: Db, key: string, excludeIvrId?: string) {
if (!/^\d$/.test(key)) return null;
const [row] = await db
.select({ name: ivrs.name })
.from(ivrOptions)
.innerJoin(ivrs, eq(ivrs.id, ivrOptions.ivrId))
.where(
and(
eq(ivrOptions.digit, key),
eq(ivrs.allowDirectDial, true),
...(excludeIvrId ? [ne(ivrs.id, excludeIvrId)] : []),
),
)
.limit(1);
return row?.name ?? null;
}
/**
* Any free internal number, for any kind (owner, 2026-10-21): the numbering ranges only choose the
* suggested next number (nextFreeNumber). `kind` is kept for that and for callers' readability.
*/
export async function assertNumberAvailable(
db: Db,
number: string,
kind: NumberKind,
_kind: NumberKind,
excludeId?: string,
): Promise<void> {
if (!/^\d{3,6}$/.test(number)) throw badRequest('Internal numbers have 3 to 6 digits');
if (!/^\d{1,6}$/.test(number)) throw badRequest('Internal numbers have 1 to 6 digits');
if (isReservedNumber(number))
throw badRequest(`${number} is reserved (emergency and service codes can't be used)`);
const [from, to] = await getSetting(db, RANGE_KEY[kind]);
const n = Number(number);
if (n < from || n > to) throw badRequest(`Use a number from ${from} to ${to}`);
const owner = await ownerOf(db, number, excludeId);
if (owner) throw conflict(`${number} is already used by ${owner}`, { owner });
const menu = await menuKeyOwner(db, number);
if (menu)
throw conflict(
`${number} is a menu key of the digital receptionist "${menu}", which also lets callers dial numbers: pressing ${number} there would always choose the menu option`,
{ owner: `digital receptionist "${menu}"` },
);
}
/** Lowest free number in the kind's range, or null when the range is full. */
+63 -2
View File
@@ -22,6 +22,8 @@ const RECORD = process.argv.includes('--record')
const NET = 'sp-test';
const AST = 'sp-test-ast';
const PHONES = 'sp-test-phones';
const PN_PHONE = 'sp-test-pn-phone';
const RTP_PORTS = { start: 31000, end: 31200 };
const AMI_PORT = 15038;
const AMI_SECRET = 'call-test-secret';
const PASS = { '101': 'CallTest101xxxxxxxxxxxxx', '102': 'CallTest102xxxxxxxxxxxxx' };
@@ -78,6 +80,8 @@ function snapshot(): Snapshot {
announcements: [],
recordingAnnouncement: null,
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
// Not rtp.conf's 10000–20000: the generated range must win (RTP_PORT_START/END).
rtpPorts: RTP_PORTS,
};
}
@@ -142,7 +146,7 @@ async function main() {
backoffMs: [500],
});
try {
docker('rm', '-f', AST, PHONES);
docker('rm', '-f', AST, PHONES, PN_PHONE);
docker('network', 'create', NET);
const run = docker(
'run',
@@ -173,6 +177,13 @@ async function main() {
ami.start();
await new Promise((r) => ami.once('connected', r));
log('AMI connected from the host');
const rtp = await ami.command('rtp show settings');
if (
!new RegExp(`Port start:\\s+${RTP_PORTS.start}\\b`).test(rtp) ||
!new RegExp(`Port end:\\s+${RTP_PORTS.end}\\b`).test(rtp)
)
problems.push(`RTP ports aren't ${RTP_PORTS.start}–${RTP_PORTS.end}:\n${rtp}`);
else log(`RTP ports ${RTP_PORTS.start}–${RTP_PORTS.end} (generated, over rtp.conf's)`);
if (RECORD) {
// A second, raw AMI session so the fixture holds exactly what Asterisk sent.
recorder = new Socket();
@@ -236,6 +247,51 @@ async function main() {
).out.trim();
if (!wsReg.startsWith('SIP/2.0 200')) problems.push(`102-web over WebSocket: ${wsReg}`);
else log('102-web over WebSocket from 127.0.0.1: registered');
// The network test probe's SIP checks (08 §2.6): Asterisk answers its OPTIONS, UDP and TCP.
const probed = docker(
'run',
'--rm',
'--network',
NET,
'-v',
`${join(import.meta.dirname, '../../../probe/src')}:/probe:ro`,
'node:24-trixie-slim',
'node',
'--input-type=module',
'-e',
`import { runTest } from '/probe/checks.ts';
for (const kind of ['sip-udp', 'sip-tcp'])
console.log(JSON.stringify(await runTest('${AST}', 'pbx.test', { id: kind, kind, port: 5060 })));`,
).out;
for (const kind of ['sip-udp', 'sip-tcp'])
if (!new RegExp(`"id":"${kind}","ok":true,"detail":"SIP/2.0 \\d{3}`).test(probed))
problems.push(`probe ${kind}: ${probed.trim()}`);
if (!problems.some((p) => p.startsWith('probe ')))
log(`probe: Asterisk answers SIP OPTIONS over UDP and TCP`);
// 101 also on an iOS Linphone: '&' in its push params used to break Dial for every device.
const pn = docker(
'run',
'-d',
'--rm',
'--name',
PN_PHONE,
'--network',
NET,
'-v',
`${join(import.meta.dirname, 'pn-phone.mjs')}:/pn-phone.mjs:ro`,
'node:24-trixie-slim',
'node',
'/pn-phone.mjs',
AST,
'101',
PASS['101'],
);
if (!pn.ok) throw new Error(`pn phone: ${pn.out}`);
for (let i = 0; i < 20 && !/REGISTER SIP\/2.0 200/.test(docker('logs', PN_PHONE).out); i++)
await sleep(500);
if (!/REGISTER SIP\/2.0 200/.test(docker('logs', PN_PHONE).out))
throw new Error(`pn phone did not register: ${docker('logs', PN_PHONE).out}`);
log('101 also registered with iOS Linphone push params');
// Real list responses with registered phones, for the live-state fixture.
recorder?.write(
'Action: PJSIPShowContacts\r\nActionID: rec-2\r\n\r\n' +
@@ -263,6 +319,11 @@ async function main() {
break;
}
if (!/Up!Dial/.test(channels)) throw new Error(`call did not connect:\n${channels}`);
// Rung too, without its pn-* params; the rest of its Contact kept.
const pnInvite = /^INVITE .*$/m.exec(docker('logs', PN_PHONE).out)?.[0] ?? 'no INVITE';
if (!/;x-test=keep-me$/.test(pnInvite) || /pn-/.test(pnInvite))
problems.push(`push-params phone: ${pnInvite}`);
else log(`push-params phone rung: ${pnInvite}`);
log(`call up, holding ${CALL_SECONDS} s`);
await sleep(CALL_SECONDS * 1000);
@@ -307,7 +368,7 @@ async function main() {
${docker('exec', PHONES, 'tail', '-n', '25', `/phones/phone-${e}/log`).out}`);
recorder?.destroy();
ami.stop();
docker('rm', '-f', PHONES, AST);
docker('rm', '-f', PHONES, PN_PHONE, AST);
docker('network', 'rm', NET);
rmSync(dir, { recursive: true, force: true });
}
+8
View File
@@ -141,6 +141,14 @@ async function check(name) {
if (!shownQ.startsWith(`${q} has`) || listed !== members)
problems.push(`queue ${q}: expected ${members} members, got: ${shownQ.trim()}`);
}
// A case can list [number, context] pairs Asterisk must match (short-numbers: one-digit
// numbers, which a pattern like _X. needs two digits for).
const snap = JSON.parse(readFileSync(join(GOLDEN, name, 'snapshot.json'), 'utf8'));
for (const [exten, ctx] of snap.asteriskMatches ?? []) {
const d = cli(`dialplan show ${exten}@${ctx}`).out;
if (!/-= \d+ extensions? \(/.test(d) || /There is no existence/.test(d))
problems.push(`${exten} doesn't match in ${ctx}`);
}
// Asterisk colors its log: strip the escape codes before looking for ERROR[.
// eslint-disable-next-line no-control-regex -- matching ANSI escapes is the point
const log = docker('logs', NAME).out.replace(/\x1b\[[0-9;]*m/g, '');
+147
View File
@@ -0,0 +1,147 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Music on hold order (owner, 2026-10-21, 03 §8): a class plays its files in the order set in the
// UI, and a shuffled one doesn't. Asterisk plays each class to an echo test while
// res_musiconhold's debug log says which file it opens, in what order.
// Usage: node --conditions=source apps/api/test/asterisk/moh-test.ts (ASTERISK_IMAGE)
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 { GENERATED_FILES, render } from '../../src/generator/index.ts';
import type { Snapshot } from '../../src/generator/load.ts';
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
const AST = 'sp-test-moh';
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 cli = (cmd: string) => docker('exec', AST, 'asterisk', '-rx', cmd).out;
const log = (...a: unknown[]) => console.log('[moh-test]', ...a);
// The play order set in the UI: not the ids' sort order, so a sorted folder wouldn't pass.
const ORDER = [
'c0000000-0000-4000-8000-000000000003',
'a0000000-0000-4000-8000-000000000001',
'b0000000-0000-4000-8000-000000000002',
];
const SHUF = [
'd0000000-0000-4000-8000-000000000004',
'e0000000-0000-4000-8000-000000000005',
'f0000000-0000-4000-8000-000000000006',
];
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'];
return {
today: '2026-10-21',
settings,
dids: [],
trunks: [],
routes: [],
ringGroups: [],
queues: [],
conferences: [],
pagingGroups: [],
ivrs: [],
timeConditions: [],
holidays: [],
announcements: [],
recordingAnnouncement: null,
extensions: [],
mohClasses: [
{ name: 'order-test', shuffle: false, files: ORDER },
{ name: 'shuffle-test', shuffle: true, files: SHUF },
],
};
}
/** The files `cls` opened, in order, from the debug log (ids only). */
const opened = (debug: string, cls: string) =>
[
...debug.matchAll(
new RegExp(`Opened file \\d+ '/var/lib/asterisk/moh/${cls}/([0-9a-f-]+)'`, 'g'),
),
].map((m) => m[1]!);
async function main() {
const dir = mkdtempSync(join(tmpdir(), 'sp-moh-'));
const conf = join(dir, 'conf');
const moh = join(dir, 'moh');
mkdirSync(conf);
for (const c of ['order-test', 'shuffle-test']) mkdirSync(join(moh, c), { recursive: true });
const files = render(snapshot());
for (const f of GENERATED_FILES) writeFileSync(join(conf, f), files[f]);
const problems: string[] = [];
try {
docker('rm', '-f', AST);
const run = docker(
'run',
'-d',
'--rm',
'--name',
AST,
'-e',
'AMI_SECRET=moh-test-secret',
'-v',
`${conf}:/etc/asterisk/selfphoneng`,
'-v',
`${moh}:/var/lib/asterisk/moh`,
IMAGE,
);
if (!run.ok) throw new Error(`asterisk: ${run.out}`);
for (
let i = 0;
i < 60 && !docker('exec', AST, 'asterisk', '-rx', 'core waitfullybooted').ok;
i++
)
await sleep(500);
// Short recordings (spoken digits) so each class goes through its list in a few seconds.
const copies = [...ORDER, ...SHUF]
.map(
(id, i) =>
`cp /var/lib/asterisk/sounds/en/digits/${i + 1}.wav /var/lib/asterisk/moh/${ORDER.includes(id) ? 'order-test' : 'shuffle-test'}/${id}.wav`,
)
.join(' && ');
const cp = docker('exec', AST, 'sh', '-c', copies);
if (!cp.ok) throw new Error(`copying sounds: ${cp.out}`);
cli('moh reload');
cli('logger add channel moh-debug.log debug');
cli('core set debug 1 res_musiconhold.so');
for (const cls of ['order-test', 'shuffle-test']) {
cli(`channel originate Local/*43@sp-featurecodes application MusicOnHold ${cls},6`);
await sleep(7000);
}
const debug = docker('exec', AST, 'cat', '/var/log/asterisk/moh-debug.log').out;
const inOrder = opened(debug, 'order-test');
log(`order-test played: ${inOrder.map((id) => id[0]).join(' ')}`);
const want = Array.from({ length: inOrder.length }, (_, i) => ORDER[i % ORDER.length]);
if (inOrder.length < 4 || inOrder.join() !== want.join())
problems.push(`order-test didn't play c a b c…: ${inOrder.join(', ') || 'nothing'}`);
const shuffled = opened(debug, 'shuffle-test');
log(`shuffle-test played: ${shuffled.map((id) => id[0]).join(' ')}`);
const cyclic = Array.from({ length: shuffled.length }, (_, i) => SHUF[i % SHUF.length]);
if (shuffled.length < 4 || shuffled.join() === cyclic.join())
problems.push(`shuffle-test played in list order: ${shuffled.join(', ') || 'nothing'}`);
} catch (e) {
problems.push((e as Error).message);
} finally {
docker('rm', '-f', AST);
rmSync(dir, { recursive: true, force: true });
}
if (problems.length) {
console.log('FAIL music on hold');
for (const p of problems) console.log(` ${p}`);
process.exit(1);
}
console.log('ok music on hold (in the order set, and shuffled)');
}
await main();
+78
View File
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// A phone that registers like iOS Linphone: push params holding '&' in its Contact. Answers
// qualify OPTIONS, prints the Request-URI of each INVITE ("INVITE <uri>") and turns it down (486),
// so the real phone on the same extension takes the call. call-test.ts runs it.
// Usage: node pn-phone.mjs <asterisk host> <user> <password>
import { createHash, randomUUID } from 'node:crypto';
import { createSocket } from 'node:dgram';
import { lookup } from 'node:dns/promises';
import { networkInterfaces } from 'node:os';
const [astHost, user, pass] = process.argv.slice(2);
const md5 = (s) => createHash('md5').update(s).digest('hex');
const ast = (await lookup(astHost)).address;
const me = Object.values(networkInterfaces())
.flat()
.find((i) => i.family === 'IPv4' && !i.internal).address;
const PORT = 5080;
const uri = `sip:${astHost}`;
const callId = randomUUID();
const tag = randomUUID().slice(0, 8);
const contact =
`<sip:${user}@${me}:${PORT};pn-prid=AAAA1111:voip&bbbb2222:remote;pn-provider=apns;` +
`pn-param=ABCD1234.org.linphone.phone.voip&remote;x-test=keep-me>`;
const sock = createSocket('udp4');
const send = (msg) => sock.send(msg, 5060, ast);
const register = (cseq, auth) =>
[
`REGISTER ${uri} SIP/2.0`,
`Via: SIP/2.0/UDP ${me}:${PORT};rport;branch=z9hG4bK${randomUUID().slice(0, 12)}`,
'Max-Forwards: 70',
`From: <sip:${user}@${astHost}>;tag=${tag}`,
`To: <sip:${user}@${astHost}>`,
`Call-ID: ${callId}`,
`CSeq: ${cseq} REGISTER`,
`Contact: ${contact}`,
'Expires: 300',
...(auth ? [`Authorization: ${auth}`] : []),
'Content-Length: 0',
'',
'',
].join('\r\n');
/** A response to a request: its Via, From, Call-ID and CSeq, and a To tag. */
const reply = (req, status) => {
const keep = req.split('\r\n').filter((l) => /^(Via|From|To|Call-ID|CSeq):/i.test(l));
const lines = keep.map((l) => (/^To:/i.test(l) && !/;tag=/.test(l) ? `${l};tag=${tag}` : l));
return [`SIP/2.0 ${status}`, ...lines, 'Content-Length: 0', '', ''].join('\r\n');
};
sock.on('message', (buf) => {
const msg = buf.toString();
const first = msg.split('\r\n')[0];
const challenge = /WWW-Authenticate: Digest (.*)/i.exec(msg)?.[1];
if (first.includes(' 401 ') && challenge) {
const p = Object.fromEntries(
[...challenge.matchAll(/(\w+)="?([^",]*)"?/g)].map((m) => [m[1], m[2]]),
);
const cnonce = randomUUID().slice(0, 8);
const ha1 = md5(`${user}:${p.realm}:${pass}`);
const ha2 = md5(`REGISTER:${uri}`);
const response = md5(`${ha1}:${p.nonce}:00000001:${cnonce}:auth:${ha2}`);
send(
register(
2,
`Digest username="${user}", realm="${p.realm}", nonce="${p.nonce}", uri="${uri}", ` +
`response="${response}", algorithm=MD5, qop=auth, nc=00000001, cnonce="${cnonce}"` +
(p.opaque ? `, opaque="${p.opaque}"` : ''),
),
);
} else if (first.startsWith('SIP/2.0 ')) console.log(`REGISTER ${first}`);
else if (first.startsWith('OPTIONS ')) send(reply(msg, '200 OK'));
else if (first.startsWith('INVITE ')) {
console.log(first.split(' ').slice(0, 2).join(' '));
send(reply(msg, '486 Busy Here'));
}
});
sock.bind(PORT, () => send(register(1)));
+25
View File
@@ -285,6 +285,31 @@ async function main() {
await sleep(1000);
}
// Voicemail logins: *97 above counted once; with 10 this hour, *98 doesn't reach a PIN prompt.
const hour = new Date().toISOString().replace(/\D/g, '').slice(0, 10);
const counted = await ami.command('database get SP/VMLOGIN 101');
if (!counted.includes(`${hour}:1`)) problems.push(`*97 not counted: ${counted}`);
await ami.command(`database put SP/VMLOGIN 101 ${hour}:10`);
await ami.action({
Action: 'Originate',
Channel: 'Local/*98@sp-from-ext',
Application: 'Wait',
Data: '5',
Variable: '__SP_EXT=101',
Async: 'true',
});
let refused = false;
for (let i = 0; i < 20 && !refused; i++) {
await sleep(500);
refused = /Playing 'an-error-has-occurred/.test(docker('logs', AST).out);
}
if (!refused) problems.push('*98 past the hourly voicemail login limit was not refused');
else if (/VoiceMailMain\("[^"]*", "@default"\)/.test(docker('logs', AST).out))
problems.push('*98 reached VoiceMailMain past the limit');
else log('*98 past 10 logins an hour: refused');
await ami.command('channel request hangup all');
await waitIdle();
// eslint-disable-next-line no-control-regex -- strips Asterisk's ANSI colors
const plainLog = docker('logs', AST).out.replace(/\x1b\[[0-9;]*m/g, '');
problems.push(
+4 -1
View File
@@ -50,14 +50,17 @@ describe.skipIf(!hasTestDb)('backup routes', () => {
it('lists backups newest first, ignoring other files', async () => {
writeFileSync(join(dir(), 'selfphoneng-20261018-0330.tar.gz'), 'old');
writeFileSync(join(dir(), 'selfphoneng-20261019-0330.tar.gz'), 'newer');
// with seconds (from 2026-10-21): a second one in the same minute sorts after it, newer
writeFileSync(join(dir(), 'selfphoneng-20261019-033045.tar.gz'), 'newest');
writeFileSync(join(dir(), '.selfphoneng-20261020-0330.tar.gz.tmp'), 'half-written');
writeFileSync(join(dir(), 'notes.txt'), 'x');
const r = await req('GET', '/api/system/backups');
expect(r.json().map((b: { name: string }) => b.name)).toEqual([
'selfphoneng-20261019-033045.tar.gz',
'selfphoneng-20261019-0330.tar.gz',
'selfphoneng-20261018-0330.tar.gz',
]);
expect(r.json()[0].size).toBe(5);
expect(r.json()[1].size).toBe(5);
});
it('downloads one by name, nothing else', async () => {
+1 -1
View File
@@ -62,7 +62,7 @@ describe.skipIf(!hasTestDb)('/api/conferences', () => {
it('checks the number and the PINs', async () => {
const bad = (body: object) =>
req('POST', '/api/conferences', { number: '801', name: 'X', ...body });
expect((await bad({ number: '700' })).statusCode).toBe(400); // parking, not 800–849
expect((await bad({ number: '700' })).statusCode).toBe(409); // the parking lot
expect((await bad({ number: '800' })).statusCode).toBe(409);
expect((await bad({ pin: '12' })).statusCode).toBe(400);
expect((await bad({ pin: '12a4' })).statusCode).toBe(400);
+20 -5
View File
@@ -105,10 +105,25 @@ describe.skipIf(!hasTestDb)('/api/devices and /prov', () => {
expect((await prov('y000000000044.cfg')).body).toBe('#!version:1.0.0.1\n');
});
it('unknown MAC and the other vendor’s file are 404; ten failures block the IP', async () => {
expect((await prov('001565000000.cfg')).statusCode).toBe(404);
expect((await prov(`cfg${device.mac}.xml`)).statusCode).toBe(404); // a Yealink here
it('unknown MAC and the other vendor’s file answer like a known MAC; ten failures block the IP', async () => {
// 08 §6: the answers mustn't tell which MACs exist.
const known = await prov(`${device.mac}.cfg`);
for (const f of ['001565000000.cfg', `cfg${device.mac}.xml`]) {
const r = await prov(f);
expect([r.statusCode, r.headers['www-authenticate'], r.body]).toEqual([
401,
known.headers['www-authenticate'],
known.body,
]);
}
expect((await prov('anything.boot')).statusCode).toBe(404);
// with credentials they're failures, like a wrong password
expect(
(await prov('001565000000.cfg', '192.168.1.20', 'guess', '001565000000')).statusCode,
).toBe(401);
expect(
(await prov(`cfg${device.mac}.xml`, '192.168.1.20', device.provisioningPassword)).statusCode,
).toBe(401); // a Yealink here
for (let i = 0; i < 8; i++)
expect((await prov(`${device.mac}.cfg`, '192.168.1.20', 'guess')).statusCode).toBe(401);
// 10 failures (2 unknown MACs, 8 wrong passwords): even the right password is refused now
@@ -181,7 +196,7 @@ describe.skipIf(!hasTestDb)('/api/devices and /prov', () => {
expect(
(await prov('112233445566.cfg', '192.168.1.40', generic.provisioningPassword, '112233445566'))
.statusCode,
).toBe(404);
).toBe(401);
});
it('M10-T2: BLF keys named after their users, the time zone, and the phonebook', async () => {
@@ -256,6 +271,6 @@ describe.skipIf(!hasTestDb)('/api/devices and /prov', () => {
expect((await req('DELETE', `/api/devices/${device.id}`)).statusCode).toBe(200);
expect(
(await prov(`${device.mac}.cfg`, '192.168.1.50', device.provisioningPassword)).statusCode,
).toBe(404);
).toBe(401);
});
});
+157
View File
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// System → Diagnostics (08 §2.6): the inside checks with the outside lookups faked, and the
// network test against a fake probe that really sends the audio-port datagrams.
import { createSocket } from 'node:dgram';
import type { DiagnosticCheck } from '@selfphoneng/shared';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { insideChecks, networkTest, type DiagDeps } from '../src/services/diagnostics.ts';
import { setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const byId = (checks: DiagnosticCheck[]) => Object.fromEntries(checks.map((c) => [c.id, c]));
describe.skipIf(!hasTestDb)('diagnostics', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let deps: DiagDeps;
beforeAll(async () => {
t = await createTestApp();
deps = {
db: t.db,
// A small range, so the busy-port scan and the samples stay quick and local.
config: { ...t.app.config, RTP_PORT_START: 41000, RTP_PORT_END: 41020 },
ami: t.app.ami,
lookupPublicIp: async () => '203.0.113.10',
resolveHost: async () => ['203.0.113.10'],
};
});
afterAll(() => t?.close());
it('flags what keeps phones off the network: no public IP, DNS elsewhere, the phone ACL', async () => {
let c = byId(await insideChecks({ ...deps, resolveHost: async () => ['198.51.100.7'] }));
expect(c['public-ip']).toMatchObject({ status: 'fail' });
expect(c['public-ip']!.fix).toContain('203.0.113.10');
expect(c.dns).toMatchObject({ status: 'fail' });
expect(c.dns!.fix).toContain('DNS only');
expect(c['phone-acl']).toMatchObject({ status: 'ok' });
expect(c.probe).toMatchObject({ status: 'skip' });
expect(c.rtp).toMatchObject({
status: 'ok',
detail: expect.stringContaining('UDP 41000–41020'),
});
await setSetting(t.db, 'network.publicIp', '203.0.113.10');
await setSetting(t.db, 'sip.restrictPhonesToLocalNets', true);
await setSetting(t.db, 'network.lanIp', '10.9.0.5');
await setSetting(t.db, 'network.localNets', ['192.168.0.0/16']);
c = byId(await insideChecks(deps));
expect(c['public-ip']).toMatchObject({ status: 'ok', detail: '203.0.113.10' });
expect(c.dns).toMatchObject({ status: 'ok' });
expect(c['phone-acl']).toMatchObject({ status: 'warn' });
expect(c['phone-acl']!.detail).toContain('mobile data');
expect(c['local-nets']).toMatchObject({ status: 'fail' });
expect(c['local-nets']!.fix).toContain('10.9.0.0/24');
await setSetting(t.db, 'sip.restrictPhonesToLocalNets', false);
});
it('a busy audio range is a warning that says how to move it', async () => {
const held = await Promise.all(
[41000, 41002, 41004, 41006].map(
(p) =>
new Promise<ReturnType<typeof createSocket>>((r) => {
const s = createSocket('udp4');
s.bind(p, () => r(s));
}),
),
);
const c = byId(await insideChecks(deps));
expect(c.rtp).toMatchObject({ status: 'warn' });
expect(c.rtp!.fix).toContain('RTP_PORT_START');
held.forEach((s) => s.close());
});
it('network test: maps the probe’s answers to fixes; audio ports must really get through', async () => {
await setSetting(t.db, 'network.probeUrl', 'https://probe.test');
let seen:
| { host: string; tests: { id: string; kind: string; port: number; token?: string }[] }
| undefined;
const fetchProbe = (async (_url: URL, init: RequestInit) => {
seen = JSON.parse(String(init.body));
// The firewall lets the first sampled audio port through and drops the rest.
const udp = seen!.tests.filter((x) => x.kind === 'udp');
const s = createSocket('udp4');
await new Promise<void>((r) =>
s.send(`selfphoneng-probe ${udp[0]!.token}`, udp[0]!.port, '127.0.0.1', () => r()),
);
s.close();
const ok = (id: string, detail: string) => ({ id, ok: true, detail });
const no = (id: string) => ({ id, ok: false, detail: 'no answer' });
return new Response(
JSON.stringify({
ip: '203.0.113.10',
results: [
no('http'),
ok('https', 'HTTP 200'),
no('sip-udp'),
ok('sip-tcp', 'SIP/2.0 401 Unauthorized'),
{ id: 'ws', ok: false, detail: 'HTTP 404' },
...udp.map((x) => ok(x.id, 'sent')),
],
}),
);
}) as unknown as typeof fetch;
const r = await networkTest({ ...deps, fetchProbe }, 'complete');
expect(seen!.host).toBe(t.app.config.PUBLIC_HOSTNAME);
expect(seen!.tests.map((x) => x.id)).toEqual(
expect.arrayContaining(['http', 'https', 'sip-udp', 'sip-tcp', 'ws']),
);
const c = byId(r.checks);
expect(r.testedIp).toBe('203.0.113.10');
expect(c['probe-ip']).toMatchObject({ status: 'ok' });
expect(c.http).toMatchObject({ status: 'warn' });
expect(c.https).toMatchObject({ status: 'ok' });
expect(c['sip-udp']).toMatchObject({ status: 'fail' });
expect(c['sip-udp']!.fix).toContain('SIP ALG');
expect(c['sip-tcp']).toMatchObject({ status: 'ok' });
expect(c.ws).toMatchObject({ status: 'fail' });
expect(c.rtp).toMatchObject({ status: 'warn' });
expect(c.rtp!.fix).toContain('ufw allow 41000:41020/udp');
// Quick: no audio ports, no /ws.
const quick = await networkTest(
{
...deps,
fetchProbe: (async (_u: URL, init: RequestInit) => {
seen = JSON.parse(String(init.body));
return new Response(JSON.stringify({ ip: '192.168.1.9', results: [] }));
}) as unknown as typeof fetch,
},
'quick',
);
expect(seen!.tests.map((x) => x.kind)).not.toContain('udp');
expect(byId(quick.checks)['probe-ip']).toMatchObject({ status: 'warn' }); // a private address
});
it('routes: admins only; no probe set is a 400', async () => {
await t.clearMustChange(ADMIN.email);
const cookie = await t.login(ADMIN.email, ADMIN.password);
const r = await t.app.inject({
method: 'GET',
url: '/api/system/diagnostics',
headers: { ...H, cookie },
});
expect(r.statusCode, r.body).toBe(200);
expect(r.json().checks.length).toBeGreaterThan(3);
await setSetting(t.db, 'network.probeUrl', null);
const n = await t.app.inject({
method: 'POST',
url: '/api/system/diagnostics/network',
headers: { ...H, cookie },
payload: { mode: 'quick' },
});
expect(n.statusCode).toBe(400);
expect((await t.app.inject({ method: 'GET', url: '/api/system/diagnostics' })).statusCode).toBe(
401,
);
});
});
+6 -3
View File
@@ -72,9 +72,12 @@ describe.skipIf(!hasTestDb)('extensions API', () => {
const make = (number: string, extra = {}) =>
req('POST', '/api/extensions', { number, name: 'X', ...extra });
expect((await make('211')).json().error.message).toMatch(/reserved/);
expect((await make('600')).json().error.message).toMatch(/from 100 to 499/);
expect((await make('12')).statusCode).toBe(400);
expect((await make('710')).statusCode).toBe(400); // outside the users range anyway
expect((await make('1234567')).statusCode).toBe(400); // 1 to 6 digits
expect((await make('710')).json().error.message).toMatch(/parking/);
// Any free number, in or out of the users range (owner, 2026-10-21): "1" for the first user.
const one = await make('1');
expect(one.statusCode, one.body).toBe(201);
expect((await req('DELETE', `/api/extensions/${one.json().id}`)).statusCode).toBe(200);
const dup = await make('101');
expect(dup.statusCode).toBe(409);
expect(dup.json().error.message).toMatch(/extension "Dan Doe"/);
+18 -1
View File
@@ -81,7 +81,24 @@ describe.skipIf(!hasTestDb)('/api/ivrs', () => {
const post = (body: object) => req('POST', '/api/ivrs', { ...base(), number: '601', ...body });
const missing = '00000000-0000-4000-8000-000000000999';
expect((await post({ number: '600' })).statusCode).toBe(409);
expect((await post({ number: '500' })).statusCode).toBe(400); // ring-group range
expect((await post({ number: '1234567' })).statusCode).toBe(400);
// A one-digit number and a menu key (direct dialing on) can't be the same, either way round.
const one = await req('POST', '/api/extensions', { number: '1', name: 'X' });
expect(one.statusCode).toBe(409);
expect(one.json().error.message).toMatch(/menu key of the digital receptionist "Main menu"/);
const four = await req('POST', '/api/extensions', { number: '4', name: 'Four' });
expect(four.statusCode, four.body).toBe(201);
const clash = await post({ options: [{ digit: '4', destination: { type: 'hangup' } }] });
expect(clash.statusCode).toBe(409);
expect(clash.json().error.message).toMatch(/Key 4 is also the number of extension "Four"/);
const noDial = await post({
allowDirectDial: false,
number: '602',
options: [{ digit: '4', destination: { type: 'hangup' } }],
});
expect(noDial.statusCode).toBe(201);
await req('DELETE', `/api/ivrs/${noDial.json().id}`);
await req('DELETE', `/api/extensions/${four.json().id}`);
expect((await post({ greetingSoundId: missing })).statusCode).toBe(400);
const twice = await post({
options: [
+39
View File
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// migrationsPending (08 §3.1): the api backs up before running new migrations, not on every start.
import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sql } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { migrationsPending } from '../src/db/client.ts';
import { createTestDb, hasTestDb } from './db.ts';
const MIGRATIONS = fileURLToPath(new URL('../src/db/migrations', import.meta.url));
describe.skipIf(!hasTestDb)('migrationsPending', () => {
let t: Awaited<ReturnType<typeof createTestDb>>;
let newer: string;
beforeAll(async () => {
t = await createTestDb();
// This image's migrations plus one the database hasn't run: a newer version.
newer = await mkdtemp(join(tmpdir(), 'sp-mig-'));
await cp(MIGRATIONS, newer, { recursive: true });
const file = join(newer, 'meta', '_journal.json');
const j = JSON.parse(await readFile(file, 'utf8'));
j.entries.push({ ...j.entries.at(-1), idx: j.entries.length, tag: '9999_next' });
await writeFile(file, JSON.stringify(j));
});
afterAll(async () => {
await t?.drop();
await rm(newer, { recursive: true, force: true });
});
it('none when up to date, counts when the image has more, none for an empty database', async () => {
expect(await migrationsPending(t.db)).toBeNull();
const pending = await migrationsPending(t.db, newer);
expect(pending).toEqual({ applied: pending!.total - 1, total: pending!.total });
await t.db.execute(sql`drop schema drizzle cascade`);
expect(await migrationsPending(t.db, newer)).toBeNull();
});
});
+43 -1
View File
@@ -65,7 +65,7 @@ describe.skipIf(!hasTestDb || !hasFfmpeg)('/api/moh-classes', () => {
const wav = join(mohDir(), 'sales-jazz', `${fileId}.wav`);
expect(readFileSync(wav).readUInt32LE(24)).toBe(8000);
expect(await conf()).toContain(
'[sales-jazz]\nmode = files\ndirectory = /var/lib/asterisk/moh/sales-jazz\nsort = random',
`[sales-jazz]\nmode = playlist\nentry = /var/lib/asterisk/moh/sales-jazz/${fileId}\n`,
);
const audio = await req('GET', `/api/moh-classes/${classId}/files/${fileId}/audio`);
expect(audio.statusCode).toBe(200);
@@ -77,6 +77,48 @@ describe.skipIf(!hasTestDb || !hasFfmpeg)('/api/moh-classes', () => {
expect(bad.statusCode).toBe(400);
});
it('plays in upload order until reordered; shuffle; the order must list every file', async () => {
const add = async (name: string) =>
(
await req('POST', `/api/moh-classes/${classId}/files?name=${name}`, silenceWav(), {
'content-type': 'audio/wav',
})
).json();
await add('Second');
const dto = await add('Third');
const ids = dto.files.map((f: { id: string }) => f.id);
expect(dto.files.map((f: { name: string }) => f.name)).toEqual(['Smooth', 'Second', 'Third']);
const entries = async () =>
[...(await conf()).matchAll(/^entry = .*\/([0-9a-f-]+)$/gm)].map((m) => m[1]);
expect(await entries()).toEqual(ids);
const reordered = await req('PATCH', `/api/moh-classes/${classId}`, {
order: [ids[2], ids[0], ids[1]],
});
expect(reordered.statusCode, reordered.body).toBe(200);
expect(reordered.json().files.map((f: { name: string }) => f.name)).toEqual([
'Third',
'Smooth',
'Second',
]);
expect(await entries()).toEqual([ids[2], ids[0], ids[1]]);
expect(await conf()).not.toContain('sort = random');
const shuffled = await req('PATCH', `/api/moh-classes/${classId}`, { shuffle: true });
expect(shuffled.json().shuffle).toBe(true);
expect(await conf()).toContain('sort = random');
for (const order of [
[ids[0], ids[1]],
[ids[0], ids[0], ids[1]],
[...ids.slice(1), extId],
])
expect((await req('PATCH', `/api/moh-classes/${classId}`, { order })).statusCode).toBe(400);
await req('PATCH', `/api/moh-classes/${classId}`, { shuffle: false });
for (const id of [ids[1], ids[2]])
await req('DELETE', `/api/moh-classes/${classId}/files/${id}`);
});
it('users and queues may pick only classes that exist; delete is refused while used', async () => {
const pick = (mohClass: string) => req('PATCH', `/api/extensions/${extId}`, { mohClass });
expect((await pick('nope')).statusCode).toBe(400);
+1 -1
View File
@@ -53,7 +53,7 @@ describe.skipIf(!hasTestDb)('/api/paging-groups', () => {
it('checks the number and the members', async () => {
const bad = (body: object) =>
req('POST', '/api/paging-groups', { number: '851', name: 'X', ...body });
expect((await bad({ number: '800' })).statusCode).toBe(400); // conference range
expect((await bad({ number: '1234567' })).statusCode).toBe(400); // 1 to 6 digits
expect((await bad({ number: '850' })).statusCode).toBe(409);
expect((await bad({ memberIds: [ext['101'], ext['101']] })).statusCode).toBe(400);
expect((await bad({ memberIds: ['00000000-0000-4000-8000-000000000000'] })).statusCode).toBe(
+1 -1
View File
@@ -99,7 +99,7 @@ describe.skipIf(!hasTestDb)('/api/queues', () => {
timeoutDestination: { type: 'hangup' },
...body,
});
expect((await bad({ number: '500' })).statusCode).toBe(400); // outside 550–599
expect((await bad({ number: '1234567' })).statusCode).toBe(400); // 1 to 6 digits
expect((await bad({ number: '550' })).statusCode).toBe(409);
const twice = { extensionId: ext['101'], penalty: 0 };
expect((await bad({ members: [twice, twice] })).statusCode).toBe(400);
+19 -10
View File
@@ -24,20 +24,21 @@ describe.skipIf(!hasTestDb)('/api/reports', () => {
direction: 'inbound' | 'outbound' | 'internal',
status: 'answered' | 'missed' | 'voicemail' | 'busy',
talkSeconds = 0,
lineSeconds = talkSeconds, // arrival to hang-up
) => ({
linkedid,
direction,
status,
startedAt: new Date(at),
endedAt: new Date(at),
endedAt: new Date(Date.parse(at) + lineSeconds * 1000),
talkSeconds,
});
await t.db.insert(calls).values([
call('a', '2026-05-04T14:00:00Z', 'inbound', 'answered', 60), // Mon 09:00 local
call('b', '2026-05-04T14:30:00Z', 'inbound', 'missed'), // Mon 09:30
call('c', '2026-05-04T15:00:00Z', 'outbound', 'answered', 120), // Mon 10:00
call('d', '2026-05-05T03:00:00Z', 'inbound', 'voicemail'), // still Mon, 22:00
call('e', '2026-05-06T13:00:00Z', 'internal', 'busy'), // Wed 08:00
call('a', '2026-05-04T14:00:00Z', 'inbound', 'answered', 60, 75), // Mon 09:00 local
call('b', '2026-05-04T14:30:00Z', 'inbound', 'missed', 0, 20), // Mon 09:30
call('c', '2026-05-04T15:00:00Z', 'outbound', 'answered', 120, 130), // Mon 10:00
call('d', '2026-05-05T03:00:00Z', 'inbound', 'voicemail', 0, 40), // still Mon, 22:00
call('e', '2026-05-06T13:00:00Z', 'internal', 'busy', 0, 5), // Wed 08:00
call('f', '2026-05-09T12:00:00Z', 'inbound', 'answered', 30), // outside the range
]);
const [q] = await t.db
@@ -78,11 +79,19 @@ describe.skipIf(!hasTestDb)('/api/reports', () => {
expect(r.statusCode, r.body).toBe(200);
const s = r.json();
expect(s.days).toEqual([
{ date: '2026-05-04', inbound: 3, outbound: 1, internal: 0, missed: 2 },
{ date: '2026-05-05', inbound: 0, outbound: 0, internal: 0, missed: 0 },
{ date: '2026-05-06', inbound: 0, outbound: 0, internal: 1, missed: 0 },
// outside: inbound 75 + 20 + 40 s from arrival, outbound 120 s talk = 255 s, 4 minutes
{ date: '2026-05-04', inbound: 3, outbound: 1, internal: 0, missed: 2, outsideMinutes: 4 },
{ date: '2026-05-05', inbound: 0, outbound: 0, internal: 0, missed: 0, outsideMinutes: 0 },
{ date: '2026-05-06', inbound: 0, outbound: 0, internal: 1, missed: 0, outsideMinutes: 0 },
]);
expect(s.totals).toEqual({ calls: 5, missed: 2, avgTalkSeconds: 90 });
expect(s.totals).toEqual({
calls: 5,
answered: 2,
missed: 2,
avgTalkSeconds: 90,
talkSeconds: 180,
outsideSeconds: { inbound: 135, outbound: 120 },
});
expect(s.hours).toHaveLength(7);
expect(s.hours[1][9]).toBe(2);
expect(s.hours[1][10]).toBe(1);
+1 -1
View File
@@ -64,7 +64,7 @@ describe.skipIf(!hasTestDb)('/api/ring-groups', () => {
noAnswerDestination: { type: 'hangup' },
...body,
});
expect((await bad({ number: '101' })).statusCode).toBe(400); // outside 500–549
expect((await bad({ number: '101' })).statusCode).toBe(409); // extension 101 has it
expect((await bad({ number: '500' })).statusCode).toBe(409);
expect((await bad({ memberIds: [ext['101'], ext['101']] })).statusCode).toBe(400);
expect((await bad({ memberIds: ['00000000-0000-4000-8000-000000000999'] })).statusCode).toBe(
+12 -6
View File
@@ -44,12 +44,18 @@ describe.skipIf(!hasTestDb)('two-step sign-in', () => {
secret = setup.secret;
expect(setup.otpauthUrl).toMatch(/^otpauth:\/\/totp\/.+\?secret=[A-Z2-7]{32}&issuer=/);
expect(setup.qr).toMatch(/^data:image\/png;base64,/);
const bad = await req('POST', '/api/auth/totp/enable', { secret, code: '000000' });
const password = ADMIN.password;
const bad = await req('POST', '/api/auth/totp/enable', { secret, code: '000000', password });
expect(bad.statusCode).toBe(400);
const ok = await req('POST', '/api/auth/totp/enable', {
secret,
code: totpCode(secret, stepAt()),
});
// A session alone isn't enough: the password again, like turning it off.
const code = totpCode(secret, stepAt());
for (const payload of [
{ secret, code },
{ secret, code, password: 'not-it' },
])
expect((await req('POST', '/api/auth/totp/enable', payload)).statusCode).toBe(400);
expect((await req('GET', '/api/auth/totp')).json().enabled).toBe(false);
const ok = await req('POST', '/api/auth/totp/enable', { secret, code, password });
expect(ok.statusCode, ok.body).toBe(200);
recovery = ok.json().recoveryCodes;
expect(recovery).toHaveLength(10);
@@ -58,7 +64,7 @@ describe.skipIf(!hasTestDb)('two-step sign-in', () => {
recoveryCodesLeft: 10,
});
expect(
(await req('POST', '/api/auth/totp/enable', { secret, code: '123456' })).statusCode,
(await req('POST', '/api/auth/totp/enable', { secret, code: '123456', password })).statusCode,
).toBe(409);
});
+23
View File
@@ -19,6 +19,10 @@ function fakeVoipms() {
{ server_name: 'Chicago 1', server_hostname: 'chicago1.voip.ms', server_ip: '1.2.3.5' },
],
},
getBalance: {
status: 'success',
balance: { current_balance: '12.3456', spent_total: '7.89', calls_total: '42' },
},
getDIDsInfo: {
status: 'success',
dids: [
@@ -124,6 +128,15 @@ describe.skipIf(!hasTestDb)('/api/trunks/:id voip.ms routes', () => {
const r = await req('GET', `/api/trunks/${trunkId}/voipms-servers`);
expect(r.statusCode).toBe(400);
expect(r.json().error.message).toMatch(/API username and password/);
// The balance on Reports says so instead of failing (owner, 2026-10-21).
expect((await req('GET', '/api/reports/balance')).json().items).toEqual([
{
trunkId,
trunkName: 'voip.ms',
balance: null,
error: expect.stringMatching(/API username and password/),
},
]);
});
it('a wrong API password is a 502 with voip.ms’s reason', async () => {
@@ -135,10 +148,20 @@ describe.skipIf(!hasTestDb)('/api/trunks/:id voip.ms routes', () => {
expect(r.statusCode).toBe(502);
expect(r.json().error.message).toBe('voip.ms rejected the API username or password');
expect(r.body).not.toContain('nope');
const bal = await req('GET', '/api/reports/balance');
expect(bal.json().items[0]).toMatchObject({
balance: null,
error: 'voip.ms rejected the API username or password',
});
expect(bal.body).not.toContain('nope');
});
it('lists servers, previews and imports numbers (not routed, label from voip.ms)', async () => {
await req('PATCH', `/api/trunks/${trunkId}`, { voipmsApiPassword: 'api-pw' });
expect((await req('GET', '/api/reports/balance')).json().items).toEqual([
{ trunkId, trunkName: 'voip.ms', balance: 12.3456, error: null },
]);
expect(fake.seen.at(-1)!.get('method')).toBe('getBalance');
expect((await req('GET', `/api/trunks/${trunkId}/voipms-servers`)).json()).toEqual({
servers: ['chicago1.voip.ms', 'seattle1.voip.ms'],
});
+12
View File
@@ -0,0 +1,12 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# The network test probe (08 §2.6), run outside the network SelfPhoneNG is in. No dependencies:
# Node 24 runs the TypeScript as it is. Build from the repo root:
# docker build -f apps/probe/Dockerfile -t selfphoneng-probe .
FROM node:24-alpine
WORKDIR /app
COPY apps/probe/package.json ./
COPY apps/probe/src/checks.ts apps/probe/src/server.ts apps/probe/src/index.ts ./src/
ENV NODE_ENV=production
USER node
EXPOSE 8080
CMD ["node", "src/index.ts"]
+13
View File
@@ -0,0 +1,13 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Builds the probe's downloadable files (build-binary.mjs) and hands them back, no image:
# docker build -f apps/probe/binary.Dockerfile --output type=local,dest=probe-dist .
FROM node:24-trixie-slim AS build
RUN apt-get update && apt-get install -y --no-install-recommends xz-utils \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY . .
RUN corepack enable && pnpm install --frozen-lockfile --filter @selfphoneng/probe \
&& node apps/probe/build-binary.mjs
FROM scratch
COPY --from=build /src/apps/probe/dist /
+94
View File
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Builds the network test probe (08 §2.6) for machines without Docker:
// dist/selfphoneng-probe.cjs one file; runs with Node 22 or newer
// dist/selfphoneng-probe-linux-x64 standalone executables with Node inside (Node's
// dist/selfphoneng-probe-linux-arm64 single executable applications), nothing to install
// dist/SHA256SUMS
// The executables carry this Node's version, downloaded from nodejs.org and checked against its
// SHASUMS256.txt. Run on Linux (needs tar with xz): node apps/probe/build-binary.mjs
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
chmodSync,
copyFileSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from 'esbuild';
import postject from 'postject';
const here = dirname(fileURLToPath(import.meta.url));
const dist = join(here, 'dist');
const NAME = 'selfphoneng-probe';
const version = process.version;
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
rmSync(dist, { recursive: true, force: true });
mkdirSync(dist);
const script = join(dist, `${NAME}.cjs`);
await build({
entryPoints: [join(here, 'src/index.ts')],
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node22',
outfile: script,
banner: { js: '#!/usr/bin/env node\n// SPDX-License-Identifier: AGPL-3.0-or-later' },
});
chmodSync(script, 0o755);
const work = mkdtempSync(join(tmpdir(), 'sp-probe-'));
try {
const blob = join(work, 'sea.blob');
writeFileSync(
join(work, 'sea.json'),
JSON.stringify({
main: script,
output: blob,
disableExperimentalSEAWarning: true,
// Neither is portable between CPU architectures; the probe starts in milliseconds anyway.
useSnapshot: false,
useCodeCache: false,
}),
);
execFileSync(process.execPath, ['--experimental-sea-config', join(work, 'sea.json')], {
stdio: 'inherit',
});
const base = `https://nodejs.org/dist/${version}`;
const sums = await (await fetch(`${base}/SHASUMS256.txt`)).text();
for (const arch of ['x64', 'arm64']) {
const file = `node-${version}-linux-${arch}.tar.xz`;
const want = sums
.split('\n')
.find((l) => l.endsWith(` ${file}`))
?.split(' ')[0];
const tarball = Buffer.from(await (await fetch(`${base}/${file}`)).arrayBuffer());
if (!want || sha256(tarball) !== want) throw new Error(`${file}: checksum doesn't match`);
writeFileSync(join(work, file), tarball);
const inner = `node-${version}-linux-${arch}/bin/node`;
execFileSync('tar', ['-xJf', join(work, file), '-C', work, inner]);
const out = join(dist, `${NAME}-linux-${arch}`);
copyFileSync(join(work, inner), out);
await postject.inject(out, 'NODE_SEA_BLOB', readFileSync(blob), {
sentinelFuse: 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2',
});
chmodSync(out, 0o755);
console.log(`${out} (Node ${version})`);
}
} finally {
rmSync(work, { recursive: true, force: true });
}
const files = [`${NAME}.cjs`, `${NAME}-linux-x64`, `${NAME}-linux-arm64`];
writeFileSync(
join(dist, 'SHA256SUMS'),
files.map((f) => `${sha256(readFileSync(join(dist, f)))} ${f}\n`).join(''),
);
console.log(readFileSync(join(dist, 'SHA256SUMS'), 'utf8'));
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@selfphoneng/probe",
"version": "0.0.3",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {
"start": "node src/index.ts",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"esbuild": "0.28.2",
"postject": "1.0.0-alpha.6"
}
}
+202
View File
@@ -0,0 +1,202 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The network test probe's checks (08 §2.6). Each reaches a SelfPhoneNG server from outside its
// network, the way a phone on mobile data or the SIP provider would, and only ever at the address
// that asked (server.ts), so nobody can aim it at someone else.
import { randomBytes } from 'node:crypto';
import { createSocket } from 'node:dgram';
import { connect as tcpConnect, isIP, type Socket } from 'node:net';
import { connect as tlsConnect } from 'node:tls';
export type Test =
| { id: string; kind: 'tcp'; port: number }
/** An HTTP(S) request; `websocket` asks for a SIP WebSocket upgrade (the browser phone's /ws). */
| { id: string; kind: 'http' | 'https'; port: number; path?: string; websocket?: boolean }
/** SIP OPTIONS: any SIP answer means Asterisk got it. */
| { id: string; kind: 'sip-udp' | 'sip-tcp' | 'sip-tls'; port: number }
/** Datagrams carrying `token`; the server listens and knows whether they came. */
| { id: string; kind: 'udp'; port: number; token: string };
export type Result = { id: string; ok: boolean; detail: string; ms: number };
export const TIMEOUT_MS = 4000;
const rand = () => randomBytes(8).toString('hex');
/** Sends `request` over TCP or TLS and resolves with what came back once `done` matches it. */
function exchange(
ip: string,
port: number,
opts: { tls: boolean; host: string; request: string; done: RegExp },
): Promise<{ data: string; certError: string | null }> {
return new Promise((resolve, reject) => {
let data = '';
const onConnect = () => sock.write(opts.request);
const sock: Socket = opts.tls
? tlsConnect(
{
host: ip,
port,
// The name to check the certificate against; SNI can't carry an IP.
...(isIP(opts.host) ? {} : { servername: opts.host }),
rejectUnauthorized: false,
},
onConnect,
)
: tcpConnect({ host: ip, port }, onConnect);
const finish = (err?: Error) => {
clearTimeout(timer);
sock.destroy();
if (err) reject(err);
else {
const tls = sock as Socket & { authorized?: boolean; authorizationError?: Error };
resolve({
data,
certError:
opts.tls && !tls.authorized ? String(tls.authorizationError ?? 'not valid') : null,
});
}
};
const timer = setTimeout(() => finish(new Error('no answer')), TIMEOUT_MS);
sock.setEncoding('utf8');
sock.on('data', (d: string) => {
data += d;
if (opts.done.test(data)) finish();
});
sock.on('error', (e) => finish(e));
sock.on('close', () => (opts.done.test(data) ? finish() : finish(new Error('closed'))));
});
}
const sipOptions = (transport: string, host: string, port: number) =>
[
`OPTIONS sip:${host}:${port};transport=${transport.toLowerCase()} SIP/2.0`,
// rport: answer the address and port this came from (RFC 3581), not the made-up host.
`Via: SIP/2.0/${transport} probe.invalid;branch=z9hG4bK${rand()};rport`,
'Max-Forwards: 70',
`From: <sip:probe@probe.invalid>;tag=${rand()}`,
`To: <sip:${host}>`,
`Call-ID: ${rand()}@probe.invalid`,
'CSeq: 1 OPTIONS',
'User-Agent: selfphoneng-probe',
'Content-Length: 0',
'',
'',
].join('\r\n');
/** OPTIONS over UDP, sent three times (UDP drops); resolves with the first answer's status line. */
function sipUdp(ip: string, port: number, host: string): Promise<string> {
return new Promise((resolve, reject) => {
const sock = createSocket(isIP(ip) === 6 ? 'udp6' : 'udp4');
const msg = Buffer.from(sipOptions('UDP', host, port));
const timers = [0, 500, 1500].map((ms) =>
setTimeout(() => sock.send(msg, port, ip, () => {}), ms),
);
const done = (fn: () => void) => {
timers.forEach(clearTimeout);
clearTimeout(timer);
sock.close();
fn();
};
const timer = setTimeout(() => done(() => reject(new Error('no answer'))), TIMEOUT_MS);
sock.on('message', (m) => {
const line = m.toString('utf8').split('\r\n')[0]!;
if (line.startsWith('SIP/2.0 ')) done(() => resolve(line));
});
sock.on('error', (e) => done(() => reject(e)));
});
}
function sendUdp(ip: string, port: number, token: string): Promise<void> {
return new Promise((resolve) => {
const sock = createSocket(isIP(ip) === 6 ? 'udp6' : 'udp4');
const msg = Buffer.from(`selfphoneng-probe ${token}`);
let sent = 0;
const one = () =>
sock.send(msg, port, ip, () => {
if (++sent === 3) {
sock.close();
resolve();
}
});
one();
setTimeout(one, 300);
setTimeout(one, 600);
});
}
const HTTP_STATUS = /^HTTP\/1\.[01] (\d{3})[^\r\n]*\r\n/;
export async function runTest(ip: string, host: string, t: Test): Promise<Result> {
const start = Date.now();
const result = (ok: boolean, detail: string): Result => ({
id: t.id,
ok,
detail,
ms: Date.now() - start,
});
try {
switch (t.kind) {
case 'tcp':
await new Promise<void>((resolve, reject) => {
const s = tcpConnect({ host: ip, port: t.port }, () => (s.destroy(), resolve()));
s.setTimeout(TIMEOUT_MS, () => (s.destroy(), reject(new Error('no answer'))));
s.on('error', reject);
});
return result(true, 'connected');
case 'http':
case 'https': {
const upgrade = t.websocket
? [
'Connection: Upgrade',
'Upgrade: websocket',
`Sec-WebSocket-Key: ${randomBytes(16).toString('base64')}`,
'Sec-WebSocket-Version: 13',
'Sec-WebSocket-Protocol: sip',
]
: ['Connection: close'];
const request = [
`GET ${t.path ?? '/'} HTTP/1.1`,
`Host: ${host}`,
'User-Agent: selfphoneng-probe',
...upgrade,
'',
'',
].join('\r\n');
const r = await exchange(ip, t.port, {
tls: t.kind === 'https',
host,
request,
done: HTTP_STATUS,
});
const status = Number(HTTP_STATUS.exec(r.data)?.[1]);
const cert = r.certError
? `; the certificate isn't valid for ${host} (${r.certError})`
: '';
if (t.websocket)
return result(status === 101, `${status === 101 ? 'upgraded' : `HTTP ${status}`}${cert}`);
return result(true, `HTTP ${status}${cert}`);
}
case 'sip-udp':
return result(true, await sipUdp(ip, t.port, host));
case 'sip-tcp':
case 'sip-tls': {
const tls = t.kind === 'sip-tls';
const r = await exchange(ip, t.port, {
tls,
host,
request: sipOptions(tls ? 'TLS' : 'TCP', host, t.port),
done: /^SIP\/2\.0 \d{3}[^\r\n]*\r\n/,
});
return result(true, r.data.split('\r\n')[0]!);
}
case 'udp':
await sendUdp(ip, t.port, t.token);
return result(true, 'sent');
}
} catch (e) {
const code = (e as NodeJS.ErrnoException).code;
return result(
false,
code === 'ECONNREFUSED' ? 'refused' : code ? code.toLowerCase() : (e as Error).message,
);
}
}
+10
View File
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The network test probe (08 §2.6): run it outside the network SelfPhoneNG is in, and put its URL
// in Settings → Network. `node src/index.ts` (Node 24 runs TypeScript as is).
// PORT (default 8080), TRUST_PROXY=true behind a reverse proxy that sets X-Forwarded-For.
import { createProbe } from './server.ts';
const port = Number(process.env.PORT ?? 8080);
const probe = createProbe({ trustProxy: process.env.TRUST_PROXY === 'true' });
probe.listen(port, () => console.log(`selfphoneng-probe listening on ${port}`));
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => probe.close(() => process.exit(0)));
+167
View File
@@ -0,0 +1,167 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The probe against stand-ins on 127.0.0.1: TCP, HTTP and a WebSocket upgrade, SIP over UDP and
// TCP, raw UDP; and the server: it only tests the address that asked, validates, rate-limits.
import { createSocket, type Socket as UdpSocket } from 'node:dgram';
import { createServer as httpServer, type Server as HttpServer } from 'node:http';
import { createServer as tcpServer, type AddressInfo, type Server } from 'node:net';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { runTest } from './checks.ts';
import { createProbe, parseCheck } from './server.ts';
const port = (s: Server | HttpServer) => (s.address() as AddressInfo).port;
const listen = <T extends Server | HttpServer>(s: T) =>
new Promise<T>((r) => s.listen(0, '127.0.0.1', () => r(s)));
const bindUdp = (
onMessage: (m: Buffer, from: { address: string; port: number }, s: UdpSocket) => void,
) =>
new Promise<UdpSocket>((r) => {
const s = createSocket('udp4');
s.on('message', (m, from) => onMessage(m, from, s));
s.bind(0, '127.0.0.1', () => r(s));
});
const SIP_OK = (req: string) =>
`SIP/2.0 401 Unauthorized\r\n${req
.split('\r\n')
.filter((l) => /^(Via|From|To|Call-ID|CSeq):/.test(l))
.join('\r\n')}\r\nContent-Length: 0\r\n\r\n`;
describe('probe checks', () => {
let http: HttpServer;
let sipTcp: Server;
let sipUdp: UdpSocket;
let raw: UdpSocket;
const got: string[] = [];
beforeAll(async () => {
http = await listen(httpServer((_req, res) => res.end('{"ok":true}')));
http.on('upgrade', (req, sock) => {
const ok = req.headers['sec-websocket-protocol'] === 'sip';
sock.end(
ok
? 'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n'
: 'HTTP/1.1 400 Bad Request\r\n\r\n',
);
});
sipTcp = await listen(tcpServer((s) => s.on('data', (d) => s.write(SIP_OK(d.toString())))));
sipUdp = await bindUdp((m, from, s) => s.send(SIP_OK(m.toString()), from.port, from.address));
raw = await bindUdp((m) => got.push(m.toString()));
});
afterAll(() => {
http.close();
sipTcp.close();
sipUdp.close();
raw.close();
});
it('TCP: connected, or refused', async () => {
expect(
await runTest('127.0.0.1', 'pbx.test', { id: 't', kind: 'tcp', port: port(http) }),
).toMatchObject({ ok: true, detail: 'connected' });
const closed = await listen(tcpServer());
const p = port(closed);
closed.close();
expect(await runTest('127.0.0.1', 'pbx.test', { id: 't', kind: 'tcp', port: p })).toMatchObject(
{ ok: false, detail: 'refused' },
);
});
it('HTTP status, and a SIP WebSocket upgrade', async () => {
expect(
await runTest('127.0.0.1', 'pbx.test', {
id: 'h',
kind: 'http',
port: port(http),
path: '/healthz',
}),
).toMatchObject({ ok: true, detail: 'HTTP 200' });
expect(
await runTest('127.0.0.1', 'pbx.test', {
id: 'w',
kind: 'http',
port: port(http),
path: '/ws',
websocket: true,
}),
).toMatchObject({ ok: true, detail: 'upgraded' });
});
it('SIP OPTIONS over UDP and TCP: any SIP answer', async () => {
expect(
await runTest('127.0.0.1', 'pbx.test', {
id: 'u',
kind: 'sip-udp',
port: sipUdp.address().port,
}),
).toMatchObject({ ok: true, detail: 'SIP/2.0 401 Unauthorized' });
expect(
await runTest('127.0.0.1', 'pbx.test', { id: 'c', kind: 'sip-tcp', port: port(sipTcp) }),
).toMatchObject({ ok: true, detail: 'SIP/2.0 401 Unauthorized' });
});
it('UDP: the token arrives', async () => {
expect(
await runTest('127.0.0.1', 'pbx.test', {
id: 'r',
kind: 'udp',
port: raw.address().port,
token: 'abcd1234ef',
}),
).toMatchObject({ ok: true });
expect(got).toContain('selfphoneng-probe abcd1234ef');
});
});
describe('probe server', () => {
it('validates: a host, 1–20 known tests, no target of its own', () => {
expect(parseCheck({ host: 'pbx.test', tests: [{ id: 'a', kind: 'tcp', port: 443 }] })).toEqual({
host: 'pbx.test',
tests: [{ id: 'a', kind: 'tcp', port: 443 }],
});
// A target address in the body is simply not read.
expect(
parseCheck({
host: 'pbx.test',
ip: '192.0.2.1',
tests: [{ id: 'a', kind: 'tcp', port: 1, ip: '192.0.2.1' }],
}),
).toEqual({
host: 'pbx.test',
tests: [{ id: 'a', kind: 'tcp', port: 1 }],
});
for (const bad of [
{ tests: [{ id: 'a', kind: 'tcp', port: 1 }] },
{ host: 'a b', tests: [{ id: 'a', kind: 'tcp', port: 1 }] },
{ host: 'pbx.test', tests: [] },
{
host: 'pbx.test',
tests: Array.from({ length: 21 }, (_, i) => ({ id: `a${i}`, kind: 'tcp', port: 1 })),
},
{ host: 'pbx.test', tests: [{ id: 'a', kind: 'exec', port: 1 }] },
{ host: 'pbx.test', tests: [{ id: 'a', kind: 'tcp', port: 70000 }] },
{ host: 'pbx.test', tests: [{ id: 'a', kind: 'udp', port: 1, token: 'x' }] },
{ host: 'pbx.test', tests: [{ id: 'a', kind: 'http', port: 1, path: 'http://evil/' }] },
])
expect(typeof parseCheck(bad)).toBe('string');
});
it('tests the address that asked (X-Forwarded-For only behind a proxy); limits checks', async () => {
const target = await listen(tcpServer());
const probe = await listen(createProbe({ maxChecks: 2 }));
const ask = () =>
fetch(`http://127.0.0.1:${port(probe)}/v1/check`, {
method: 'POST',
headers: { 'x-forwarded-for': '192.0.2.1' },
body: JSON.stringify({
host: 'pbx.test',
tests: [{ id: 'tcp', kind: 'tcp', port: port(target) }],
}),
});
const r = await ask();
expect(r.status).toBe(200);
expect(await r.json()).toMatchObject({ ip: '127.0.0.1', results: [{ id: 'tcp', ok: true }] });
expect((await ask()).status).toBe(200);
expect((await ask()).status).toBe(429);
expect((await fetch(`http://127.0.0.1:${port(probe)}/healthz`)).status).toBe(200);
probe.close();
target.close();
});
});
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more