Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaa294ae6a | ||
|
|
4d331fb70e | ||
|
|
d9f9c6e444 | ||
|
|
a26a5ca740 | ||
|
|
274242ce32 | ||
|
|
0deff2a637 | ||
|
|
eacc571093 | ||
|
|
4a175a0d77 | ||
|
|
f2868cf5d3 | ||
|
|
6893c55e1b | ||
|
|
e54e8a1576 | ||
|
|
338ab8d7f1 | ||
|
|
175d5a2c73 | ||
|
|
aa6681da03 | ||
|
|
4143f6d064 | ||
|
|
65a0438dde | ||
|
|
90b3081368 | ||
|
|
22b3ef9a8c | ||
|
|
e2518b03d8 | ||
|
|
ff59bc7799 | ||
|
|
4d9b5dab8a | ||
|
|
54e1e201a1 | ||
|
|
8d5475cfbf |
No files matched your search
@@ -14,3 +14,14 @@ APP_UID=1000
|
||||
APP_GID=1000
|
||||
LOG_LEVEL=info
|
||||
TRUST_PROXY=true # true when behind Caddy/nginx
|
||||
RTP_PORT_START=10000 # call audio (UDP), both ends included: open this range on the
|
||||
RTP_PORT_END=20000 # firewall and router. Two ports per call; 200 is plenty for a small office
|
||||
# Local AI (admin guide: AI). Starting values for Settings → AI; that page wins once saved.
|
||||
# Only localhost or a local-network IP is accepted. Leave out what you don't use.
|
||||
#AI_LLM_URL=http://127.0.0.1:11434/v1 # Ollama
|
||||
#AI_LLM_MODEL=qwen3:4b
|
||||
#AI_STT_URL=http://127.0.0.1:8000/v1 # deploy/ai (Speaches)
|
||||
#AI_STT_MODEL=Systran/faster-whisper-small
|
||||
#AI_TTS_URL=http://127.0.0.1:8000/v1
|
||||
#AI_TTS_MODEL=speaches-ai/Kokoro-82M-v1.0-ONNX
|
||||
#AI_TTS_VOICE=af_heart
|
||||
@@ -1,17 +1,18 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# Container images, built only when a release is published (08 §3.1). Runs on GitHub Actions and
|
||||
# Gitea Actions (both read .github/workflows); each pushes to its own registry:
|
||||
# GitHub: ghcr.io/<owner>/selfphoneng-{api,asterisk} (the job's GITHUB_TOKEN)
|
||||
# Gitea: <gitea host>/<owner>/selfphoneng-{api,asterisk} (secrets REGISTRY_USER + REGISTRY_TOKEN:
|
||||
# GitHub: ghcr.io/<owner>/selfphoneng-{api,asterisk,probe} (the job's GITHUB_TOKEN)
|
||||
# Gitea: <gitea host>/<owner>/selfphoneng-{api,asterisk,probe} (secrets REGISTRY_USER + REGISTRY_TOKEN:
|
||||
# a personal access token with write:package; Gitea's job token can't push packages)
|
||||
# Tags: the version (0.0.1), plus <major>.<minor> and latest; a pre-release gets the version and dev.
|
||||
# The release also gets the probe as downloads (selfphoneng-probe-linux-{x64,arm64}, .cjs, SHA256SUMS).
|
||||
name: release
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write # attaching the probe downloads to the release
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
@@ -79,6 +80,7 @@ jobs:
|
||||
)
|
||||
docker build "${labels[@]}" -f apps/api/Dockerfile -t "$p-api:build" .
|
||||
docker build "${labels[@]}" -t "$p-asterisk:build" asterisk
|
||||
docker build "${labels[@]}" -f apps/probe/Dockerfile -t "$p-probe:build" .
|
||||
- name: Check
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -88,15 +90,41 @@ jobs:
|
||||
docker run --rm --entrypoint node "$p-api:build" --input-type=module -e "await import('/app/dist/app.js')"
|
||||
test "$(docker run --rm --entrypoint cat "$p-api:build" /app/VERSION)" = "${{ steps.v.outputs.version }}"
|
||||
docker run --rm --entrypoint asterisk "$p-asterisk:build" -V
|
||||
docker run --rm "$p-probe:build" node --input-type=module -e "await import('/app/src/server.ts')"
|
||||
- name: Push
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
p="${{ steps.v.outputs.prefix }}"
|
||||
for image in api asterisk; do
|
||||
for image in api asterisk probe; do
|
||||
for t in ${{ steps.v.outputs.tags }}; do
|
||||
docker tag "$p-$image:build" "$p-$image:$t"
|
||||
docker push "$p-$image:$t"
|
||||
done
|
||||
done
|
||||
echo "pushed: $p-{api,asterisk}:{${{ steps.v.outputs.tags }}}" | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"
|
||||
echo "pushed: $p-{api,asterisk,probe}:{${{ steps.v.outputs.tags }}}" | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"
|
||||
- name: Probe downloads
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The probe without Docker (08 §2.6): a .cjs for Node 22+, and Linux executables with Node
|
||||
# inside. Built in BuildKit and handed back as files, so it works on Gitea's runner too.
|
||||
docker build -f apps/probe/binary.Dockerfile --output type=local,dest=probe-dist .
|
||||
id="${{ github.event.release.id }}"
|
||||
for f in probe-dist/*; do
|
||||
name=$(basename "$f")
|
||||
if [ "${{ steps.v.outputs.registry }}" = ghcr.io ]; then
|
||||
curl -fsS -o /dev/null -X POST -H "Authorization: Bearer $GH_TOKEN" \
|
||||
-H 'Content-Type: application/octet-stream' --data-binary "@$f" \
|
||||
"https://uploads.github.com/repos/$GITHUB_REPOSITORY/releases/$id/assets?name=$name"
|
||||
else
|
||||
# The REGISTRY_TOKEN personal access token needs write:repository for this.
|
||||
curl -fsS -o /dev/null -X POST -H "Authorization: token ${REGISTRY_TOKEN:-$GH_TOKEN}" \
|
||||
-F "attachment=@$f" \
|
||||
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/$id/assets?name=$name"
|
||||
fi
|
||||
echo "attached $name" | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"
|
||||
done
|
||||
+91
-1
@@ -1,7 +1,97 @@
|
||||
# Changelog
|
||||
|
||||
Versions follow [semver](https://semver.org). Each release's container images are built from its
|
||||
tag (08 §3.1): `selfphoneng-api` and `selfphoneng-asterisk`, on ghcr.io and on the Gitea registry.
|
||||
tag (08 §3.1): `selfphoneng-api`, `selfphoneng-asterisk` and `selfphoneng-probe`, on ghcr.io and on
|
||||
the Gitea registry. From v0.0.3 the release also has the probe as downloads for machines without
|
||||
Docker.
|
||||
|
||||
## v0.0.6 — 2026-10-21
|
||||
|
||||
**Before you update:** this version changes the database (call transcripts); the api backs up
|
||||
first, by itself. Portainer: change both tags to `0.0.6` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.6`. Call transcripts are off until you turn them on in Settings → AI;
|
||||
calls recorded before that get no transcript.
|
||||
|
||||
- **Call transcripts and summaries** (Settings → AI, admin guide: [Local AI](docs/admin-guide/ai.md)):
|
||||
recorded calls are written out a minute or two after they end, caller and other side apart, with
|
||||
a summary and action items from the language model. The Recordings page shows them and searches
|
||||
what was said; the people on a call can read its transcript. Database change: four columns on
|
||||
`recordings` (the api backs up first).
|
||||
|
||||
## v0.0.5 — 2026-10-21
|
||||
|
||||
**Before you update:** this version changes the database (voicemail transcripts); the api backs
|
||||
up first, by itself. Portainer: change both tags to `0.0.5` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.5`. The AI is optional and off until you set it up: a second stack
|
||||
from `deploy/ai/compose.yml`, then Settings → AI (admin guide: [Local AI](docs/admin-guide/ai.md)).
|
||||
After updating, phones set up automatically pick up the dial plan at their next daily check (or
|
||||
reboot them).
|
||||
|
||||
- Admin guide: [Analog phones (ATAs)](docs/admin-guide/ata.md), setting up a Grandstream HT802 V2
|
||||
by hand.
|
||||
- **Dial plan:** a user's Credentials tab shows a dial plan for analog phone adapters, made from
|
||||
your outbound routes and internal number lengths: 911 and full outside numbers go out the moment
|
||||
they're dialed instead of after a pause. Yealink desk phones get the same as dial-now rules.
|
||||
- New internal numbers can no longer start with 911 or 9911, so 911 can go out at once (numbers
|
||||
you already have keep working).
|
||||
- **Local AI** (admin guide: [Local AI](docs/admin-guide/ai.md)): Settings → AI sets a language
|
||||
model, speech to text and text to speech, each with a Test, on this server or the local network
|
||||
only (anything else is refused). `deploy/ai/compose.yml` is a second stack with Speaches
|
||||
(Whisper and Kokoro) on the GPU. `AI_*` in `.env` sets them on a new install.
|
||||
- **Voicemail transcripts:** with AI on, a new message's words are in the voicemail email and on the
|
||||
voicemail page. Database change: `voicemail_messages.transcript` (the api backs up first).
|
||||
|
||||
## v0.0.4 — 2026-10-21
|
||||
|
||||
**Updating:** no database changes. Portainer: change both tags to `0.0.4` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.4`.
|
||||
|
||||
- Settings → Security has the **SIP port** and **Accept SIP over TCP** (5060 and on by default,
|
||||
as before). Both were settings with no field. Another port, such as 5080, helps when a router or
|
||||
the ISP keeps UDP 5060 for itself: a Complete network test shows UDP getting through while SIP
|
||||
on UDP 5060 doesn't. Changing it needs a telephony restart, a new router forward and setting the
|
||||
phones up again; trunks keep working. A port that another part of SelfPhoneNG listens on is
|
||||
refused. The admin guide (Network) explains when to change it.
|
||||
|
||||
## v0.0.3 — 2026-10-21
|
||||
|
||||
**Before you update:** this version changes the database (music-on-hold order). The api backs up
|
||||
first, by itself (see the admin guide, Updating). Compose installs from v0.0.2 get
|
||||
`scripts/update.sh` with the release: `git fetch --tags && git checkout v0.0.3`, then
|
||||
`sudo scripts/update.sh v0.0.3`. Portainer: change both tags to `0.0.3` and redeploy. Optional:
|
||||
`RTP_PORT_START`/`RTP_PORT_END` on the api, then open that range instead of 10000–20000.
|
||||
|
||||
- **Any internal number:** users, ring groups, queues, digital receptionists, conference rooms and
|
||||
paging groups take any free number of 1 to 6 digits, for example **1** for the first user (the
|
||||
setup wizard asks for it too). Settings → **Numbering** sets where **Next free** looks. A
|
||||
one-digit number and a menu key of a digital receptionist that lets callers dial numbers can't
|
||||
be the same. Ring groups, queues, voicemail, pickup (`**`), queue login (`*45`), forwarding
|
||||
(`*72`) and intercom (`*80`) now work with one-digit numbers.
|
||||
- **Music on hold order:** a set plays in the order shown on Sounds & music (move files up and
|
||||
down; new uploads go last), or **Shuffle** for random order. Before, the order was Asterisk's.
|
||||
- **Reports:** totals now include answered calls, talk time and **outside minutes** (in and out,
|
||||
roughly what voip.ms bills), with outside minutes per day, and the **voip.ms balance** of each
|
||||
trunk that has the API login.
|
||||
- **System → Diagnostics:** checks what usually keeps phones off the network (public IP, DNS,
|
||||
local networks, the phone network limit, SIP listening, free audio ports), with how to fix each.
|
||||
A **Quick** or **Complete network test** checks the ports from the internet through a network
|
||||
test probe you run outside your network; set its URL in Settings → Network. The probe comes as
|
||||
a Docker image (`selfphoneng-probe`), and, for a machine without Docker, as a single Linux
|
||||
executable (x64 and arm64) attached to each release, with a systemd unit.
|
||||
- The call audio port range is configurable: `RTP_PORT_START` and `RTP_PORT_END` (default
|
||||
10000–20000).
|
||||
- Updating (Docker only): a new version backs up before it changes the database, and stops
|
||||
instead if the backup fails. `scripts/update.sh` updates a compose install to a release, checks
|
||||
the result and goes back by itself if the check fails. The admin guide's "Updating" section
|
||||
covers compose and Portainer.
|
||||
- Container logs are capped (3 × 10 MB) in `docker-compose.yml`.
|
||||
- Security leftovers from the review (08 §6), all fixed:
|
||||
- Asterisk no longer loads the modules that can run host commands (`app_system`, `func_shell`,
|
||||
`app_disa`, `res_agi`, `app_externalivr`).
|
||||
- Turning two-step sign-in on asks for your password again.
|
||||
- `*97`/`*98` voicemail logins: one at a time per phone, 10 an hour.
|
||||
- Phone setup files: an unknown MAC gets the same answer as a known one.
|
||||
- The login limiter no longer keeps every email ever tried until a restart.
|
||||
|
||||
## v0.0.2 — 2026-10-21
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
A fully open source, self-hosted business phone system. Bring your own SIP trunk and numbers
|
||||
(reference provider: voip.ms). Built on Asterisk 24 + TypeScript + PostgreSQL 18, runs in Docker.
|
||||
|
||||
**Status:** pre-1.0 (v0.0.2), for testing. See [what's left for v1](docs/v1-checklist.md).
|
||||
**Status:** pre-1.0 (v0.0.6), for testing. See [what's left for v1](docs/v1-checklist.md).
|
||||
|
||||
## What it does today
|
||||
|
||||
@@ -17,24 +17,30 @@ A fully open source, self-hosted business phone system. Bring your own SIP trunk
|
||||
outbound rules with calling permissions, 911 with E911 caller ID and alerts.
|
||||
- **Call flows:** digital receptionists, office hours and holidays, ring groups, queues, parking,
|
||||
pickup, conference rooms, paging and intercom, music on hold.
|
||||
- **Voicemail** with email, **call history**, **recordings**, a **live dashboard** (listen,
|
||||
- **Voicemail** with email (and transcripts by local AI), **call history**, **recordings**, a **live dashboard** (listen,
|
||||
whisper, barge) and reports.
|
||||
- **Running it:** backups and restore, background health checks, webhooks and API tokens,
|
||||
- **Running it:** updates (`scripts/update.sh` for compose installs; a backup before every
|
||||
database change), backups and restore, background health checks, webhooks and API tokens,
|
||||
two-step sign-in for admins, limits on outside calls at once.
|
||||
|
||||
## Wanted
|
||||
|
||||
- More SIP providers: **Telnyx, Flowroute and Twilio** first.
|
||||
- More local AI: call transcripts and summaries, typed prompts, type-to-speak, an AI
|
||||
receptionist ([spec](docs/spec/10-ai.md)).
|
||||
- Analog phone adapters set up automatically: **Grandstream HT802 V2** first (two ports, a dial
|
||||
plan from your numbers), then Cisco ATA 191/192 and SPA112/122. Today they're set up by hand
|
||||
([how](docs/admin-guide/ata.md)).
|
||||
- SMS (built for voip.ms, parked until wanted).
|
||||
- Push notifications, so phone apps ring in the background.
|
||||
- An update script for running installs.
|
||||
- Text-to-speech prompts, recording prompts by phone, a queue wallboard, Polycom phones.
|
||||
|
||||
## Docs
|
||||
|
||||
- [Admin guide](docs/admin-guide/README.md): install, [voip.ms setup](docs/admin-guide/voipms.md),
|
||||
[network and firewall](docs/admin-guide/network.md), [E911 and recording law](docs/admin-guide/legal.md),
|
||||
[troubleshooting](docs/admin-guide/troubleshooting.md), [updating and backups](docs/admin-guide/maintenance.md)
|
||||
[troubleshooting](docs/admin-guide/troubleshooting.md), [updating and backups](docs/admin-guide/maintenance.md),
|
||||
[analog phones (ATAs)](docs/admin-guide/ata.md), [local AI](docs/admin-guide/ai.md)
|
||||
- [Changelog](CHANGELOG.md)
|
||||
- [Ready for v1 checklist](docs/v1-checklist.md)
|
||||
- Writing code (human or AI): start with [`AGENTS.md`](AGENTS.md), then the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfphoneng/api",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.6",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mergeSides, sideFiles, speechStretches, transcriptText, wavSlice } from './calls.ts';
|
||||
|
||||
describe('call transcripts', () => {
|
||||
it('side files sit next to the recording', () => {
|
||||
expect(sideFiles('2026/10/21/1792.5.wav')).toEqual([
|
||||
'2026/10/21/1792.5-in.wav',
|
||||
'2026/10/21/1792.5-out.wav',
|
||||
]);
|
||||
});
|
||||
|
||||
it('merges by time; the caller first when both start together', () => {
|
||||
expect(
|
||||
mergeSides(
|
||||
[
|
||||
{ at: 0, text: 'Hello?' },
|
||||
{ at: 5, text: 'Bye.' },
|
||||
],
|
||||
[
|
||||
{ at: 0, text: 'Hi.' },
|
||||
{ at: 2.5, text: 'How can I help?' },
|
||||
],
|
||||
).map((l) => `${l.side} ${l.at}`),
|
||||
).toEqual(['caller 0', 'other 0', 'other 2.5', 'caller 5']);
|
||||
});
|
||||
|
||||
it('labels the lines and cuts a long call', () => {
|
||||
const lines = mergeSides([{ at: 0, text: 'a'.repeat(50) }], [{ at: 1, text: 'b' }]);
|
||||
expect(transcriptText(lines)).toBe(`Caller: ${'a'.repeat(50)}\nOther side: b`);
|
||||
expect(transcriptText(lines, 20)).toBe(
|
||||
`Caller: ${'a'.repeat(12)}\n[the rest of the call is cut]`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('speech stretches', () => {
|
||||
/** 8 kHz mono 16-bit: [seconds, loud?] pieces one after another. */
|
||||
const wav = (parts: [number, boolean][]) => {
|
||||
const samples = parts.flatMap(([s, loud]) =>
|
||||
Array.from({ length: s * 8000 }, (_, i) => (loud ? Math.round(5000 * Math.sin(i / 3)) : 0)),
|
||||
);
|
||||
const b = Buffer.alloc(44 + samples.length * 2);
|
||||
b.writeUInt32LE(8000, 24);
|
||||
b.writeUInt32LE(16000, 28);
|
||||
samples.forEach((v, i) => b.writeInt16LE(v, 44 + i * 2));
|
||||
return b;
|
||||
};
|
||||
const round = (x: { from: number; to: number }[]) =>
|
||||
x.map((s) => [Math.round(s.from * 10) / 10, Math.round(s.to * 10) / 10]);
|
||||
|
||||
it('finds each stretch of speech, joining short pauses, with a little margin', () => {
|
||||
const w = wav([
|
||||
[1, false],
|
||||
[2, true],
|
||||
[0.4, false], // a breath: same stretch
|
||||
[1, true],
|
||||
[3, false],
|
||||
[1, true],
|
||||
[1, false],
|
||||
]);
|
||||
expect(round(speechStretches(w))).toEqual([
|
||||
[0.8, 4.6],
|
||||
[7.2, 8.6],
|
||||
]);
|
||||
});
|
||||
|
||||
it('silence and a click are nothing', () => {
|
||||
expect(speechStretches(wav([[3, false]]))).toEqual([]);
|
||||
expect(
|
||||
speechStretches(
|
||||
wav([
|
||||
[1, false],
|
||||
[0.1, true],
|
||||
[1, false],
|
||||
]),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('a slice is a WAV of just those seconds', () => {
|
||||
const w = wav([[2, true]]);
|
||||
const s = wavSlice(w, 0.5, 1.5);
|
||||
expect(s.length).toBe(44 + 16000);
|
||||
expect(s.readUInt32LE(40)).toBe(16000);
|
||||
expect(s.readUInt32LE(4)).toBe(36 + 16000);
|
||||
expect(s.subarray(44, 54)).toEqual(w.subarray(44 + 8000, 54 + 8000));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Call transcripts and summaries (spec 10 §3). With ai.callTranscripts on, sp-sub-record also
|
||||
// writes <call>-in.wav (what the caller said) and <call>-out.wav (what they heard), so who said
|
||||
// what needs no speaker detection. A job writes each side out, merges them by time, asks ai.llm
|
||||
// for a summary (ai.callSummaries), and deletes the two side files: the mixed recording stays.
|
||||
import { access, readFile, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { and, asc, eq, gt, isNull, lt, sql } from 'drizzle-orm';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { recordings } from '../db/schema.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { AiError, chat, transcribeSegments } from './client.ts';
|
||||
|
||||
export type Line = { side: 'caller' | 'other'; at: number; text: string };
|
||||
|
||||
/** The two side files next to a recording (relative, like recordings.file_path). */
|
||||
export const sideFiles = (filePath: string) => {
|
||||
const base = filePath.replace(/\.wav$/, '');
|
||||
return [`${base}-in.wav`, `${base}-out.wav`] as const;
|
||||
};
|
||||
|
||||
/**
|
||||
* Where someone speaks in a 16-bit mono WAV (MixMonitor's: 44-byte header), in seconds: 20 ms
|
||||
* frames louder than the line's noise, joined across pauses under `gap` s. Whisper's own timings
|
||||
* drift across the long silences one side of a call has while the other talks, so each stretch is
|
||||
* transcribed on its own, from its known start.
|
||||
*/
|
||||
export function speechStretches(wav: Buffer, gap = 0.6): { from: number; to: number }[] {
|
||||
const rate = wav.readUInt32LE(24) || 8000;
|
||||
const frame = Math.round(rate / 50) * 2; // bytes in 20 ms
|
||||
const levels: number[] = [];
|
||||
for (let i = 44; i + frame <= wav.length; i += frame) {
|
||||
let sum = 0;
|
||||
for (let j = i; j < i + frame; j += 2) sum += Math.abs(wav.readInt16LE(j));
|
||||
levels.push(sum / (frame / 2));
|
||||
}
|
||||
// ponytail: a fixed share of the loudest speech, with a floor for silent lines; tune if a noisy
|
||||
// line splits badly.
|
||||
const loud = [...levels].sort((a, b) => a - b)[Math.floor(levels.length * 0.95)] ?? 0;
|
||||
const threshold = Math.max(120, loud / 10);
|
||||
const out: { from: number; to: number }[] = [];
|
||||
levels.forEach((l, i) => {
|
||||
if (l < threshold) return;
|
||||
const at = i * 0.02;
|
||||
const last = out.at(-1);
|
||||
if (last && at - last.to < gap) last.to = at + 0.02;
|
||||
else out.push({ from: at, to: at + 0.02 });
|
||||
});
|
||||
const end = levels.length * 0.02;
|
||||
return out
|
||||
.filter((s) => s.to - s.from >= 0.2) // a click, not a word
|
||||
.map((s) => ({ from: Math.max(0, s.from - 0.2), to: Math.min(end, s.to + 0.2) }));
|
||||
}
|
||||
|
||||
/** Seconds `from`–`to` of a WAV as a WAV of its own. */
|
||||
export function wavSlice(wav: Buffer, from: number, to: number): Buffer {
|
||||
const bytesPerSec = wav.readUInt32LE(28) || 16000;
|
||||
const at = (s: number) => 44 + Math.min(wav.length - 44, Math.round(s * bytesPerSec) & ~1);
|
||||
const data = wav.subarray(at(from), at(to));
|
||||
const head = Buffer.from(wav.subarray(0, 44));
|
||||
head.writeUInt32LE(36 + data.length, 4);
|
||||
head.writeUInt32LE(data.length, 40);
|
||||
return Buffer.concat([head, data]);
|
||||
}
|
||||
|
||||
/** One side of a call, stretch by stretch, each line at its time in the whole call. */
|
||||
async function transcribeSide(stt: Parameters<typeof transcribeSegments>[0], wav: Buffer) {
|
||||
const lines: Omit<Line, 'side'>[] = [];
|
||||
for (const s of speechStretches(wav))
|
||||
for (const seg of await transcribeSegments(stt, wavSlice(wav, s.from, s.to)))
|
||||
lines.push({ at: Math.round((s.from + seg.at) * 10) / 10, text: seg.text });
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Both sides in time order (the files start together, so their timings line up). */
|
||||
export const mergeSides = (caller: Omit<Line, 'side'>[], other: Omit<Line, 'side'>[]): Line[] =>
|
||||
[
|
||||
...caller.map((l) => ({ side: 'caller' as const, ...l })),
|
||||
...other.map((l) => ({ side: 'other' as const, ...l })),
|
||||
].sort((a, b) => a.at - b.at || (a.side === 'caller' ? -1 : 1));
|
||||
|
||||
/** "Caller: …" lines, cut to fit a small model's context. */
|
||||
export function transcriptText(lines: Line[], max = 12_000): string {
|
||||
const text = lines
|
||||
.map((l) => `${l.side === 'caller' ? 'Caller' : 'Other side'}: ${l.text}`)
|
||||
.join('\n');
|
||||
// ponytail: a long call is cut, not chunked; summarize in parts if calls this long matter.
|
||||
return text.length > max ? `${text.slice(0, max)}\n[the rest of the call is cut]` : text;
|
||||
}
|
||||
|
||||
const SUMMARY_PROMPT = `You summarize business phone calls from their transcript. Write in the \
|
||||
language of the call, in plain text, no markdown headings:
|
||||
- 1 to 3 sentences on what the call was about and what was agreed;
|
||||
- then a line "Action items:" and one "- " line per item (who does what), or "Action items: none".
|
||||
Use only what the transcript says. The transcript is automatic and may have mistakes.`;
|
||||
|
||||
/** Recordings the job still has to do: recent, finished a moment ago, not tried yet. */
|
||||
const pending = (db: Db) =>
|
||||
db
|
||||
.select({ id: recordings.id, filePath: recordings.filePath })
|
||||
.from(recordings)
|
||||
.where(
|
||||
and(
|
||||
isNull(recordings.aiDoneAt),
|
||||
gt(recordings.startedAt, sql`now() - interval '2 days'`),
|
||||
lt(recordings.createdAt, sql`now() - interval '20 seconds'`), // files closed
|
||||
),
|
||||
)
|
||||
.orderBy(asc(recordings.startedAt))
|
||||
.limit(20);
|
||||
|
||||
const exists = (f: string) =>
|
||||
access(f).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
|
||||
/** One run: every pending recording, one at a time (the GPU is shared). */
|
||||
export async function transcribeCalls(d: {
|
||||
db: Db;
|
||||
dir: string;
|
||||
log: { error(o: object, m: string): void };
|
||||
}): Promise<number> {
|
||||
const stt = await getSetting(d.db, 'ai.stt');
|
||||
if (!stt || !(await getSetting(d.db, 'ai.callTranscripts'))) return 0;
|
||||
const llm = (await getSetting(d.db, 'ai.callSummaries'))
|
||||
? await getSetting(d.db, 'ai.llm')
|
||||
: null;
|
||||
let done = 0;
|
||||
for (const r of await pending(d.db)) {
|
||||
const files = sideFiles(r.filePath).map((f) => join(d.dir, f));
|
||||
const finish = (set: Partial<typeof recordings.$inferInsert>) =>
|
||||
d.db
|
||||
.update(recordings)
|
||||
.set({ aiDoneAt: new Date(), ...set })
|
||||
.where(eq(recordings.id, r.id));
|
||||
if (!(await exists(files[0]!)) || !(await exists(files[1]!))) {
|
||||
await finish({ aiError: 'Recorded before call transcripts were on' });
|
||||
continue;
|
||||
}
|
||||
let lines: Line[];
|
||||
try {
|
||||
const [caller, other] = [
|
||||
await transcribeSide(stt, await readFile(files[0]!)),
|
||||
await transcribeSide(stt, await readFile(files[1]!)),
|
||||
];
|
||||
lines = mergeSides(caller, other);
|
||||
} catch (err) {
|
||||
// The AI stack being down or busy: try again on the next run. Anything else: give up.
|
||||
if (err instanceof AiError && /Can't reach|didn't answer/.test(err.message)) {
|
||||
d.log.error({ err, recording: r.id }, 'call transcript: speech to text unreachable');
|
||||
return done;
|
||||
}
|
||||
await finish({ aiError: err instanceof Error ? err.message : String(err) });
|
||||
continue;
|
||||
}
|
||||
let summary: string | null = null;
|
||||
let aiError: string | null = null;
|
||||
if (llm && lines.length)
|
||||
try {
|
||||
summary = await chat(
|
||||
llm,
|
||||
[
|
||||
{ role: 'system', content: SUMMARY_PROMPT },
|
||||
{ role: 'user', content: transcriptText(lines) },
|
||||
],
|
||||
{ maxTokens: 2000, timeoutMs: 300_000 },
|
||||
);
|
||||
} catch (err) {
|
||||
aiError = `Summary: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
await finish({ transcript: lines, summary, aiError });
|
||||
for (const f of files) await rm(f, { force: true });
|
||||
done++;
|
||||
}
|
||||
return done;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { createServer, type IncomingMessage, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { assertLocalUrl, chat, silentWav, speak, testEndpoint, transcribe } from './client.ts';
|
||||
|
||||
describe('assertLocalUrl', () => {
|
||||
it.each([
|
||||
'http://127.0.0.1:11434/v1',
|
||||
'http://localhost:8000/v1',
|
||||
'https://192.168.1.50/v1',
|
||||
'http://10.0.0.5:8000',
|
||||
'http://172.20.0.1:8000',
|
||||
'http://[::1]:8000/v1',
|
||||
'http://[fd00::5]/v1',
|
||||
])('%s is local', (u) => expect(() => assertLocalUrl(u)).not.toThrow());
|
||||
|
||||
it.each([
|
||||
['http://api.openai.com/v1', /use an IP address/],
|
||||
['http://ollama.lan:11434', /use an IP address/],
|
||||
['http://8.8.8.8/v1', /not on this server or the local network/],
|
||||
['http://100.64.1.2/v1', /not on this server/], // CGNAT / Tailscale: may cross the internet
|
||||
['http://172.32.0.1/v1', /not on this server/],
|
||||
['ftp://127.0.0.1/', /only http/],
|
||||
['nonsense', /not an address/],
|
||||
])('%s is refused', (u, msg) => expect(() => assertLocalUrl(u)).toThrow(msg));
|
||||
});
|
||||
|
||||
describe('OpenAI-style calls', () => {
|
||||
let server: Server;
|
||||
let base: string;
|
||||
const seen: { path: string; type: string; body: string }[] = [];
|
||||
const read = (req: IncomingMessage) =>
|
||||
new Promise<string>((r) => {
|
||||
let b = '';
|
||||
req.setEncoding('latin1').on('data', (c: string) => (b += c));
|
||||
req.on('end', () => r(b));
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
server = createServer((req, res) => {
|
||||
void read(req).then((body) => {
|
||||
seen.push({ path: req.url!, type: req.headers['content-type'] ?? '', body });
|
||||
if (req.url === '/v1/audio/transcriptions') return res.end('{"text":" Call me back. "}');
|
||||
if (req.url === '/v1/audio/speech') return res.end(silentWav());
|
||||
if (req.url === '/v1/chat/completions')
|
||||
return res.end(
|
||||
JSON.stringify({
|
||||
choices: [{ message: { content: '<think>\nhm\n</think>\n\n OK ' } }],
|
||||
}),
|
||||
);
|
||||
res.writeHead(404).end('model "nope" not found');
|
||||
});
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
|
||||
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1`;
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
it('transcribe: multipart with the file and model, the text trimmed', async () => {
|
||||
expect(await transcribe({ url: base, model: 'whisper' }, silentWav())).toBe('Call me back.');
|
||||
const r = seen.at(-1)!;
|
||||
expect(r.type).toMatch(/^multipart\/form-data; boundary=/);
|
||||
expect(r.body).toContain('name="model"\r\n\r\nwhisper');
|
||||
expect(r.body).toContain('filename="audio.wav"');
|
||||
expect(r.body).toContain('RIFF');
|
||||
});
|
||||
|
||||
it('speak: JSON in, WAV out; chat: the answer', async () => {
|
||||
const wav = await speak({ url: `${base}/`, model: 'kokoro', voice: 'af_heart' }, 'Hi');
|
||||
expect(wav.subarray(0, 4).toString()).toBe('RIFF');
|
||||
expect(JSON.parse(seen.at(-1)!.body)).toEqual({
|
||||
model: 'kokoro',
|
||||
voice: 'af_heart',
|
||||
input: 'Hi',
|
||||
response_format: 'wav',
|
||||
});
|
||||
expect(await chat({ url: base, model: 'm' }, [{ role: 'user', content: 'x' }])).toBe('OK');
|
||||
});
|
||||
|
||||
it('errors say what went wrong; tests report them', async () => {
|
||||
await expect(transcribe({ url: `${base}/wrong`, model: 'm' }, silentWav())).rejects.toThrow(
|
||||
/wrong\/audio\/transcriptions: HTTP 404: model "nope" not found/,
|
||||
);
|
||||
await expect(
|
||||
transcribe({ url: 'http://127.0.0.1:1/v1', model: 'm' }, silentWav()),
|
||||
).rejects.toThrow(/Can't reach 127.0.0.1:1/);
|
||||
expect(await testEndpoint('stt', { url: 'http://8.8.8.8/v1', model: 'm' })).toEqual({
|
||||
ok: false,
|
||||
error: '8.8.8.8 is not on this server or the local network',
|
||||
});
|
||||
const tts = await testEndpoint('tts', { url: base, model: 'k', voice: 'v' });
|
||||
expect(tts).toMatchObject({
|
||||
ok: true,
|
||||
audio: expect.stringMatching(/^data:audio\/wav;base64,UklG/),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Local AI (spec 10): OpenAI-style APIs (Ollama, Speaches, llama.cpp, …) on this server or the
|
||||
// local network. Nothing else is called: an address must be an IP on a local network or
|
||||
// localhost, written as such, so no DNS answer can send audio or text anywhere else.
|
||||
import { BlockList, isIP } from 'node:net';
|
||||
import type { AiEndpoint, AiTestResult, AiTtsEndpoint } from '@selfphoneng/shared';
|
||||
|
||||
export class AiError extends Error {}
|
||||
|
||||
const LOCAL = new BlockList();
|
||||
for (const [net, bits] of [
|
||||
['127.0.0.0', 8],
|
||||
['10.0.0.0', 8],
|
||||
['172.16.0.0', 12],
|
||||
['192.168.0.0', 16],
|
||||
] as const)
|
||||
LOCAL.addSubnet(net, bits, 'ipv4');
|
||||
for (const [net, bits] of [
|
||||
['::1', 128],
|
||||
['fc00::', 7],
|
||||
['fe80::', 10],
|
||||
] as const)
|
||||
LOCAL.addSubnet(net, bits, 'ipv6');
|
||||
|
||||
/** Throws unless `url` is http(s) to localhost or a local-network IP. */
|
||||
export function assertLocalUrl(url: string): void {
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(url);
|
||||
} catch {
|
||||
throw new AiError(`${url} is not an address`);
|
||||
}
|
||||
const host = u.hostname.replace(/^\[|\]$/g, '');
|
||||
if (!/^https?:$/.test(u.protocol)) throw new AiError(`${url}: only http and https`);
|
||||
if (host === 'localhost') return;
|
||||
const v = isIP(host);
|
||||
if (!v)
|
||||
throw new AiError(
|
||||
`${host}: use an IP address (or localhost), so the address can't lead off this network`,
|
||||
);
|
||||
if (!LOCAL.check(host, v === 6 ? 'ipv6' : 'ipv4'))
|
||||
throw new AiError(`${host} is not on this server or the local network`);
|
||||
}
|
||||
|
||||
async function call(
|
||||
base: string,
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
timeoutMs: number,
|
||||
): Promise<Response> {
|
||||
assertLocalUrl(base);
|
||||
const url = new URL(path, base.endsWith('/') ? base : `${base}/`);
|
||||
let r: Response;
|
||||
try {
|
||||
r = await fetch(url, { ...init, redirect: 'error', signal: AbortSignal.timeout(timeoutMs) });
|
||||
} catch (err) {
|
||||
const e = err as Error & { cause?: { code?: string } };
|
||||
throw new AiError(
|
||||
e.name === 'TimeoutError'
|
||||
? `${url.host} didn't answer within ${timeoutMs / 1000} s`
|
||||
: `Can't reach ${url.host}${e.cause?.code ? ` (${e.cause.code})` : ''}`,
|
||||
);
|
||||
}
|
||||
if (!r.ok) {
|
||||
const body = (await r.text().catch(() => '')).slice(0, 300);
|
||||
throw new AiError(`${url.pathname}: HTTP ${r.status}${body ? `: ${body}` : ''}`);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Speech to text: the words in a WAV (or any format the server reads). */
|
||||
export async function transcribe(ep: AiEndpoint, audio: Buffer, timeoutMs = 180_000) {
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([new Uint8Array(audio)], { type: 'audio/wav' }), 'audio.wav');
|
||||
form.append('model', ep.model);
|
||||
form.append('response_format', 'json');
|
||||
const r = await call(ep.url, 'audio/transcriptions', { method: 'POST', body: form }, timeoutMs);
|
||||
const { text } = (await r.json()) as { text?: unknown };
|
||||
if (typeof text !== 'string') throw new AiError('audio/transcriptions: no text in the answer');
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
/** Speech to text with timings (verbose_json): what was said, and from which second. */
|
||||
export async function transcribeSegments(ep: AiEndpoint, audio: Buffer, timeoutMs = 600_000) {
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([new Uint8Array(audio)], { type: 'audio/wav' }), 'audio.wav');
|
||||
form.append('model', ep.model);
|
||||
form.append('response_format', 'verbose_json');
|
||||
const r = await call(ep.url, 'audio/transcriptions', { method: 'POST', body: form }, timeoutMs);
|
||||
const j = (await r.json()) as {
|
||||
text?: unknown;
|
||||
segments?: { start?: unknown; text?: unknown }[];
|
||||
};
|
||||
// A server without segments: the whole text, from the start.
|
||||
const segs = Array.isArray(j.segments)
|
||||
? j.segments.map((s) => ({ at: Number(s.start) || 0, text: String(s.text ?? '').trim() }))
|
||||
: [{ at: 0, text: typeof j.text === 'string' ? j.text.trim() : '' }];
|
||||
return segs.filter((s) => s.text);
|
||||
}
|
||||
|
||||
/** Text to speech: a WAV. */
|
||||
export async function speak(ep: AiTtsEndpoint, input: string, timeoutMs = 60_000) {
|
||||
const r = await call(
|
||||
ep.url,
|
||||
'audio/speech',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ model: ep.model, voice: ep.voice, input, response_format: 'wav' }),
|
||||
},
|
||||
timeoutMs,
|
||||
);
|
||||
return Buffer.from(await r.arrayBuffer());
|
||||
}
|
||||
|
||||
/** One chat answer from the LLM. */
|
||||
export async function chat(
|
||||
ep: AiEndpoint,
|
||||
messages: { role: 'system' | 'user' | 'assistant'; content: string }[],
|
||||
opts: { maxTokens?: number; timeoutMs?: number } = {},
|
||||
) {
|
||||
const r = await call(
|
||||
ep.url,
|
||||
'chat/completions',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: ep.model,
|
||||
messages,
|
||||
max_tokens: opts.maxTokens,
|
||||
stream: false,
|
||||
}),
|
||||
},
|
||||
opts.timeoutMs ?? 120_000,
|
||||
);
|
||||
const j = (await r.json()) as { choices?: { message?: { content?: unknown } }[] };
|
||||
const content = j.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string') throw new AiError('chat/completions: no answer');
|
||||
// Thinking models (Qwen3, …) may put their reasoning inline; only the answer is wanted.
|
||||
return content.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
||||
}
|
||||
|
||||
/** One second of 8 kHz silence: enough for a speech-to-text server to load its model and answer. */
|
||||
export function silentWav(seconds = 1): Buffer {
|
||||
const n = 8000 * seconds;
|
||||
const b = Buffer.alloc(44 + n * 2);
|
||||
b.write('RIFF', 0);
|
||||
b.writeUInt32LE(36 + n * 2, 4);
|
||||
b.write('WAVEfmt ', 8);
|
||||
b.writeUInt32LE(16, 16);
|
||||
b.writeUInt16LE(1, 20); // PCM
|
||||
b.writeUInt16LE(1, 22); // mono
|
||||
b.writeUInt32LE(8000, 24);
|
||||
b.writeUInt32LE(16000, 28);
|
||||
b.writeUInt16LE(2, 32);
|
||||
b.writeUInt16LE(16, 34);
|
||||
b.write('data', 36);
|
||||
b.writeUInt32LE(n * 2, 40);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** Settings → AI → Test: a tiny real request. The first one may load a model, so it can be slow. */
|
||||
export async function testEndpoint(
|
||||
kind: 'llm' | 'stt' | 'tts',
|
||||
ep: AiEndpoint | AiTtsEndpoint,
|
||||
): Promise<AiTestResult> {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
if (kind === 'llm') {
|
||||
const a = await chat(ep, [{ role: 'user', content: 'Reply with just: OK' }], {
|
||||
maxTokens: 400, // room for a thinking model to think first
|
||||
});
|
||||
return { ok: true, ms: Date.now() - t0, detail: `Answered: ${a.slice(0, 80)}` };
|
||||
}
|
||||
if (kind === 'stt') {
|
||||
await transcribe(ep, silentWav());
|
||||
return { ok: true, ms: Date.now() - t0, detail: 'Transcribed a second of silence' };
|
||||
}
|
||||
if (!('voice' in ep)) throw new AiError('A voice is needed');
|
||||
const wav = await speak(ep, 'Hello, this is your phone system.');
|
||||
if (wav.length < 1000) throw new AiError('audio/speech: the audio is empty');
|
||||
return {
|
||||
ok: true,
|
||||
ms: Date.now() - t0,
|
||||
detail: `${Math.round(wav.length / 1024)} KB of audio`,
|
||||
audio: `data:audio/wav;base64,${wav.toString('base64')}`,
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof AiError ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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";
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "voicemail_messages" ADD COLUMN "transcript" text;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "recordings" ADD COLUMN "transcript" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "recordings" ADD COLUMN "summary" text;--> statement-breakpoint
|
||||
ALTER TABLE "recordings" ADD COLUMN "ai_done_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "recordings" ADD COLUMN "ai_error" text;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -36,6 +36,27 @@
|
||||
"when": 1792512298128,
|
||||
"tag": "0004_system_events",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1792606712441,
|
||||
"tag": "0005_moh_order",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1792624567407,
|
||||
"tag": "0006_voicemail_transcript",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1792626762332,
|
||||
"tag": "0007_recording_transcripts",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -334,6 +334,8 @@ export const queueCalls = pgTable(
|
||||
export const mohClasses = pgTable('moh_classes', {
|
||||
id: id(),
|
||||
name: text().notNull().unique(),
|
||||
/** Play the files in random order; otherwise in `sound_files.position` order (03 §8). */
|
||||
shuffle: boolean().notNull().default(false),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
@@ -349,6 +351,8 @@ export const soundFiles = pgTable(
|
||||
originalFilename: text(),
|
||||
asteriskName: text().notNull().unique(),
|
||||
durationMs: integer().notNull(),
|
||||
/** Music on hold: where it plays in its class's list (0 first). Prompts don't use it. */
|
||||
position: integer().notNull().default(0),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
@@ -529,6 +533,12 @@ export const recordings = pgTable('recordings', {
|
||||
startedAt: tstz().notNull(),
|
||||
durationMs: integer(),
|
||||
sizeBytes: bigint({ mode: 'number' }),
|
||||
/** Spec 10 §3: [{ side: 'caller' | 'other', at: seconds, text }], in time order. */
|
||||
transcript: jsonb().$type<{ side: 'caller' | 'other'; at: number; text: string }[]>(),
|
||||
summary: text(),
|
||||
/** When the transcript job finished with it (null: not yet); aiError: why it has none. */
|
||||
aiDoneAt: tstz(),
|
||||
aiError: text(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
@@ -586,6 +596,8 @@ export const voicemailMessages = pgTable('voicemail_messages', {
|
||||
filePath: text().notNull(),
|
||||
emailedAt: tstz(),
|
||||
deletedAt: tstz(),
|
||||
/** Speech to text (spec 10), when ai.voicemailTranscripts is on; null: none (yet). */
|
||||
transcript: text(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
|
||||
+1
@@ -2,4 +2,5 @@
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CLASS_101=2
|
||||
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -2,3 +2,4 @@
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
+1
@@ -2,5 +2,6 @@
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CLASS_101=2
|
||||
SP_TCADMIN_101=1
|
||||
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CLASS_103=2
|
||||
SP_CLASS_104=2
|
||||
SP_CLASS_105=2
|
||||
+33
-7
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
SP_CLASS_103=2
|
||||
|
||||
+33
-7
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -2,5 +2,6 @@
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
+1
@@ -2,5 +2,6 @@
|
||||
SP_DEFAULT_CID=13125551234
|
||||
SP_DEFAULT_E911_CID=13125551234
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CLASS_101=2
|
||||
SP_TCADMIN_101=1
|
||||
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
stunaddr = stun.example.org:3478
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CLASS_99=2
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
+1
@@ -2,4 +2,5 @@
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CLASS_101=2
|
||||
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -2,6 +2,7 @@
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
SP_CLASS_103=2
|
||||
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
[after-hours]
|
||||
mode = files
|
||||
directory = /var/lib/asterisk/moh/after-hours
|
||||
mode = playlist
|
||||
entry = /var/lib/asterisk/moh/after-hours/5a1f0c3e-0000-4000-8000-000000000003
|
||||
sort = random
|
||||
|
||||
[support-jazz]
|
||||
mode = files
|
||||
directory = /var/lib/asterisk/moh/support-jazz
|
||||
sort = random
|
||||
mode = playlist
|
||||
entry = /var/lib/asterisk/moh/support-jazz/5a1f0c3e-0000-4000-8000-000000000002
|
||||
entry = /var/lib/asterisk/moh/support-jazz/5a1f0c3e-0000-4000-8000-000000000001
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"mohClasses": ["support-jazz", "after-hours"],
|
||||
"mohClasses": [
|
||||
{
|
||||
"name": "support-jazz",
|
||||
"files": ["5a1f0c3e-0000-4000-8000-000000000002", "5a1f0c3e-0000-4000-8000-000000000001"]
|
||||
},
|
||||
{ "name": "after-hours", "shuffle": true, "files": ["5a1f0c3e-0000-4000-8000-000000000003"] }
|
||||
],
|
||||
"extensions": [
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000101",
|
||||
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
SP_CLASS_103=2
|
||||
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -0,0 +1,6 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
[sp_admin_user]
|
||||
type = user
|
||||
admin = yes
|
||||
marked = yes
|
||||
music_on_hold_when_empty = yes
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CLASS_1=2
|
||||
SP_CLASS_12=2
|
||||
@@ -0,0 +1,326 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
; ─── Normalize a number. ARG1 = raw. Returns normalized in GOSUB_RETVAL. (matches 00 §8)
|
||||
[sp-sub-normalize]
|
||||
exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
same => n,ExecIf($["${N:0:2}" = "+1"]?Set(N=${N:1}))
|
||||
same => n,ExecIf($["${N:0:1}" = "+"]?Set(N=011${N:1}))
|
||||
same => n,ExecIf($[${LEN(${N})} = 10]?Set(N=1${N}))
|
||||
same => n,Return(${N})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
; in its Contact from reaching Dial's options (',') or another device ('&').
|
||||
[sp-sub-contacts]
|
||||
exten => s,1,Set(LOCAL(CL)=${PJSIP_AOR(${ARG1},contact)})
|
||||
same => n(next),Set(LOCAL(C)=${POP(CL)})
|
||||
same => n,GotoIf($["${C}" = ""]?done)
|
||||
same => n,Set(LOCAL(ST)=${PJSIP_CONTACT(${C},status)})
|
||||
same => n,GotoIf($["${ST}" = "Unavailable" | "${ST}" = "Unreachable"]?next)
|
||||
same => n,Set(LOCAL(U)=${PJSIP_CONTACT(${C},uri)})
|
||||
same => n,Set(LOCAL(D)=${CUT(U,\;,1)})
|
||||
same => n,Set(LOCAL(I)=1)
|
||||
same => n(param),Set(I=$[${I} + 1])
|
||||
same => n,GotoIf($[${I} > ${FIELDQTY(U,\;)}]?add)
|
||||
same => n,Set(LOCAL(P)=${CUT(U,\;,${I})})
|
||||
same => n,GotoIf($["${P:0:3}" = "pn-"]?param)
|
||||
same => n,Set(D=${D}\;${P})
|
||||
same => n,Goto(param)
|
||||
same => n(add),Set(DS=${DS}${IF($["${DS}" = ""]?:&)}PJSIP/${ARG1}/${FILTER(0-9a-zA-Z.:@\;=_+%[]\x2d,${D})})
|
||||
same => n,Goto(next)
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Call a user with all their personal rules.
|
||||
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
||||
[sp-sub-user]
|
||||
exten => s,1,Set(__SP_TARGET=${ARG1})
|
||||
same => n,ExecIf($["${SP_DIRECTION}" = "inbound" & ("${ARG4}" = "inbound" | "${ARG4}" = "all")]?Gosub(sp-sub-record,s,1))
|
||||
same => n,GotoIf($["${DB(SP/DND/${ARG1})}" = "1"]?busy)
|
||||
same => n,Set(FWD=${DB(SP/CFU/${ARG1})})
|
||||
same => n,GotoIf($["${FWD}" != ""]?forward)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${ARG1},${ARG2}))
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy)
|
||||
same => n(noanswer),Set(FWD=${DB(SP/CFNA/${ARG1})})
|
||||
same => n,GotoIf($["${FWD}" != ""]?forward)
|
||||
same => n,GotoIf($["${ARG3}" = "1"]?vmu)
|
||||
same => n,Return()
|
||||
same => n(vmu),VoiceMail(${ARG1}@default,u)
|
||||
same => n,Return()
|
||||
same => n(busy),Set(FWD=${DB(SP/CFB/${ARG1})})
|
||||
same => n,GotoIf($["${FWD}" != ""]?forward)
|
||||
same => n,GotoIf($["${ARG3}" = "1"]?vmb)
|
||||
same => n,Busy(5)
|
||||
same => n,Return()
|
||||
same => n(vmb),VoiceMail(${ARG1}@default,b)
|
||||
same => n,Return()
|
||||
same => n(forward),Gosub(sp-sub-forward,s,1(${FWD},${ARG1}))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Forward a call. ARG1 = target number, ARG2 = ext whose forward rule this is.
|
||||
; Stops after 3 hops so 101→102→101 can't loop forever.
|
||||
[sp-sub-forward]
|
||||
exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} + 1])
|
||||
same => n,GotoIf($[${SP_FWDHOPS} > 3]?stop)
|
||||
same => n,GotoIf($[${DIALPLAN_EXISTS(sp-local-numbers,${ARG1},1)}]?internal)
|
||||
same => n,Set(SP_CLASS=${GLOBAL(SP_CLASS_${ARG2})})
|
||||
same => n,Set(SP_LIMITEXT=${ARG2}) ; counts toward that extension's call limit
|
||||
same => n,Set(__SP_FORWARDED=1)
|
||||
same => n,Goto(sp-outbound-routes,${ARG1},1)
|
||||
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
[sp-sub-autoanswer]
|
||||
exten => s,1,Set(PJSIP_HEADER(add,Call-Info)=<sip:${SP_EXT}>\;answer-after=0)
|
||||
same => n,Set(PJSIP_HEADER(add,Alert-Info)=info=alert-autoanswer)
|
||||
same => n,Return()
|
||||
|
||||
; ─── 911 alert ring-out. Originated by the api (04 §5.3): Channel PJSIP/<notify ext>,
|
||||
; Exten = the extension that dialed 911.
|
||||
[sp-emergency-alert]
|
||||
exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,Wait(1)
|
||||
same => n,Playback(beep&beep&beep)
|
||||
same => n,Playback(extension)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Playback(beep&beep&beep)
|
||||
same => n,Playback(extension)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
same => n,Playback(do-not-disturb&activated)
|
||||
same => n,Hangup()
|
||||
exten => *79,1,Answer() ; DND off
|
||||
same => n,Set(X=${DB_DELETE(SP/DND/${SP_EXT})})
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
same => n,Playback(call-fwd-unconditional&activated)
|
||||
same => n,Hangup()
|
||||
exten => *73,1,Answer() ; cancel forward always
|
||||
same => n,Set(X=${DB_DELETE(SP/CFU/${SP_EXT})})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
|
||||
exten => *8,1,Pickup()
|
||||
same => n,Hangup()
|
||||
exten => *43,1,Answer() ; echo test
|
||||
same => n,Playback(demo-echotest)
|
||||
same => n,Echo()
|
||||
same => n,Playback(demo-echodone)
|
||||
same => n,Hangup()
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
|
||||
same => n,GotoIf($["${PQMSTATUS}" != "PAUSED"]?deny) ; not an agent of that queue
|
||||
same => n,Set(DB(${K})=1)
|
||||
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 1)
|
||||
same => n,Playback(agent-loggedoff)
|
||||
same => n,Hangup()
|
||||
same => n(login),Set(X=${DB_DELETE(${K})})
|
||||
same => n,UnpauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
|
||||
same => n,GotoIf($["${UPQMSTATUS}" != "UNPAUSED"]?deny)
|
||||
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(agent-loginok)
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
same => n,Hangup()
|
||||
same => n(deny),Answer()
|
||||
same => n,Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
|
||||
exten => _*3[123]X,1,Answer()
|
||||
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
|
||||
same => n,Goto(sp-tc-override,${EXTEN},1)
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-ext-forward]
|
||||
exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external destination
|
||||
same => n,Set(__SP_FORWARDED=1)
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
include => sp-emergency-dial
|
||||
include => sp-featurecodes
|
||||
include => sp-local-numbers
|
||||
include => sp-outbound-routes
|
||||
|
||||
[sp-local-numbers]
|
||||
include => sp-parkedcalls
|
||||
|
||||
; users (sorted by number)
|
||||
exten => 1,1,Gosub(sp-sub-user,s,1(1,25,1,never))
|
||||
same => n,Hangup()
|
||||
exten => 1,hint,PJSIP/1&PJSIP/1-web
|
||||
exten => 12,1,Gosub(sp-sub-user,s,1(12,25,1,never))
|
||||
same => n,Hangup()
|
||||
exten => 12,hint,PJSIP/12&PJSIP/12-web
|
||||
|
||||
; ring groups
|
||||
exten => 5,1,Goto(sp-rg-22222222-2222-4222-8222-222222222005,s,1)
|
||||
|
||||
; queues
|
||||
exten => 6,1,Goto(sp-queue-44444444-4444-4444-8444-444444444006,s,1)
|
||||
|
||||
; digital receptionists
|
||||
exten => 0,1,Goto(sp-ivr-33333333-3333-4333-8333-333333333000,s,1)
|
||||
|
||||
; ─── Ring group 5 "Sales" (ringall)
|
||||
[sp-rg-22222222-2222-4222-8222-222222222005]
|
||||
exten => s,1,Set(__SP_TARGET=5)
|
||||
same => n,Dial(Local/1@sp-rg-member&Local/12@sp-rg-member,25,t)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
|
||||
same => n,Goto(sp-vm-direct,1,1)
|
||||
same => n(done),Hangup()
|
||||
|
||||
; ─── Queue 6 "Support"
|
||||
[sp-queue-44444444-4444-4444-8444-444444444006]
|
||||
exten => s,1,Set(__SP_TARGET=6)
|
||||
same => n,Answer()
|
||||
same => n,Queue(6,t,,,300)
|
||||
same => n,GotoIf($["${QUEUESTATUS}" = "TIMEOUT" | "${QUEUESTATUS}" = "FULL" | "${QUEUESTATUS}" = "JOINEMPTY" | "${QUEUESTATUS}" = "LEAVEEMPTY" | "${QUEUESTATUS}" = "JOINUNAVAIL" | "${QUEUESTATUS}" = "LEAVEUNAVAIL"]?fail)
|
||||
same => n,Hangup()
|
||||
same => n(fail),UserEvent(SpQueueExit,Queue: 6,Status: ${QUEUESTATUS})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Digital receptionist 0 "Main menu"
|
||||
[sp-ivr-33333333-3333-4333-8333-333333333000]
|
||||
include => sp-ivr-direct
|
||||
exten => s,1,Answer()
|
||||
same => n,Set(SP_IVR_TRIES=0)
|
||||
same => n(menu),Set(TIMEOUT(digit)=3)
|
||||
same => n,Background(custom/44444444-4444-4444-8444-444444444444)
|
||||
same => n,WaitExten(5)
|
||||
exten => 2,1,Goto(sp-rg-22222222-2222-4222-8222-222222222005,s,1)
|
||||
exten => i,1,Set(SP_IVR_TRIES=$[${SP_IVR_TRIES} + 1])
|
||||
same => n,GotoIf($[${SP_IVR_TRIES} >= 3]?giveup)
|
||||
same => n,Playback(pbx-invalid)
|
||||
same => n,Goto(s,menu)
|
||||
same => n(giveup),Hangup()
|
||||
exten => t,1,Set(SP_IVR_TRIES=$[${SP_IVR_TRIES} + 1])
|
||||
same => n,GotoIf($[${SP_IVR_TRIES} >= 3]?giveup)
|
||||
same => n,Goto(s,menu)
|
||||
same => n(giveup),Hangup()
|
||||
|
||||
[sp-ivr-direct]
|
||||
exten => 0,1,Goto(sp-local-numbers,0,1)
|
||||
exten => 1,1,Goto(sp-local-numbers,1,1)
|
||||
exten => 5,1,Goto(sp-local-numbers,5,1)
|
||||
exten => 6,1,Goto(sp-local-numbers,6,1)
|
||||
exten => 12,1,Goto(sp-local-numbers,12,1)
|
||||
|
||||
; ─── Office hours overrides (admins only, see sp-featurecodes)
|
||||
[sp-tc-override]
|
||||
exten => _*3[123]X,1,Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-inbound]
|
||||
; anything else
|
||||
exten => _[0-9a-z].,1,Hangup()
|
||||
|
||||
[sp-outbound-routes]
|
||||
exten => pending,1,Hangup() ; no routes yet
|
||||
|
||||
[sp-emergency-dial]
|
||||
exten => 911,1,Goto(sp-emergency,s,1)
|
||||
exten => 9911,1,Goto(sp-emergency,s,1) ; people who dial 9 first out of habit
|
||||
|
||||
[sp-emergency]
|
||||
exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E911_CID})})
|
||||
same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
|
||||
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
||||
same => n,Set(__SP_DIRECTION=outbound)
|
||||
same => n,Set(CDR(SP_DIRECTION)=outbound)
|
||||
same => n,Hangup()
|
||||
@@ -0,0 +1 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
@@ -0,0 +1,173 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
[global]
|
||||
type = global
|
||||
user_agent = SelfPhoneNG
|
||||
endpoint_identifier_order = ip,username,anonymous
|
||||
keep_alive_interval = 90
|
||||
max_initial_qualify_time = 4
|
||||
|
||||
[system]
|
||||
type = system
|
||||
timer_t1 = 500
|
||||
timer_b = 32000
|
||||
|
||||
[transport-udp]
|
||||
type = transport
|
||||
protocol = udp
|
||||
bind = 0.0.0.0:5060
|
||||
|
||||
[transport-tcp]
|
||||
type = transport
|
||||
protocol = tcp
|
||||
bind = 0.0.0.0:5060
|
||||
|
||||
[transport-ws]
|
||||
type = transport
|
||||
protocol = ws
|
||||
bind = 0.0.0.0
|
||||
|
||||
; ── extension 1 (Dan Doe) ──
|
||||
[1]
|
||||
type = endpoint
|
||||
context = sp-from-ext
|
||||
disallow = all
|
||||
allow = g722,ulaw,alaw
|
||||
auth = 1-auth
|
||||
aors = 1
|
||||
direct_media = no
|
||||
rtp_symmetric = yes
|
||||
force_rport = yes
|
||||
rewrite_contact = yes
|
||||
send_pai = yes
|
||||
callerid = "Dan Doe" <1>
|
||||
mailboxes = 1@default
|
||||
mwi_subscribe_replaces_unsolicited = yes
|
||||
dtmf_mode = rfc4733
|
||||
allow_subscribe = yes
|
||||
language = en
|
||||
moh_suggest = default
|
||||
tone_zone = us
|
||||
set_var = SP_EXT=1
|
||||
set_var = SP_CLASS=2
|
||||
set_var = SP_RECMODE=never
|
||||
set_var = __SP_DIRECTION=internal
|
||||
|
||||
[1-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = 1
|
||||
password = Ab3dEf9hIj2kLm4nOp6qRs8t
|
||||
|
||||
[1]
|
||||
type = aor
|
||||
max_contacts = 5
|
||||
remove_existing = yes
|
||||
qualify_frequency = 30
|
||||
mailboxes = 1@default
|
||||
|
||||
[1-web]
|
||||
type = endpoint
|
||||
context = sp-from-ext
|
||||
webrtc = yes
|
||||
disallow = all
|
||||
allow = g722,ulaw
|
||||
auth = 1-web-auth
|
||||
aors = 1-web
|
||||
from_domain = selfphoneng.invalid
|
||||
callerid = "Dan Doe" <1>
|
||||
mailboxes = 1@default
|
||||
mwi_subscribe_replaces_unsolicited = yes
|
||||
dtmf_mode = rfc4733
|
||||
allow_subscribe = yes
|
||||
language = en
|
||||
moh_suggest = default
|
||||
tone_zone = us
|
||||
set_var = SP_EXT=1
|
||||
set_var = SP_CLASS=2
|
||||
set_var = SP_RECMODE=never
|
||||
set_var = __SP_DIRECTION=internal
|
||||
|
||||
[1-web-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = 1-web
|
||||
password = Ab3dEf9hIj2kLm4nOp6qRs8t
|
||||
|
||||
[1-web]
|
||||
type = aor
|
||||
max_contacts = 3
|
||||
remove_existing = yes
|
||||
qualify_frequency = 0
|
||||
|
||||
; ── extension 12 (Sue Smith) ──
|
||||
[12]
|
||||
type = endpoint
|
||||
context = sp-from-ext
|
||||
disallow = all
|
||||
allow = g722,ulaw,alaw
|
||||
auth = 12-auth
|
||||
aors = 12
|
||||
direct_media = no
|
||||
rtp_symmetric = yes
|
||||
force_rport = yes
|
||||
rewrite_contact = yes
|
||||
send_pai = yes
|
||||
callerid = "Sue Smith" <12>
|
||||
mailboxes = 12@default
|
||||
mwi_subscribe_replaces_unsolicited = yes
|
||||
dtmf_mode = rfc4733
|
||||
allow_subscribe = yes
|
||||
language = en
|
||||
moh_suggest = default
|
||||
tone_zone = us
|
||||
set_var = SP_EXT=12
|
||||
set_var = SP_CLASS=2
|
||||
set_var = SP_RECMODE=never
|
||||
set_var = __SP_DIRECTION=internal
|
||||
|
||||
[12-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = 12
|
||||
password = Bb3dEf9hIj2kLm4nOp6qRs8t
|
||||
|
||||
[12]
|
||||
type = aor
|
||||
max_contacts = 5
|
||||
remove_existing = yes
|
||||
qualify_frequency = 30
|
||||
mailboxes = 12@default
|
||||
|
||||
[12-web]
|
||||
type = endpoint
|
||||
context = sp-from-ext
|
||||
webrtc = yes
|
||||
disallow = all
|
||||
allow = g722,ulaw
|
||||
auth = 12-web-auth
|
||||
aors = 12-web
|
||||
from_domain = selfphoneng.invalid
|
||||
callerid = "Sue Smith" <12>
|
||||
mailboxes = 12@default
|
||||
mwi_subscribe_replaces_unsolicited = yes
|
||||
dtmf_mode = rfc4733
|
||||
allow_subscribe = yes
|
||||
language = en
|
||||
moh_suggest = default
|
||||
tone_zone = us
|
||||
set_var = SP_EXT=12
|
||||
set_var = SP_CLASS=2
|
||||
set_var = SP_RECMODE=never
|
||||
set_var = __SP_DIRECTION=internal
|
||||
|
||||
[12-web-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = 12-web
|
||||
password = Bb3dEf9hIj2kLm4nOp6qRs8t
|
||||
|
||||
[12-web]
|
||||
type = aor
|
||||
max_contacts = 3
|
||||
remove_existing = yes
|
||||
qualify_frequency = 0
|
||||
@@ -0,0 +1,16 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
; ─── Queue 6 "Support"
|
||||
[6]
|
||||
strategy = rrmemory
|
||||
timeout = 15
|
||||
retry = 5
|
||||
wrapuptime = 10
|
||||
maxlen = 0
|
||||
musicclass = default
|
||||
announce-frequency = 60
|
||||
announce-position = yes
|
||||
ringinuse = no
|
||||
setinterfacevar = yes
|
||||
joinempty = paused,unavailable,invalid,unknown
|
||||
leavewhenempty = paused,unavailable,invalid,unknown
|
||||
member => Local/1@sp-queue-member/n,0,Dan Doe,hint:1@sp-local-numbers
|
||||
@@ -0,0 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -0,0 +1,28 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
[general]
|
||||
format = wav
|
||||
attach = no ; the api sends emails itself
|
||||
maxmsg = 100
|
||||
maxsecs = 300
|
||||
minsecs = 1
|
||||
maxsilence = 10
|
||||
silencethreshold = 128
|
||||
maxlogins = 3
|
||||
minpassword = 4
|
||||
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
|
||||
; stores them from VoicemailPasswordChange (services/vmpin.ts).
|
||||
externpasscheck = /usr/local/bin/sp-vm-pincheck
|
||||
envelope = yes
|
||||
saycid = yes
|
||||
review = no
|
||||
nextaftercmd = yes
|
||||
sendvoicemail = no
|
||||
pollmailboxes = yes
|
||||
pollfreq = 30
|
||||
|
||||
[zonemessages]
|
||||
local = America/Chicago|'vm-received' Q 'digits/at' IMp
|
||||
|
||||
[default]
|
||||
1 => 4826,Dan Doe,,,tz=local|attach=no
|
||||
12 => 4826,Sue Smith,,,tz=local|attach=no
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"extensions": [
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000001",
|
||||
"number": "1",
|
||||
"name": "Dan Doe",
|
||||
"voicemailEnabled": true,
|
||||
"sipPassword": "Ab3dEf9hIj2kLm4nOp6qRs8t"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000012",
|
||||
"number": "12",
|
||||
"name": "Sue Smith",
|
||||
"sipPassword": "Bb3dEf9hIj2kLm4nOp6qRs8t"
|
||||
}
|
||||
],
|
||||
"ringGroups": [
|
||||
{
|
||||
"id": "22222222-2222-4222-8222-222222222005",
|
||||
"number": "5",
|
||||
"name": "Sales",
|
||||
"members": ["1", "12"],
|
||||
"noAnswerDestination": { "type": "voicemail", "id": "00000000-0000-4000-8000-000000000001" }
|
||||
}
|
||||
],
|
||||
"queues": [
|
||||
{
|
||||
"id": "44444444-4444-4444-8444-444444444006",
|
||||
"number": "6",
|
||||
"name": "Support",
|
||||
"members": [{ "number": "1", "name": "Dan Doe", "penalty": 0 }]
|
||||
}
|
||||
],
|
||||
"ivrs": [
|
||||
{
|
||||
"id": "33333333-3333-4333-8333-333333333000",
|
||||
"number": "0",
|
||||
"name": "Main menu",
|
||||
"greeting": "custom/44444444-4444-4444-8444-444444444444",
|
||||
"allowDirectDial": true,
|
||||
"options": [
|
||||
{
|
||||
"digit": "2",
|
||||
"destination": { "type": "ring_group", "id": "22222222-2222-4222-8222-222222222005" }
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"asteriskMatches": [
|
||||
["1", "sp-local-numbers"],
|
||||
["5", "sp-local-numbers"],
|
||||
["0", "sp-local-numbers"],
|
||||
["1", "sp-rg-member"],
|
||||
["1", "sp-queue-member"],
|
||||
["1", "sp-vm-direct"],
|
||||
["1", "sp-ivr-direct"],
|
||||
["**1", "sp-featurecodes"],
|
||||
["*456", "sp-featurecodes"],
|
||||
["*721", "sp-featurecodes"],
|
||||
["*801", "sp-featurecodes"]
|
||||
]
|
||||
}
|
||||
+1
@@ -2,3 +2,4 @@
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
+33
-7
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
+1
@@ -2,4 +2,5 @@
|
||||
SP_DEFAULT_CID=13125551234
|
||||
SP_DEFAULT_E911_CID=13125551234
|
||||
SP_REC_ANNOUNCE=custom/44444444-4444-4444-8444-444444444448
|
||||
SP_REC_SIDES=
|
||||
SP_CLASS_101=2
|
||||
+33
-7
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
+1
@@ -2,5 +2,6 @@
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CLASS_99=0
|
||||
SP_CLASS_102=1
|
||||
+33
-7
@@ -83,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -73,6 +73,8 @@ export function renderGlobals(snap: Snapshot): string {
|
||||
`SP_DEFAULT_CID=${defaultCid}`,
|
||||
`SP_DEFAULT_E911_CID=${didNumber(st['emergency.defaultCallerIdDidId']) || defaultCid}`,
|
||||
`SP_REC_ANNOUNCE=${snap.recordingAnnouncement ? s.sound(snap.recordingAnnouncement) : ''}`,
|
||||
// Spec 10 §3: also record each side on its own, for the transcript job.
|
||||
`SP_REC_SIDES=${st['ai.callTranscripts'] && st['ai.stt'] ? '1' : ''}`,
|
||||
];
|
||||
const exts = [...snap.extensions].sort(byNumber);
|
||||
for (const e of exts) lines.push(`SP_CLASS_${s.digits(e.number)}=${CLASS_RANK[e.outboundClass]}`);
|
||||
|
||||
@@ -86,11 +86,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[\${IF($["\${SP_FWDHOPS}" = ""]?0:\${SP_FWDHOPS})
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["\${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=\${STRFTIME(\${EPOCH},,%Y/%m/%d)}/\${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=\${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["\${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(\${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(\${SP_REC},br(\${SP_REC:0:-4}-in.wav)t(\${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -112,14 +117,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(\${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=\${SP_EXT})
|
||||
same => n,GotoIf($[\${GROUP_COUNT(\${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=\${STRFTIME(\${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=\${DB(SP/VMLOGIN/\${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=\${IF($["\${CUT(V,:,1)}" = "\${H}"]?\${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[\${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/\${SP_EXT})=\${H}:$[\${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["\${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(\${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["\${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/\${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: \${SP_EXT},Value: 1)
|
||||
@@ -130,7 +156,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: \${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(\${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/\${SP_EXT})=\${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: \${SP_EXT},Value: \${GOSUB_RETVAL})
|
||||
@@ -141,7 +167,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: \${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=\${FILTER(0-9,\${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=\${FILTER(0-9,\${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/\${T}-&PJSIP/\${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -156,7 +182,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(\${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=\${FILTER(0-9,\${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/\${Q}/\${SP_EXT})
|
||||
same => n,GotoIf($["\${DB(\${K})}" = "1"]?login)
|
||||
@@ -174,7 +200,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=\${FILTER(0-9,\${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=\${FILTER(0-9,\${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["\${T}" != "\${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["\${PJSIP_ENDPOINT(\${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/\${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -190,7 +216,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(\${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -200,12 +226,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,\${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["\${DB(SP/DND/\${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["\${DB(SP/DND/\${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(\${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(\${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(\${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import type { Generator } from './generator/index.ts';
|
||||
import { todayIn } from './generator/load.ts';
|
||||
import type { LiveState } from './live/state.ts';
|
||||
import { cleanupRecordings } from './routes/recordings.ts';
|
||||
import { transcribeCalls } from './ai/calls.ts';
|
||||
import { audit } from './services/audit.ts';
|
||||
import { backupDue, runBackup } from './services/backup.ts';
|
||||
import { logEvent } from './services/events.ts';
|
||||
@@ -258,6 +259,9 @@ export function startJobs(d: JobDeps): () => void {
|
||||
run('trunkWatch', j.trunkWatch);
|
||||
if (minute % 5 === 0) run('publicIpWatch', j.publicIpWatch);
|
||||
if (minute % 60 === 0) run('diskWatch', j.diskWatch);
|
||||
run('callTranscripts', async () => {
|
||||
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
|
||||
});
|
||||
minute++;
|
||||
};
|
||||
tick();
|
||||
|
||||
@@ -37,7 +37,7 @@ function contactStatus(s: string | undefined): ContactLive['status'] {
|
||||
|
||||
/** "101" and "101-web" both belong to extension 101; trunks and others don't match. */
|
||||
export const extOfEndpoint = (endpoint: string): string | null =>
|
||||
/^(\d{3,6})(?:-web)?$/.exec(endpoint)?.[1] ?? null;
|
||||
/^(\d{1,6})(?:-web)?$/.exec(endpoint)?.[1] ?? null;
|
||||
|
||||
/** "trunk-<uuid>", "trunk-<uuid>-reg" → the trunk id. */
|
||||
export const trunkOfObject = (name: string): string | null =>
|
||||
|
||||
@@ -58,6 +58,27 @@ linekey.30.type = 0
|
||||
linekey.31.type = 0
|
||||
linekey.32.type = 0
|
||||
linekey.33.type = 0
|
||||
## dial at once when the number is complete (911, full outside numbers); others wait for Send
|
||||
dialplan.dialnow.rule.1 = 911
|
||||
dialplan.dialnow.rule.2 = 9911
|
||||
dialplan.dialnow.rule.3 = [2-9]xx[2-9]xxxxxx
|
||||
dialplan.dialnow.rule.4 = 1[2-9]xx[2-9]xxxxxx
|
||||
dialplan.dialnow.rule.5 =
|
||||
dialplan.dialnow.rule.6 =
|
||||
dialplan.dialnow.rule.7 =
|
||||
dialplan.dialnow.rule.8 =
|
||||
dialplan.dialnow.rule.9 =
|
||||
dialplan.dialnow.rule.10 =
|
||||
dialplan.dialnow.rule.11 =
|
||||
dialplan.dialnow.rule.12 =
|
||||
dialplan.dialnow.rule.13 =
|
||||
dialplan.dialnow.rule.14 =
|
||||
dialplan.dialnow.rule.15 =
|
||||
dialplan.dialnow.rule.16 =
|
||||
dialplan.dialnow.rule.17 =
|
||||
dialplan.dialnow.rule.18 =
|
||||
dialplan.dialnow.rule.19 =
|
||||
dialplan.dialnow.rule.20 =
|
||||
## time
|
||||
local_time.time_zone = -6
|
||||
local_time.time_zone_name = United States-Central Time
|
||||
|
||||
@@ -7,6 +7,7 @@ import { decrypt } from '../crypto/secrets.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { contacts, devices, extensions } from '../db/schema.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { loadDialRules, type DialRule } from './dialplan.ts';
|
||||
|
||||
export type ProvisionContext = {
|
||||
mac: string;
|
||||
@@ -26,6 +27,8 @@ export type ProvisionContext = {
|
||||
/** The phonebook with this phone's credentials in it (phones fetch it like the config). */
|
||||
phonebookUrl: string;
|
||||
ntpServer: string;
|
||||
/** What's dialable (dialplan.ts), so phones can send complete numbers at once. */
|
||||
dialRules: DialRule[];
|
||||
};
|
||||
|
||||
export const NTP_SERVER = 'pool.ntp.org';
|
||||
@@ -77,6 +80,7 @@ export async function loadContext(
|
||||
provPassword,
|
||||
phonebookUrl: `https://${d.mac}:${provPassword}@${config.PUBLIC_HOSTNAME}/prov/phonebook/${d.mac}.xml`,
|
||||
ntpServer: NTP_SERVER,
|
||||
dialRules: await loadDialRules(db),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
dialNowRules,
|
||||
dialRules,
|
||||
fromRoutePattern,
|
||||
grandstreamDialPlan,
|
||||
ruleText,
|
||||
} from './dialplan.ts';
|
||||
|
||||
/** The routes createDefaultRoutes makes, international off. */
|
||||
const DEFAULT_ROUTES = [
|
||||
...['800', '888', '877', '866', '855', '844', '833'].map((p) => `1${p}NXXXXXX`),
|
||||
'NXXNXXXXXX',
|
||||
'1NXXNXXXXXX',
|
||||
];
|
||||
|
||||
describe('dial plan', () => {
|
||||
it('reads Asterisk patterns', () => {
|
||||
expect(ruleText(fromRoutePattern('NXXNXXXXXX'))).toBe('[2-9]xx[2-9]xxxxxx');
|
||||
expect(ruleText(fromRoutePattern('011.'))).toBe('011x+');
|
||||
expect(ruleText(fromRoutePattern('[2-46]Z'))).toBe('[2-46][1-9]');
|
||||
});
|
||||
|
||||
it('default routes, three-digit numbers: toll-free folds into 1NXX…, * codes wait', () => {
|
||||
const rules = dialRules(DEFAULT_ROUTES, [3]);
|
||||
expect(grandstreamDialPlan(rules)).toBe(
|
||||
'{ 911 | 9911 | [2-9]xx[2-9]xxxxxx | 1[2-9]xx[2-9]xxxxxx | xxx | *x+ | **x+ }',
|
||||
);
|
||||
// 911 always; 10 and 11 digits can't grow; 101 could be the start of 1012345678? no (N), but
|
||||
// 212 could start 2125551234, so three-digit numbers wait
|
||||
expect(dialNowRules(rules).map(ruleText)).toEqual([
|
||||
'911',
|
||||
'9911',
|
||||
'[2-9]xx[2-9]xxxxxx',
|
||||
'1[2-9]xx[2-9]xxxxxx',
|
||||
]);
|
||||
});
|
||||
|
||||
it('one-digit and six-digit numbers, international on', () => {
|
||||
const rules = dialRules([...DEFAULT_ROUTES, '011.'], [1, 3, 6, 3]);
|
||||
expect(grandstreamDialPlan(rules)).toBe(
|
||||
'{ 911 | 9911 | [2-9]xx[2-9]xxxxxx | 1[2-9]xx[2-9]xxxxxx | 011x+ | x | xxx | xxxxxx | *x+ | **x+ }',
|
||||
);
|
||||
// 0 can start 011…, and every short number can start a longer one
|
||||
expect(dialNowRules(rules).map(ruleText)).toEqual([
|
||||
'911',
|
||||
'9911',
|
||||
'[2-9]xx[2-9]xxxxxx',
|
||||
'1[2-9]xx[2-9]xxxxxx',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a route that only a longer one could continue', () => {
|
||||
// 4-digit internal numbers start 1-9 at the PBX, but the phone can't know: xxxx waits for 5…
|
||||
const rules = dialRules(['NXXXXX'], [4]);
|
||||
expect(dialNowRules(rules).map(ruleText)).toEqual(['911', '9911', '[2-9]xxxxx']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The phone-side dial plan: which dialed strings are complete, so phones and analog adapters
|
||||
// send them at once instead of waiting for # or a timeout. Made from what is dialable: 911/9911,
|
||||
// the enabled outbound routes, the lengths internal numbers use, and * codes.
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { RoutePattern } from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { outboundRoutes } from '../db/schema.ts';
|
||||
import { internalNumberLengths } from '../services/numbering.ts';
|
||||
|
||||
/** One rule: the characters allowed at each position; `more` = one or more digits follow (x+). */
|
||||
export type DialRule = { pos: string[]; more: boolean };
|
||||
|
||||
const ANY = '0123456789';
|
||||
const lit = (s: string): DialRule => ({ pos: [...s], more: false });
|
||||
|
||||
/** An Asterisk route pattern (X, Z, N, [..], trailing . or !) as a rule. */
|
||||
export function fromRoutePattern(match: string): DialRule {
|
||||
const pos: string[] = [];
|
||||
let more = false;
|
||||
for (let i = 0; i < match.length; i++) {
|
||||
const c = match[i]!;
|
||||
if (c === 'X') pos.push(ANY);
|
||||
else if (c === 'Z') pos.push('123456789');
|
||||
else if (c === 'N') pos.push('23456789');
|
||||
else if (c === '.' || c === '!')
|
||||
more = true; // ponytail: ! (zero or more) read as . (one or more)
|
||||
else if (c === '[') {
|
||||
const end = match.indexOf(']', i);
|
||||
pos.push(
|
||||
match.slice(i + 1, end).replace(/(\d)-(\d)/g, (_, a: string, b: string) => {
|
||||
let r = '';
|
||||
for (let d = Number(a); d <= Number(b); d++) r += d;
|
||||
return r;
|
||||
}),
|
||||
);
|
||||
i = end;
|
||||
} else pos.push(c);
|
||||
}
|
||||
return { pos, more };
|
||||
}
|
||||
|
||||
export function dialRules(routePatterns: string[], internalLengths: number[]): DialRule[] {
|
||||
const rules = [lit('911'), lit('9911'), ...routePatterns.map(fromRoutePattern)];
|
||||
for (const n of [...new Set(internalLengths)].sort((a, b) => a - b))
|
||||
rules.push({ pos: Array<string>(n).fill(ANY), more: false });
|
||||
rules.push({ pos: ['*'], more: true }, { pos: ['*', '*'], more: true });
|
||||
// A rule another one already covers says nothing new (toll-free inside 1NXXNXXXXXX); of two
|
||||
// equal ones, keep the first. 911 and 9911 always stay: dialNowRules sends them at once.
|
||||
return rules.filter(
|
||||
(r, i) => i < 2 || !rules.some((o, j) => j !== i && covers(o, r) && (j < i || !covers(r, o))),
|
||||
);
|
||||
}
|
||||
|
||||
/** Does every string `r` matches also match `o`? */
|
||||
const covers = (o: DialRule, r: DialRule) =>
|
||||
(o.more
|
||||
? r.pos.length > o.pos.length || (r.more && r.pos.length === o.pos.length)
|
||||
: !r.more && r.pos.length === o.pos.length) &&
|
||||
r.pos.every((set, i) => [...set].every((c) => (o.pos[i] ?? ANY).includes(c)));
|
||||
|
||||
/** Can some string `other` matches be longer than `r` and start with a string `r` matches? */
|
||||
const extends_ = (r: DialRule, other: DialRule) =>
|
||||
(other.more || other.pos.length > r.pos.length) &&
|
||||
r.pos.every((set, i) =>
|
||||
[...set].some((c) => (other.pos[i] ?? (other.more ? ANY : '')).includes(c)),
|
||||
);
|
||||
|
||||
/**
|
||||
* Rules a phone may dial the moment they match: no other number can continue them. 911 and 9911
|
||||
* always: no outside number starts with 911, and no internal one may (isReservedNumber).
|
||||
*/
|
||||
export const dialNowRules = (rules: DialRule[]): DialRule[] =>
|
||||
rules.filter(
|
||||
(r) =>
|
||||
!r.more &&
|
||||
((r.pos.every((s) => s.length === 1) && ['911', '9911'].includes(r.pos.join(''))) ||
|
||||
!rules.some((o) => o !== r && extends_(r, o))),
|
||||
);
|
||||
|
||||
/** A position in the x / [2-9] / digit syntax both Grandstream and Yealink read. */
|
||||
function set(s: string): string {
|
||||
if (s === ANY) return 'x';
|
||||
if (s.length === 1) return s;
|
||||
const d = [...s].map(Number).sort((a, b) => a - b);
|
||||
const runs: string[] = [];
|
||||
for (let i = 0; i < d.length;) {
|
||||
let j = i;
|
||||
while (j + 1 < d.length && d[j + 1] === d[j]! + 1) j++;
|
||||
runs.push(j - i >= 2 ? `${d[i]}-${d[j]}` : d.slice(i, j + 1).join(''));
|
||||
i = j + 1;
|
||||
}
|
||||
return `[${runs.join('')}]`;
|
||||
}
|
||||
|
||||
export const ruleText = (r: DialRule) => r.pos.map(set).join('') + (r.more ? 'x+' : '');
|
||||
|
||||
/** Grandstream's Dial Plan: `{ 911 | 9911 | [2-9]xx[2-9]xxxxxx | … }`. */
|
||||
export const grandstreamDialPlan = (rules: DialRule[]) => `{ ${rules.map(ruleText).join(' | ')} }`;
|
||||
|
||||
/** The rules for this install (enabled outbound routes, internal number lengths). */
|
||||
export async function loadDialRules(db: Db): Promise<DialRule[]> {
|
||||
const routes = await db
|
||||
.select({ patterns: outboundRoutes.patterns })
|
||||
.from(outboundRoutes)
|
||||
.where(eq(outboundRoutes.enabled, true));
|
||||
return dialRules(
|
||||
routes.flatMap((r) => (r.patterns as RoutePattern[]).map((p) => p.match)),
|
||||
await internalNumberLengths(db),
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { phoneTimezone } from '@selfphoneng/shared';
|
||||
import type { PhonebookEntry, ProvisionContext } from './context.ts';
|
||||
import { dialRules } from './dialplan.ts';
|
||||
import { grandstream, grandstreamPhonebook } from './templates/grandstream.ts';
|
||||
import { yealink, yealinkPhonebook } from './templates/yealink.ts';
|
||||
|
||||
@@ -31,6 +32,7 @@ const ctx = (over: Partial<ProvisionContext> = {}): ProvisionContext => ({
|
||||
phonebookUrl:
|
||||
'https://001565abcdef:Pr0vPassw0rdXyZ12345@pbx.example.com/prov/phonebook/001565abcdef.xml',
|
||||
ntpServer: 'pool.ntp.org',
|
||||
dialRules: dialRules(['NXXNXXXXXX', '1NXXNXXXXXX'], [3]),
|
||||
...over,
|
||||
});
|
||||
const book: PhonebookEntry[] = [
|
||||
@@ -53,6 +55,8 @@ describe('phone templates', () => {
|
||||
expect(out).toContain('linekey.4.type = 0');
|
||||
expect(out).toContain('linekey.33.type = 0');
|
||||
expect(out).not.toContain('linekey.34');
|
||||
expect(out).toMatch(/^dialplan\.dialnow\.rule\.1 = 911$/m);
|
||||
expect(out).toMatch(/^dialplan\.dialnow\.rule\.20 =$/m);
|
||||
});
|
||||
|
||||
it('Grandstream config: XML-escaped values', async () => {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Yealink T3x/T4x/T5x <mac>.cfg (07 §3.1). Key names from Yealink's docs, (verify per model).
|
||||
import { oneLine, xml, type PhonebookEntry, type ProvisionContext } from '../context.ts';
|
||||
import { dialNowRules, ruleText } from '../dialplan.ts';
|
||||
|
||||
const TRANSPORT = { udp: 0, tcp: 1, tls: 2 } as const;
|
||||
/** Line key 1 is the line; BLF keys are 2…; keys past the last BLF up to here are cleared. */
|
||||
const LAST_KEY = 33;
|
||||
/** Dial-now rules past the ones in use, up to here, are cleared. */
|
||||
const LAST_DIALNOW = 20;
|
||||
|
||||
export function yealink(c: ProvisionContext): string {
|
||||
const v = oneLine;
|
||||
const now = dialNowRules(c.dialRules).slice(0, LAST_DIALNOW);
|
||||
return [
|
||||
'#!version:1.0.0.1',
|
||||
'## account 1',
|
||||
@@ -40,6 +44,12 @@ export function yealink(c: ProvisionContext): string {
|
||||
{ length: LAST_KEY - c.blf.length - 1 },
|
||||
(_, i) => `linekey.${i + c.blf.length + 2}.type = 0`,
|
||||
),
|
||||
'## dial at once when the number is complete (911, full outside numbers); others wait for Send',
|
||||
...now.map((d, i) => `dialplan.dialnow.rule.${i + 1} = ${ruleText(d)}`),
|
||||
...Array.from(
|
||||
{ length: LAST_DIALNOW - now.length },
|
||||
(_, i) => `dialplan.dialnow.rule.${i + now.length + 1} =`,
|
||||
),
|
||||
'## time',
|
||||
`local_time.time_zone = ${c.timezone.yealinkOffset}`,
|
||||
`local_time.time_zone_name = ${c.timezone.yealinkName}`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -29,6 +29,7 @@ import { assertMohClass } from './moh.ts';
|
||||
import { softphoneQr } from '../provisioning/linphone.ts';
|
||||
import { findReferences } from '../services/destinations.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { grandstreamDialPlan, loadDialRules } from '../provisioning/dialplan.ts';
|
||||
|
||||
type ExtRow = typeof extensions.$inferSelect;
|
||||
|
||||
@@ -243,6 +244,7 @@ export async function extensionRoutes(app: FastifyInstance) {
|
||||
username: e.number,
|
||||
password: decrypt(e.sipPasswordEnc),
|
||||
webUsername: e.webrtcEnabled ? `${e.number}-web` : null,
|
||||
dialPlan: grandstreamDialPlan(await loadDialRules(db)),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3,18 +3,28 @@
|
||||
// routes. Admins see all; a user may play the recordings of calls they took part in.
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { and, desc, eq, inArray, lt, sql } from 'drizzle-orm';
|
||||
import { and, desc, eq, ilike, inArray, lt, or, sql } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { ListResponse, RecordingDto } from '@selfphoneng/shared';
|
||||
import type { ListResponse, RecordingDto, TranscriptDto } from '@selfphoneng/shared';
|
||||
import { requireAdmin, requireAuth } from '../auth/guards.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { callExtensions, calls, extensions, recordings } from '../db/schema.ts';
|
||||
import { badRequest, forbidden, notFound } from '../errors.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { sideFiles } from '../ai/calls.ts';
|
||||
|
||||
/** The recording and, when call transcripts left them, its two side files. */
|
||||
const removeFiles = async (dir: string, filePath: string) => {
|
||||
for (const f of [filePath, ...sideFiles(filePath)]) await rm(join(dir, f), { force: true });
|
||||
};
|
||||
|
||||
const IdParams = z.object({ id: z.uuid() });
|
||||
const ListQuery = z.object({ cursor: z.string().max(100).optional() });
|
||||
const ListQuery = z.object({
|
||||
cursor: z.string().max(100).optional(),
|
||||
/** Words in the transcript or summary (spec 10 §3). */
|
||||
q: z.string().trim().max(100).optional(),
|
||||
});
|
||||
|
||||
/** Deletes recordings older than `days` (file and row); 0 keeps them forever (04 §8). */
|
||||
export async function cleanupRecordings(db: Db, dir: string, days: number): Promise<number> {
|
||||
@@ -23,7 +33,7 @@ export async function cleanupRecordings(db: Db, dir: string, days: number): Prom
|
||||
.select({ id: recordings.id, filePath: recordings.filePath })
|
||||
.from(recordings)
|
||||
.where(lt(recordings.startedAt, sql`now() - make_interval(days => ${days})`));
|
||||
for (const r of old) await rm(join(dir, r.filePath), { force: true });
|
||||
for (const r of old) await removeFiles(dir, r.filePath);
|
||||
if (old.length)
|
||||
await db.delete(recordings).where(
|
||||
inArray(
|
||||
@@ -34,6 +44,9 @@ export async function cleanupRecordings(db: Db, dir: string, days: number): Prom
|
||||
return old.length;
|
||||
}
|
||||
|
||||
/** A search term taken literally by ILIKE: \, % and _ escaped. */
|
||||
const likeEscape = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
|
||||
|
||||
export async function recordingRoutes(app: FastifyInstance) {
|
||||
const { db } = app;
|
||||
const dir = resolve(app.config.RECORDINGS_DIR);
|
||||
@@ -48,14 +61,24 @@ export async function recordingRoutes(app: FastifyInstance) {
|
||||
'/api/recordings',
|
||||
{ preHandler: requireAdmin },
|
||||
async (req): Promise<ListResponse<RecordingDto>> => {
|
||||
const { cursor } = ListQuery.parse(req.query);
|
||||
const { cursor, q } = ListQuery.parse(req.query);
|
||||
const before = cursor ? new Date(Buffer.from(cursor, 'base64url').toString()) : null;
|
||||
if (before && Number.isNaN(before.getTime())) throw badRequest('Bad cursor');
|
||||
const rows = await db
|
||||
.select({ r: recordings, c: calls })
|
||||
.from(recordings)
|
||||
.leftJoin(calls, eq(calls.recordingId, recordings.id))
|
||||
.where(before ? lt(recordings.startedAt, before) : undefined)
|
||||
.where(
|
||||
and(
|
||||
before ? lt(recordings.startedAt, before) : undefined,
|
||||
q
|
||||
? or(
|
||||
ilike(sql`${recordings.transcript}::text`, `%${likeEscape(q)}%`),
|
||||
ilike(recordings.summary, `%${likeEscape(q)}%`),
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(recordings.startedAt))
|
||||
.limit(51);
|
||||
const items = rows.slice(0, 50);
|
||||
@@ -65,6 +88,9 @@ export async function recordingRoutes(app: FastifyInstance) {
|
||||
startedAt: r.startedAt.toISOString(),
|
||||
durationMs: r.durationMs,
|
||||
sizeBytes: r.sizeBytes,
|
||||
summary: r.summary,
|
||||
hasTranscript: r.transcript !== null,
|
||||
aiError: r.aiError,
|
||||
call: c
|
||||
? {
|
||||
id: c.id,
|
||||
@@ -83,19 +109,32 @@ export async function recordingRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
/** Admins, or a participant: their extension is on the call this recording belongs to. */
|
||||
async function mayHear(user: { id: string; role: string }, recordingId: string) {
|
||||
if (user.role === 'admin') return;
|
||||
const [mine] = await db
|
||||
.select({ id: calls.id })
|
||||
.from(calls)
|
||||
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
|
||||
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
|
||||
.where(and(eq(calls.recordingId, recordingId), eq(extensions.userId, user.id)))
|
||||
.limit(1);
|
||||
if (!mine) throw forbidden('Not your call');
|
||||
}
|
||||
|
||||
app.get('/api/recordings/:id/transcript', { preHandler: requireAuth }, async (req) => {
|
||||
const r = await getRecording(IdParams.parse(req.params).id);
|
||||
await mayHear(req.user!, r.id);
|
||||
return {
|
||||
lines: r.transcript ?? [],
|
||||
summary: r.summary,
|
||||
aiError: r.aiError,
|
||||
} satisfies TranscriptDto;
|
||||
});
|
||||
|
||||
app.get('/api/recordings/:id/audio', { preHandler: requireAuth }, async (req, reply) => {
|
||||
const r = await getRecording(IdParams.parse(req.params).id);
|
||||
if (req.user!.role !== 'admin') {
|
||||
// Only a participant: their extension is on the call this recording belongs to.
|
||||
const [mine] = await db
|
||||
.select({ id: calls.id })
|
||||
.from(calls)
|
||||
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
|
||||
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
|
||||
.where(and(eq(calls.recordingId, r.id), eq(extensions.userId, req.user!.id)))
|
||||
.limit(1);
|
||||
if (!mine) throw forbidden('Not your call');
|
||||
}
|
||||
await mayHear(req.user!, r.id);
|
||||
if ((req.query as { download?: string }).download)
|
||||
reply.header(
|
||||
'content-disposition',
|
||||
@@ -106,7 +145,7 @@ export async function recordingRoutes(app: FastifyInstance) {
|
||||
|
||||
app.delete('/api/recordings/:id', { preHandler: requireAdmin }, async (req) => {
|
||||
const r = await getRecording(IdParams.parse(req.params).id);
|
||||
await rm(join(dir, r.filePath), { force: true });
|
||||
await removeFiles(dir, r.filePath);
|
||||
await db.delete(recordings).where(eq(recordings.id, r.id)); // calls.recording_id → null
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
|
||||
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user