23 Commits
Author SHA1 Message Date
iamdoubz aaa294ae6a release: version 0.0.6
ci / check (push) Successful in 1m17s
ci / images (push) Successful in 11s
release / images (release) Successful in 54s
2026-10-21 20:24:46 -05:00
iamdoubz 4d331fb70e ai: call transcripts (each side recorded, merged by time) and summaries; search on Recordings
ci / check (push) Successful in 1m22s
ci / images (push) Successful in 27s
2026-10-21 19:10:02 -05:00
iamdoubz d9f9c6e444 release: version 0.0.5
ci / check (push) Successful in 1m27s
ci / images (push) Successful in 26s
2026-10-21 18:46:04 -05:00
iamdoubz a26a5ca740 ai: Settings → AI (local endpoints only, with tests), the AI stack, voicemail transcripts
ci / check (push) Successful in 1m0s
ci / images (push) Successful in 26s
2026-10-21 18:37:13 -05:00
iamdoubz 274242ce32 roadmap: a backlog, the HT802 V2 setup first
ci / check (push) Successful in 1m0s
ci / images (push) Successful in 26s
2026-10-21 17:57:32 -05:00
iamdoubz 0deff2a637 dial plan: made from routes and number lengths; Yealink dial-now, the ATA plan on the Credentials tab 2026-10-21 17:14:13 -05:00
iamdoubz eacc571093 docs: analog phones (ATAs) set up by hand; the ATA plan in the roadmap
ci / check (push) Successful in 57s
ci / images (push) Successful in 30s
2026-10-21 17:01:42 -05:00
iamdoubz 4a175a0d77 release: version 0.0.4
ci / check (push) Successful in 58s
ci / images (push) Successful in 4s
release / images (release) Successful in 38s
2026-10-21 16:22:54 -05:00
iamdoubz f2868cf5d3 settings: the SIP port, with a check against other listeners
ci / check (push) Successful in 58s
ci / images (push) Successful in 30s
2026-10-21 16:12:12 -05:00
iamdoubz 6893c55e1b settings: a switch for SIP over TCP
ci / check (push) Successful in 1m8s
ci / images (push) Successful in 36s
2026-10-21 15:22:39 -05:00
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
196 changed files with 19228 additions and 380 deletions

No files matched your search

