Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a175a0d77 | ||
|
|
f2868cf5d3 | ||
|
|
6893c55e1b | ||
|
|
e54e8a1576 | ||
|
|
338ab8d7f1 | ||
|
|
175d5a2c73 | ||
|
|
aa6681da03 | ||
|
|
4143f6d064 | ||
|
|
65a0438dde | ||
|
|
90b3081368 | ||
|
|
22b3ef9a8c | ||
|
|
e2518b03d8 | ||
|
|
ff59bc7799 | ||
|
|
4d9b5dab8a | ||
|
|
54e1e201a1 | ||
|
|
8d5475cfbf |
No files matched your search
@@ -14,3 +14,5 @@ APP_UID=1000
|
||||
APP_GID=1000
|
||||
LOG_LEVEL=info
|
||||
TRUST_PROXY=true # true when behind Caddy/nginx
|
||||
RTP_PORT_START=10000 # call audio (UDP), both ends included: open this range on the
|
||||
RTP_PORT_END=20000 # firewall and router. Two ports per call; 200 is plenty for a small office
|
||||
@@ -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
|
||||
+55
-1
@@ -1,7 +1,61 @@
|
||||
# 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.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
|
||||
|
||||
|
||||
@@ -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.4), for testing. See [what's left for v1](docs/v1-checklist.md).
|
||||
|
||||
## What it does today
|
||||
|
||||
@@ -19,7 +19,8 @@ A fully open source, self-hosted business phone system. Bring your own SIP trunk
|
||||
pickup, conference rooms, paging and intercom, music on hold.
|
||||
- **Voicemail** with email, **call history**, **recordings**, a **live dashboard** (listen,
|
||||
whisper, barge) and reports.
|
||||
- **Running it:** 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
|
||||
@@ -27,7 +28,6 @@ A fully open source, self-hosted business phone system. Bring your own SIP trunk
|
||||
- More SIP providers: **Telnyx, Flowroute and Twilio** first.
|
||||
- SMS (built for voip.ms, parked until wanted).
|
||||
- Push notifications, so phone apps ring in the background.
|
||||
- An update script for running installs.
|
||||
- Text-to-speech prompts, recording prompts by phone, a queue wallboard, Polycom phones.
|
||||
|
||||
## Docs
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfphoneng/api",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.4",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
+1
-1
@@ -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'>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
||||
import pg from 'pg';
|
||||
@@ -19,3 +22,26 @@ export type Db = ReturnType<typeof createDb>['db'];
|
||||
export async function runMigrations(db: Db): Promise<void> {
|
||||
await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrations this image has and the database hasn't run yet: how many are applied, of how many.
|
||||
* Null when none are, or for an empty database (a fresh install: nothing to lose). Reads drizzle's
|
||||
* own bookkeeping, `drizzle.__drizzle_migrations`.
|
||||
*/
|
||||
export async function migrationsPending(
|
||||
db: Db,
|
||||
folder = MIGRATIONS_DIR,
|
||||
): Promise<{ applied: number; total: number } | null> {
|
||||
const journal = JSON.parse(await readFile(join(folder, 'meta', '_journal.json'), 'utf8')) as {
|
||||
entries: unknown[];
|
||||
};
|
||||
const t = await db.execute<{ t: string | null }>(
|
||||
sql`select to_regclass('drizzle.__drizzle_migrations')::text as t`,
|
||||
);
|
||||
if (!t.rows[0]?.t) return null;
|
||||
const r = await db.execute<{ n: number }>(
|
||||
sql`select count(*)::int as n from drizzle.__drizzle_migrations`,
|
||||
);
|
||||
const applied = r.rows[0]!.n;
|
||||
return applied < journal.entries.length ? { applied, total: journal.entries.length } : null;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE "moh_classes" ADD COLUMN "shuffle" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "sound_files" ADD COLUMN "position" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
-- Existing music on hold keeps the order it was uploaded in (owner, 2026-10-21).
|
||||
UPDATE "sound_files" s SET "position" = o.n FROM (
|
||||
SELECT "id", row_number() OVER (PARTITION BY "moh_class_id" ORDER BY "created_at", "id") - 1 AS n
|
||||
FROM "sound_files" WHERE "moh_class_id" IS NOT NULL
|
||||
) o WHERE s."id" = o."id";
|
||||
File diff suppressed because it is too large.
Load diff
@@ -36,6 +36,13 @@
|
||||
"when": 1792512298128,
|
||||
"tag": "0004_system_events",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1792606712441,
|
||||
"tag": "0005_moh_order",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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) => [
|
||||
|
||||
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +148,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +174,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +208,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +148,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +174,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +208,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +148,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +174,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +208,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
+28
-7
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +148,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +174,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +208,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
+28
-7
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +148,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +174,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +208,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +148,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +174,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +208,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +148,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +174,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +208,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
stunaddr = stun.example.org:3478
|
||||
|
||||
|
||||
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +148,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +174,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +208,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +148,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +174,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +208,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +148,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +174,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +208,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
[after-hours]
|
||||
mode = files
|
||||
directory = /var/lib/asterisk/moh/after-hours
|
||||
mode = playlist
|
||||
entry = /var/lib/asterisk/moh/after-hours/5a1f0c3e-0000-4000-8000-000000000003
|
||||
sort = random
|
||||
|
||||
[support-jazz]
|
||||
mode = files
|
||||
directory = /var/lib/asterisk/moh/support-jazz
|
||||
sort = random
|
||||
mode = playlist
|
||||
entry = /var/lib/asterisk/moh/support-jazz/5a1f0c3e-0000-4000-8000-000000000002
|
||||
entry = /var/lib/asterisk/moh/support-jazz/5a1f0c3e-0000-4000-8000-000000000001
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"mohClasses": ["support-jazz", "after-hours"],
|
||||
"mohClasses": [
|
||||
{
|
||||
"name": "support-jazz",
|
||||
"files": ["5a1f0c3e-0000-4000-8000-000000000002", "5a1f0c3e-0000-4000-8000-000000000001"]
|
||||
},
|
||||
{ "name": "after-hours", "shuffle": true, "files": ["5a1f0c3e-0000-4000-8000-000000000003"] }
|
||||
],
|
||||
"extensions": [
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000101",
|
||||
|
||||
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +148,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +174,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +208,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -0,0 +1,6 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
[sp_admin_user]
|
||||
type = user
|
||||
admin = yes
|
||||
marked = yes
|
||||
music_on_hold_when_empty = yes
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_CLASS_1=2
|
||||
SP_CLASS_12=2
|
||||
@@ -0,0 +1,321 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
; ─── Normalize a number. ARG1 = raw. Returns normalized in GOSUB_RETVAL. (matches 00 §8)
|
||||
[sp-sub-normalize]
|
||||
exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
same => n,ExecIf($["${N:0:2}" = "+1"]?Set(N=${N:1}))
|
||||
same => n,ExecIf($["${N:0:1}" = "+"]?Set(N=011${N:1}))
|
||||
same => n,ExecIf($[${LEN(${N})} = 10]?Set(N=1${N}))
|
||||
same => n,Return(${N})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
; in its Contact from reaching Dial's options (',') or another device ('&').
|
||||
[sp-sub-contacts]
|
||||
exten => s,1,Set(LOCAL(CL)=${PJSIP_AOR(${ARG1},contact)})
|
||||
same => n(next),Set(LOCAL(C)=${POP(CL)})
|
||||
same => n,GotoIf($["${C}" = ""]?done)
|
||||
same => n,Set(LOCAL(ST)=${PJSIP_CONTACT(${C},status)})
|
||||
same => n,GotoIf($["${ST}" = "Unavailable" | "${ST}" = "Unreachable"]?next)
|
||||
same => n,Set(LOCAL(U)=${PJSIP_CONTACT(${C},uri)})
|
||||
same => n,Set(LOCAL(D)=${CUT(U,\;,1)})
|
||||
same => n,Set(LOCAL(I)=1)
|
||||
same => n(param),Set(I=$[${I} + 1])
|
||||
same => n,GotoIf($[${I} > ${FIELDQTY(U,\;)}]?add)
|
||||
same => n,Set(LOCAL(P)=${CUT(U,\;,${I})})
|
||||
same => n,GotoIf($["${P:0:3}" = "pn-"]?param)
|
||||
same => n,Set(D=${D}\;${P})
|
||||
same => n,Goto(param)
|
||||
same => n(add),Set(DS=${DS}${IF($["${DS}" = ""]?:&)}PJSIP/${ARG1}/${FILTER(0-9a-zA-Z.:@\;=_+%[]\x2d,${D})})
|
||||
same => n,Goto(next)
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Call a user with all their personal rules.
|
||||
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
||||
[sp-sub-user]
|
||||
exten => s,1,Set(__SP_TARGET=${ARG1})
|
||||
same => n,ExecIf($["${SP_DIRECTION}" = "inbound" & ("${ARG4}" = "inbound" | "${ARG4}" = "all")]?Gosub(sp-sub-record,s,1))
|
||||
same => n,GotoIf($["${DB(SP/DND/${ARG1})}" = "1"]?busy)
|
||||
same => n,Set(FWD=${DB(SP/CFU/${ARG1})})
|
||||
same => n,GotoIf($["${FWD}" != ""]?forward)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${ARG1},${ARG2}))
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy)
|
||||
same => n(noanswer),Set(FWD=${DB(SP/CFNA/${ARG1})})
|
||||
same => n,GotoIf($["${FWD}" != ""]?forward)
|
||||
same => n,GotoIf($["${ARG3}" = "1"]?vmu)
|
||||
same => n,Return()
|
||||
same => n(vmu),VoiceMail(${ARG1}@default,u)
|
||||
same => n,Return()
|
||||
same => n(busy),Set(FWD=${DB(SP/CFB/${ARG1})})
|
||||
same => n,GotoIf($["${FWD}" != ""]?forward)
|
||||
same => n,GotoIf($["${ARG3}" = "1"]?vmb)
|
||||
same => n,Busy(5)
|
||||
same => n,Return()
|
||||
same => n(vmb),VoiceMail(${ARG1}@default,b)
|
||||
same => n,Return()
|
||||
same => n(forward),Gosub(sp-sub-forward,s,1(${FWD},${ARG1}))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Forward a call. ARG1 = target number, ARG2 = ext whose forward rule this is.
|
||||
; Stops after 3 hops so 101→102→101 can't loop forever.
|
||||
[sp-sub-forward]
|
||||
exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} + 1])
|
||||
same => n,GotoIf($[${SP_FWDHOPS} > 3]?stop)
|
||||
same => n,GotoIf($[${DIALPLAN_EXISTS(sp-local-numbers,${ARG1},1)}]?internal)
|
||||
same => n,Set(SP_CLASS=${GLOBAL(SP_CLASS_${ARG2})})
|
||||
same => n,Set(SP_LIMITEXT=${ARG2}) ; counts toward that extension's call limit
|
||||
same => n,Set(__SP_FORWARDED=1)
|
||||
same => n,Goto(sp-outbound-routes,${ARG1},1)
|
||||
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
[sp-sub-autoanswer]
|
||||
exten => s,1,Set(PJSIP_HEADER(add,Call-Info)=<sip:${SP_EXT}>\;answer-after=0)
|
||||
same => n,Set(PJSIP_HEADER(add,Alert-Info)=info=alert-autoanswer)
|
||||
same => n,Return()
|
||||
|
||||
; ─── 911 alert ring-out. Originated by the api (04 §5.3): Channel PJSIP/<notify ext>,
|
||||
; Exten = the extension that dialed 911.
|
||||
[sp-emergency-alert]
|
||||
exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,Wait(1)
|
||||
same => n,Playback(beep&beep&beep)
|
||||
same => n,Playback(extension)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Playback(beep&beep&beep)
|
||||
same => n,Playback(extension)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
same => n,Playback(do-not-disturb&activated)
|
||||
same => n,Hangup()
|
||||
exten => *79,1,Answer() ; DND off
|
||||
same => n,Set(X=${DB_DELETE(SP/DND/${SP_EXT})})
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
same => n,Playback(call-fwd-unconditional&activated)
|
||||
same => n,Hangup()
|
||||
exten => *73,1,Answer() ; cancel forward always
|
||||
same => n,Set(X=${DB_DELETE(SP/CFU/${SP_EXT})})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
|
||||
exten => *8,1,Pickup()
|
||||
same => n,Hangup()
|
||||
exten => *43,1,Answer() ; echo test
|
||||
same => n,Playback(demo-echotest)
|
||||
same => n,Echo()
|
||||
same => n,Playback(demo-echodone)
|
||||
same => n,Hangup()
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
|
||||
same => n,GotoIf($["${PQMSTATUS}" != "PAUSED"]?deny) ; not an agent of that queue
|
||||
same => n,Set(DB(${K})=1)
|
||||
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 1)
|
||||
same => n,Playback(agent-loggedoff)
|
||||
same => n,Hangup()
|
||||
same => n(login),Set(X=${DB_DELETE(${K})})
|
||||
same => n,UnpauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
|
||||
same => n,GotoIf($["${UPQMSTATUS}" != "UNPAUSED"]?deny)
|
||||
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(agent-loginok)
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
same => n,Hangup()
|
||||
same => n(deny),Answer()
|
||||
same => n,Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
|
||||
exten => _*3[123]X,1,Answer()
|
||||
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
|
||||
same => n,Goto(sp-tc-override,${EXTEN},1)
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-ext-forward]
|
||||
exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external destination
|
||||
same => n,Set(__SP_FORWARDED=1)
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
include => sp-emergency-dial
|
||||
include => sp-featurecodes
|
||||
include => sp-local-numbers
|
||||
include => sp-outbound-routes
|
||||
|
||||
[sp-local-numbers]
|
||||
include => sp-parkedcalls
|
||||
|
||||
; users (sorted by number)
|
||||
exten => 1,1,Gosub(sp-sub-user,s,1(1,25,1,never))
|
||||
same => n,Hangup()
|
||||
exten => 1,hint,PJSIP/1&PJSIP/1-web
|
||||
exten => 12,1,Gosub(sp-sub-user,s,1(12,25,1,never))
|
||||
same => n,Hangup()
|
||||
exten => 12,hint,PJSIP/12&PJSIP/12-web
|
||||
|
||||
; ring groups
|
||||
exten => 5,1,Goto(sp-rg-22222222-2222-4222-8222-222222222005,s,1)
|
||||
|
||||
; queues
|
||||
exten => 6,1,Goto(sp-queue-44444444-4444-4444-8444-444444444006,s,1)
|
||||
|
||||
; digital receptionists
|
||||
exten => 0,1,Goto(sp-ivr-33333333-3333-4333-8333-333333333000,s,1)
|
||||
|
||||
; ─── Ring group 5 "Sales" (ringall)
|
||||
[sp-rg-22222222-2222-4222-8222-222222222005]
|
||||
exten => s,1,Set(__SP_TARGET=5)
|
||||
same => n,Dial(Local/1@sp-rg-member&Local/12@sp-rg-member,25,t)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
|
||||
same => n,Goto(sp-vm-direct,1,1)
|
||||
same => n(done),Hangup()
|
||||
|
||||
; ─── Queue 6 "Support"
|
||||
[sp-queue-44444444-4444-4444-8444-444444444006]
|
||||
exten => s,1,Set(__SP_TARGET=6)
|
||||
same => n,Answer()
|
||||
same => n,Queue(6,t,,,300)
|
||||
same => n,GotoIf($["${QUEUESTATUS}" = "TIMEOUT" | "${QUEUESTATUS}" = "FULL" | "${QUEUESTATUS}" = "JOINEMPTY" | "${QUEUESTATUS}" = "LEAVEEMPTY" | "${QUEUESTATUS}" = "JOINUNAVAIL" | "${QUEUESTATUS}" = "LEAVEUNAVAIL"]?fail)
|
||||
same => n,Hangup()
|
||||
same => n(fail),UserEvent(SpQueueExit,Queue: 6,Status: ${QUEUESTATUS})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Digital receptionist 0 "Main menu"
|
||||
[sp-ivr-33333333-3333-4333-8333-333333333000]
|
||||
include => sp-ivr-direct
|
||||
exten => s,1,Answer()
|
||||
same => n,Set(SP_IVR_TRIES=0)
|
||||
same => n(menu),Set(TIMEOUT(digit)=3)
|
||||
same => n,Background(custom/44444444-4444-4444-8444-444444444444)
|
||||
same => n,WaitExten(5)
|
||||
exten => 2,1,Goto(sp-rg-22222222-2222-4222-8222-222222222005,s,1)
|
||||
exten => i,1,Set(SP_IVR_TRIES=$[${SP_IVR_TRIES} + 1])
|
||||
same => n,GotoIf($[${SP_IVR_TRIES} >= 3]?giveup)
|
||||
same => n,Playback(pbx-invalid)
|
||||
same => n,Goto(s,menu)
|
||||
same => n(giveup),Hangup()
|
||||
exten => t,1,Set(SP_IVR_TRIES=$[${SP_IVR_TRIES} + 1])
|
||||
same => n,GotoIf($[${SP_IVR_TRIES} >= 3]?giveup)
|
||||
same => n,Goto(s,menu)
|
||||
same => n(giveup),Hangup()
|
||||
|
||||
[sp-ivr-direct]
|
||||
exten => 0,1,Goto(sp-local-numbers,0,1)
|
||||
exten => 1,1,Goto(sp-local-numbers,1,1)
|
||||
exten => 5,1,Goto(sp-local-numbers,5,1)
|
||||
exten => 6,1,Goto(sp-local-numbers,6,1)
|
||||
exten => 12,1,Goto(sp-local-numbers,12,1)
|
||||
|
||||
; ─── Office hours overrides (admins only, see sp-featurecodes)
|
||||
[sp-tc-override]
|
||||
exten => _*3[123]X,1,Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-inbound]
|
||||
; anything else
|
||||
exten => _[0-9a-z].,1,Hangup()
|
||||
|
||||
[sp-outbound-routes]
|
||||
exten => pending,1,Hangup() ; no routes yet
|
||||
|
||||
[sp-emergency-dial]
|
||||
exten => 911,1,Goto(sp-emergency,s,1)
|
||||
exten => 9911,1,Goto(sp-emergency,s,1) ; people who dial 9 first out of habit
|
||||
|
||||
[sp-emergency]
|
||||
exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E911_CID})})
|
||||
same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
|
||||
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
||||
same => n,Set(__SP_DIRECTION=outbound)
|
||||
same => n,Set(CDR(SP_DIRECTION)=outbound)
|
||||
same => n,Hangup()
|
||||
@@ -0,0 +1 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
@@ -0,0 +1,173 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
[global]
|
||||
type = global
|
||||
user_agent = SelfPhoneNG
|
||||
endpoint_identifier_order = ip,username,anonymous
|
||||
keep_alive_interval = 90
|
||||
max_initial_qualify_time = 4
|
||||
|
||||
[system]
|
||||
type = system
|
||||
timer_t1 = 500
|
||||
timer_b = 32000
|
||||
|
||||
[transport-udp]
|
||||
type = transport
|
||||
protocol = udp
|
||||
bind = 0.0.0.0:5060
|
||||
|
||||
[transport-tcp]
|
||||
type = transport
|
||||
protocol = tcp
|
||||
bind = 0.0.0.0:5060
|
||||
|
||||
[transport-ws]
|
||||
type = transport
|
||||
protocol = ws
|
||||
bind = 0.0.0.0
|
||||
|
||||
; ── extension 1 (Dan Doe) ──
|
||||
[1]
|
||||
type = endpoint
|
||||
context = sp-from-ext
|
||||
disallow = all
|
||||
allow = g722,ulaw,alaw
|
||||
auth = 1-auth
|
||||
aors = 1
|
||||
direct_media = no
|
||||
rtp_symmetric = yes
|
||||
force_rport = yes
|
||||
rewrite_contact = yes
|
||||
send_pai = yes
|
||||
callerid = "Dan Doe" <1>
|
||||
mailboxes = 1@default
|
||||
mwi_subscribe_replaces_unsolicited = yes
|
||||
dtmf_mode = rfc4733
|
||||
allow_subscribe = yes
|
||||
language = en
|
||||
moh_suggest = default
|
||||
tone_zone = us
|
||||
set_var = SP_EXT=1
|
||||
set_var = SP_CLASS=2
|
||||
set_var = SP_RECMODE=never
|
||||
set_var = __SP_DIRECTION=internal
|
||||
|
||||
[1-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = 1
|
||||
password = Ab3dEf9hIj2kLm4nOp6qRs8t
|
||||
|
||||
[1]
|
||||
type = aor
|
||||
max_contacts = 5
|
||||
remove_existing = yes
|
||||
qualify_frequency = 30
|
||||
mailboxes = 1@default
|
||||
|
||||
[1-web]
|
||||
type = endpoint
|
||||
context = sp-from-ext
|
||||
webrtc = yes
|
||||
disallow = all
|
||||
allow = g722,ulaw
|
||||
auth = 1-web-auth
|
||||
aors = 1-web
|
||||
from_domain = selfphoneng.invalid
|
||||
callerid = "Dan Doe" <1>
|
||||
mailboxes = 1@default
|
||||
mwi_subscribe_replaces_unsolicited = yes
|
||||
dtmf_mode = rfc4733
|
||||
allow_subscribe = yes
|
||||
language = en
|
||||
moh_suggest = default
|
||||
tone_zone = us
|
||||
set_var = SP_EXT=1
|
||||
set_var = SP_CLASS=2
|
||||
set_var = SP_RECMODE=never
|
||||
set_var = __SP_DIRECTION=internal
|
||||
|
||||
[1-web-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = 1-web
|
||||
password = Ab3dEf9hIj2kLm4nOp6qRs8t
|
||||
|
||||
[1-web]
|
||||
type = aor
|
||||
max_contacts = 3
|
||||
remove_existing = yes
|
||||
qualify_frequency = 0
|
||||
|
||||
; ── extension 12 (Sue Smith) ──
|
||||
[12]
|
||||
type = endpoint
|
||||
context = sp-from-ext
|
||||
disallow = all
|
||||
allow = g722,ulaw,alaw
|
||||
auth = 12-auth
|
||||
aors = 12
|
||||
direct_media = no
|
||||
rtp_symmetric = yes
|
||||
force_rport = yes
|
||||
rewrite_contact = yes
|
||||
send_pai = yes
|
||||
callerid = "Sue Smith" <12>
|
||||
mailboxes = 12@default
|
||||
mwi_subscribe_replaces_unsolicited = yes
|
||||
dtmf_mode = rfc4733
|
||||
allow_subscribe = yes
|
||||
language = en
|
||||
moh_suggest = default
|
||||
tone_zone = us
|
||||
set_var = SP_EXT=12
|
||||
set_var = SP_CLASS=2
|
||||
set_var = SP_RECMODE=never
|
||||
set_var = __SP_DIRECTION=internal
|
||||
|
||||
[12-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = 12
|
||||
password = Bb3dEf9hIj2kLm4nOp6qRs8t
|
||||
|
||||
[12]
|
||||
type = aor
|
||||
max_contacts = 5
|
||||
remove_existing = yes
|
||||
qualify_frequency = 30
|
||||
mailboxes = 12@default
|
||||
|
||||
[12-web]
|
||||
type = endpoint
|
||||
context = sp-from-ext
|
||||
webrtc = yes
|
||||
disallow = all
|
||||
allow = g722,ulaw
|
||||
auth = 12-web-auth
|
||||
aors = 12-web
|
||||
from_domain = selfphoneng.invalid
|
||||
callerid = "Sue Smith" <12>
|
||||
mailboxes = 12@default
|
||||
mwi_subscribe_replaces_unsolicited = yes
|
||||
dtmf_mode = rfc4733
|
||||
allow_subscribe = yes
|
||||
language = en
|
||||
moh_suggest = default
|
||||
tone_zone = us
|
||||
set_var = SP_EXT=12
|
||||
set_var = SP_CLASS=2
|
||||
set_var = SP_RECMODE=never
|
||||
set_var = __SP_DIRECTION=internal
|
||||
|
||||
[12-web-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = 12-web
|
||||
password = Bb3dEf9hIj2kLm4nOp6qRs8t
|
||||
|
||||
[12-web]
|
||||
type = aor
|
||||
max_contacts = 3
|
||||
remove_existing = yes
|
||||
qualify_frequency = 0
|
||||
@@ -0,0 +1,16 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
; ─── Queue 6 "Support"
|
||||
[6]
|
||||
strategy = rrmemory
|
||||
timeout = 15
|
||||
retry = 5
|
||||
wrapuptime = 10
|
||||
maxlen = 0
|
||||
musicclass = default
|
||||
announce-frequency = 60
|
||||
announce-position = yes
|
||||
ringinuse = no
|
||||
setinterfacevar = yes
|
||||
joinempty = paused,unavailable,invalid,unknown
|
||||
leavewhenempty = paused,unavailable,invalid,unknown
|
||||
member => Local/1@sp-queue-member/n,0,Dan Doe,hint:1@sp-local-numbers
|
||||
@@ -0,0 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -0,0 +1,28 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
[general]
|
||||
format = wav
|
||||
attach = no ; the api sends emails itself
|
||||
maxmsg = 100
|
||||
maxsecs = 300
|
||||
minsecs = 1
|
||||
maxsilence = 10
|
||||
silencethreshold = 128
|
||||
maxlogins = 3
|
||||
minpassword = 4
|
||||
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
|
||||
; stores them from VoicemailPasswordChange (services/vmpin.ts).
|
||||
externpasscheck = /usr/local/bin/sp-vm-pincheck
|
||||
envelope = yes
|
||||
saycid = yes
|
||||
review = no
|
||||
nextaftercmd = yes
|
||||
sendvoicemail = no
|
||||
pollmailboxes = yes
|
||||
pollfreq = 30
|
||||
|
||||
[zonemessages]
|
||||
local = America/Chicago|'vm-received' Q 'digits/at' IMp
|
||||
|
||||
[default]
|
||||
1 => 4826,Dan Doe,,,tz=local|attach=no
|
||||
12 => 4826,Sue Smith,,,tz=local|attach=no
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"extensions": [
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000001",
|
||||
"number": "1",
|
||||
"name": "Dan Doe",
|
||||
"voicemailEnabled": true,
|
||||
"sipPassword": "Ab3dEf9hIj2kLm4nOp6qRs8t"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000012",
|
||||
"number": "12",
|
||||
"name": "Sue Smith",
|
||||
"sipPassword": "Bb3dEf9hIj2kLm4nOp6qRs8t"
|
||||
}
|
||||
],
|
||||
"ringGroups": [
|
||||
{
|
||||
"id": "22222222-2222-4222-8222-222222222005",
|
||||
"number": "5",
|
||||
"name": "Sales",
|
||||
"members": ["1", "12"],
|
||||
"noAnswerDestination": { "type": "voicemail", "id": "00000000-0000-4000-8000-000000000001" }
|
||||
}
|
||||
],
|
||||
"queues": [
|
||||
{
|
||||
"id": "44444444-4444-4444-8444-444444444006",
|
||||
"number": "6",
|
||||
"name": "Support",
|
||||
"members": [{ "number": "1", "name": "Dan Doe", "penalty": 0 }]
|
||||
}
|
||||
],
|
||||
"ivrs": [
|
||||
{
|
||||
"id": "33333333-3333-4333-8333-333333333000",
|
||||
"number": "0",
|
||||
"name": "Main menu",
|
||||
"greeting": "custom/44444444-4444-4444-8444-444444444444",
|
||||
"allowDirectDial": true,
|
||||
"options": [
|
||||
{
|
||||
"digit": "2",
|
||||
"destination": { "type": "ring_group", "id": "22222222-2222-4222-8222-222222222005" }
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"asteriskMatches": [
|
||||
["1", "sp-local-numbers"],
|
||||
["5", "sp-local-numbers"],
|
||||
["0", "sp-local-numbers"],
|
||||
["1", "sp-rg-member"],
|
||||
["1", "sp-queue-member"],
|
||||
["1", "sp-vm-direct"],
|
||||
["1", "sp-ivr-direct"],
|
||||
["**1", "sp-featurecodes"],
|
||||
["*456", "sp-featurecodes"],
|
||||
["*721", "sp-featurecodes"],
|
||||
["*801", "sp-featurecodes"]
|
||||
]
|
||||
}
|
||||
+28
-7
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +148,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +174,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +208,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
+28
-7
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +148,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +174,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +208,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
+28
-7
@@ -109,14 +109,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +148,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +159,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +174,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +192,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +208,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +218,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -112,14 +112,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(\${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=\${SP_EXT})
|
||||
same => n,GotoIf($[\${GROUP_COUNT(\${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=\${STRFTIME(\${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=\${DB(SP/VMLOGIN/\${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=\${IF($["\${CUT(V,:,1)}" = "\${H}"]?\${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[\${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/\${SP_EXT})=\${H}:$[\${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["\${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(\${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["\${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/\${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: \${SP_EXT},Value: 1)
|
||||
@@ -130,7 +151,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: \${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(\${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/\${SP_EXT})=\${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: \${SP_EXT},Value: \${GOSUB_RETVAL})
|
||||
@@ -141,7 +162,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: \${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=\${FILTER(0-9,\${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=\${FILTER(0-9,\${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/\${T}-&PJSIP/\${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -156,7 +177,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(\${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=\${FILTER(0-9,\${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/\${Q}/\${SP_EXT})
|
||||
same => n,GotoIf($["\${DB(\${K})}" = "1"]?login)
|
||||
@@ -174,7 +195,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=\${FILTER(0-9,\${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=\${FILTER(0-9,\${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["\${T}" != "\${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["\${PJSIP_ENDPOINT(\${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/\${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -190,7 +211,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(\${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -200,12 +221,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,\${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["\${DB(SP/DND/\${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["\${DB(SP/DND/\${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(\${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(\${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(\${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/reports (04 §7.3, 05 §2 Reports): calls per day, busiest hours, per-queue stats. Admin only.
|
||||
import { and, asc, sql } from 'drizzle-orm';
|
||||
import { and, asc, eq, sql } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
CALL_DIRECTIONS,
|
||||
ReportRangeSchema,
|
||||
type ListResponse,
|
||||
type QueueReportRow,
|
||||
type ProviderBalanceDto,
|
||||
type ReportSummary,
|
||||
} from '@selfphoneng/shared';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { calls, queues } from '../db/schema.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import { calls, queues, trunks } from '../db/schema.ts';
|
||||
import { getBalance, VoipmsError } from '../integrations/voipms/client.ts';
|
||||
import { badRequest } from '../errors.ts';
|
||||
import { todayIn } from '../generator/load.ts';
|
||||
import { queueStats } from '../services/queuestats.ts';
|
||||
@@ -42,6 +45,10 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
);
|
||||
const local = sql`(${calls.startedAt} at time zone ${tz})`;
|
||||
const missed = sql`${calls.status} in ('missed', 'voicemail', 'abandoned')`;
|
||||
// Outside line time: inbound from arrival (a receptionist or voicemail answers too),
|
||||
// outbound from the answer (talk time).
|
||||
const lineIn = sql`coalesce(sum(extract(epoch from ${calls.endedAt} - ${calls.startedAt})) filter (where ${calls.direction} = 'inbound'), 0)`;
|
||||
const lineOut = sql`coalesce(sum(${calls.talkSeconds}) filter (where ${calls.direction} = 'outbound'), 0)`;
|
||||
const [perDay, perHour, [t]] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
@@ -49,6 +56,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
direction: calls.direction,
|
||||
n: sql<number>`count(*)::int`,
|
||||
missed: sql<number>`count(*) filter (where ${missed})::int`,
|
||||
outside: sql<number>`round(${lineIn} + ${lineOut})::int`,
|
||||
})
|
||||
.from(calls)
|
||||
.where(where)
|
||||
@@ -65,7 +73,11 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
db
|
||||
.select({
|
||||
calls: sql<number>`count(*)::int`,
|
||||
answered: sql<number>`count(*) filter (where ${calls.status} = 'answered')::int`,
|
||||
missed: sql<number>`count(*) filter (where ${missed})::int`,
|
||||
talk: sql<number>`coalesce(sum(${calls.talkSeconds}), 0)::int`,
|
||||
lineIn: sql<number>`round(${lineIn})::int`,
|
||||
lineOut: sql<number>`${lineOut}::int`,
|
||||
avgTalk: sql<
|
||||
number | null
|
||||
>`round(avg(${calls.talkSeconds}) filter (where ${calls.status} = 'answered'))::int`,
|
||||
@@ -82,6 +94,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
CALL_DIRECTIONS.map((dir) => [dir, rows.find((r) => r.direction === dir)?.n ?? 0]),
|
||||
) as Record<(typeof CALL_DIRECTIONS)[number], number>),
|
||||
missed: rows.reduce((s, r) => s + r.missed, 0),
|
||||
outsideMinutes: Math.round(rows.reduce((s, r) => s + r.outside, 0) / 60),
|
||||
});
|
||||
}
|
||||
const hours = Array.from({ length: 7 }, () => Array<number>(24).fill(0));
|
||||
@@ -90,11 +103,53 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
from,
|
||||
to,
|
||||
days,
|
||||
totals: { calls: t!.calls, missed: t!.missed, avgTalkSeconds: t!.avgTalk },
|
||||
totals: {
|
||||
calls: t!.calls,
|
||||
answered: t!.answered,
|
||||
missed: t!.missed,
|
||||
avgTalkSeconds: t!.avgTalk,
|
||||
talkSeconds: t!.talk,
|
||||
outsideSeconds: { inbound: t!.lineIn, outbound: t!.lineOut },
|
||||
},
|
||||
hours,
|
||||
};
|
||||
});
|
||||
|
||||
/** Each voip.ms trunk's account balance (owner, 2026-10-21); a trunk without API login says so. */
|
||||
app.get('/api/reports/balance', async (): Promise<ListResponse<ProviderBalanceDto>> => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(trunks)
|
||||
.where(eq(trunks.provider, 'voipms'))
|
||||
.orderBy(asc(trunks.name));
|
||||
const items = await Promise.all(
|
||||
rows.map(async (t): Promise<ProviderBalanceDto> => {
|
||||
const base = { trunkId: t.id, trunkName: t.name };
|
||||
if (!t.voipmsApiUsername || !t.voipmsApiPasswordEnc)
|
||||
return {
|
||||
...base,
|
||||
balance: null,
|
||||
error: 'Add the voip.ms API username and password to this trunk (Trunks) to see it',
|
||||
};
|
||||
try {
|
||||
const creds = {
|
||||
username: t.voipmsApiUsername,
|
||||
password: decrypt(t.voipmsApiPasswordEnc),
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
balance: await getBalance(app.config.VOIPMS_API_URL, creds),
|
||||
error: null,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof VoipmsError) return { ...base, balance: null, error: e.message };
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { items, nextCursor: null };
|
||||
});
|
||||
|
||||
app.get('/api/reports/queues', async (req): Promise<ListResponse<QueueReportRow>> => {
|
||||
const { from, to } = await range(db, req.query);
|
||||
const qs = await db.select().from(queues).orderBy(asc(queues.number));
|
||||
|
||||
@@ -115,6 +115,20 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
await checkRefs(db, values);
|
||||
const before = await getAllSettings(db);
|
||||
// SIP ports may not land on another listener (Asterisk would fail to start the transport).
|
||||
const c = app.config;
|
||||
const [sip, tls] = [
|
||||
(values['sip.udpPort'] ?? before['sip.udpPort']) as number,
|
||||
(values['sip.tlsPort'] ?? before['sip.tlsPort']) as number,
|
||||
];
|
||||
for (const port of [sip, tls])
|
||||
if ([80, 443, 8088, c.AMI_PORT, c.API_PORT].includes(port))
|
||||
throw badRequest(`Port ${port} is already used by SelfPhoneNG`);
|
||||
else if (port >= c.RTP_PORT_START && port <= c.RTP_PORT_END)
|
||||
throw badRequest(
|
||||
`Port ${port} is in the call audio range (${c.RTP_PORT_START}–${c.RTP_PORT_END})`,
|
||||
);
|
||||
if (sip === tls) throw badRequest('The SIP port and the TLS port must differ');
|
||||
if (values['sip.tlsEnabled'] === true && !before['sip.tlsEnabled']) {
|
||||
const cert = await readTlsCert(app.config.TLS_DIR);
|
||||
if (!cert.ok) throw badRequest(`Can't turn on TLS. ${cert.error}`);
|
||||
|
||||
@@ -5,7 +5,13 @@ import { networkInterfaces } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { desc, lt } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import type { ListResponse, SystemEventDto, SystemStatus } from '@selfphoneng/shared';
|
||||
import type {
|
||||
DiagnosticsDto,
|
||||
ListResponse,
|
||||
NetworkTestDto,
|
||||
SystemEventDto,
|
||||
SystemStatus,
|
||||
} from '@selfphoneng/shared';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
@@ -14,7 +20,8 @@ import { systemEvents } from '../db/schema.ts';
|
||||
import { appVersion } from './about.ts';
|
||||
import { csvCell } from './calls.ts';
|
||||
import { BACKUP_NAME, listBackups, runBackup } from '../services/backup.ts';
|
||||
import { HttpError, notFound } from '../errors.ts';
|
||||
import { badRequest, HttpError, notFound } from '../errors.ts';
|
||||
import { insideChecks, networkTest } from '../services/diagnostics.ts';
|
||||
import { AmiDisconnectedError } from '../ami/client.ts';
|
||||
import { GENERATED_FILES } from '../generator/index.ts';
|
||||
|
||||
@@ -67,7 +74,7 @@ export async function systemRoutes(app: FastifyInstance) {
|
||||
sip: s['sip.udpPort'],
|
||||
sipTcp: s['sip.tcpEnabled'],
|
||||
sips: s['sip.tlsEnabled'] ? s['sip.tlsPort'] : null,
|
||||
rtp: '10000–20000', // asterisk/conf/rtp.conf
|
||||
rtp: `${app.config.RTP_PORT_START}–${app.config.RTP_PORT_END}`, // RTP_PORT_START/END
|
||||
https: 443,
|
||||
},
|
||||
ami: { connected: app.ami.isConnected() },
|
||||
@@ -163,6 +170,20 @@ export async function systemRoutes(app: FastifyInstance) {
|
||||
return reply.sendFile(name, dir);
|
||||
});
|
||||
|
||||
// Diagnostics (08 §2.6): the inside checks; the probe's outside test, quick or complete.
|
||||
const diag = () => ({ db: app.db, config: app.config, ami: app.ami });
|
||||
app.get('/api/system/diagnostics', async (): Promise<DiagnosticsDto> => ({
|
||||
checks: await insideChecks(diag()),
|
||||
}));
|
||||
app.post('/api/system/diagnostics/network', async (req): Promise<NetworkTestDto> => {
|
||||
const { mode } = z
|
||||
.object({ mode: z.enum(['quick', 'complete']).default('quick') })
|
||||
.parse(req.body ?? {});
|
||||
if (!(await getAllSettings(app.db))['network.probeUrl'])
|
||||
throw badRequest('No network test probe set (Settings → Network)');
|
||||
return networkTest(diag(), mode);
|
||||
});
|
||||
|
||||
app.get('/api/system/generated-files', async () => ({
|
||||
files: await Promise.all(
|
||||
GENERATED_FILES.map(async (name) => ({
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Backups (04 §8.1): BACKUPS_DIR/selfphoneng-YYYYMMDD-HHMM.tar.gz with db.sql (pg_dump), the
|
||||
// Backups (04 §8.1): BACKUPS_DIR/selfphoneng-YYYYMMDD-HHMMSS.tar.gz with db.sql (pg_dump), the
|
||||
// voicemail, custom sounds and music-on-hold folders, optionally recordings, and manifest.json.
|
||||
// No generated Asterisk config (plaintext SIP passwords; it's rebuilt from the DB on start).
|
||||
// Restore is scripts/restore.sh only.
|
||||
import { execFile } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtemp, readdir, rename, rm, stat, writeFile, chmod } from 'node:fs/promises';
|
||||
import { chmod, mkdtemp, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
@@ -17,7 +17,9 @@ import { getSetting } from './settings.ts';
|
||||
const run = promisify(execFile);
|
||||
/** manifest.json `version`: the backup format, which restore.sh checks. */
|
||||
export const BACKUP_FORMAT = 1;
|
||||
export const BACKUP_NAME = /^selfphoneng-\d{8}-\d{4}\.tar\.gz$/;
|
||||
/** With seconds since 2026-10-21, so two backups in one minute (the update's and the api's own
|
||||
* before migrating) don't share a name; older names have none. */
|
||||
export const BACKUP_NAME = /^selfphoneng-\d{8}-\d{4}(\d{2})?\.tar\.gz$/;
|
||||
|
||||
/**
|
||||
* Lets restore.sh tell whether .env has the APP_SECRET the backup's encrypted columns need,
|
||||
@@ -70,7 +72,9 @@ export async function createBackup(
|
||||
src: BackupSources,
|
||||
opts: { keep: number; now?: Date },
|
||||
): Promise<BackupDto> {
|
||||
const name = `selfphoneng-${stamp(src.timezone, opts.now)}.tar.gz`;
|
||||
const now = opts.now ?? new Date();
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
const name = `selfphoneng-${stamp(src.timezone, now)}${seconds}.tar.gz`;
|
||||
const work = await mkdtemp(join(tmpdir(), 'sp-backup-'));
|
||||
const tmp = join(src.outDir, `.${name}.tmp`);
|
||||
try {
|
||||
@@ -99,7 +103,7 @@ export async function createBackup(
|
||||
}
|
||||
const manifest = {
|
||||
version: BACKUP_FORMAT,
|
||||
createdAt: (opts.now ?? new Date()).toISOString(),
|
||||
createdAt: now.toISOString(),
|
||||
includesRecordings: included.includes('recordings'),
|
||||
folders: included,
|
||||
secretCheck: secretCheck(src.appSecret),
|
||||
@@ -151,6 +155,51 @@ export function runBackup(config: Config, db: Db): Promise<BackupDto> {
|
||||
return running;
|
||||
}
|
||||
|
||||
/**
|
||||
* Before a new version migrates the database (index.ts): the database and the folders the daily
|
||||
* backup has, without recordings (big, and no migration touches them). Prunes nothing, so it
|
||||
* doesn't push a daily backup out; the daily job's pruning removes it in time. Reads as little of
|
||||
* the old schema as it can: the time zone only, falling back to TZ.
|
||||
*
|
||||
* With `step` (the migrations about to run), once per step: a crash-looping api would otherwise
|
||||
* back up on every restart until the disk fills. `.before-migrate.json` in the backups folder
|
||||
* remembers the step and its backup; `made: false` means that one is reused.
|
||||
*/
|
||||
export async function backupBeforeMigrate(
|
||||
config: Config,
|
||||
db: Db,
|
||||
step?: { applied: number; total: number },
|
||||
): Promise<BackupDto & { made: boolean }> {
|
||||
const dir = resolve(config.BACKUPS_DIR);
|
||||
const marker = join(dir, '.before-migrate.json');
|
||||
const key = step && `${step.applied}-${step.total}`;
|
||||
if (key) {
|
||||
const last = await readFile(marker, 'utf8').then(
|
||||
(s) => JSON.parse(s) as { key: string; name: string },
|
||||
() => null,
|
||||
);
|
||||
const kept = last?.key === key && (await listBackups(dir)).find((b) => b.name === last.name);
|
||||
if (kept) return { ...kept, made: false };
|
||||
}
|
||||
const timezone = await getSetting(db, 'general.timezone').catch(() => config.TZ);
|
||||
const made = await createBackup(
|
||||
{
|
||||
databaseUrl: config.DATABASE_URL,
|
||||
appSecret: config.APP_SECRET,
|
||||
outDir: resolve(config.BACKUPS_DIR),
|
||||
timezone,
|
||||
dirs: {
|
||||
voicemail: resolve(config.VOICEMAIL_DIR),
|
||||
'sounds-custom': resolve(config.SOUNDS_DIR),
|
||||
moh: resolve(config.MOH_DIR),
|
||||
},
|
||||
},
|
||||
{ keep: Infinity },
|
||||
);
|
||||
if (key) await writeFile(marker, JSON.stringify({ key, name: made.name }));
|
||||
return { ...made, made: true };
|
||||
}
|
||||
|
||||
/** The daily backup is due: enabled, past `backup.time` here, and none made today yet. */
|
||||
export async function backupDue(config: Config, db: Db, now = new Date()): Promise<boolean> {
|
||||
if (!(await getSetting(db, 'backup.enabled'))) return false;
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// System → Diagnostics (08 §2.6). insideChecks: what this server can tell about itself (settings,
|
||||
// DNS, listening transports, the audio port range). networkTest: the probe (apps/probe) tests it
|
||||
// from outside, the only way to know what a phone on mobile data or the SIP provider can reach.
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { createSocket, type Socket } from 'node:dgram';
|
||||
import { resolve4 } from 'node:dns/promises';
|
||||
import { BlockList, isIPv4 } from 'node:net';
|
||||
import type { DiagnosticCheck, NetworkTestDto } from '@selfphoneng/shared';
|
||||
import type { AmiClient } from '../ami/client.ts';
|
||||
import type { Config } from '../config.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { detectPublicIp } from '../jobs.ts';
|
||||
import { getAllSettings } from './settings.ts';
|
||||
|
||||
export type DiagDeps = {
|
||||
db: Db;
|
||||
config: Pick<Config, 'PUBLIC_HOSTNAME' | 'RTP_PORT_START' | 'RTP_PORT_END'>;
|
||||
ami: Pick<AmiClient, 'isConnected' | 'command'>;
|
||||
/** Overridable in tests. */
|
||||
lookupPublicIp?: () => Promise<string | null>;
|
||||
resolveHost?: (host: string) => Promise<string[]>;
|
||||
fetchProbe?: typeof fetch;
|
||||
};
|
||||
|
||||
const PRIVATE = new BlockList();
|
||||
for (const [a, b] of [
|
||||
['10.0.0.0', 8],
|
||||
['172.16.0.0', 12],
|
||||
['192.168.0.0', 16],
|
||||
['100.64.0.0', 10],
|
||||
['127.0.0.0', 8],
|
||||
] as const)
|
||||
PRIVATE.addSubnet(a, b, 'ipv4');
|
||||
|
||||
const inNets = (ip: string, nets: string[]) => {
|
||||
const list = new BlockList();
|
||||
for (const n of nets) {
|
||||
const [a, b] = n.split('/');
|
||||
list.addSubnet(a!, Number(b), 'ipv4');
|
||||
}
|
||||
return list.check(ip, 'ipv4');
|
||||
};
|
||||
|
||||
const check = (
|
||||
id: string,
|
||||
label: string,
|
||||
status: DiagnosticCheck['status'],
|
||||
detail: string,
|
||||
fix?: string,
|
||||
): DiagnosticCheck => ({ id, label, status, detail, ...(fix && { fix }) });
|
||||
|
||||
/** Even ports in the range that something else already holds (RTP takes even ones). */
|
||||
async function busyRtpPorts(start: number, end: number): Promise<number> {
|
||||
const ports: number[] = [];
|
||||
for (let p = start + (start % 2); p <= end; p += 2) ports.push(p);
|
||||
let busy = 0;
|
||||
// ponytail: binds every even port once, 200 at a time (~1 s for 10 000 ports).
|
||||
for (let i = 0; i < ports.length; i += 200)
|
||||
await Promise.all(
|
||||
ports.slice(i, i + 200).map(
|
||||
(port) =>
|
||||
new Promise<void>((done) => {
|
||||
const s = createSocket('udp4');
|
||||
s.once('error', () => (busy++, s.close(), done()));
|
||||
s.bind(port, () => s.close(done));
|
||||
}),
|
||||
),
|
||||
);
|
||||
return busy;
|
||||
}
|
||||
|
||||
export async function insideChecks(d: DiagDeps): Promise<DiagnosticCheck[]> {
|
||||
const s = await getAllSettings(d.db);
|
||||
const host = d.config.PUBLIC_HOSTNAME;
|
||||
const out: DiagnosticCheck[] = [];
|
||||
const setIp = s['network.publicIp'];
|
||||
const seenIp = await (d.lookupPublicIp ?? detectPublicIp)();
|
||||
const ip = setIp ?? seenIp;
|
||||
|
||||
if (!setIp)
|
||||
out.push(
|
||||
check(
|
||||
'public-ip',
|
||||
'Public IP',
|
||||
'fail',
|
||||
"Not set. Phones outside your network are given the server's private address, so their calls fail or have no sound.",
|
||||
`Settings → Network → Public IP: ${seenIp ?? "this server's internet address"}, then restart telephony (System).`,
|
||||
),
|
||||
);
|
||||
else if (seenIp && seenIp !== setIp)
|
||||
out.push(
|
||||
check(
|
||||
'public-ip',
|
||||
'Public IP',
|
||||
'fail',
|
||||
`Set to ${setIp}, but this server reaches the internet from ${seenIp}.`,
|
||||
`Settings → Network → Public IP: ${seenIp}, then restart telephony. If it keeps changing, turn on "Re-check the public IP".`,
|
||||
),
|
||||
);
|
||||
else
|
||||
out.push(
|
||||
check(
|
||||
'public-ip',
|
||||
'Public IP',
|
||||
seenIp ? 'ok' : 'warn',
|
||||
seenIp ? setIp : `${setIp} (couldn't look it up to compare: no internet?)`,
|
||||
),
|
||||
);
|
||||
|
||||
const names = await (d.resolveHost ?? resolve4)(host).catch(() => [] as string[]);
|
||||
if (!names.length)
|
||||
out.push(
|
||||
check(
|
||||
'dns',
|
||||
`DNS for ${host}`,
|
||||
'fail',
|
||||
`${host} doesn't resolve. Phones and the browser find the server by this name.`,
|
||||
`At your DNS provider, add an A record: ${host} → ${ip ?? 'your public IP'}.`,
|
||||
),
|
||||
);
|
||||
else if (ip && !names.includes(ip))
|
||||
out.push(
|
||||
check(
|
||||
'dns',
|
||||
`DNS for ${host}`,
|
||||
'fail',
|
||||
`${host} points at ${names.join(', ')}, not ${ip}.`,
|
||||
`Point the A record at ${ip}. With Cloudflare, set it to "DNS only" (grey cloud): its proxy carries web pages, not phone calls (SIP).`,
|
||||
),
|
||||
);
|
||||
else out.push(check('dns', `DNS for ${host}`, 'ok', `${host} → ${names.join(', ')}`));
|
||||
|
||||
const nets = s['network.localNets'];
|
||||
const lan = s['network.lanIp'];
|
||||
if (lan && !inNets(lan, nets))
|
||||
out.push(
|
||||
check(
|
||||
'local-nets',
|
||||
'Local networks',
|
||||
'fail',
|
||||
`The server's LAN address ${lan} isn't in them (${nets.join(', ') || 'none'}).`,
|
||||
`Settings → Network: add ${lan.replace(/\.\d+$/, '.0')}/24 (or your LAN's range) to the local networks.`,
|
||||
),
|
||||
);
|
||||
else out.push(check('local-nets', 'Local networks', 'ok', nets.join(', ') || 'none'));
|
||||
|
||||
out.push(
|
||||
s['sip.restrictPhonesToLocalNets']
|
||||
? check(
|
||||
'phone-acl',
|
||||
'Where phones can sign in from',
|
||||
'warn',
|
||||
`Only from ${[...nets, ...s['sip.extraAllowedNets']].join(', ')}. Phone apps on mobile data, or phones at home, are refused.`,
|
||||
'Settings → Security: untick "Only accept desk phones from the networks below", or add the networks they call from.',
|
||||
)
|
||||
: check('phone-acl', 'Where phones can sign in from', 'ok', 'Anywhere, with their password'),
|
||||
);
|
||||
if (s['provisioning.lanOnly'])
|
||||
out.push(
|
||||
check(
|
||||
'prov-lan',
|
||||
'Phone setup files',
|
||||
'warn',
|
||||
'Only phones on the local networks get them: the Linphone QR code fails on mobile data.',
|
||||
'Settings → Security: untick "Only to phones on the local networks" while setting up phones elsewhere.',
|
||||
),
|
||||
);
|
||||
|
||||
const port = s['sip.udpPort'];
|
||||
if (!d.ami.isConnected())
|
||||
out.push(
|
||||
check(
|
||||
'sip',
|
||||
'SIP',
|
||||
'fail',
|
||||
"Telephony (Asterisk) isn't reachable.",
|
||||
'Check that the asterisk container runs: docker compose ps, then docker compose logs asterisk.',
|
||||
),
|
||||
);
|
||||
else {
|
||||
const t = await d.ami.command('pjsip show transports').catch(() => '');
|
||||
const want = [
|
||||
['transport-udp', `UDP ${port}`],
|
||||
...(s['sip.tcpEnabled'] ? [['transport-tcp', `TCP ${port}`]] : []),
|
||||
...(s['sip.tlsEnabled'] ? [['transport-tls', `TLS ${s['sip.tlsPort']}`]] : []),
|
||||
];
|
||||
const missing = want.filter(([name]) => !t.includes(name!)).map(([, what]) => what);
|
||||
out.push(
|
||||
missing.length
|
||||
? check(
|
||||
'sip',
|
||||
'SIP',
|
||||
'fail',
|
||||
`Not listening on ${missing.join(', ')}.`,
|
||||
'Restart telephony (System): ports and TLS changes only take effect then.',
|
||||
)
|
||||
: check('sip', 'SIP', 'ok', `Listening on ${want.map(([, w]) => w).join(', ')}`),
|
||||
);
|
||||
}
|
||||
|
||||
const { RTP_PORT_START: a, RTP_PORT_END: b } = d.config;
|
||||
const total = Math.floor((b - a) / 2) + 1;
|
||||
const busy = await busyRtpPorts(a, b);
|
||||
const range = `UDP ${a}–${b}`;
|
||||
out.push(
|
||||
busy > total / 4
|
||||
? check(
|
||||
'rtp',
|
||||
'Call audio ports',
|
||||
'warn',
|
||||
`${range}: ${busy} of ${total} are taken by other programs (or calls).`,
|
||||
`Pick a free range with RTP_PORT_START and RTP_PORT_END (in .env, or the api's environment in your stack), restart, and open that range instead. Two ports per call: 200 ports is plenty for a small office.`,
|
||||
)
|
||||
: check(
|
||||
'rtp',
|
||||
'Call audio ports',
|
||||
'ok',
|
||||
`${range}, ${total} usable${busy ? `, ${busy} in use` : ''}`,
|
||||
),
|
||||
);
|
||||
|
||||
out.push(
|
||||
s['network.probeUrl']
|
||||
? check('probe', 'Network test probe', 'ok', s['network.probeUrl'])
|
||||
: check(
|
||||
'probe',
|
||||
'Network test probe',
|
||||
'skip',
|
||||
'None set, so nothing is tested from outside your network.',
|
||||
'Run apps/probe on a machine outside this network (deploy/probe), then put its URL in Settings → Network.',
|
||||
),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
type ProbeResult = { id: string; ok: boolean; detail: string };
|
||||
/** The probe's reason in brackets, unless it only repeats "no answer". */
|
||||
const why = (detail: string) => (detail === 'no answer' ? '' : ` (${detail})`);
|
||||
|
||||
/** The probe's outside tests; `complete` adds TLS, the browser phone's /ws and audio ports. */
|
||||
export async function networkTest(
|
||||
d: DiagDeps,
|
||||
mode: 'quick' | 'complete',
|
||||
): Promise<NetworkTestDto> {
|
||||
const s = await getAllSettings(d.db);
|
||||
const probe = s['network.probeUrl'];
|
||||
if (!probe) throw new Error('No network test probe set (Settings → Network)');
|
||||
const host = d.config.PUBLIC_HOSTNAME;
|
||||
const port = s['sip.udpPort'];
|
||||
const { RTP_PORT_START: a, RTP_PORT_END: b } = d.config;
|
||||
const lan = s['network.lanIp'] ?? 'this server';
|
||||
|
||||
const tests: Record<string, unknown>[] = [
|
||||
{ id: 'http', kind: 'http', port: 80, path: '/' },
|
||||
{ id: 'https', kind: 'https', port: 443, path: '/healthz' },
|
||||
{ id: 'sip-udp', kind: 'sip-udp', port },
|
||||
...(s['sip.tcpEnabled'] ? [{ id: 'sip-tcp', kind: 'sip-tcp', port }] : []),
|
||||
];
|
||||
// Audio ports: a few, listened on here for the probe's datagrams.
|
||||
const listeners: { port: number; sock: Socket; got: boolean }[] = [];
|
||||
if (mode === 'complete') {
|
||||
tests.push({ id: 'ws', kind: 'https', port: 443, path: '/ws', websocket: true });
|
||||
if (s['sip.tlsEnabled']) tests.push({ id: 'sip-tls', kind: 'sip-tls', port: s['sip.tlsPort'] });
|
||||
const even = (p: number) => p - (p % 2);
|
||||
const sample = [
|
||||
...new Set([
|
||||
even(a + 1),
|
||||
even(b),
|
||||
even(a + Math.floor((b - a) / 2)),
|
||||
even(Math.min(b, a + 2 + Math.floor(Math.random() * Math.max(0, b - a - 2)))),
|
||||
]),
|
||||
];
|
||||
const token = randomBytes(8).toString('hex');
|
||||
for (const p of sample) {
|
||||
const sock = createSocket('udp4');
|
||||
const ok = await new Promise<boolean>((r) => {
|
||||
sock.once('error', () => r(false));
|
||||
sock.bind(p, () => r(true));
|
||||
});
|
||||
if (!ok) continue; // a call or another program has it: try the others
|
||||
const l = { port: p, sock, got: false };
|
||||
sock.on('message', (m) => (l.got ||= m.toString().includes(token)));
|
||||
listeners.push(l);
|
||||
tests.push({ id: `rtp-${p}`, kind: 'udp', port: p, token });
|
||||
}
|
||||
}
|
||||
|
||||
let ip: string | null = null;
|
||||
let results: ProbeResult[] = [];
|
||||
let probeError: string | null = null;
|
||||
try {
|
||||
const r = await (d.fetchProbe ?? fetch)(new URL('/v1/check', probe), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ host, tests }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const body = (await r.json()) as { ip?: string; results?: ProbeResult[]; error?: string };
|
||||
if (!r.ok) probeError = body.error ?? `HTTP ${r.status}`;
|
||||
else ({ ip = null, results = [] } = body as { ip: string; results: ProbeResult[] });
|
||||
// Datagrams sent last arrive just after the answer.
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
} catch (e) {
|
||||
probeError = (e as Error).message;
|
||||
} finally {
|
||||
for (const l of listeners) l.sock.close();
|
||||
}
|
||||
if (probeError)
|
||||
return {
|
||||
mode,
|
||||
testedIp: null,
|
||||
checks: [
|
||||
check(
|
||||
'probe',
|
||||
'Network test probe',
|
||||
'fail',
|
||||
`${probe} didn't answer: ${probeError}`,
|
||||
'Check the probe runs and is reachable from this server, then try again.',
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
const res = (id: string) => results.find((x) => x.id === id);
|
||||
const out: DiagnosticCheck[] = [];
|
||||
if (ip && isIPv4(ip) && PRIVATE.check(ip, 'ipv4'))
|
||||
out.push(
|
||||
check(
|
||||
'probe-ip',
|
||||
'Tested address',
|
||||
'warn',
|
||||
`The probe saw ${ip}, a private address: it's inside your network, so these results don't show what the internet sees.`,
|
||||
'Run the probe on a machine outside this network.',
|
||||
),
|
||||
);
|
||||
else if (ip && s['network.publicIp'] && ip !== s['network.publicIp'])
|
||||
out.push(
|
||||
check(
|
||||
'probe-ip',
|
||||
'Tested address',
|
||||
'warn',
|
||||
`The probe saw this server as ${ip}, but the public IP is set to ${s['network.publicIp']} (a VPN, or a changed address?).`,
|
||||
`If ${ip} is right: Settings → Network → Public IP, then restart telephony.`,
|
||||
),
|
||||
);
|
||||
else if (ip) out.push(check('probe-ip', 'Tested address', 'ok', ip));
|
||||
|
||||
const http = res('http');
|
||||
if (http)
|
||||
out.push(
|
||||
http.ok
|
||||
? check('http', 'HTTP (TCP 80)', 'ok', http.detail)
|
||||
: check(
|
||||
'http',
|
||||
'HTTP (TCP 80)',
|
||||
'warn',
|
||||
`Not reachable${why(http.detail)}. Only needed to get the HTTPS certificate over HTTP (the bundled Caddy, or certbot).`,
|
||||
'If you need it: forward TCP 80 to this server on the router, and sudo ufw allow 80/tcp.',
|
||||
),
|
||||
);
|
||||
const https = res('https');
|
||||
if (https) {
|
||||
const status = Number(/HTTP (\d{3})/.exec(https.detail)?.[1]);
|
||||
out.push(
|
||||
!https.ok
|
||||
? check(
|
||||
'https',
|
||||
'HTTPS (TCP 443)',
|
||||
'fail',
|
||||
`Not reachable${why(https.detail)}. The web app, the browser phone and phone setup all use it.`,
|
||||
`Forward TCP 443 to ${lan} on the router, and sudo ufw allow 443/tcp.`,
|
||||
)
|
||||
: status !== 200
|
||||
? check(
|
||||
'https',
|
||||
'HTTPS (TCP 443)',
|
||||
'fail',
|
||||
`Reachable, but answered ${https.detail.split('; ')[0]} instead of SelfPhoneNG.`,
|
||||
"Your reverse proxy can't reach the api: it must send this host name to 127.0.0.1 on the api's port (API_PORT, 8080 by default).",
|
||||
)
|
||||
: /certificate isn't valid/.test(https.detail)
|
||||
? check(
|
||||
'https',
|
||||
'HTTPS (TCP 443)',
|
||||
'warn',
|
||||
https.detail,
|
||||
`Get a certificate for ${host}: the bundled Caddy does it by itself; with your own proxy, certbot.`,
|
||||
)
|
||||
: check('https', 'HTTPS (TCP 443)', 'ok', https.detail),
|
||||
);
|
||||
}
|
||||
const udp = res('sip-udp');
|
||||
if (udp)
|
||||
out.push(
|
||||
udp.ok
|
||||
? check('sip-udp', `SIP (UDP ${port})`, 'ok', `Asterisk answered: ${udp.detail}`)
|
||||
: check(
|
||||
'sip-udp',
|
||||
`SIP (UDP ${port})`,
|
||||
'fail',
|
||||
`No answer${why(udp.detail)}. Phones away from the office, phone apps on mobile data and the SIP provider's calls all need it.`,
|
||||
`Forward UDP ${port} to ${lan} on the router (on both, behind two), sudo ufw allow ${port}/udp, and turn off SIP ALG on the router.`,
|
||||
),
|
||||
);
|
||||
const tcp = res('sip-tcp');
|
||||
if (tcp)
|
||||
out.push(
|
||||
tcp.ok
|
||||
? check('sip-tcp', `SIP (TCP ${port})`, 'ok', `Asterisk answered: ${tcp.detail}`)
|
||||
: check(
|
||||
'sip-tcp',
|
||||
`SIP (TCP ${port})`,
|
||||
'warn',
|
||||
`No answer${why(tcp.detail)}. Only phones set to TCP need it.`,
|
||||
`Forward TCP ${port} to ${lan} and sudo ufw allow ${port}/tcp, or keep phones on UDP.`,
|
||||
),
|
||||
);
|
||||
const tls = res('sip-tls');
|
||||
if (tls)
|
||||
out.push(
|
||||
tls.ok
|
||||
? check(
|
||||
'sip-tls',
|
||||
`SIP over TLS (TCP ${s['sip.tlsPort']})`,
|
||||
'ok',
|
||||
`Asterisk answered: ${tls.detail}`,
|
||||
)
|
||||
: check(
|
||||
'sip-tls',
|
||||
`SIP over TLS (TCP ${s['sip.tlsPort']})`,
|
||||
'fail',
|
||||
`No answer${why(tls.detail)}.`,
|
||||
`Forward TCP ${s['sip.tlsPort']} to ${lan} and sudo ufw allow ${s['sip.tlsPort']}/tcp.`,
|
||||
),
|
||||
);
|
||||
const ws = res('ws');
|
||||
if (ws)
|
||||
out.push(
|
||||
ws.ok
|
||||
? check('ws', 'Browser phone (/ws)', 'ok', ws.detail)
|
||||
: check(
|
||||
'ws',
|
||||
'Browser phone (/ws)',
|
||||
'fail',
|
||||
`The WebSocket didn't open${why(ws.detail)}.`,
|
||||
'Your reverse proxy must pass WebSocket upgrades on /ws to 127.0.0.1:8088 (see deploy/nginx/selfphoneng.conf.example).',
|
||||
),
|
||||
);
|
||||
if (mode === 'complete') {
|
||||
const got = listeners.filter((l) => l.got);
|
||||
const range = `UDP ${a}–${b}`;
|
||||
const fix = `Forward ${range} to ${lan} on the router, and sudo ufw allow ${a}:${b}/udp. Without it calls connect but have no sound, or only one way.`;
|
||||
out.push(
|
||||
!listeners.length
|
||||
? check(
|
||||
'rtp',
|
||||
`Call audio (${range})`,
|
||||
'skip',
|
||||
'Every sampled port was in use here: try again.',
|
||||
)
|
||||
: got.length === listeners.length
|
||||
? check(
|
||||
'rtp',
|
||||
`Call audio (${range})`,
|
||||
'ok',
|
||||
`${got.length} of ${listeners.length} sampled ports got through`,
|
||||
)
|
||||
: check(
|
||||
'rtp',
|
||||
`Call audio (${range})`,
|
||||
got.length ? 'warn' : 'fail',
|
||||
`${got.length} of ${listeners.length} sampled ports got through (missing: ${listeners
|
||||
.filter((l) => !l.got)
|
||||
.map((l) => l.port)
|
||||
.join(', ')}).`,
|
||||
got.length
|
||||
? `The forward or firewall rule doesn't cover the whole range. ${fix}`
|
||||
: fix,
|
||||
),
|
||||
);
|
||||
}
|
||||
return { mode, testedIp: ip, checks: out };
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export async function checkForward(
|
||||
const digits = dialedDigits(input);
|
||||
if (!digits) throw badRequest('Enter a number');
|
||||
if (digits === ext.number) throw badRequest("You can't forward calls to yourself");
|
||||
if (/^\d{3,6}$/.test(digits)) {
|
||||
if (/^\d{1,6}$/.test(digits)) {
|
||||
if (await ownerOf(db, digits)) return digits;
|
||||
throw badRequest(`${digits} isn't an extension or group here`);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,15 @@
|
||||
import { and, eq, ne } from 'drizzle-orm';
|
||||
import { isReservedNumber, type NumberKind } from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { conferences, extensions, ivrs, pagingGroups, queues, ringGroups } from '../db/schema.ts';
|
||||
import {
|
||||
conferences,
|
||||
extensions,
|
||||
ivrOptions,
|
||||
ivrs,
|
||||
pagingGroups,
|
||||
queues,
|
||||
ringGroups,
|
||||
} from '../db/schema.ts';
|
||||
import { badRequest, conflict } from '../errors.ts';
|
||||
import { getSetting } from './settings.ts';
|
||||
|
||||
@@ -46,20 +54,48 @@ export async function ownerOf(db: Db, number: string, excludeId?: string): Promi
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A digital receptionist that lets callers dial numbers and has `key` in its menu: callers pressing
|
||||
* it get the menu option, so a one-digit number `key` can't be dialed from there.
|
||||
*/
|
||||
export async function menuKeyOwner(db: Db, key: string, excludeIvrId?: string) {
|
||||
if (!/^\d$/.test(key)) return null;
|
||||
const [row] = await db
|
||||
.select({ name: ivrs.name })
|
||||
.from(ivrOptions)
|
||||
.innerJoin(ivrs, eq(ivrs.id, ivrOptions.ivrId))
|
||||
.where(
|
||||
and(
|
||||
eq(ivrOptions.digit, key),
|
||||
eq(ivrs.allowDirectDial, true),
|
||||
...(excludeIvrId ? [ne(ivrs.id, excludeIvrId)] : []),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return row?.name ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Any free internal number, for any kind (owner, 2026-10-21): the numbering ranges only choose the
|
||||
* suggested next number (nextFreeNumber). `kind` is kept for that and for callers' readability.
|
||||
*/
|
||||
export async function assertNumberAvailable(
|
||||
db: Db,
|
||||
number: string,
|
||||
kind: NumberKind,
|
||||
_kind: NumberKind,
|
||||
excludeId?: string,
|
||||
): Promise<void> {
|
||||
if (!/^\d{3,6}$/.test(number)) throw badRequest('Internal numbers have 3 to 6 digits');
|
||||
if (!/^\d{1,6}$/.test(number)) throw badRequest('Internal numbers have 1 to 6 digits');
|
||||
if (isReservedNumber(number))
|
||||
throw badRequest(`${number} is reserved (emergency and service codes can't be used)`);
|
||||
const [from, to] = await getSetting(db, RANGE_KEY[kind]);
|
||||
const n = Number(number);
|
||||
if (n < from || n > to) throw badRequest(`Use a number from ${from} to ${to}`);
|
||||
const owner = await ownerOf(db, number, excludeId);
|
||||
if (owner) throw conflict(`${number} is already used by ${owner}`, { owner });
|
||||
const menu = await menuKeyOwner(db, number);
|
||||
if (menu)
|
||||
throw conflict(
|
||||
`${number} is a menu key of the digital receptionist "${menu}", which also lets callers dial numbers: pressing ${number} there would always choose the menu option`,
|
||||
{ owner: `digital receptionist "${menu}"` },
|
||||
);
|
||||
}
|
||||
|
||||
/** Lowest free number in the kind's range, or null when the range is full. */
|
||||
|
||||
@@ -23,6 +23,7 @@ const NET = 'sp-test';
|
||||
const AST = 'sp-test-ast';
|
||||
const PHONES = 'sp-test-phones';
|
||||
const PN_PHONE = 'sp-test-pn-phone';
|
||||
const RTP_PORTS = { start: 31000, end: 31200 };
|
||||
const AMI_PORT = 15038;
|
||||
const AMI_SECRET = 'call-test-secret';
|
||||
const PASS = { '101': 'CallTest101xxxxxxxxxxxxx', '102': 'CallTest102xxxxxxxxxxxxx' };
|
||||
@@ -79,6 +80,8 @@ function snapshot(): Snapshot {
|
||||
announcements: [],
|
||||
recordingAnnouncement: null,
|
||||
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
|
||||
// Not rtp.conf's 10000–20000: the generated range must win (RTP_PORT_START/END).
|
||||
rtpPorts: RTP_PORTS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -174,6 +177,13 @@ async function main() {
|
||||
ami.start();
|
||||
await new Promise((r) => ami.once('connected', r));
|
||||
log('AMI connected from the host');
|
||||
const rtp = await ami.command('rtp show settings');
|
||||
if (
|
||||
!new RegExp(`Port start:\\s+${RTP_PORTS.start}\\b`).test(rtp) ||
|
||||
!new RegExp(`Port end:\\s+${RTP_PORTS.end}\\b`).test(rtp)
|
||||
)
|
||||
problems.push(`RTP ports aren't ${RTP_PORTS.start}–${RTP_PORTS.end}:\n${rtp}`);
|
||||
else log(`RTP ports ${RTP_PORTS.start}–${RTP_PORTS.end} (generated, over rtp.conf's)`);
|
||||
if (RECORD) {
|
||||
// A second, raw AMI session so the fixture holds exactly what Asterisk sent.
|
||||
recorder = new Socket();
|
||||
@@ -237,6 +247,27 @@ async function main() {
|
||||
).out.trim();
|
||||
if (!wsReg.startsWith('SIP/2.0 200')) problems.push(`102-web over WebSocket: ${wsReg}`);
|
||||
else log('102-web over WebSocket from 127.0.0.1: registered');
|
||||
// The network test probe's SIP checks (08 §2.6): Asterisk answers its OPTIONS, UDP and TCP.
|
||||
const probed = docker(
|
||||
'run',
|
||||
'--rm',
|
||||
'--network',
|
||||
NET,
|
||||
'-v',
|
||||
`${join(import.meta.dirname, '../../../probe/src')}:/probe:ro`,
|
||||
'node:24-trixie-slim',
|
||||
'node',
|
||||
'--input-type=module',
|
||||
'-e',
|
||||
`import { runTest } from '/probe/checks.ts';
|
||||
for (const kind of ['sip-udp', 'sip-tcp'])
|
||||
console.log(JSON.stringify(await runTest('${AST}', 'pbx.test', { id: kind, kind, port: 5060 })));`,
|
||||
).out;
|
||||
for (const kind of ['sip-udp', 'sip-tcp'])
|
||||
if (!new RegExp(`"id":"${kind}","ok":true,"detail":"SIP/2.0 \\d{3}`).test(probed))
|
||||
problems.push(`probe ${kind}: ${probed.trim()}`);
|
||||
if (!problems.some((p) => p.startsWith('probe ')))
|
||||
log(`probe: Asterisk answers SIP OPTIONS over UDP and TCP`);
|
||||
// 101 also on an iOS Linphone: '&' in its push params used to break Dial for every device.
|
||||
const pn = docker(
|
||||
'run',
|
||||
|
||||
@@ -141,6 +141,14 @@ async function check(name) {
|
||||
if (!shownQ.startsWith(`${q} has`) || listed !== members)
|
||||
problems.push(`queue ${q}: expected ${members} members, got: ${shownQ.trim()}`);
|
||||
}
|
||||
// A case can list [number, context] pairs Asterisk must match (short-numbers: one-digit
|
||||
// numbers, which a pattern like _X. needs two digits for).
|
||||
const snap = JSON.parse(readFileSync(join(GOLDEN, name, 'snapshot.json'), 'utf8'));
|
||||
for (const [exten, ctx] of snap.asteriskMatches ?? []) {
|
||||
const d = cli(`dialplan show ${exten}@${ctx}`).out;
|
||||
if (!/-= \d+ extensions? \(/.test(d) || /There is no existence/.test(d))
|
||||
problems.push(`${exten} doesn't match in ${ctx}`);
|
||||
}
|
||||
// Asterisk colors its log: strip the escape codes before looking for ERROR[.
|
||||
// eslint-disable-next-line no-control-regex -- matching ANSI escapes is the point
|
||||
const log = docker('logs', NAME).out.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Music on hold order (owner, 2026-10-21, 03 §8): a class plays its files in the order set in the
|
||||
// UI, and a shuffled one doesn't. Asterisk plays each class to an echo test while
|
||||
// res_musiconhold's debug log says which file it opens, in what order.
|
||||
// Usage: node --conditions=source apps/api/test/asterisk/moh-test.ts (ASTERISK_IMAGE)
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared';
|
||||
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
||||
import type { Snapshot } from '../../src/generator/load.ts';
|
||||
|
||||
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
||||
const AST = 'sp-test-moh';
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
const docker = (...args: string[]) => {
|
||||
const r = spawnSync('docker', args, { encoding: 'utf8' });
|
||||
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
|
||||
};
|
||||
const cli = (cmd: string) => docker('exec', AST, 'asterisk', '-rx', cmd).out;
|
||||
const log = (...a: unknown[]) => console.log('[moh-test]', ...a);
|
||||
|
||||
// The play order set in the UI: not the ids' sort order, so a sorted folder wouldn't pass.
|
||||
const ORDER = [
|
||||
'c0000000-0000-4000-8000-000000000003',
|
||||
'a0000000-0000-4000-8000-000000000001',
|
||||
'b0000000-0000-4000-8000-000000000002',
|
||||
];
|
||||
const SHUF = [
|
||||
'd0000000-0000-4000-8000-000000000004',
|
||||
'e0000000-0000-4000-8000-000000000005',
|
||||
'f0000000-0000-4000-8000-000000000006',
|
||||
];
|
||||
|
||||
function snapshot(): Snapshot {
|
||||
const settings = Object.fromEntries(
|
||||
(Object.keys(SettingsSchemas) as SettingKey[])
|
||||
.filter((k) => k !== 'sms.webhookToken')
|
||||
.map((k) => [k, SettingsSchemas[k].parse(undefined)]),
|
||||
) as Snapshot['settings'];
|
||||
return {
|
||||
today: '2026-10-21',
|
||||
settings,
|
||||
dids: [],
|
||||
trunks: [],
|
||||
routes: [],
|
||||
ringGroups: [],
|
||||
queues: [],
|
||||
conferences: [],
|
||||
pagingGroups: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
announcements: [],
|
||||
recordingAnnouncement: null,
|
||||
extensions: [],
|
||||
mohClasses: [
|
||||
{ name: 'order-test', shuffle: false, files: ORDER },
|
||||
{ name: 'shuffle-test', shuffle: true, files: SHUF },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** The files `cls` opened, in order, from the debug log (ids only). */
|
||||
const opened = (debug: string, cls: string) =>
|
||||
[
|
||||
...debug.matchAll(
|
||||
new RegExp(`Opened file \\d+ '/var/lib/asterisk/moh/${cls}/([0-9a-f-]+)'`, 'g'),
|
||||
),
|
||||
].map((m) => m[1]!);
|
||||
|
||||
async function main() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sp-moh-'));
|
||||
const conf = join(dir, 'conf');
|
||||
const moh = join(dir, 'moh');
|
||||
mkdirSync(conf);
|
||||
for (const c of ['order-test', 'shuffle-test']) mkdirSync(join(moh, c), { recursive: true });
|
||||
const files = render(snapshot());
|
||||
for (const f of GENERATED_FILES) writeFileSync(join(conf, f), files[f]);
|
||||
const problems: string[] = [];
|
||||
try {
|
||||
docker('rm', '-f', AST);
|
||||
const run = docker(
|
||||
'run',
|
||||
'-d',
|
||||
'--rm',
|
||||
'--name',
|
||||
AST,
|
||||
'-e',
|
||||
'AMI_SECRET=moh-test-secret',
|
||||
'-v',
|
||||
`${conf}:/etc/asterisk/selfphoneng`,
|
||||
'-v',
|
||||
`${moh}:/var/lib/asterisk/moh`,
|
||||
IMAGE,
|
||||
);
|
||||
if (!run.ok) throw new Error(`asterisk: ${run.out}`);
|
||||
for (
|
||||
let i = 0;
|
||||
i < 60 && !docker('exec', AST, 'asterisk', '-rx', 'core waitfullybooted').ok;
|
||||
i++
|
||||
)
|
||||
await sleep(500);
|
||||
// Short recordings (spoken digits) so each class goes through its list in a few seconds.
|
||||
const copies = [...ORDER, ...SHUF]
|
||||
.map(
|
||||
(id, i) =>
|
||||
`cp /var/lib/asterisk/sounds/en/digits/${i + 1}.wav /var/lib/asterisk/moh/${ORDER.includes(id) ? 'order-test' : 'shuffle-test'}/${id}.wav`,
|
||||
)
|
||||
.join(' && ');
|
||||
const cp = docker('exec', AST, 'sh', '-c', copies);
|
||||
if (!cp.ok) throw new Error(`copying sounds: ${cp.out}`);
|
||||
cli('moh reload');
|
||||
cli('logger add channel moh-debug.log debug');
|
||||
cli('core set debug 1 res_musiconhold.so');
|
||||
|
||||
for (const cls of ['order-test', 'shuffle-test']) {
|
||||
cli(`channel originate Local/*43@sp-featurecodes application MusicOnHold ${cls},6`);
|
||||
await sleep(7000);
|
||||
}
|
||||
const debug = docker('exec', AST, 'cat', '/var/log/asterisk/moh-debug.log').out;
|
||||
const inOrder = opened(debug, 'order-test');
|
||||
log(`order-test played: ${inOrder.map((id) => id[0]).join(' ')}`);
|
||||
const want = Array.from({ length: inOrder.length }, (_, i) => ORDER[i % ORDER.length]);
|
||||
if (inOrder.length < 4 || inOrder.join() !== want.join())
|
||||
problems.push(`order-test didn't play c a b c…: ${inOrder.join(', ') || 'nothing'}`);
|
||||
const shuffled = opened(debug, 'shuffle-test');
|
||||
log(`shuffle-test played: ${shuffled.map((id) => id[0]).join(' ')}`);
|
||||
const cyclic = Array.from({ length: shuffled.length }, (_, i) => SHUF[i % SHUF.length]);
|
||||
if (shuffled.length < 4 || shuffled.join() === cyclic.join())
|
||||
problems.push(`shuffle-test played in list order: ${shuffled.join(', ') || 'nothing'}`);
|
||||
} catch (e) {
|
||||
problems.push((e as Error).message);
|
||||
} finally {
|
||||
docker('rm', '-f', AST);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
if (problems.length) {
|
||||
console.log('FAIL music on hold');
|
||||
for (const p of problems) console.log(` ${p}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('ok music on hold (in the order set, and shuffled)');
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -285,6 +285,31 @@ async function main() {
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
// Voicemail logins: *97 above counted once; with 10 this hour, *98 doesn't reach a PIN prompt.
|
||||
const hour = new Date().toISOString().replace(/\D/g, '').slice(0, 10);
|
||||
const counted = await ami.command('database get SP/VMLOGIN 101');
|
||||
if (!counted.includes(`${hour}:1`)) problems.push(`*97 not counted: ${counted}`);
|
||||
await ami.command(`database put SP/VMLOGIN 101 ${hour}:10`);
|
||||
await ami.action({
|
||||
Action: 'Originate',
|
||||
Channel: 'Local/*98@sp-from-ext',
|
||||
Application: 'Wait',
|
||||
Data: '5',
|
||||
Variable: '__SP_EXT=101',
|
||||
Async: 'true',
|
||||
});
|
||||
let refused = false;
|
||||
for (let i = 0; i < 20 && !refused; i++) {
|
||||
await sleep(500);
|
||||
refused = /Playing 'an-error-has-occurred/.test(docker('logs', AST).out);
|
||||
}
|
||||
if (!refused) problems.push('*98 past the hourly voicemail login limit was not refused');
|
||||
else if (/VoiceMailMain\("[^"]*", "@default"\)/.test(docker('logs', AST).out))
|
||||
problems.push('*98 reached VoiceMailMain past the limit');
|
||||
else log('*98 past 10 logins an hour: refused');
|
||||
await ami.command('channel request hangup all');
|
||||
await waitIdle();
|
||||
|
||||
// eslint-disable-next-line no-control-regex -- strips Asterisk's ANSI colors
|
||||
const plainLog = docker('logs', AST).out.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
problems.push(
|
||||
|
||||
@@ -50,14 +50,17 @@ describe.skipIf(!hasTestDb)('backup routes', () => {
|
||||
it('lists backups newest first, ignoring other files', async () => {
|
||||
writeFileSync(join(dir(), 'selfphoneng-20261018-0330.tar.gz'), 'old');
|
||||
writeFileSync(join(dir(), 'selfphoneng-20261019-0330.tar.gz'), 'newer');
|
||||
// with seconds (from 2026-10-21): a second one in the same minute sorts after it, newer
|
||||
writeFileSync(join(dir(), 'selfphoneng-20261019-033045.tar.gz'), 'newest');
|
||||
writeFileSync(join(dir(), '.selfphoneng-20261020-0330.tar.gz.tmp'), 'half-written');
|
||||
writeFileSync(join(dir(), 'notes.txt'), 'x');
|
||||
const r = await req('GET', '/api/system/backups');
|
||||
expect(r.json().map((b: { name: string }) => b.name)).toEqual([
|
||||
'selfphoneng-20261019-033045.tar.gz',
|
||||
'selfphoneng-20261019-0330.tar.gz',
|
||||
'selfphoneng-20261018-0330.tar.gz',
|
||||
]);
|
||||
expect(r.json()[0].size).toBe(5);
|
||||
expect(r.json()[1].size).toBe(5);
|
||||
});
|
||||
|
||||
it('downloads one by name, nothing else', async () => {
|
||||
|
||||
@@ -62,7 +62,7 @@ describe.skipIf(!hasTestDb)('/api/conferences', () => {
|
||||
it('checks the number and the PINs', async () => {
|
||||
const bad = (body: object) =>
|
||||
req('POST', '/api/conferences', { number: '801', name: 'X', ...body });
|
||||
expect((await bad({ number: '700' })).statusCode).toBe(400); // parking, not 800–849
|
||||
expect((await bad({ number: '700' })).statusCode).toBe(409); // the parking lot
|
||||
expect((await bad({ number: '800' })).statusCode).toBe(409);
|
||||
expect((await bad({ pin: '12' })).statusCode).toBe(400);
|
||||
expect((await bad({ pin: '12a4' })).statusCode).toBe(400);
|
||||
|
||||
@@ -105,10 +105,25 @@ describe.skipIf(!hasTestDb)('/api/devices and /prov', () => {
|
||||
expect((await prov('y000000000044.cfg')).body).toBe('#!version:1.0.0.1\n');
|
||||
});
|
||||
|
||||
it('unknown MAC and the other vendor’s file are 404; ten failures block the IP', async () => {
|
||||
expect((await prov('001565000000.cfg')).statusCode).toBe(404);
|
||||
expect((await prov(`cfg${device.mac}.xml`)).statusCode).toBe(404); // a Yealink here
|
||||
it('unknown MAC and the other vendor’s file answer like a known MAC; ten failures block the IP', async () => {
|
||||
// 08 §6: the answers mustn't tell which MACs exist.
|
||||
const known = await prov(`${device.mac}.cfg`);
|
||||
for (const f of ['001565000000.cfg', `cfg${device.mac}.xml`]) {
|
||||
const r = await prov(f);
|
||||
expect([r.statusCode, r.headers['www-authenticate'], r.body]).toEqual([
|
||||
401,
|
||||
known.headers['www-authenticate'],
|
||||
known.body,
|
||||
]);
|
||||
}
|
||||
expect((await prov('anything.boot')).statusCode).toBe(404);
|
||||
// with credentials they're failures, like a wrong password
|
||||
expect(
|
||||
(await prov('001565000000.cfg', '192.168.1.20', 'guess', '001565000000')).statusCode,
|
||||
).toBe(401);
|
||||
expect(
|
||||
(await prov(`cfg${device.mac}.xml`, '192.168.1.20', device.provisioningPassword)).statusCode,
|
||||
).toBe(401); // a Yealink here
|
||||
for (let i = 0; i < 8; i++)
|
||||
expect((await prov(`${device.mac}.cfg`, '192.168.1.20', 'guess')).statusCode).toBe(401);
|
||||
// 10 failures (2 unknown MACs, 8 wrong passwords): even the right password is refused now
|
||||
@@ -181,7 +196,7 @@ describe.skipIf(!hasTestDb)('/api/devices and /prov', () => {
|
||||
expect(
|
||||
(await prov('112233445566.cfg', '192.168.1.40', generic.provisioningPassword, '112233445566'))
|
||||
.statusCode,
|
||||
).toBe(404);
|
||||
).toBe(401);
|
||||
});
|
||||
|
||||
it('M10-T2: BLF keys named after their users, the time zone, and the phonebook', async () => {
|
||||
@@ -256,6 +271,6 @@ describe.skipIf(!hasTestDb)('/api/devices and /prov', () => {
|
||||
expect((await req('DELETE', `/api/devices/${device.id}`)).statusCode).toBe(200);
|
||||
expect(
|
||||
(await prov(`${device.mac}.cfg`, '192.168.1.50', device.provisioningPassword)).statusCode,
|
||||
).toBe(404);
|
||||
).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// System → Diagnostics (08 §2.6): the inside checks with the outside lookups faked, and the
|
||||
// network test against a fake probe that really sends the audio-port datagrams.
|
||||
import { createSocket } from 'node:dgram';
|
||||
import type { DiagnosticCheck } from '@selfphoneng/shared';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { insideChecks, networkTest, type DiagDeps } from '../src/services/diagnostics.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
const byId = (checks: DiagnosticCheck[]) => Object.fromEntries(checks.map((c) => [c.id, c]));
|
||||
|
||||
describe.skipIf(!hasTestDb)('diagnostics', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let deps: DiagDeps;
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
deps = {
|
||||
db: t.db,
|
||||
// A small range, so the busy-port scan and the samples stay quick and local.
|
||||
config: { ...t.app.config, RTP_PORT_START: 41000, RTP_PORT_END: 41020 },
|
||||
ami: t.app.ami,
|
||||
lookupPublicIp: async () => '203.0.113.10',
|
||||
resolveHost: async () => ['203.0.113.10'],
|
||||
};
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
|
||||
it('flags what keeps phones off the network: no public IP, DNS elsewhere, the phone ACL', async () => {
|
||||
let c = byId(await insideChecks({ ...deps, resolveHost: async () => ['198.51.100.7'] }));
|
||||
expect(c['public-ip']).toMatchObject({ status: 'fail' });
|
||||
expect(c['public-ip']!.fix).toContain('203.0.113.10');
|
||||
expect(c.dns).toMatchObject({ status: 'fail' });
|
||||
expect(c.dns!.fix).toContain('DNS only');
|
||||
expect(c['phone-acl']).toMatchObject({ status: 'ok' });
|
||||
expect(c.probe).toMatchObject({ status: 'skip' });
|
||||
expect(c.rtp).toMatchObject({
|
||||
status: 'ok',
|
||||
detail: expect.stringContaining('UDP 41000–41020'),
|
||||
});
|
||||
|
||||
await setSetting(t.db, 'network.publicIp', '203.0.113.10');
|
||||
await setSetting(t.db, 'sip.restrictPhonesToLocalNets', true);
|
||||
await setSetting(t.db, 'network.lanIp', '10.9.0.5');
|
||||
await setSetting(t.db, 'network.localNets', ['192.168.0.0/16']);
|
||||
c = byId(await insideChecks(deps));
|
||||
expect(c['public-ip']).toMatchObject({ status: 'ok', detail: '203.0.113.10' });
|
||||
expect(c.dns).toMatchObject({ status: 'ok' });
|
||||
expect(c['phone-acl']).toMatchObject({ status: 'warn' });
|
||||
expect(c['phone-acl']!.detail).toContain('mobile data');
|
||||
expect(c['local-nets']).toMatchObject({ status: 'fail' });
|
||||
expect(c['local-nets']!.fix).toContain('10.9.0.0/24');
|
||||
await setSetting(t.db, 'sip.restrictPhonesToLocalNets', false);
|
||||
});
|
||||
|
||||
it('a busy audio range is a warning that says how to move it', async () => {
|
||||
const held = await Promise.all(
|
||||
[41000, 41002, 41004, 41006].map(
|
||||
(p) =>
|
||||
new Promise<ReturnType<typeof createSocket>>((r) => {
|
||||
const s = createSocket('udp4');
|
||||
s.bind(p, () => r(s));
|
||||
}),
|
||||
),
|
||||
);
|
||||
const c = byId(await insideChecks(deps));
|
||||
expect(c.rtp).toMatchObject({ status: 'warn' });
|
||||
expect(c.rtp!.fix).toContain('RTP_PORT_START');
|
||||
held.forEach((s) => s.close());
|
||||
});
|
||||
|
||||
it('network test: maps the probe’s answers to fixes; audio ports must really get through', async () => {
|
||||
await setSetting(t.db, 'network.probeUrl', 'https://probe.test');
|
||||
let seen:
|
||||
| { host: string; tests: { id: string; kind: string; port: number; token?: string }[] }
|
||||
| undefined;
|
||||
const fetchProbe = (async (_url: URL, init: RequestInit) => {
|
||||
seen = JSON.parse(String(init.body));
|
||||
// The firewall lets the first sampled audio port through and drops the rest.
|
||||
const udp = seen!.tests.filter((x) => x.kind === 'udp');
|
||||
const s = createSocket('udp4');
|
||||
await new Promise<void>((r) =>
|
||||
s.send(`selfphoneng-probe ${udp[0]!.token}`, udp[0]!.port, '127.0.0.1', () => r()),
|
||||
);
|
||||
s.close();
|
||||
const ok = (id: string, detail: string) => ({ id, ok: true, detail });
|
||||
const no = (id: string) => ({ id, ok: false, detail: 'no answer' });
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ip: '203.0.113.10',
|
||||
results: [
|
||||
no('http'),
|
||||
ok('https', 'HTTP 200'),
|
||||
no('sip-udp'),
|
||||
ok('sip-tcp', 'SIP/2.0 401 Unauthorized'),
|
||||
{ id: 'ws', ok: false, detail: 'HTTP 404' },
|
||||
...udp.map((x) => ok(x.id, 'sent')),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
const r = await networkTest({ ...deps, fetchProbe }, 'complete');
|
||||
expect(seen!.host).toBe(t.app.config.PUBLIC_HOSTNAME);
|
||||
expect(seen!.tests.map((x) => x.id)).toEqual(
|
||||
expect.arrayContaining(['http', 'https', 'sip-udp', 'sip-tcp', 'ws']),
|
||||
);
|
||||
const c = byId(r.checks);
|
||||
expect(r.testedIp).toBe('203.0.113.10');
|
||||
expect(c['probe-ip']).toMatchObject({ status: 'ok' });
|
||||
expect(c.http).toMatchObject({ status: 'warn' });
|
||||
expect(c.https).toMatchObject({ status: 'ok' });
|
||||
expect(c['sip-udp']).toMatchObject({ status: 'fail' });
|
||||
expect(c['sip-udp']!.fix).toContain('SIP ALG');
|
||||
expect(c['sip-tcp']).toMatchObject({ status: 'ok' });
|
||||
expect(c.ws).toMatchObject({ status: 'fail' });
|
||||
expect(c.rtp).toMatchObject({ status: 'warn' });
|
||||
expect(c.rtp!.fix).toContain('ufw allow 41000:41020/udp');
|
||||
|
||||
// Quick: no audio ports, no /ws.
|
||||
const quick = await networkTest(
|
||||
{
|
||||
...deps,
|
||||
fetchProbe: (async (_u: URL, init: RequestInit) => {
|
||||
seen = JSON.parse(String(init.body));
|
||||
return new Response(JSON.stringify({ ip: '192.168.1.9', results: [] }));
|
||||
}) as unknown as typeof fetch,
|
||||
},
|
||||
'quick',
|
||||
);
|
||||
expect(seen!.tests.map((x) => x.kind)).not.toContain('udp');
|
||||
expect(byId(quick.checks)['probe-ip']).toMatchObject({ status: 'warn' }); // a private address
|
||||
});
|
||||
|
||||
it('routes: admins only; no probe set is a 400', async () => {
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
const cookie = await t.login(ADMIN.email, ADMIN.password);
|
||||
const r = await t.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/system/diagnostics',
|
||||
headers: { ...H, cookie },
|
||||
});
|
||||
expect(r.statusCode, r.body).toBe(200);
|
||||
expect(r.json().checks.length).toBeGreaterThan(3);
|
||||
await setSetting(t.db, 'network.probeUrl', null);
|
||||
const n = await t.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/system/diagnostics/network',
|
||||
headers: { ...H, cookie },
|
||||
payload: { mode: 'quick' },
|
||||
});
|
||||
expect(n.statusCode).toBe(400);
|
||||
expect((await t.app.inject({ method: 'GET', url: '/api/system/diagnostics' })).statusCode).toBe(
|
||||
401,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -72,9 +72,12 @@ describe.skipIf(!hasTestDb)('extensions API', () => {
|
||||
const make = (number: string, extra = {}) =>
|
||||
req('POST', '/api/extensions', { number, name: 'X', ...extra });
|
||||
expect((await make('211')).json().error.message).toMatch(/reserved/);
|
||||
expect((await make('600')).json().error.message).toMatch(/from 100 to 499/);
|
||||
expect((await make('12')).statusCode).toBe(400);
|
||||
expect((await make('710')).statusCode).toBe(400); // outside the users range anyway
|
||||
expect((await make('1234567')).statusCode).toBe(400); // 1 to 6 digits
|
||||
expect((await make('710')).json().error.message).toMatch(/parking/);
|
||||
// Any free number, in or out of the users range (owner, 2026-10-21): "1" for the first user.
|
||||
const one = await make('1');
|
||||
expect(one.statusCode, one.body).toBe(201);
|
||||
expect((await req('DELETE', `/api/extensions/${one.json().id}`)).statusCode).toBe(200);
|
||||
const dup = await make('101');
|
||||
expect(dup.statusCode).toBe(409);
|
||||
expect(dup.json().error.message).toMatch(/extension "Dan Doe"/);
|
||||
|
||||
@@ -81,7 +81,24 @@ describe.skipIf(!hasTestDb)('/api/ivrs', () => {
|
||||
const post = (body: object) => req('POST', '/api/ivrs', { ...base(), number: '601', ...body });
|
||||
const missing = '00000000-0000-4000-8000-000000000999';
|
||||
expect((await post({ number: '600' })).statusCode).toBe(409);
|
||||
expect((await post({ number: '500' })).statusCode).toBe(400); // ring-group range
|
||||
expect((await post({ number: '1234567' })).statusCode).toBe(400);
|
||||
// A one-digit number and a menu key (direct dialing on) can't be the same, either way round.
|
||||
const one = await req('POST', '/api/extensions', { number: '1', name: 'X' });
|
||||
expect(one.statusCode).toBe(409);
|
||||
expect(one.json().error.message).toMatch(/menu key of the digital receptionist "Main menu"/);
|
||||
const four = await req('POST', '/api/extensions', { number: '4', name: 'Four' });
|
||||
expect(four.statusCode, four.body).toBe(201);
|
||||
const clash = await post({ options: [{ digit: '4', destination: { type: 'hangup' } }] });
|
||||
expect(clash.statusCode).toBe(409);
|
||||
expect(clash.json().error.message).toMatch(/Key 4 is also the number of extension "Four"/);
|
||||
const noDial = await post({
|
||||
allowDirectDial: false,
|
||||
number: '602',
|
||||
options: [{ digit: '4', destination: { type: 'hangup' } }],
|
||||
});
|
||||
expect(noDial.statusCode).toBe(201);
|
||||
await req('DELETE', `/api/ivrs/${noDial.json().id}`);
|
||||
await req('DELETE', `/api/extensions/${four.json().id}`);
|
||||
expect((await post({ greetingSoundId: missing })).statusCode).toBe(400);
|
||||
const twice = await post({
|
||||
options: [
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// migrationsPending (08 §3.1): the api backs up before running new migrations, not on every start.
|
||||
import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { migrationsPending } from '../src/db/client.ts';
|
||||
import { createTestDb, hasTestDb } from './db.ts';
|
||||
|
||||
const MIGRATIONS = fileURLToPath(new URL('../src/db/migrations', import.meta.url));
|
||||
|
||||
describe.skipIf(!hasTestDb)('migrationsPending', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestDb>>;
|
||||
let newer: string;
|
||||
beforeAll(async () => {
|
||||
t = await createTestDb();
|
||||
// This image's migrations plus one the database hasn't run: a newer version.
|
||||
newer = await mkdtemp(join(tmpdir(), 'sp-mig-'));
|
||||
await cp(MIGRATIONS, newer, { recursive: true });
|
||||
const file = join(newer, 'meta', '_journal.json');
|
||||
const j = JSON.parse(await readFile(file, 'utf8'));
|
||||
j.entries.push({ ...j.entries.at(-1), idx: j.entries.length, tag: '9999_next' });
|
||||
await writeFile(file, JSON.stringify(j));
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t?.drop();
|
||||
await rm(newer, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('none when up to date, counts when the image has more, none for an empty database', async () => {
|
||||
expect(await migrationsPending(t.db)).toBeNull();
|
||||
const pending = await migrationsPending(t.db, newer);
|
||||
expect(pending).toEqual({ applied: pending!.total - 1, total: pending!.total });
|
||||
await t.db.execute(sql`drop schema drizzle cascade`);
|
||||
expect(await migrationsPending(t.db, newer)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -65,7 +65,7 @@ describe.skipIf(!hasTestDb || !hasFfmpeg)('/api/moh-classes', () => {
|
||||
const wav = join(mohDir(), 'sales-jazz', `${fileId}.wav`);
|
||||
expect(readFileSync(wav).readUInt32LE(24)).toBe(8000);
|
||||
expect(await conf()).toContain(
|
||||
'[sales-jazz]\nmode = files\ndirectory = /var/lib/asterisk/moh/sales-jazz\nsort = random',
|
||||
`[sales-jazz]\nmode = playlist\nentry = /var/lib/asterisk/moh/sales-jazz/${fileId}\n`,
|
||||
);
|
||||
const audio = await req('GET', `/api/moh-classes/${classId}/files/${fileId}/audio`);
|
||||
expect(audio.statusCode).toBe(200);
|
||||
@@ -77,6 +77,48 @@ describe.skipIf(!hasTestDb || !hasFfmpeg)('/api/moh-classes', () => {
|
||||
expect(bad.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('plays in upload order until reordered; shuffle; the order must list every file', async () => {
|
||||
const add = async (name: string) =>
|
||||
(
|
||||
await req('POST', `/api/moh-classes/${classId}/files?name=${name}`, silenceWav(), {
|
||||
'content-type': 'audio/wav',
|
||||
})
|
||||
).json();
|
||||
await add('Second');
|
||||
const dto = await add('Third');
|
||||
const ids = dto.files.map((f: { id: string }) => f.id);
|
||||
expect(dto.files.map((f: { name: string }) => f.name)).toEqual(['Smooth', 'Second', 'Third']);
|
||||
const entries = async () =>
|
||||
[...(await conf()).matchAll(/^entry = .*\/([0-9a-f-]+)$/gm)].map((m) => m[1]);
|
||||
expect(await entries()).toEqual(ids);
|
||||
|
||||
const reordered = await req('PATCH', `/api/moh-classes/${classId}`, {
|
||||
order: [ids[2], ids[0], ids[1]],
|
||||
});
|
||||
expect(reordered.statusCode, reordered.body).toBe(200);
|
||||
expect(reordered.json().files.map((f: { name: string }) => f.name)).toEqual([
|
||||
'Third',
|
||||
'Smooth',
|
||||
'Second',
|
||||
]);
|
||||
expect(await entries()).toEqual([ids[2], ids[0], ids[1]]);
|
||||
expect(await conf()).not.toContain('sort = random');
|
||||
|
||||
const shuffled = await req('PATCH', `/api/moh-classes/${classId}`, { shuffle: true });
|
||||
expect(shuffled.json().shuffle).toBe(true);
|
||||
expect(await conf()).toContain('sort = random');
|
||||
for (const order of [
|
||||
[ids[0], ids[1]],
|
||||
[ids[0], ids[0], ids[1]],
|
||||
[...ids.slice(1), extId],
|
||||
])
|
||||
expect((await req('PATCH', `/api/moh-classes/${classId}`, { order })).statusCode).toBe(400);
|
||||
|
||||
await req('PATCH', `/api/moh-classes/${classId}`, { shuffle: false });
|
||||
for (const id of [ids[1], ids[2]])
|
||||
await req('DELETE', `/api/moh-classes/${classId}/files/${id}`);
|
||||
});
|
||||
|
||||
it('users and queues may pick only classes that exist; delete is refused while used', async () => {
|
||||
const pick = (mohClass: string) => req('PATCH', `/api/extensions/${extId}`, { mohClass });
|
||||
expect((await pick('nope')).statusCode).toBe(400);
|
||||
|
||||
@@ -53,7 +53,7 @@ describe.skipIf(!hasTestDb)('/api/paging-groups', () => {
|
||||
it('checks the number and the members', async () => {
|
||||
const bad = (body: object) =>
|
||||
req('POST', '/api/paging-groups', { number: '851', name: 'X', ...body });
|
||||
expect((await bad({ number: '800' })).statusCode).toBe(400); // conference range
|
||||
expect((await bad({ number: '1234567' })).statusCode).toBe(400); // 1 to 6 digits
|
||||
expect((await bad({ number: '850' })).statusCode).toBe(409);
|
||||
expect((await bad({ memberIds: [ext['101'], ext['101']] })).statusCode).toBe(400);
|
||||
expect((await bad({ memberIds: ['00000000-0000-4000-8000-000000000000'] })).statusCode).toBe(
|
||||
|
||||
@@ -99,7 +99,7 @@ describe.skipIf(!hasTestDb)('/api/queues', () => {
|
||||
timeoutDestination: { type: 'hangup' },
|
||||
...body,
|
||||
});
|
||||
expect((await bad({ number: '500' })).statusCode).toBe(400); // outside 550–599
|
||||
expect((await bad({ number: '1234567' })).statusCode).toBe(400); // 1 to 6 digits
|
||||
expect((await bad({ number: '550' })).statusCode).toBe(409);
|
||||
const twice = { extensionId: ext['101'], penalty: 0 };
|
||||
expect((await bad({ members: [twice, twice] })).statusCode).toBe(400);
|
||||
|
||||
@@ -24,20 +24,21 @@ describe.skipIf(!hasTestDb)('/api/reports', () => {
|
||||
direction: 'inbound' | 'outbound' | 'internal',
|
||||
status: 'answered' | 'missed' | 'voicemail' | 'busy',
|
||||
talkSeconds = 0,
|
||||
lineSeconds = talkSeconds, // arrival to hang-up
|
||||
) => ({
|
||||
linkedid,
|
||||
direction,
|
||||
status,
|
||||
startedAt: new Date(at),
|
||||
endedAt: new Date(at),
|
||||
endedAt: new Date(Date.parse(at) + lineSeconds * 1000),
|
||||
talkSeconds,
|
||||
});
|
||||
await t.db.insert(calls).values([
|
||||
call('a', '2026-05-04T14:00:00Z', 'inbound', 'answered', 60), // Mon 09:00 local
|
||||
call('b', '2026-05-04T14:30:00Z', 'inbound', 'missed'), // Mon 09:30
|
||||
call('c', '2026-05-04T15:00:00Z', 'outbound', 'answered', 120), // Mon 10:00
|
||||
call('d', '2026-05-05T03:00:00Z', 'inbound', 'voicemail'), // still Mon, 22:00
|
||||
call('e', '2026-05-06T13:00:00Z', 'internal', 'busy'), // Wed 08:00
|
||||
call('a', '2026-05-04T14:00:00Z', 'inbound', 'answered', 60, 75), // Mon 09:00 local
|
||||
call('b', '2026-05-04T14:30:00Z', 'inbound', 'missed', 0, 20), // Mon 09:30
|
||||
call('c', '2026-05-04T15:00:00Z', 'outbound', 'answered', 120, 130), // Mon 10:00
|
||||
call('d', '2026-05-05T03:00:00Z', 'inbound', 'voicemail', 0, 40), // still Mon, 22:00
|
||||
call('e', '2026-05-06T13:00:00Z', 'internal', 'busy', 0, 5), // Wed 08:00
|
||||
call('f', '2026-05-09T12:00:00Z', 'inbound', 'answered', 30), // outside the range
|
||||
]);
|
||||
const [q] = await t.db
|
||||
@@ -78,11 +79,19 @@ describe.skipIf(!hasTestDb)('/api/reports', () => {
|
||||
expect(r.statusCode, r.body).toBe(200);
|
||||
const s = r.json();
|
||||
expect(s.days).toEqual([
|
||||
{ date: '2026-05-04', inbound: 3, outbound: 1, internal: 0, missed: 2 },
|
||||
{ date: '2026-05-05', inbound: 0, outbound: 0, internal: 0, missed: 0 },
|
||||
{ date: '2026-05-06', inbound: 0, outbound: 0, internal: 1, missed: 0 },
|
||||
// outside: inbound 75 + 20 + 40 s from arrival, outbound 120 s talk = 255 s, 4 minutes
|
||||
{ date: '2026-05-04', inbound: 3, outbound: 1, internal: 0, missed: 2, outsideMinutes: 4 },
|
||||
{ date: '2026-05-05', inbound: 0, outbound: 0, internal: 0, missed: 0, outsideMinutes: 0 },
|
||||
{ date: '2026-05-06', inbound: 0, outbound: 0, internal: 1, missed: 0, outsideMinutes: 0 },
|
||||
]);
|
||||
expect(s.totals).toEqual({ calls: 5, missed: 2, avgTalkSeconds: 90 });
|
||||
expect(s.totals).toEqual({
|
||||
calls: 5,
|
||||
answered: 2,
|
||||
missed: 2,
|
||||
avgTalkSeconds: 90,
|
||||
talkSeconds: 180,
|
||||
outsideSeconds: { inbound: 135, outbound: 120 },
|
||||
});
|
||||
expect(s.hours).toHaveLength(7);
|
||||
expect(s.hours[1][9]).toBe(2);
|
||||
expect(s.hours[1][10]).toBe(1);
|
||||
|
||||
@@ -64,7 +64,7 @@ describe.skipIf(!hasTestDb)('/api/ring-groups', () => {
|
||||
noAnswerDestination: { type: 'hangup' },
|
||||
...body,
|
||||
});
|
||||
expect((await bad({ number: '101' })).statusCode).toBe(400); // outside 500–549
|
||||
expect((await bad({ number: '101' })).statusCode).toBe(409); // extension 101 has it
|
||||
expect((await bad({ number: '500' })).statusCode).toBe(409);
|
||||
expect((await bad({ memberIds: [ext['101'], ext['101']] })).statusCode).toBe(400);
|
||||
expect((await bad({ memberIds: ['00000000-0000-4000-8000-000000000999'] })).statusCode).toBe(
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The SIP port (Settings → Security), for routers or ISPs that keep UDP 5060 for themselves.
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
describe.skipIf(!hasTestDb)('SIP port setting', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
const cookie = await t.login(ADMIN.email, ADMIN.password);
|
||||
req = (method, url, payload) =>
|
||||
t.app.inject({
|
||||
method: method as 'GET',
|
||||
url,
|
||||
payload: payload as object,
|
||||
headers: { ...H, cookie },
|
||||
});
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
|
||||
it('refuses ports something else listens on', async () => {
|
||||
for (const [port, msg] of [
|
||||
[8080, /already used/],
|
||||
[5038, /already used/],
|
||||
[15000, /call audio range/],
|
||||
[5061, /must differ/],
|
||||
] as const) {
|
||||
const r = await req('PATCH', '/api/settings', { 'sip.udpPort': port });
|
||||
expect(r.statusCode, String(port)).toBe(400);
|
||||
expect(r.json().error.message).toMatch(msg);
|
||||
}
|
||||
});
|
||||
|
||||
it('5080 is saved, and phones are told', async () => {
|
||||
const r = await req('PATCH', '/api/settings', { 'sip.udpPort': 5080 });
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect(r.json()['sip.udpPort']).toBe(5080);
|
||||
expect((await req('GET', '/api/system/status')).json().ports.sip).toBe(5080);
|
||||
});
|
||||
});
|
||||
@@ -44,12 +44,18 @@ describe.skipIf(!hasTestDb)('two-step sign-in', () => {
|
||||
secret = setup.secret;
|
||||
expect(setup.otpauthUrl).toMatch(/^otpauth:\/\/totp\/.+\?secret=[A-Z2-7]{32}&issuer=/);
|
||||
expect(setup.qr).toMatch(/^data:image\/png;base64,/);
|
||||
const bad = await req('POST', '/api/auth/totp/enable', { secret, code: '000000' });
|
||||
const password = ADMIN.password;
|
||||
const bad = await req('POST', '/api/auth/totp/enable', { secret, code: '000000', password });
|
||||
expect(bad.statusCode).toBe(400);
|
||||
const ok = await req('POST', '/api/auth/totp/enable', {
|
||||
secret,
|
||||
code: totpCode(secret, stepAt()),
|
||||
});
|
||||
// A session alone isn't enough: the password again, like turning it off.
|
||||
const code = totpCode(secret, stepAt());
|
||||
for (const payload of [
|
||||
{ secret, code },
|
||||
{ secret, code, password: 'not-it' },
|
||||
])
|
||||
expect((await req('POST', '/api/auth/totp/enable', payload)).statusCode).toBe(400);
|
||||
expect((await req('GET', '/api/auth/totp')).json().enabled).toBe(false);
|
||||
const ok = await req('POST', '/api/auth/totp/enable', { secret, code, password });
|
||||
expect(ok.statusCode, ok.body).toBe(200);
|
||||
recovery = ok.json().recoveryCodes;
|
||||
expect(recovery).toHaveLength(10);
|
||||
@@ -58,7 +64,7 @@ describe.skipIf(!hasTestDb)('two-step sign-in', () => {
|
||||
recoveryCodesLeft: 10,
|
||||
});
|
||||
expect(
|
||||
(await req('POST', '/api/auth/totp/enable', { secret, code: '123456' })).statusCode,
|
||||
(await req('POST', '/api/auth/totp/enable', { secret, code: '123456', password })).statusCode,
|
||||
).toBe(409);
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ function fakeVoipms() {
|
||||
{ server_name: 'Chicago 1', server_hostname: 'chicago1.voip.ms', server_ip: '1.2.3.5' },
|
||||
],
|
||||
},
|
||||
getBalance: {
|
||||
status: 'success',
|
||||
balance: { current_balance: '12.3456', spent_total: '7.89', calls_total: '42' },
|
||||
},
|
||||
getDIDsInfo: {
|
||||
status: 'success',
|
||||
dids: [
|
||||
@@ -124,6 +128,15 @@ describe.skipIf(!hasTestDb)('/api/trunks/:id voip.ms routes', () => {
|
||||
const r = await req('GET', `/api/trunks/${trunkId}/voipms-servers`);
|
||||
expect(r.statusCode).toBe(400);
|
||||
expect(r.json().error.message).toMatch(/API username and password/);
|
||||
// The balance on Reports says so instead of failing (owner, 2026-10-21).
|
||||
expect((await req('GET', '/api/reports/balance')).json().items).toEqual([
|
||||
{
|
||||
trunkId,
|
||||
trunkName: 'voip.ms',
|
||||
balance: null,
|
||||
error: expect.stringMatching(/API username and password/),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('a wrong API password is a 502 with voip.ms’s reason', async () => {
|
||||
@@ -135,10 +148,20 @@ describe.skipIf(!hasTestDb)('/api/trunks/:id voip.ms routes', () => {
|
||||
expect(r.statusCode).toBe(502);
|
||||
expect(r.json().error.message).toBe('voip.ms rejected the API username or password');
|
||||
expect(r.body).not.toContain('nope');
|
||||
const bal = await req('GET', '/api/reports/balance');
|
||||
expect(bal.json().items[0]).toMatchObject({
|
||||
balance: null,
|
||||
error: 'voip.ms rejected the API username or password',
|
||||
});
|
||||
expect(bal.body).not.toContain('nope');
|
||||
});
|
||||
|
||||
it('lists servers, previews and imports numbers (not routed, label from voip.ms)', async () => {
|
||||
await req('PATCH', `/api/trunks/${trunkId}`, { voipmsApiPassword: 'api-pw' });
|
||||
expect((await req('GET', '/api/reports/balance')).json().items).toEqual([
|
||||
{ trunkId, trunkName: 'voip.ms', balance: 12.3456, error: null },
|
||||
]);
|
||||
expect(fake.seen.at(-1)!.get('method')).toBe('getBalance');
|
||||
expect((await req('GET', `/api/trunks/${trunkId}/voipms-servers`)).json()).toEqual({
|
||||
servers: ['chicago1.voip.ms', 'seattle1.voip.ms'],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# The network test probe (08 §2.6), run outside the network SelfPhoneNG is in. No dependencies:
|
||||
# Node 24 runs the TypeScript as it is. Build from the repo root:
|
||||
# docker build -f apps/probe/Dockerfile -t selfphoneng-probe .
|
||||
FROM node:24-alpine
|
||||
WORKDIR /app
|
||||
COPY apps/probe/package.json ./
|
||||
COPY apps/probe/src/checks.ts apps/probe/src/server.ts apps/probe/src/index.ts ./src/
|
||||
ENV NODE_ENV=production
|
||||
USER node
|
||||
EXPOSE 8080
|
||||
CMD ["node", "src/index.ts"]
|
||||
@@ -0,0 +1,13 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# Builds the probe's downloadable files (build-binary.mjs) and hands them back, no image:
|
||||
# docker build -f apps/probe/binary.Dockerfile --output type=local,dest=probe-dist .
|
||||
FROM node:24-trixie-slim AS build
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends xz-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN corepack enable && pnpm install --frozen-lockfile --filter @selfphoneng/probe \
|
||||
&& node apps/probe/build-binary.mjs
|
||||
|
||||
FROM scratch
|
||||
COPY --from=build /src/apps/probe/dist /
|
||||
@@ -0,0 +1,94 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Builds the network test probe (08 §2.6) for machines without Docker:
|
||||
// dist/selfphoneng-probe.cjs one file; runs with Node 22 or newer
|
||||
// dist/selfphoneng-probe-linux-x64 standalone executables with Node inside (Node's
|
||||
// dist/selfphoneng-probe-linux-arm64 single executable applications), nothing to install
|
||||
// dist/SHA256SUMS
|
||||
// The executables carry this Node's version, downloaded from nodejs.org and checked against its
|
||||
// SHASUMS256.txt. Run on Linux (needs tar with xz): node apps/probe/build-binary.mjs
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { build } from 'esbuild';
|
||||
import postject from 'postject';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const dist = join(here, 'dist');
|
||||
const NAME = 'selfphoneng-probe';
|
||||
const version = process.version;
|
||||
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
|
||||
|
||||
rmSync(dist, { recursive: true, force: true });
|
||||
mkdirSync(dist);
|
||||
const script = join(dist, `${NAME}.cjs`);
|
||||
await build({
|
||||
entryPoints: [join(here, 'src/index.ts')],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node22',
|
||||
outfile: script,
|
||||
banner: { js: '#!/usr/bin/env node\n// SPDX-License-Identifier: AGPL-3.0-or-later' },
|
||||
});
|
||||
chmodSync(script, 0o755);
|
||||
|
||||
const work = mkdtempSync(join(tmpdir(), 'sp-probe-'));
|
||||
try {
|
||||
const blob = join(work, 'sea.blob');
|
||||
writeFileSync(
|
||||
join(work, 'sea.json'),
|
||||
JSON.stringify({
|
||||
main: script,
|
||||
output: blob,
|
||||
disableExperimentalSEAWarning: true,
|
||||
// Neither is portable between CPU architectures; the probe starts in milliseconds anyway.
|
||||
useSnapshot: false,
|
||||
useCodeCache: false,
|
||||
}),
|
||||
);
|
||||
execFileSync(process.execPath, ['--experimental-sea-config', join(work, 'sea.json')], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
const base = `https://nodejs.org/dist/${version}`;
|
||||
const sums = await (await fetch(`${base}/SHASUMS256.txt`)).text();
|
||||
for (const arch of ['x64', 'arm64']) {
|
||||
const file = `node-${version}-linux-${arch}.tar.xz`;
|
||||
const want = sums
|
||||
.split('\n')
|
||||
.find((l) => l.endsWith(` ${file}`))
|
||||
?.split(' ')[0];
|
||||
const tarball = Buffer.from(await (await fetch(`${base}/${file}`)).arrayBuffer());
|
||||
if (!want || sha256(tarball) !== want) throw new Error(`${file}: checksum doesn't match`);
|
||||
writeFileSync(join(work, file), tarball);
|
||||
const inner = `node-${version}-linux-${arch}/bin/node`;
|
||||
execFileSync('tar', ['-xJf', join(work, file), '-C', work, inner]);
|
||||
const out = join(dist, `${NAME}-linux-${arch}`);
|
||||
copyFileSync(join(work, inner), out);
|
||||
await postject.inject(out, 'NODE_SEA_BLOB', readFileSync(blob), {
|
||||
sentinelFuse: 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2',
|
||||
});
|
||||
chmodSync(out, 0o755);
|
||||
console.log(`${out} (Node ${version})`);
|
||||
}
|
||||
} finally {
|
||||
rmSync(work, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const files = [`${NAME}.cjs`, `${NAME}-linux-x64`, `${NAME}-linux-arm64`];
|
||||
writeFileSync(
|
||||
join(dist, 'SHA256SUMS'),
|
||||
files.map((f) => `${sha256(readFileSync(join(dist, f)))} ${f}\n`).join(''),
|
||||
);
|
||||
console.log(readFileSync(join(dist, 'SHA256SUMS'), 'utf8'));
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@selfphoneng/probe",
|
||||
"version": "0.0.4",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.28.2",
|
||||
"postject": "1.0.0-alpha.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The network test probe's checks (08 §2.6). Each reaches a SelfPhoneNG server from outside its
|
||||
// network, the way a phone on mobile data or the SIP provider would, and only ever at the address
|
||||
// that asked (server.ts), so nobody can aim it at someone else.
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { createSocket } from 'node:dgram';
|
||||
import { connect as tcpConnect, isIP, type Socket } from 'node:net';
|
||||
import { connect as tlsConnect } from 'node:tls';
|
||||
|
||||
export type Test =
|
||||
| { id: string; kind: 'tcp'; port: number }
|
||||
/** An HTTP(S) request; `websocket` asks for a SIP WebSocket upgrade (the browser phone's /ws). */
|
||||
| { id: string; kind: 'http' | 'https'; port: number; path?: string; websocket?: boolean }
|
||||
/** SIP OPTIONS: any SIP answer means Asterisk got it. */
|
||||
| { id: string; kind: 'sip-udp' | 'sip-tcp' | 'sip-tls'; port: number }
|
||||
/** Datagrams carrying `token`; the server listens and knows whether they came. */
|
||||
| { id: string; kind: 'udp'; port: number; token: string };
|
||||
|
||||
export type Result = { id: string; ok: boolean; detail: string; ms: number };
|
||||
|
||||
export const TIMEOUT_MS = 4000;
|
||||
const rand = () => randomBytes(8).toString('hex');
|
||||
|
||||
/** Sends `request` over TCP or TLS and resolves with what came back once `done` matches it. */
|
||||
function exchange(
|
||||
ip: string,
|
||||
port: number,
|
||||
opts: { tls: boolean; host: string; request: string; done: RegExp },
|
||||
): Promise<{ data: string; certError: string | null }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
const onConnect = () => sock.write(opts.request);
|
||||
const sock: Socket = opts.tls
|
||||
? tlsConnect(
|
||||
{
|
||||
host: ip,
|
||||
port,
|
||||
// The name to check the certificate against; SNI can't carry an IP.
|
||||
...(isIP(opts.host) ? {} : { servername: opts.host }),
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
onConnect,
|
||||
)
|
||||
: tcpConnect({ host: ip, port }, onConnect);
|
||||
const finish = (err?: Error) => {
|
||||
clearTimeout(timer);
|
||||
sock.destroy();
|
||||
if (err) reject(err);
|
||||
else {
|
||||
const tls = sock as Socket & { authorized?: boolean; authorizationError?: Error };
|
||||
resolve({
|
||||
data,
|
||||
certError:
|
||||
opts.tls && !tls.authorized ? String(tls.authorizationError ?? 'not valid') : null,
|
||||
});
|
||||
}
|
||||
};
|
||||
const timer = setTimeout(() => finish(new Error('no answer')), TIMEOUT_MS);
|
||||
sock.setEncoding('utf8');
|
||||
sock.on('data', (d: string) => {
|
||||
data += d;
|
||||
if (opts.done.test(data)) finish();
|
||||
});
|
||||
sock.on('error', (e) => finish(e));
|
||||
sock.on('close', () => (opts.done.test(data) ? finish() : finish(new Error('closed'))));
|
||||
});
|
||||
}
|
||||
|
||||
const sipOptions = (transport: string, host: string, port: number) =>
|
||||
[
|
||||
`OPTIONS sip:${host}:${port};transport=${transport.toLowerCase()} SIP/2.0`,
|
||||
// rport: answer the address and port this came from (RFC 3581), not the made-up host.
|
||||
`Via: SIP/2.0/${transport} probe.invalid;branch=z9hG4bK${rand()};rport`,
|
||||
'Max-Forwards: 70',
|
||||
`From: <sip:probe@probe.invalid>;tag=${rand()}`,
|
||||
`To: <sip:${host}>`,
|
||||
`Call-ID: ${rand()}@probe.invalid`,
|
||||
'CSeq: 1 OPTIONS',
|
||||
'User-Agent: selfphoneng-probe',
|
||||
'Content-Length: 0',
|
||||
'',
|
||||
'',
|
||||
].join('\r\n');
|
||||
|
||||
/** OPTIONS over UDP, sent three times (UDP drops); resolves with the first answer's status line. */
|
||||
function sipUdp(ip: string, port: number, host: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sock = createSocket(isIP(ip) === 6 ? 'udp6' : 'udp4');
|
||||
const msg = Buffer.from(sipOptions('UDP', host, port));
|
||||
const timers = [0, 500, 1500].map((ms) =>
|
||||
setTimeout(() => sock.send(msg, port, ip, () => {}), ms),
|
||||
);
|
||||
const done = (fn: () => void) => {
|
||||
timers.forEach(clearTimeout);
|
||||
clearTimeout(timer);
|
||||
sock.close();
|
||||
fn();
|
||||
};
|
||||
const timer = setTimeout(() => done(() => reject(new Error('no answer'))), TIMEOUT_MS);
|
||||
sock.on('message', (m) => {
|
||||
const line = m.toString('utf8').split('\r\n')[0]!;
|
||||
if (line.startsWith('SIP/2.0 ')) done(() => resolve(line));
|
||||
});
|
||||
sock.on('error', (e) => done(() => reject(e)));
|
||||
});
|
||||
}
|
||||
|
||||
function sendUdp(ip: string, port: number, token: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const sock = createSocket(isIP(ip) === 6 ? 'udp6' : 'udp4');
|
||||
const msg = Buffer.from(`selfphoneng-probe ${token}`);
|
||||
let sent = 0;
|
||||
const one = () =>
|
||||
sock.send(msg, port, ip, () => {
|
||||
if (++sent === 3) {
|
||||
sock.close();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
one();
|
||||
setTimeout(one, 300);
|
||||
setTimeout(one, 600);
|
||||
});
|
||||
}
|
||||
|
||||
const HTTP_STATUS = /^HTTP\/1\.[01] (\d{3})[^\r\n]*\r\n/;
|
||||
|
||||
export async function runTest(ip: string, host: string, t: Test): Promise<Result> {
|
||||
const start = Date.now();
|
||||
const result = (ok: boolean, detail: string): Result => ({
|
||||
id: t.id,
|
||||
ok,
|
||||
detail,
|
||||
ms: Date.now() - start,
|
||||
});
|
||||
try {
|
||||
switch (t.kind) {
|
||||
case 'tcp':
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const s = tcpConnect({ host: ip, port: t.port }, () => (s.destroy(), resolve()));
|
||||
s.setTimeout(TIMEOUT_MS, () => (s.destroy(), reject(new Error('no answer'))));
|
||||
s.on('error', reject);
|
||||
});
|
||||
return result(true, 'connected');
|
||||
case 'http':
|
||||
case 'https': {
|
||||
const upgrade = t.websocket
|
||||
? [
|
||||
'Connection: Upgrade',
|
||||
'Upgrade: websocket',
|
||||
`Sec-WebSocket-Key: ${randomBytes(16).toString('base64')}`,
|
||||
'Sec-WebSocket-Version: 13',
|
||||
'Sec-WebSocket-Protocol: sip',
|
||||
]
|
||||
: ['Connection: close'];
|
||||
const request = [
|
||||
`GET ${t.path ?? '/'} HTTP/1.1`,
|
||||
`Host: ${host}`,
|
||||
'User-Agent: selfphoneng-probe',
|
||||
...upgrade,
|
||||
'',
|
||||
'',
|
||||
].join('\r\n');
|
||||
const r = await exchange(ip, t.port, {
|
||||
tls: t.kind === 'https',
|
||||
host,
|
||||
request,
|
||||
done: HTTP_STATUS,
|
||||
});
|
||||
const status = Number(HTTP_STATUS.exec(r.data)?.[1]);
|
||||
const cert = r.certError
|
||||
? `; the certificate isn't valid for ${host} (${r.certError})`
|
||||
: '';
|
||||
if (t.websocket)
|
||||
return result(status === 101, `${status === 101 ? 'upgraded' : `HTTP ${status}`}${cert}`);
|
||||
return result(true, `HTTP ${status}${cert}`);
|
||||
}
|
||||
case 'sip-udp':
|
||||
return result(true, await sipUdp(ip, t.port, host));
|
||||
case 'sip-tcp':
|
||||
case 'sip-tls': {
|
||||
const tls = t.kind === 'sip-tls';
|
||||
const r = await exchange(ip, t.port, {
|
||||
tls,
|
||||
host,
|
||||
request: sipOptions(tls ? 'TLS' : 'TCP', host, t.port),
|
||||
done: /^SIP\/2\.0 \d{3}[^\r\n]*\r\n/,
|
||||
});
|
||||
return result(true, r.data.split('\r\n')[0]!);
|
||||
}
|
||||
case 'udp':
|
||||
await sendUdp(ip, t.port, t.token);
|
||||
return result(true, 'sent');
|
||||
}
|
||||
} catch (e) {
|
||||
const code = (e as NodeJS.ErrnoException).code;
|
||||
return result(
|
||||
false,
|
||||
code === 'ECONNREFUSED' ? 'refused' : code ? code.toLowerCase() : (e as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The network test probe (08 §2.6): run it outside the network SelfPhoneNG is in, and put its URL
|
||||
// in Settings → Network. `node src/index.ts` (Node 24 runs TypeScript as is).
|
||||
// PORT (default 8080), TRUST_PROXY=true behind a reverse proxy that sets X-Forwarded-For.
|
||||
import { createProbe } from './server.ts';
|
||||
|
||||
const port = Number(process.env.PORT ?? 8080);
|
||||
const probe = createProbe({ trustProxy: process.env.TRUST_PROXY === 'true' });
|
||||
probe.listen(port, () => console.log(`selfphoneng-probe listening on ${port}`));
|
||||
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => probe.close(() => process.exit(0)));
|
||||
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user