+11
View File
@@ -14,3 +14,14 @@ 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
# Local AI (admin guide: AI). Starting values for Settings → AI; that page wins once saved.
# Only localhost or a local-network IP is accepted. Leave out what you don't use.
#AI_LLM_URL=http://127.0.0.1:11434/v1 # Ollama
#AI_LLM_MODEL=qwen3:4b
#AI_STT_URL=http://127.0.0.1:8000/v1 # deploy/ai (Speaches)
#AI_STT_MODEL=Systran/faster-whisper-small
#AI_TTS_URL=http://127.0.0.1:8000/v1
#AI_TTS_MODEL=speaches-ai/Kokoro-82M-v1.0-ONNX
#AI_TTS_VOICE=af_heart
+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
+91 -1
View File
@@ -1,7 +1,97 @@
# 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.6 — 2026-10-21
**Before you update:** this version changes the database (call transcripts); the api backs up
first, by itself. Portainer: change both tags to `0.0.6` and redeploy. Compose:
`sudo scripts/update.sh v0.0.6`. Call transcripts are off until you turn them on in Settings → AI;
calls recorded before that get no transcript.
- **Call transcripts and summaries** (Settings → AI, admin guide: [Local AI](docs/admin-guide/ai.md)):
recorded calls are written out a minute or two after they end, caller and other side apart, with
a summary and action items from the language model. The Recordings page shows them and searches
what was said; the people on a call can read its transcript. Database change: four columns on
`recordings` (the api backs up first).
## v0.0.5 — 2026-10-21
**Before you update:** this version changes the database (voicemail transcripts); the api backs
up first, by itself. Portainer: change both tags to `0.0.5` and redeploy. Compose:
`sudo scripts/update.sh v0.0.5`. The AI is optional and off until you set it up: a second stack
from `deploy/ai/compose.yml`, then Settings → AI (admin guide: [Local AI](docs/admin-guide/ai.md)).
After updating, phones set up automatically pick up the dial plan at their next daily check (or
reboot them).
- Admin guide: [Analog phones (ATAs)](docs/admin-guide/ata.md), setting up a Grandstream HT802 V2
by hand.
- **Dial plan:** a user's Credentials tab shows a dial plan for analog phone adapters, made from
your outbound routes and internal number lengths: 911 and full outside numbers go out the moment
they're dialed instead of after a pause. Yealink desk phones get the same as dial-now rules.
- New internal numbers can no longer start with 911 or 9911, so 911 can go out at once (numbers
you already have keep working).
- **Local AI** (admin guide: [Local AI](docs/admin-guide/ai.md)): Settings → AI sets a language
model, speech to text and text to speech, each with a Test, on this server or the local network
only (anything else is refused). `deploy/ai/compose.yml` is a second stack with Speaches
(Whisper and Kokoro) on the GPU. `AI_*` in `.env` sets them on a new install.
- **Voicemail transcripts:** with AI on, a new message's words are in the voicemail email and on the
voicemail page. Database change: `voicemail_messages.transcript` (the api backs up first).
## v0.0.4 — 2026-10-21
**Updating:** no database changes. Portainer: change both tags to `0.0.4` and redeploy. Compose:
`sudo scripts/update.sh v0.0.4`.
- Settings → Security has the **SIP port** and **Accept SIP over TCP** (5060 and on by default,
as before). Both were settings with no field. Another port, such as 5080, helps when a router or
the ISP keeps UDP 5060 for itself: a Complete network test shows UDP getting through while SIP
on UDP 5060 doesn't. Changing it needs a telephony restart, a new router forward and setting the
phones up again; trunks keep working. A port that another part of SelfPhoneNG listens on is
refused. The admin guide (Network) explains when to change it.
## 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
+11 -5
View File
@@ -7,7 +7,7 @@
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 (v0.0.2), for testing. See [what's left for v1](docs/v1-checklist.md).
**Status:** pre-1.0 (v0.0.6), for testing. See [what's left for v1](docs/v1-checklist.md).
## What it does today
@@ -17,24 +17,30 @@ A fully open source, self-hosted business phone system. Bring your own SIP trunk
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,
- **Voicemail** with email (and transcripts by local AI), **call history**, **recordings**, a **live dashboard** (listen,
whisper, barge) and reports.
- **Running it:** backups and restore, background health checks, webhooks and API tokens,
- **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.
- More local AI: call transcripts and summaries, typed prompts, type-to-speak, an AI
receptionist ([spec](docs/spec/10-ai.md)).
- Analog phone adapters set up automatically: **Grandstream HT802 V2** first (two ports, a dial
plan from your numbers), then Cisco ATA 191/192 and SPA112/122. Today they're set up by hand
([how](docs/admin-guide/ata.md)).
- SMS (built for voip.ms, parked until wanted).
- Push notifications, so phone apps ring in the background.
- An update script for running installs.
- 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)
[troubleshooting](docs/admin-guide/troubleshooting.md), [updating and backups](docs/admin-guide/maintenance.md),
[analog phones (ATAs)](docs/admin-guide/ata.md), [local AI](docs/admin-guide/ai.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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@selfphoneng/api",
"version": "0.0.2",
"version": "0.0.6",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+89
View File
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { describe, expect, it } from 'vitest';
import { mergeSides, sideFiles, speechStretches, transcriptText, wavSlice } from './calls.ts';
describe('call transcripts', () => {
it('side files sit next to the recording', () => {
expect(sideFiles('2026/10/21/1792.5.wav')).toEqual([
'2026/10/21/1792.5-in.wav',
'2026/10/21/1792.5-out.wav',
]);
});
it('merges by time; the caller first when both start together', () => {
expect(
mergeSides(
[
{ at: 0, text: 'Hello?' },
{ at: 5, text: 'Bye.' },
],
[
{ at: 0, text: 'Hi.' },
{ at: 2.5, text: 'How can I help?' },
],
).map((l) => `${l.side} ${l.at}`),
).toEqual(['caller 0', 'other 0', 'other 2.5', 'caller 5']);
});
it('labels the lines and cuts a long call', () => {
const lines = mergeSides([{ at: 0, text: 'a'.repeat(50) }], [{ at: 1, text: 'b' }]);
expect(transcriptText(lines)).toBe(`Caller: ${'a'.repeat(50)}\nOther side: b`);
expect(transcriptText(lines, 20)).toBe(
`Caller: ${'a'.repeat(12)}\n[the rest of the call is cut]`,
);
});
});
describe('speech stretches', () => {
/** 8 kHz mono 16-bit: [seconds, loud?] pieces one after another. */
const wav = (parts: [number, boolean][]) => {
const samples = parts.flatMap(([s, loud]) =>
Array.from({ length: s * 8000 }, (_, i) => (loud ? Math.round(5000 * Math.sin(i / 3)) : 0)),
);
const b = Buffer.alloc(44 + samples.length * 2);
b.writeUInt32LE(8000, 24);
b.writeUInt32LE(16000, 28);
samples.forEach((v, i) => b.writeInt16LE(v, 44 + i * 2));
return b;
};
const round = (x: { from: number; to: number }[]) =>
x.map((s) => [Math.round(s.from * 10) / 10, Math.round(s.to * 10) / 10]);
it('finds each stretch of speech, joining short pauses, with a little margin', () => {
const w = wav([
[1, false],
[2, true],
[0.4, false], // a breath: same stretch
[1, true],
[3, false],
[1, true],
[1, false],
]);
expect(round(speechStretches(w))).toEqual([
[0.8, 4.6],
[7.2, 8.6],
]);
});
it('silence and a click are nothing', () => {
expect(speechStretches(wav([[3, false]]))).toEqual([]);
expect(
speechStretches(
wav([
[1, false],
[0.1, true],
[1, false],
]),
),
).toEqual([]);
});
it('a slice is a WAV of just those seconds', () => {
const w = wav([[2, true]]);
const s = wavSlice(w, 0.5, 1.5);
expect(s.length).toBe(44 + 16000);
expect(s.readUInt32LE(40)).toBe(16000);
expect(s.readUInt32LE(4)).toBe(36 + 16000);
expect(s.subarray(44, 54)).toEqual(w.subarray(44 + 8000, 54 + 8000));
});
});
+177
View File
@@ -0,0 +1,177 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Call transcripts and summaries (spec 10 §3). With ai.callTranscripts on, sp-sub-record also
// writes <call>-in.wav (what the caller said) and <call>-out.wav (what they heard), so who said
// what needs no speaker detection. A job writes each side out, merges them by time, asks ai.llm
// for a summary (ai.callSummaries), and deletes the two side files: the mixed recording stays.
import { access, readFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { and, asc, eq, gt, isNull, lt, sql } from 'drizzle-orm';
import type { Db } from '../db/client.ts';
import { recordings } from '../db/schema.ts';
import { getSetting } from '../services/settings.ts';
import { AiError, chat, transcribeSegments } from './client.ts';
export type Line = { side: 'caller' | 'other'; at: number; text: string };
/** The two side files next to a recording (relative, like recordings.file_path). */
export const sideFiles = (filePath: string) => {
const base = filePath.replace(/\.wav$/, '');
return [`${base}-in.wav`, `${base}-out.wav`] as const;
};
/**
* Where someone speaks in a 16-bit mono WAV (MixMonitor's: 44-byte header), in seconds: 20 ms
* frames louder than the line's noise, joined across pauses under `gap` s. Whisper's own timings
* drift across the long silences one side of a call has while the other talks, so each stretch is
* transcribed on its own, from its known start.
*/
export function speechStretches(wav: Buffer, gap = 0.6): { from: number; to: number }[] {
const rate = wav.readUInt32LE(24) || 8000;
const frame = Math.round(rate / 50) * 2; // bytes in 20 ms
const levels: number[] = [];
for (let i = 44; i + frame <= wav.length; i += frame) {
let sum = 0;
for (let j = i; j < i + frame; j += 2) sum += Math.abs(wav.readInt16LE(j));
levels.push(sum / (frame / 2));
}
// ponytail: a fixed share of the loudest speech, with a floor for silent lines; tune if a noisy
// line splits badly.
const loud = [...levels].sort((a, b) => a - b)[Math.floor(levels.length * 0.95)] ?? 0;
const threshold = Math.max(120, loud / 10);
const out: { from: number; to: number }[] = [];
levels.forEach((l, i) => {
if (l < threshold) return;
const at = i * 0.02;
const last = out.at(-1);
if (last && at - last.to < gap) last.to = at + 0.02;
else out.push({ from: at, to: at + 0.02 });
});
const end = levels.length * 0.02;
return out
.filter((s) => s.to - s.from >= 0.2) // a click, not a word
.map((s) => ({ from: Math.max(0, s.from - 0.2), to: Math.min(end, s.to + 0.2) }));
}
/** Seconds `from`–`to` of a WAV as a WAV of its own. */
export function wavSlice(wav: Buffer, from: number, to: number): Buffer {
const bytesPerSec = wav.readUInt32LE(28) || 16000;
const at = (s: number) => 44 + Math.min(wav.length - 44, Math.round(s * bytesPerSec) & ~1);
const data = wav.subarray(at(from), at(to));
const head = Buffer.from(wav.subarray(0, 44));
head.writeUInt32LE(36 + data.length, 4);
head.writeUInt32LE(data.length, 40);
return Buffer.concat([head, data]);
}
/** One side of a call, stretch by stretch, each line at its time in the whole call. */
async function transcribeSide(stt: Parameters<typeof transcribeSegments>[0], wav: Buffer) {
const lines: Omit<Line, 'side'>[] = [];
for (const s of speechStretches(wav))
for (const seg of await transcribeSegments(stt, wavSlice(wav, s.from, s.to)))
lines.push({ at: Math.round((s.from + seg.at) * 10) / 10, text: seg.text });
return lines;
}
/** Both sides in time order (the files start together, so their timings line up). */
export const mergeSides = (caller: Omit<Line, 'side'>[], other: Omit<Line, 'side'>[]): Line[] =>
[
...caller.map((l) => ({ side: 'caller' as const, ...l })),
...other.map((l) => ({ side: 'other' as const, ...l })),
].sort((a, b) => a.at - b.at || (a.side === 'caller' ? -1 : 1));
/** "Caller: …" lines, cut to fit a small model's context. */
export function transcriptText(lines: Line[], max = 12_000): string {
const text = lines
.map((l) => `${l.side === 'caller' ? 'Caller' : 'Other side'}: ${l.text}`)
.join('\n');
// ponytail: a long call is cut, not chunked; summarize in parts if calls this long matter.
return text.length > max ? `${text.slice(0, max)}\n[the rest of the call is cut]` : text;
}
const SUMMARY_PROMPT = `You summarize business phone calls from their transcript. Write in the \
language of the call, in plain text, no markdown headings:
- 1 to 3 sentences on what the call was about and what was agreed;
- then a line "Action items:" and one "- " line per item (who does what), or "Action items: none".
Use only what the transcript says. The transcript is automatic and may have mistakes.`;
/** Recordings the job still has to do: recent, finished a moment ago, not tried yet. */
const pending = (db: Db) =>
db
.select({ id: recordings.id, filePath: recordings.filePath })
.from(recordings)
.where(
and(
isNull(recordings.aiDoneAt),
gt(recordings.startedAt, sql`now() - interval '2 days'`),
lt(recordings.createdAt, sql`now() - interval '20 seconds'`), // files closed
),
)
.orderBy(asc(recordings.startedAt))
.limit(20);
const exists = (f: string) =>
access(f).then(
() => true,
() => false,
);
/** One run: every pending recording, one at a time (the GPU is shared). */
export async function transcribeCalls(d: {
db: Db;
dir: string;
log: { error(o: object, m: string): void };
}): Promise<number> {
const stt = await getSetting(d.db, 'ai.stt');
if (!stt || !(await getSetting(d.db, 'ai.callTranscripts'))) return 0;
const llm = (await getSetting(d.db, 'ai.callSummaries'))
? await getSetting(d.db, 'ai.llm')
: null;
let done = 0;
for (const r of await pending(d.db)) {
const files = sideFiles(r.filePath).map((f) => join(d.dir, f));
const finish = (set: Partial<typeof recordings.$inferInsert>) =>
d.db
.update(recordings)
.set({ aiDoneAt: new Date(), ...set })
.where(eq(recordings.id, r.id));
if (!(await exists(files[0]!)) || !(await exists(files[1]!))) {
await finish({ aiError: 'Recorded before call transcripts were on' });
continue;
}
let lines: Line[];
try {
const [caller, other] = [
await transcribeSide(stt, await readFile(files[0]!)),
await transcribeSide(stt, await readFile(files[1]!)),
];
lines = mergeSides(caller, other);
} catch (err) {
// The AI stack being down or busy: try again on the next run. Anything else: give up.
if (err instanceof AiError && /Can't reach|didn't answer/.test(err.message)) {
d.log.error({ err, recording: r.id }, 'call transcript: speech to text unreachable');
return done;
}
await finish({ aiError: err instanceof Error ? err.message : String(err) });
continue;
}
let summary: string | null = null;
let aiError: string | null = null;
if (llm && lines.length)
try {
summary = await chat(
llm,
[
{ role: 'system', content: SUMMARY_PROMPT },
{ role: 'user', content: transcriptText(lines) },
],
{ maxTokens: 2000, timeoutMs: 300_000 },
);
} catch (err) {
aiError = `Summary: ${err instanceof Error ? err.message : String(err)}`;
}
await finish({ transcript: lines, summary, aiError });
for (const f of files) await rm(f, { force: true });
done++;
}
return done;
}
+98
View File
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { createServer, type IncomingMessage, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { assertLocalUrl, chat, silentWav, speak, testEndpoint, transcribe } from './client.ts';
describe('assertLocalUrl', () => {
it.each([
'http://127.0.0.1:11434/v1',
'http://localhost:8000/v1',
'https://192.168.1.50/v1',
'http://10.0.0.5:8000',
'http://172.20.0.1:8000',
'http://[::1]:8000/v1',
'http://[fd00::5]/v1',
])('%s is local', (u) => expect(() => assertLocalUrl(u)).not.toThrow());
it.each([
['http://api.openai.com/v1', /use an IP address/],
['http://ollama.lan:11434', /use an IP address/],
['http://8.8.8.8/v1', /not on this server or the local network/],
['http://100.64.1.2/v1', /not on this server/], // CGNAT / Tailscale: may cross the internet
['http://172.32.0.1/v1', /not on this server/],
['ftp://127.0.0.1/', /only http/],
['nonsense', /not an address/],
])('%s is refused', (u, msg) => expect(() => assertLocalUrl(u)).toThrow(msg));
});
describe('OpenAI-style calls', () => {
let server: Server;
let base: string;
const seen: { path: string; type: string; body: string }[] = [];
const read = (req: IncomingMessage) =>
new Promise<string>((r) => {
let b = '';
req.setEncoding('latin1').on('data', (c: string) => (b += c));
req.on('end', () => r(b));
});
beforeAll(async () => {
server = createServer((req, res) => {
void read(req).then((body) => {
seen.push({ path: req.url!, type: req.headers['content-type'] ?? '', body });
if (req.url === '/v1/audio/transcriptions') return res.end('{"text":" Call me back. "}');
if (req.url === '/v1/audio/speech') return res.end(silentWav());
if (req.url === '/v1/chat/completions')
return res.end(
JSON.stringify({
choices: [{ message: { content: '<think>\nhm\n</think>\n\n OK ' } }],
}),
);
res.writeHead(404).end('model "nope" not found');
});
});
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1`;
});
afterAll(() => server.close());
it('transcribe: multipart with the file and model, the text trimmed', async () => {
expect(await transcribe({ url: base, model: 'whisper' }, silentWav())).toBe('Call me back.');
const r = seen.at(-1)!;
expect(r.type).toMatch(/^multipart\/form-data; boundary=/);
expect(r.body).toContain('name="model"\r\n\r\nwhisper');
expect(r.body).toContain('filename="audio.wav"');
expect(r.body).toContain('RIFF');
});
it('speak: JSON in, WAV out; chat: the answer', async () => {
const wav = await speak({ url: `${base}/`, model: 'kokoro', voice: 'af_heart' }, 'Hi');
expect(wav.subarray(0, 4).toString()).toBe('RIFF');
expect(JSON.parse(seen.at(-1)!.body)).toEqual({
model: 'kokoro',
voice: 'af_heart',
input: 'Hi',
response_format: 'wav',
});
expect(await chat({ url: base, model: 'm' }, [{ role: 'user', content: 'x' }])).toBe('OK');
});
it('errors say what went wrong; tests report them', async () => {
await expect(transcribe({ url: `${base}/wrong`, model: 'm' }, silentWav())).rejects.toThrow(
/wrong\/audio\/transcriptions: HTTP 404: model "nope" not found/,
);
await expect(
transcribe({ url: 'http://127.0.0.1:1/v1', model: 'm' }, silentWav()),
).rejects.toThrow(/Can't reach 127.0.0.1:1/);
expect(await testEndpoint('stt', { url: 'http://8.8.8.8/v1', model: 'm' })).toEqual({
ok: false,
error: '8.8.8.8 is not on this server or the local network',
});
const tts = await testEndpoint('tts', { url: base, model: 'k', voice: 'v' });
expect(tts).toMatchObject({
ok: true,
audio: expect.stringMatching(/^data:audio\/wav;base64,UklG/),
});
});
});
+192
View File
@@ -0,0 +1,192 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Local AI (spec 10): OpenAI-style APIs (Ollama, Speaches, llama.cpp, …) on this server or the
// local network. Nothing else is called: an address must be an IP on a local network or
// localhost, written as such, so no DNS answer can send audio or text anywhere else.
import { BlockList, isIP } from 'node:net';
import type { AiEndpoint, AiTestResult, AiTtsEndpoint } from '@selfphoneng/shared';
export class AiError extends Error {}
const LOCAL = new BlockList();
for (const [net, bits] of [
['127.0.0.0', 8],
['10.0.0.0', 8],
['172.16.0.0', 12],
['192.168.0.0', 16],
] as const)
LOCAL.addSubnet(net, bits, 'ipv4');
for (const [net, bits] of [
['::1', 128],
['fc00::', 7],
['fe80::', 10],
] as const)
LOCAL.addSubnet(net, bits, 'ipv6');
/** Throws unless `url` is http(s) to localhost or a local-network IP. */
export function assertLocalUrl(url: string): void {
let u: URL;
try {
u = new URL(url);
} catch {
throw new AiError(`${url} is not an address`);
}
const host = u.hostname.replace(/^\[|\]$/g, '');
if (!/^https?:$/.test(u.protocol)) throw new AiError(`${url}: only http and https`);
if (host === 'localhost') return;
const v = isIP(host);
if (!v)
throw new AiError(
`${host}: use an IP address (or localhost), so the address can't lead off this network`,
);
if (!LOCAL.check(host, v === 6 ? 'ipv6' : 'ipv4'))
throw new AiError(`${host} is not on this server or the local network`);
}
async function call(
base: string,
path: string,
init: RequestInit,
timeoutMs: number,
): Promise<Response> {
assertLocalUrl(base);
const url = new URL(path, base.endsWith('/') ? base : `${base}/`);
let r: Response;
try {
r = await fetch(url, { ...init, redirect: 'error', signal: AbortSignal.timeout(timeoutMs) });
} catch (err) {
const e = err as Error & { cause?: { code?: string } };
throw new AiError(
e.name === 'TimeoutError'
? `${url.host} didn't answer within ${timeoutMs / 1000} s`
: `Can't reach ${url.host}${e.cause?.code ? ` (${e.cause.code})` : ''}`,
);
}
if (!r.ok) {
const body = (await r.text().catch(() => '')).slice(0, 300);
throw new AiError(`${url.pathname}: HTTP ${r.status}${body ? `: ${body}` : ''}`);
}
return r;
}
/** Speech to text: the words in a WAV (or any format the server reads). */
export async function transcribe(ep: AiEndpoint, audio: Buffer, timeoutMs = 180_000) {
const form = new FormData();
form.append('file', new Blob([new Uint8Array(audio)], { type: 'audio/wav' }), 'audio.wav');
form.append('model', ep.model);
form.append('response_format', 'json');
const r = await call(ep.url, 'audio/transcriptions', { method: 'POST', body: form }, timeoutMs);
const { text } = (await r.json()) as { text?: unknown };
if (typeof text !== 'string') throw new AiError('audio/transcriptions: no text in the answer');
return text.trim();
}
/** Speech to text with timings (verbose_json): what was said, and from which second. */
export async function transcribeSegments(ep: AiEndpoint, audio: Buffer, timeoutMs = 600_000) {
const form = new FormData();
form.append('file', new Blob([new Uint8Array(audio)], { type: 'audio/wav' }), 'audio.wav');
form.append('model', ep.model);
form.append('response_format', 'verbose_json');
const r = await call(ep.url, 'audio/transcriptions', { method: 'POST', body: form }, timeoutMs);
const j = (await r.json()) as {
text?: unknown;
segments?: { start?: unknown; text?: unknown }[];
};
// A server without segments: the whole text, from the start.
const segs = Array.isArray(j.segments)
? j.segments.map((s) => ({ at: Number(s.start) || 0, text: String(s.text ?? '').trim() }))
: [{ at: 0, text: typeof j.text === 'string' ? j.text.trim() : '' }];
return segs.filter((s) => s.text);
}
/** Text to speech: a WAV. */
export async function speak(ep: AiTtsEndpoint, input: string, timeoutMs = 60_000) {
const r = await call(
ep.url,
'audio/speech',
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model: ep.model, voice: ep.voice, input, response_format: 'wav' }),
},
timeoutMs,
);
return Buffer.from(await r.arrayBuffer());
}
/** One chat answer from the LLM. */
export async function chat(
ep: AiEndpoint,
messages: { role: 'system' | 'user' | 'assistant'; content: string }[],
opts: { maxTokens?: number; timeoutMs?: number } = {},
) {
const r = await call(
ep.url,
'chat/completions',
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
model: ep.model,
messages,
max_tokens: opts.maxTokens,
stream: false,
}),
},
opts.timeoutMs ?? 120_000,
);
const j = (await r.json()) as { choices?: { message?: { content?: unknown } }[] };
const content = j.choices?.[0]?.message?.content;
if (typeof content !== 'string') throw new AiError('chat/completions: no answer');
// Thinking models (Qwen3, …) may put their reasoning inline; only the answer is wanted.
return content.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
}
/** One second of 8 kHz silence: enough for a speech-to-text server to load its model and answer. */
export function silentWav(seconds = 1): Buffer {
const n = 8000 * seconds;
const b = Buffer.alloc(44 + n * 2);
b.write('RIFF', 0);
b.writeUInt32LE(36 + n * 2, 4);
b.write('WAVEfmt ', 8);
b.writeUInt32LE(16, 16);
b.writeUInt16LE(1, 20); // PCM
b.writeUInt16LE(1, 22); // mono
b.writeUInt32LE(8000, 24);
b.writeUInt32LE(16000, 28);
b.writeUInt16LE(2, 32);
b.writeUInt16LE(16, 34);
b.write('data', 36);
b.writeUInt32LE(n * 2, 40);
return b;
}
/** Settings → AI → Test: a tiny real request. The first one may load a model, so it can be slow. */
export async function testEndpoint(
kind: 'llm' | 'stt' | 'tts',
ep: AiEndpoint | AiTtsEndpoint,
): Promise<AiTestResult> {
const t0 = Date.now();
try {
if (kind === 'llm') {
const a = await chat(ep, [{ role: 'user', content: 'Reply with just: OK' }], {
maxTokens: 400, // room for a thinking model to think first
});
return { ok: true, ms: Date.now() - t0, detail: `Answered: ${a.slice(0, 80)}` };
}
if (kind === 'stt') {
await transcribe(ep, silentWav());
return { ok: true, ms: Date.now() - t0, detail: 'Transcribed a second of silence' };
}
if (!('voice' in ep)) throw new AiError('A voice is needed');
const wav = await speak(ep, 'Hello, this is your phone system.');
if (wav.length < 1000) throw new AiError('audio/speech: the audio is empty');
return {
ok: true,
ms: Date.now() - t0,
detail: `${Math.round(wav.length / 1024)} KB of audio`,
audio: `data:audio/wav;base64,${wav.toString('base64')}`,
};
} catch (err) {
return { ok: false, error: err instanceof AiError ? err.message : String(err) };
}
}
+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";
@@ -0,0 +1 @@
ALTER TABLE "voicemail_messages" ADD COLUMN "transcript" text;
@@ -0,0 +1,4 @@
ALTER TABLE "recordings" ADD COLUMN "transcript" jsonb;--> statement-breakpoint
ALTER TABLE "recordings" ADD COLUMN "summary" text;--> statement-breakpoint
ALTER TABLE "recordings" ADD COLUMN "ai_done_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "recordings" ADD COLUMN "ai_error" text;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -36,6 +36,27 @@
"when": 1792512298128,
"tag": "0004_system_events",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1792606712441,
"tag": "0005_moh_order",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1792624567407,
"tag": "0006_voicemail_transcript",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1792626762332,
"tag": "0007_recording_transcripts",
"breakpoints": true
}
]
}
+12
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) => [
@@ -529,6 +533,12 @@ export const recordings = pgTable('recordings', {
startedAt: tstz().notNull(),
durationMs: integer(),
sizeBytes: bigint({ mode: 'number' }),
/** Spec 10 §3: [{ side: 'caller' | 'other', at: seconds, text }], in time order. */
transcript: jsonb().$type<{ side: 'caller' | 'other'; at: number; text: string }[]>(),
summary: text(),
/** When the transcript job finished with it (null: not yet); aiError: why it has none. */
aiDoneAt: tstz(),
aiError: text(),
...timestamps,
});
@@ -586,6 +596,8 @@ export const voicemailMessages = pgTable('voicemail_messages', {
filePath: text().notNull(),
emailedAt: tstz(),
deletedAt: tstz(),
/** Speech to text (spec 10), when ai.voicemailTranscripts is on; null: none (yet). */
transcript: text(),
...timestamps,
});
@@ -2,4 +2,5 @@
SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
SP_CLASS_101=2
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
same => n(stop),Return()
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
; what the caller said and -out.wav what they heard (the other side).
[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,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
same => n,MixMonitor(${SP_REC},b)
same => n,Return()
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
same => n(done),Return()
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
@@ -109,14 +114,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)
@@ -127,7 +153,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})
@@ -138,7 +164,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
@@ -153,7 +179,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)
@@ -171,7 +197,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)
@@ -187,7 +213,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()
@@ -197,12 +223,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
@@ -2,3 +2,4 @@
SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
same => n(stop),Return()
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
; what the caller said and -out.wav what they heard (the other side).
[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,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
same => n,MixMonitor(${SP_REC},b)
same => n,Return()
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
same => n(done),Return()
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
@@ -109,14 +114,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)
@@ -127,7 +153,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})
@@ -138,7 +164,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
@@ -153,7 +179,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)
@@ -171,7 +197,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)
@@ -187,7 +213,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()
@@ -197,12 +223,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
@@ -2,5 +2,6 @@
SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
SP_CLASS_101=2
SP_TCADMIN_101=1
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
same => n(stop),Return()
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
; what the caller said and -out.wav what they heard (the other side).
[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,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
same => n,MixMonitor(${SP_REC},b)
same => n,Return()
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
same => n(done),Return()
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
@@ -109,14 +114,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)
@@ -127,7 +153,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})
@@ -138,7 +164,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
@@ -153,7 +179,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)
@@ -171,7 +197,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)
@@ -187,7 +213,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()
@@ -197,12 +223,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
@@ -2,6 +2,7 @@
SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
SP_CLASS_103=2
SP_CLASS_104=2
SP_CLASS_105=2
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
same => n(stop),Return()
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
; what the caller said and -out.wav what they heard (the other side).
[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,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
same => n,MixMonitor(${SP_REC},b)
same => n,Return()
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
same => n(done),Return()
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
@@ -109,14 +114,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)
@@ -127,7 +153,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})
@@ -138,7 +164,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
@@ -153,7 +179,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)
@@ -171,7 +197,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)
@@ -187,7 +213,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()
@@ -197,12 +223,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
@@ -2,6 +2,7 @@
SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
SP_CLASS_101=2
SP_CLASS_102=2
SP_CLASS_103=2
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
same => n(stop),Return()
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
; what the caller said and -out.wav what they heard (the other side).
[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,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
same => n,MixMonitor(${SP_REC},b)
same => n,Return()
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
same => n(done),Return()
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
@@ -109,14 +114,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)
@@ -127,7 +153,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})
@@ -138,7 +164,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
@@ -153,7 +179,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)
@@ -171,7 +197,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)
@@ -187,7 +213,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()
@@ -197,12 +223,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
@@ -2,5 +2,6 @@
SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
SP_CLASS_101=2
SP_CLASS_102=2
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
same => n(stop),Return()
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
; what the caller said and -out.wav what they heard (the other side).
[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,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
same => n,MixMonitor(${SP_REC},b)
same => n,Return()
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
same => n(done),Return()
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
@@ -109,14 +114,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)
@@ -127,7 +153,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})
@@ -138,7 +164,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
@@ -153,7 +179,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)
@@ -171,7 +197,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)
@@ -187,7 +213,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()
@@ -197,12 +223,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
@@ -2,5 +2,6 @@
SP_DEFAULT_CID=13125551234
SP_DEFAULT_E911_CID=13125551234
SP_REC_ANNOUNCE=
SP_REC_SIDES=
SP_CLASS_101=2
SP_TCADMIN_101=1
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
same => n(stop),Return()
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
; what the caller said and -out.wav what they heard (the other side).
[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,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
same => n,MixMonitor(${SP_REC},b)
same => n,Return()
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
same => n(done),Return()
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
@@ -109,14 +114,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)
@@ -127,7 +153,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})
@@ -138,7 +164,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
@@ -153,7 +179,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)
@@ -171,7 +197,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)
@@ -187,7 +213,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()
@@ -197,12 +223,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
@@ -2,6 +2,7 @@
SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
SP_CLASS_99=2
SP_CLASS_101=2
SP_CLASS_102=2
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
same => n(stop),Return()
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
; what the caller said and -out.wav what they heard (the other side).
[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,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
same => n,MixMonitor(${SP_REC},b)
same => n,Return()
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
same => n(done),Return()
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
@@ -109,14 +114,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)
@@ -127,7 +153,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})
@@ -138,7 +164,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
@@ -153,7 +179,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)
@@ -171,7 +197,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)
@@ -187,7 +213,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()
@@ -197,12 +223,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
@@ -2,4 +2,5 @@
SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
SP_CLASS_101=2
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
same => n(stop),Return()
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
; what the caller said and -out.wav what they heard (the other side).
[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,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
same => n,MixMonitor(${SP_REC},b)
same => n,Return()
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
same => n(done),Return()
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
@@ -109,14 +114,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)
@@ -127,7 +153,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})
@@ -138,7 +164,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
@@ -153,7 +179,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)
@@ -171,7 +197,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)
@@ -187,7 +213,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()
@@ -197,12 +223,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
@@ -2,6 +2,7 @@
SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
SP_CLASS_101=2
SP_CLASS_102=2
SP_CLASS_103=2
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
same => n(stop),Return()
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
; what the caller said and -out.wav what they heard (the other side).
[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,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
same => n,MixMonitor(${SP_REC},b)
same => n,Return()
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
same => n(done),Return()
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
@@ -109,14 +114,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)
@@ -127,7 +153,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})
@@ -138,7 +164,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
@@ -153,7 +179,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)
@@ -171,7 +197,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)
@@ -187,7 +213,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()
@@ -197,12 +223,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",
@@ -2,6 +2,7 @@
SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
SP_CLASS_101=2
SP_CLASS_102=2
SP_CLASS_103=2
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
same => n(stop),Return()
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
; what the caller said and -out.wav what they heard (the other side).
[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,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
same => n,MixMonitor(${SP_REC},b)
same => n,Return()
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
same => n(done),Return()
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
@@ -109,14 +114,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)
@@ -127,7 +153,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})
@@ -138,7 +164,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
@@ -153,7 +179,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)
@@ -171,7 +197,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)
@@ -187,7 +213,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()
@@ -197,12 +223,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,7 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
SP_CLASS_1=2
SP_CLASS_12=2
@@ -0,0 +1,326 @@
; 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.
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
; what the caller said and -out.wav what they heard (the other side).
[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,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
same => n,MixMonitor(${SP_REC},b)
same => n,Return()
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
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"]
]
}
@@ -2,3 +2,4 @@
SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
same => n(stop),Return()
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
; what the caller said and -out.wav what they heard (the other side).
[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,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
same => n,MixMonitor(${SP_REC},b)
same => n,Return()
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
same => n(done),Return()
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
@@ -109,14 +114,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)
@@ -127,7 +153,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})
@@ -138,7 +164,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
@@ -153,7 +179,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)
@@ -171,7 +197,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)
@@ -187,7 +213,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()
@@ -197,12 +223,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
@@ -2,4 +2,5 @@
SP_DEFAULT_CID=13125551234
SP_DEFAULT_E911_CID=13125551234
SP_REC_ANNOUNCE=custom/44444444-4444-4444-8444-444444444448
SP_REC_SIDES=
SP_CLASS_101=2
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
same => n(stop),Return()
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
; what the caller said and -out.wav what they heard (the other side).
[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,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
same => n,MixMonitor(${SP_REC},b)
same => n,Return()
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
same => n(done),Return()
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
@@ -109,14 +114,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)
@@ -127,7 +153,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})
@@ -138,7 +164,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
@@ -153,7 +179,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)
@@ -171,7 +197,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)
@@ -187,7 +213,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()
@@ -197,12 +223,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
@@ -2,5 +2,6 @@
SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
SP_CLASS_99=0
SP_CLASS_102=1
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
same => n(stop),Return()
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
; what the caller said and -out.wav what they heard (the other side).
[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,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
same => n,MixMonitor(${SP_REC},b)
same => n,Return()
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
same => n(done),Return()
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
@@ -109,14 +114,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)
@@ -127,7 +153,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})
@@ -138,7 +164,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
@@ -153,7 +179,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)
@@ -171,7 +197,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)
@@ -187,7 +213,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()
@@ -197,12 +223,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
+2
View File
@@ -73,6 +73,8 @@ export function renderGlobals(snap: Snapshot): string {
`SP_DEFAULT_CID=${defaultCid}`,
`SP_DEFAULT_E911_CID=${didNumber(st['emergency.defaultCallerIdDidId']) || defaultCid}`,
`SP_REC_ANNOUNCE=${snap.recordingAnnouncement ? s.sound(snap.recordingAnnouncement) : ''}`,
// Spec 10 §3: also record each side on its own, for the transcript job.
`SP_REC_SIDES=${st['ai.callTranscripts'] && st['ai.stt'] ? '1' : ''}`,
];
const exts = [...snap.extensions].sort(byNumber);
for (const e of exts) lines.push(`SP_CLASS_${s.digits(e.number)}=${CLASS_RANK[e.outboundClass]}`);
+33 -7
View File
@@ -86,11 +86,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[\${IF($["\${SP_FWDHOPS}" = ""]?0:\${SP_FWDHOPS})
same => n(stop),Return()
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
; what the caller said and -out.wav what they heard (the other side).
[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,GotoIf($["\${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
same => n,MixMonitor(\${SP_REC},b)
same => n,Return()
same => n(sides),MixMonitor(\${SP_REC},br(\${SP_REC:0:-4}-in.wav)t(\${SP_REC:0:-4}-out.wav))
same => n(done),Return()
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
@@ -112,14 +117,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)
@@ -130,7 +156,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})
@@ -141,7 +167,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
@@ -156,7 +182,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)
@@ -174,7 +200,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)
@@ -190,7 +216,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()
@@ -200,12 +226,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;
}
+4
View File
@@ -20,6 +20,7 @@ import type { Generator } from './generator/index.ts';
import { todayIn } from './generator/load.ts';
import type { LiveState } from './live/state.ts';
import { cleanupRecordings } from './routes/recordings.ts';
import { transcribeCalls } from './ai/calls.ts';
import { audit } from './services/audit.ts';
import { backupDue, runBackup } from './services/backup.ts';
import { logEvent } from './services/events.ts';
@@ -258,6 +259,9 @@ export function startJobs(d: JobDeps): () => void {
run('trunkWatch', j.trunkWatch);
if (minute % 5 === 0) run('publicIpWatch', j.publicIpWatch);
if (minute % 60 === 0) run('diskWatch', j.diskWatch);
run('callTranscripts', async () => {
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
});
minute++;
};
tick();
+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 =>
@@ -58,6 +58,27 @@ linekey.30.type = 0
linekey.31.type = 0
linekey.32.type = 0
linekey.33.type = 0
## dial at once when the number is complete (911, full outside numbers); others wait for Send
dialplan.dialnow.rule.1 = 911
dialplan.dialnow.rule.2 = 9911
dialplan.dialnow.rule.3 = [2-9]xx[2-9]xxxxxx
dialplan.dialnow.rule.4 = 1[2-9]xx[2-9]xxxxxx
dialplan.dialnow.rule.5 =
dialplan.dialnow.rule.6 =
dialplan.dialnow.rule.7 =
dialplan.dialnow.rule.8 =
dialplan.dialnow.rule.9 =
dialplan.dialnow.rule.10 =
dialplan.dialnow.rule.11 =
dialplan.dialnow.rule.12 =
dialplan.dialnow.rule.13 =
dialplan.dialnow.rule.14 =
dialplan.dialnow.rule.15 =
dialplan.dialnow.rule.16 =
dialplan.dialnow.rule.17 =
dialplan.dialnow.rule.18 =
dialplan.dialnow.rule.19 =
dialplan.dialnow.rule.20 =
## time
local_time.time_zone = -6
local_time.time_zone_name = United States-Central Time
+4
View File
@@ -7,6 +7,7 @@ import { decrypt } from '../crypto/secrets.ts';
import type { Db } from '../db/client.ts';
import { contacts, devices, extensions } from '../db/schema.ts';
import { getSetting } from '../services/settings.ts';
import { loadDialRules, type DialRule } from './dialplan.ts';
export type ProvisionContext = {
mac: string;
@@ -26,6 +27,8 @@ export type ProvisionContext = {
/** The phonebook with this phone's credentials in it (phones fetch it like the config). */
phonebookUrl: string;
ntpServer: string;
/** What's dialable (dialplan.ts), so phones can send complete numbers at once. */
dialRules: DialRule[];
};
export const NTP_SERVER = 'pool.ntp.org';
@@ -77,6 +80,7 @@ export async function loadContext(
provPassword,
phonebookUrl: `https://${d.mac}:${provPassword}@${config.PUBLIC_HOSTNAME}/prov/phonebook/${d.mac}.xml`,
ntpServer: NTP_SERVER,
dialRules: await loadDialRules(db),
},
};
}
@@ -0,0 +1,59 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { describe, expect, it } from 'vitest';
import {
dialNowRules,
dialRules,
fromRoutePattern,
grandstreamDialPlan,
ruleText,
} from './dialplan.ts';
/** The routes createDefaultRoutes makes, international off. */
const DEFAULT_ROUTES = [
...['800', '888', '877', '866', '855', '844', '833'].map((p) => `1${p}NXXXXXX`),
'NXXNXXXXXX',
'1NXXNXXXXXX',
];
describe('dial plan', () => {
it('reads Asterisk patterns', () => {
expect(ruleText(fromRoutePattern('NXXNXXXXXX'))).toBe('[2-9]xx[2-9]xxxxxx');
expect(ruleText(fromRoutePattern('011.'))).toBe('011x+');
expect(ruleText(fromRoutePattern('[2-46]Z'))).toBe('[2-46][1-9]');
});
it('default routes, three-digit numbers: toll-free folds into 1NXX…, * codes wait', () => {
const rules = dialRules(DEFAULT_ROUTES, [3]);
expect(grandstreamDialPlan(rules)).toBe(
'{ 911 | 9911 | [2-9]xx[2-9]xxxxxx | 1[2-9]xx[2-9]xxxxxx | xxx | *x+ | **x+ }',
);
// 911 always; 10 and 11 digits can't grow; 101 could be the start of 1012345678? no (N), but
// 212 could start 2125551234, so three-digit numbers wait
expect(dialNowRules(rules).map(ruleText)).toEqual([
'911',
'9911',
'[2-9]xx[2-9]xxxxxx',
'1[2-9]xx[2-9]xxxxxx',
]);
});
it('one-digit and six-digit numbers, international on', () => {
const rules = dialRules([...DEFAULT_ROUTES, '011.'], [1, 3, 6, 3]);
expect(grandstreamDialPlan(rules)).toBe(
'{ 911 | 9911 | [2-9]xx[2-9]xxxxxx | 1[2-9]xx[2-9]xxxxxx | 011x+ | x | xxx | xxxxxx | *x+ | **x+ }',
);
// 0 can start 011…, and every short number can start a longer one
expect(dialNowRules(rules).map(ruleText)).toEqual([
'911',
'9911',
'[2-9]xx[2-9]xxxxxx',
'1[2-9]xx[2-9]xxxxxx',
]);
});
it('a route that only a longer one could continue', () => {
// 4-digit internal numbers start 1-9 at the PBX, but the phone can't know: xxxx waits for 5…
const rules = dialRules(['NXXXXX'], [4]);
expect(dialNowRules(rules).map(ruleText)).toEqual(['911', '9911', '[2-9]xxxxx']);
});
});
+111
View File
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The phone-side dial plan: which dialed strings are complete, so phones and analog adapters
// send them at once instead of waiting for # or a timeout. Made from what is dialable: 911/9911,
// the enabled outbound routes, the lengths internal numbers use, and * codes.
import { eq } from 'drizzle-orm';
import type { RoutePattern } from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { outboundRoutes } from '../db/schema.ts';
import { internalNumberLengths } from '../services/numbering.ts';
/** One rule: the characters allowed at each position; `more` = one or more digits follow (x+). */
export type DialRule = { pos: string[]; more: boolean };
const ANY = '0123456789';
const lit = (s: string): DialRule => ({ pos: [...s], more: false });
/** An Asterisk route pattern (X, Z, N, [..], trailing . or !) as a rule. */
export function fromRoutePattern(match: string): DialRule {
const pos: string[] = [];
let more = false;
for (let i = 0; i < match.length; i++) {
const c = match[i]!;
if (c === 'X') pos.push(ANY);
else if (c === 'Z') pos.push('123456789');
else if (c === 'N') pos.push('23456789');
else if (c === '.' || c === '!')
more = true; // ponytail: ! (zero or more) read as . (one or more)
else if (c === '[') {
const end = match.indexOf(']', i);
pos.push(
match.slice(i + 1, end).replace(/(\d)-(\d)/g, (_, a: string, b: string) => {
let r = '';
for (let d = Number(a); d <= Number(b); d++) r += d;
return r;
}),
);
i = end;
} else pos.push(c);
}
return { pos, more };
}
export function dialRules(routePatterns: string[], internalLengths: number[]): DialRule[] {
const rules = [lit('911'), lit('9911'), ...routePatterns.map(fromRoutePattern)];
for (const n of [...new Set(internalLengths)].sort((a, b) => a - b))
rules.push({ pos: Array<string>(n).fill(ANY), more: false });
rules.push({ pos: ['*'], more: true }, { pos: ['*', '*'], more: true });
// A rule another one already covers says nothing new (toll-free inside 1NXXNXXXXXX); of two
// equal ones, keep the first. 911 and 9911 always stay: dialNowRules sends them at once.
return rules.filter(
(r, i) => i < 2 || !rules.some((o, j) => j !== i && covers(o, r) && (j < i || !covers(r, o))),
);
}
/** Does every string `r` matches also match `o`? */
const covers = (o: DialRule, r: DialRule) =>
(o.more
? r.pos.length > o.pos.length || (r.more && r.pos.length === o.pos.length)
: !r.more && r.pos.length === o.pos.length) &&
r.pos.every((set, i) => [...set].every((c) => (o.pos[i] ?? ANY).includes(c)));
/** Can some string `other` matches be longer than `r` and start with a string `r` matches? */
const extends_ = (r: DialRule, other: DialRule) =>
(other.more || other.pos.length > r.pos.length) &&
r.pos.every((set, i) =>
[...set].some((c) => (other.pos[i] ?? (other.more ? ANY : '')).includes(c)),
);
/**
* Rules a phone may dial the moment they match: no other number can continue them. 911 and 9911
* always: no outside number starts with 911, and no internal one may (isReservedNumber).
*/
export const dialNowRules = (rules: DialRule[]): DialRule[] =>
rules.filter(
(r) =>
!r.more &&
((r.pos.every((s) => s.length === 1) && ['911', '9911'].includes(r.pos.join(''))) ||
!rules.some((o) => o !== r && extends_(r, o))),
);
/** A position in the x / [2-9] / digit syntax both Grandstream and Yealink read. */
function set(s: string): string {
if (s === ANY) return 'x';
if (s.length === 1) return s;
const d = [...s].map(Number).sort((a, b) => a - b);
const runs: string[] = [];
for (let i = 0; i < d.length;) {
let j = i;
while (j + 1 < d.length && d[j + 1] === d[j]! + 1) j++;
runs.push(j - i >= 2 ? `${d[i]}-${d[j]}` : d.slice(i, j + 1).join(''));
i = j + 1;
}
return `[${runs.join('')}]`;
}
export const ruleText = (r: DialRule) => r.pos.map(set).join('') + (r.more ? 'x+' : '');
/** Grandstream's Dial Plan: `{ 911 | 9911 | [2-9]xx[2-9]xxxxxx | … }`. */
export const grandstreamDialPlan = (rules: DialRule[]) => `{ ${rules.map(ruleText).join(' | ')} }`;
/** The rules for this install (enabled outbound routes, internal number lengths). */
export async function loadDialRules(db: Db): Promise<DialRule[]> {
const routes = await db
.select({ patterns: outboundRoutes.patterns })
.from(outboundRoutes)
.where(eq(outboundRoutes.enabled, true));
return dialRules(
routes.flatMap((r) => (r.patterns as RoutePattern[]).map((p) => p.match)),
await internalNumberLengths(db),
);
}
@@ -4,6 +4,7 @@
import { describe, expect, it } from 'vitest';
import { phoneTimezone } from '@selfphoneng/shared';
import type { PhonebookEntry, ProvisionContext } from './context.ts';
import { dialRules } from './dialplan.ts';
import { grandstream, grandstreamPhonebook } from './templates/grandstream.ts';
import { yealink, yealinkPhonebook } from './templates/yealink.ts';
@@ -31,6 +32,7 @@ const ctx = (over: Partial<ProvisionContext> = {}): ProvisionContext => ({
phonebookUrl:
'https://001565abcdef:Pr0vPassw0rdXyZ12345@pbx.example.com/prov/phonebook/001565abcdef.xml',
ntpServer: 'pool.ntp.org',
dialRules: dialRules(['NXXNXXXXXX', '1NXXNXXXXXX'], [3]),
...over,
});
const book: PhonebookEntry[] = [
@@ -53,6 +55,8 @@ describe('phone templates', () => {
expect(out).toContain('linekey.4.type = 0');
expect(out).toContain('linekey.33.type = 0');
expect(out).not.toContain('linekey.34');
expect(out).toMatch(/^dialplan\.dialnow\.rule\.1 = 911$/m);
expect(out).toMatch(/^dialplan\.dialnow\.rule\.20 =$/m);
});
it('Grandstream config: XML-escaped values', async () => {
@@ -1,13 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Yealink T3x/T4x/T5x <mac>.cfg (07 §3.1). Key names from Yealink's docs, (verify per model).
import { oneLine, xml, type PhonebookEntry, type ProvisionContext } from '../context.ts';
import { dialNowRules, ruleText } from '../dialplan.ts';
const TRANSPORT = { udp: 0, tcp: 1, tls: 2 } as const;
/** Line key 1 is the line; BLF keys are 2…; keys past the last BLF up to here are cleared. */
const LAST_KEY = 33;
/** Dial-now rules past the ones in use, up to here, are cleared. */
const LAST_DIALNOW = 20;
export function yealink(c: ProvisionContext): string {
const v = oneLine;
const now = dialNowRules(c.dialRules).slice(0, LAST_DIALNOW);
return [
'#!version:1.0.0.1',
'## account 1',
@@ -40,6 +44,12 @@ export function yealink(c: ProvisionContext): string {
{ length: LAST_KEY - c.blf.length - 1 },
(_, i) => `linekey.${i + c.blf.length + 2}.type = 0`,
),
'## dial at once when the number is complete (911, full outside numbers); others wait for Send',
...now.map((d, i) => `dialplan.dialnow.rule.${i + 1} = ${ruleText(d)}`),
...Array.from(
{ length: LAST_DIALNOW - now.length },
(_, i) => `dialplan.dialnow.rule.${i + now.length + 1} =`,
),
'## time',
`local_time.time_zone = ${c.timezone.yealinkOffset}`,
`local_time.time_zone_name = ${c.timezone.yealinkName}`,
+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);
+2
View File
@@ -29,6 +29,7 @@ import { assertMohClass } from './moh.ts';
import { softphoneQr } from '../provisioning/linphone.ts';
import { findReferences } from '../services/destinations.ts';
import { getSetting } from '../services/settings.ts';
import { grandstreamDialPlan, loadDialRules } from '../provisioning/dialplan.ts';
type ExtRow = typeof extensions.$inferSelect;
@@ -243,6 +244,7 @@ export async function extensionRoutes(app: FastifyInstance) {
username: e.number,
password: decrypt(e.sipPasswordEnc),
webUsername: e.webrtcEnabled ? `${e.number}-web` : null,
dialPlan: grandstreamDialPlan(await loadDialRules(db)),
};
});
+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)
+57 -18
View File
@@ -3,18 +3,28 @@
// routes. Admins see all; a user may play the recordings of calls they took part in.
import { rm } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { and, desc, eq, inArray, lt, sql } from 'drizzle-orm';
import { and, desc, eq, ilike, inArray, lt, or, sql } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { ListResponse, RecordingDto } from '@selfphoneng/shared';
import type { ListResponse, RecordingDto, TranscriptDto } from '@selfphoneng/shared';
import { requireAdmin, requireAuth } from '../auth/guards.ts';
import type { Db } from '../db/client.ts';
import { callExtensions, calls, extensions, recordings } from '../db/schema.ts';
import { badRequest, forbidden, notFound } from '../errors.ts';
import { audit } from '../services/audit.ts';
import { sideFiles } from '../ai/calls.ts';
/** The recording and, when call transcripts left them, its two side files. */
const removeFiles = async (dir: string, filePath: string) => {
for (const f of [filePath, ...sideFiles(filePath)]) await rm(join(dir, f), { force: true });
};
const IdParams = z.object({ id: z.uuid() });
const ListQuery = z.object({ cursor: z.string().max(100).optional() });
const ListQuery = z.object({
cursor: z.string().max(100).optional(),
/** Words in the transcript or summary (spec 10 §3). */
q: z.string().trim().max(100).optional(),
});
/** Deletes recordings older than `days` (file and row); 0 keeps them forever (04 §8). */
export async function cleanupRecordings(db: Db, dir: string, days: number): Promise<number> {
@@ -23,7 +33,7 @@ export async function cleanupRecordings(db: Db, dir: string, days: number): Prom
.select({ id: recordings.id, filePath: recordings.filePath })
.from(recordings)
.where(lt(recordings.startedAt, sql`now() - make_interval(days => ${days})`));
for (const r of old) await rm(join(dir, r.filePath), { force: true });
for (const r of old) await removeFiles(dir, r.filePath);
if (old.length)
await db.delete(recordings).where(
inArray(
@@ -34,6 +44,9 @@ export async function cleanupRecordings(db: Db, dir: string, days: number): Prom
return old.length;
}
/** A search term taken literally by ILIKE: \, % and _ escaped. */
const likeEscape = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
export async function recordingRoutes(app: FastifyInstance) {
const { db } = app;
const dir = resolve(app.config.RECORDINGS_DIR);
@@ -48,14 +61,24 @@ export async function recordingRoutes(app: FastifyInstance) {
'/api/recordings',
{ preHandler: requireAdmin },
async (req): Promise<ListResponse<RecordingDto>> => {
const { cursor } = ListQuery.parse(req.query);
const { cursor, q } = ListQuery.parse(req.query);
const before = cursor ? new Date(Buffer.from(cursor, 'base64url').toString()) : null;
if (before && Number.isNaN(before.getTime())) throw badRequest('Bad cursor');
const rows = await db
.select({ r: recordings, c: calls })
.from(recordings)
.leftJoin(calls, eq(calls.recordingId, recordings.id))
.where(before ? lt(recordings.startedAt, before) : undefined)
.where(
and(
before ? lt(recordings.startedAt, before) : undefined,
q
? or(
ilike(sql`${recordings.transcript}::text`, `%${likeEscape(q)}%`),
ilike(recordings.summary, `%${likeEscape(q)}%`),
)
: undefined,
),
)
.orderBy(desc(recordings.startedAt))
.limit(51);
const items = rows.slice(0, 50);
@@ -65,6 +88,9 @@ export async function recordingRoutes(app: FastifyInstance) {
startedAt: r.startedAt.toISOString(),
durationMs: r.durationMs,
sizeBytes: r.sizeBytes,
summary: r.summary,
hasTranscript: r.transcript !== null,
aiError: r.aiError,
call: c
? {
id: c.id,
@@ -83,19 +109,32 @@ export async function recordingRoutes(app: FastifyInstance) {
},
);
/** Admins, or a participant: their extension is on the call this recording belongs to. */
async function mayHear(user: { id: string; role: string }, recordingId: string) {
if (user.role === 'admin') return;
const [mine] = await db
.select({ id: calls.id })
.from(calls)
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
.where(and(eq(calls.recordingId, recordingId), eq(extensions.userId, user.id)))
.limit(1);
if (!mine) throw forbidden('Not your call');
}
app.get('/api/recordings/:id/transcript', { preHandler: requireAuth }, async (req) => {
const r = await getRecording(IdParams.parse(req.params).id);
await mayHear(req.user!, r.id);
return {
lines: r.transcript ?? [],
summary: r.summary,
aiError: r.aiError,
} satisfies TranscriptDto;
});
app.get('/api/recordings/:id/audio', { preHandler: requireAuth }, async (req, reply) => {
const r = await getRecording(IdParams.parse(req.params).id);
if (req.user!.role !== 'admin') {
// Only a participant: their extension is on the call this recording belongs to.
const [mine] = await db
.select({ id: calls.id })
.from(calls)
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
.where(and(eq(calls.recordingId, r.id), eq(extensions.userId, req.user!.id)))
.limit(1);
if (!mine) throw forbidden('Not your call');
}
await mayHear(req.user!, r.id);
if ((req.query as { download?: string }).download)
reply.header(
'content-disposition',
@@ -106,7 +145,7 @@ export async function recordingRoutes(app: FastifyInstance) {
app.delete('/api/recordings/:id', { preHandler: requireAdmin }, async (req) => {
const r = await getRecording(IdParams.parse(req.params).id);
await rm(join(dir, r.filePath), { force: true });
await removeFiles(dir, r.filePath);
await db.delete(recordings).where(eq(recordings.id, r.id)); // calls.recording_id → null
await audit(db, {
actorUserId: req.user!.id,
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff. Show more