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 | ||
|
|
3c8828eedb | ||
|
|
0a2264d8af | ||
|
|
996685e1ab | ||
|
|
38f39c9028 |
No files matched your search
@@ -14,3 +14,14 @@ APP_UID=1000
|
|||||||
APP_GID=1000
|
APP_GID=1000
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
TRUST_PROXY=true # true when behind Caddy/nginx
|
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
|
# 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
|
# 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:
|
# Gitea Actions (both read .github/workflows); each pushes to its own registry:
|
||||||
# GitHub: ghcr.io/<owner>/selfphoneng-{api,asterisk} (the job's GITHUB_TOKEN)
|
# GitHub: ghcr.io/<owner>/selfphoneng-{api,asterisk,probe} (the job's GITHUB_TOKEN)
|
||||||
# Gitea: <gitea host>/<owner>/selfphoneng-{api,asterisk} (secrets REGISTRY_USER + REGISTRY_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)
|
# 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.
|
# 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
|
name: release
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: write # attaching the probe downloads to the release
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -79,6 +80,7 @@ jobs:
|
|||||||
)
|
)
|
||||||
docker build "${labels[@]}" -f apps/api/Dockerfile -t "$p-api:build" .
|
docker build "${labels[@]}" -f apps/api/Dockerfile -t "$p-api:build" .
|
||||||
docker build "${labels[@]}" -t "$p-asterisk:build" asterisk
|
docker build "${labels[@]}" -t "$p-asterisk:build" asterisk
|
||||||
|
docker build "${labels[@]}" -f apps/probe/Dockerfile -t "$p-probe:build" .
|
||||||
- name: Check
|
- name: Check
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -88,15 +90,41 @@ jobs:
|
|||||||
docker run --rm --entrypoint node "$p-api:build" --input-type=module -e "await import('/app/dist/app.js')"
|
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 }}"
|
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 --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
|
- name: Push
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
p="${{ steps.v.outputs.prefix }}"
|
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
|
for t in ${{ steps.v.outputs.tags }}; do
|
||||||
docker tag "$p-$image:build" "$p-$image:$t"
|
docker tag "$p-$image:build" "$p-$image:$t"
|
||||||
docker push "$p-$image:$t"
|
docker push "$p-$image:$t"
|
||||||
done
|
done
|
||||||
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
|
||||||
+96
-1
@@ -1,7 +1,102 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
Versions follow [semver](https://semver.org). Each release's container images are built from its
|
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
|
||||||
|
|
||||||
|
- Fix: calls to a user with Linphone on an iPhone registered dropped at once, for every device of
|
||||||
|
that user. Its push parameters hold `&`, which split the dial string.
|
||||||
|
|
||||||
## v0.0.1 — 2026-10-20
|
## v0.0.1 — 2026-10-20
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,49 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="apps/web/public/logo-512.png" alt="SelfPhoneNG" width="220">
|
||||||
|
</p>
|
||||||
|
|
||||||
# SelfPhoneNG
|
# SelfPhoneNG
|
||||||
|
|
||||||
A fully open source, self-hosted business phone system: extensions, desk phones and a
|
A fully open source, self-hosted business phone system. Bring your own SIP trunk and numbers
|
||||||
browser softphone, voicemail to email, IVRs, ring groups, queues, office hours, call
|
(reference provider: voip.ms). Built on Asterisk 24 + TypeScript + PostgreSQL 18, runs in Docker.
|
||||||
recording, live dashboard, SMS and phone provisioning. Bring your own SIP trunk and
|
|
||||||
numbers (reference provider: voip.ms). Built on Asterisk 24 LTS + TypeScript + PostgreSQL 18.
|
|
||||||
|
|
||||||
**Status:** pre-1.0, not yet tested by an outside installer.
|
**Status:** pre-1.0 (v0.0.6), for testing. See [what's left for v1](docs/v1-checklist.md).
|
||||||
|
|
||||||
- Installing and running it: the [admin guide](docs/admin-guide/README.md)
|
## What it does today
|
||||||
- Start here if you are writing code (human or AI): [`AGENTS.md`](AGENTS.md)
|
|
||||||
- The full spec: [`docs/spec/`](docs/spec/00-overview.md)
|
- **Phones:** users and extensions, desk phones (Yealink, Grandstream) set up automatically, a
|
||||||
- Build order: [`docs/spec/09-roadmap.md`](docs/spec/09-roadmap.md)
|
browser phone, and Linphone set up by scanning a QR code.
|
||||||
|
- **Numbers:** voip.ms trunks (registration or IP auth, UDP/TCP/TLS + SRTP), phone numbers,
|
||||||
|
outbound rules with calling permissions, 911 with E911 caller ID and alerts.
|
||||||
|
- **Call flows:** digital receptionists, office hours and holidays, ring groups, queues, parking,
|
||||||
|
pickup, conference rooms, paging and intercom, music on hold.
|
||||||
|
- **Voicemail** with email (and transcripts by local AI), **call history**, **recordings**, a **live dashboard** (listen,
|
||||||
|
whisper, barge) and reports.
|
||||||
|
- **Running it:** updates (`scripts/update.sh` for compose installs; a backup before every
|
||||||
|
database change), backups and restore, background health checks, webhooks and API tokens,
|
||||||
|
two-step sign-in for admins, limits on outside calls at once.
|
||||||
|
|
||||||
|
## Wanted
|
||||||
|
|
||||||
|
- More SIP providers: **Telnyx, Flowroute and Twilio** first.
|
||||||
|
- 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.
|
||||||
|
- 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),
|
||||||
|
[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
|
||||||
|
[spec](docs/spec/00-overview.md) and the [roadmap](docs/spec/09-roadmap.md)
|
||||||
|
|
||||||
License: AGPL-3.0-or-later.
|
License: AGPL-3.0-or-later.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@selfphoneng/api",
|
"name": "@selfphoneng/api",
|
||||||
"version": "0.0.1",
|
"version": "0.0.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"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. */
|
/** Decorated by the caller (index.ts, tests) before ready(): it needs app.log. */
|
||||||
generator: Generator;
|
generator: Generator;
|
||||||
/** Decorated by the caller too (tests pass a fake). */
|
/** 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).
|
// ponytail: in-process memory, resets on restart; fine for one api process (v1 has no HA).
|
||||||
const MIN = 60_000;
|
const MIN = 60_000;
|
||||||
const RULES = { ip: { max: 5, windowMs: 15 * MIN }, email: { max: 10, windowMs: 60 * MIN } };
|
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[]>();
|
const failures = new Map<string, number[]>();
|
||||||
|
let lastSweep = 0;
|
||||||
|
|
||||||
function recent(key: string, windowMs: number, now: number): number[] {
|
function recent(key: string, windowMs: number, now: number): number[] {
|
||||||
const list = (failures.get(key) ?? []).filter((t) => now - t < windowMs);
|
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 {
|
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()}`]) {
|
for (const key of [`ip:${ip}`, `email:${email.toLowerCase()}`]) {
|
||||||
failures.set(key, [...(failures.get(key) ?? []), now]);
|
failures.set(key, [...(failures.get(key) ?? []), now]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tests only. */
|
||||||
|
export function loginLimiterSize(): number {
|
||||||
|
return failures.size;
|
||||||
|
}
|
||||||
|
|
||||||
/** Tests only. */
|
/** Tests only. */
|
||||||
export function resetLoginLimiter(): void {
|
export function resetLoginLimiter(): void {
|
||||||
failures.clear();
|
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: undefined })).toThrow(ConfigError);
|
||||||
expect(() => loadConfig({ ...env, APP_SECRET: 'c2hvcnQ=' })).toThrow(/APP_SECRET/);
|
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', () => {
|
it('rejects a non-postgres DATABASE_URL', () => {
|
||||||
expect(() => loadConfig({ ...env, DATABASE_URL: 'mysql://x' })).toThrow(/DATABASE_URL/);
|
expect(() => loadConfig({ ...env, DATABASE_URL: 'mysql://x' })).toThrow(/DATABASE_URL/);
|
||||||
});
|
});
|
||||||
|
|||||||
+11
-1
@@ -1,7 +1,8 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const EnvSchema = z.object({
|
const EnvSchema = z
|
||||||
|
.object({
|
||||||
APP_SECRET: z
|
APP_SECRET: z
|
||||||
.string()
|
.string()
|
||||||
.refine((v) => Buffer.from(v, 'base64').length === 32, 'must be 32 random bytes, base64'),
|
.refine((v) => Buffer.from(v, 'base64').length === 32, 'must be 32 random bytes, base64'),
|
||||||
@@ -34,7 +35,16 @@ const EnvSchema = z.object({
|
|||||||
API_PORT: z.coerce.number().int().min(1).max(65535).default(8080),
|
API_PORT: z.coerce.number().int().min(1).max(65535).default(8080),
|
||||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||||
TRUST_PROXY: z.stringbool().default(true),
|
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'),
|
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>;
|
export type Config = z.infer<typeof EnvSchema>;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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 { fileURLToPath } from 'node:url';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
@@ -19,3 +22,26 @@ export type Db = ReturnType<typeof createDb>['db'];
|
|||||||
export async function runMigrations(db: Db): Promise<void> {
|
export async function runMigrations(db: Db): Promise<void> {
|
||||||
await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
|
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,
|
"when": 1792512298128,
|
||||||
"tag": "0004_system_events",
|
"tag": "0004_system_events",
|
||||||
"breakpoints": true
|
"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', {
|
export const mohClasses = pgTable('moh_classes', {
|
||||||
id: id(),
|
id: id(),
|
||||||
name: text().notNull().unique(),
|
name: text().notNull().unique(),
|
||||||
|
/** Play the files in random order; otherwise in `sound_files.position` order (03 §8). */
|
||||||
|
shuffle: boolean().notNull().default(false),
|
||||||
...timestamps,
|
...timestamps,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -349,6 +351,8 @@ export const soundFiles = pgTable(
|
|||||||
originalFilename: text(),
|
originalFilename: text(),
|
||||||
asteriskName: text().notNull().unique(),
|
asteriskName: text().notNull().unique(),
|
||||||
durationMs: integer().notNull(),
|
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,
|
...timestamps,
|
||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
@@ -529,6 +533,12 @@ export const recordings = pgTable('recordings', {
|
|||||||
startedAt: tstz().notNull(),
|
startedAt: tstz().notNull(),
|
||||||
durationMs: integer(),
|
durationMs: integer(),
|
||||||
sizeBytes: bigint({ mode: 'number' }),
|
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,
|
...timestamps,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -586,6 +596,8 @@ export const voicemailMessages = pgTable('voicemail_messages', {
|
|||||||
filePath: text().notNull(),
|
filePath: text().notNull(),
|
||||||
emailedAt: tstz(),
|
emailedAt: tstz(),
|
||||||
deletedAt: tstz(),
|
deletedAt: tstz(),
|
||||||
|
/** Speech to text (spec 10), when ai.voicemailTranscripts is on; null: none (yet). */
|
||||||
|
transcript: text(),
|
||||||
...timestamps,
|
...timestamps,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+1
@@ -2,4 +2,5 @@
|
|||||||
SP_DEFAULT_CID=
|
SP_DEFAULT_CID=
|
||||||
SP_DEFAULT_E911_CID=
|
SP_DEFAULT_E911_CID=
|
||||||
SP_REC_ANNOUNCE=
|
SP_REC_ANNOUNCE=
|
||||||
|
SP_REC_SIDES=
|
||||||
SP_CLASS_101=2
|
SP_CLASS_101=2
|
||||||
+59
-11
@@ -10,16 +10,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
|||||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||||
[sp-sub-dial-user]
|
[sp-sub-dial-user]
|
||||||
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
|
exten => s,1,Set(DS=)
|
||||||
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||||
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||||
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
|
|
||||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||||
same => n,Dial(${DS},${ARG2},tx)
|
same => n,Dial(${DS},${ARG2},tx)
|
||||||
same => n,Return()
|
same => n,Return()
|
||||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||||
same => n,Return()
|
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.
|
; ─── Call a user with all their personal rules.
|
||||||
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
||||||
[sp-sub-user]
|
[sp-sub-user]
|
||||||
@@ -61,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
|||||||
same => n(stop),Return()
|
same => n(stop),Return()
|
||||||
|
|
||||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
; ─── 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]
|
[sp-sub-record]
|
||||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
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,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,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()
|
same => n(done),Return()
|
||||||
|
|
||||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||||
@@ -87,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
|||||||
same => n,SayDigits(${EXTEN})
|
same => n,SayDigits(${EXTEN})
|
||||||
same => n,Hangup()
|
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).
|
; ─── Feature codes (03 §6.13).
|
||||||
[sp-featurecodes]
|
[sp-featurecodes]
|
||||||
exten => *97,1,Answer() ; my voicemail
|
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,VoiceMailMain(${SP_EXT}@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
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,VoiceMailMain(@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *78,1,Answer() ; DND on
|
exten => *78,1,Answer() ; DND on
|
||||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||||
@@ -105,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
|||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||||
same => n,Playback(do-not-disturb&de-activated)
|
same => n,Playback(do-not-disturb&de-activated)
|
||||||
same => n,Hangup()
|
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,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||||
@@ -116,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
|||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||||
same => n,Hangup()
|
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,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||||
@@ -131,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
|||||||
exten => *65,1,Answer() ; say my extension
|
exten => *65,1,Answer() ; say my extension
|
||||||
same => n,SayDigits(${SP_EXT})
|
same => n,SayDigits(${SP_EXT})
|
||||||
same => n,Hangup()
|
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(Q=${FILTER(0-9,${EXTEN:3})})
|
||||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||||
@@ -149,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
same => n(deny),Playback(ss-noservice)
|
same => n(deny),Playback(ss-noservice)
|
||||||
same => n,Hangup()
|
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($["${T}" != "${EXTEN:3}"]?deny)
|
||||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
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,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||||
@@ -165,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-vm-direct]
|
[sp-vm-direct]
|
||||||
exten => _X.,1,Answer()
|
exten => _X!,1,Answer()
|
||||||
same => n,VoiceMail(${EXTEN}@default,u)
|
same => n,VoiceMail(${EXTEN}@default,u)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
@@ -175,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
|||||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||||
|
|
||||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
[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,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||||
same => n(skip),Hangup()
|
same => n(skip),Hangup()
|
||||||
|
|
||||||
[sp-queue-member] ; one queue agent (Local channel)
|
[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()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-from-ext]
|
[sp-from-ext]
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||||
|
rtpstart = 10000
|
||||||
|
rtpend = 20000
|
||||||
icesupport = yes
|
icesupport = yes
|
||||||
@@ -2,3 +2,4 @@
|
|||||||
SP_DEFAULT_CID=
|
SP_DEFAULT_CID=
|
||||||
SP_DEFAULT_E911_CID=
|
SP_DEFAULT_E911_CID=
|
||||||
SP_REC_ANNOUNCE=
|
SP_REC_ANNOUNCE=
|
||||||
|
SP_REC_SIDES=
|
||||||
@@ -10,16 +10,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
|||||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||||
[sp-sub-dial-user]
|
[sp-sub-dial-user]
|
||||||
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
|
exten => s,1,Set(DS=)
|
||||||
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||||
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||||
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
|
|
||||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||||
same => n,Dial(${DS},${ARG2},tx)
|
same => n,Dial(${DS},${ARG2},tx)
|
||||||
same => n,Return()
|
same => n,Return()
|
||||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||||
same => n,Return()
|
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.
|
; ─── Call a user with all their personal rules.
|
||||||
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
||||||
[sp-sub-user]
|
[sp-sub-user]
|
||||||
@@ -61,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
|||||||
same => n(stop),Return()
|
same => n(stop),Return()
|
||||||
|
|
||||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
; ─── 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]
|
[sp-sub-record]
|
||||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
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,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,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()
|
same => n(done),Return()
|
||||||
|
|
||||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||||
@@ -87,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
|||||||
same => n,SayDigits(${EXTEN})
|
same => n,SayDigits(${EXTEN})
|
||||||
same => n,Hangup()
|
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).
|
; ─── Feature codes (03 §6.13).
|
||||||
[sp-featurecodes]
|
[sp-featurecodes]
|
||||||
exten => *97,1,Answer() ; my voicemail
|
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,VoiceMailMain(${SP_EXT}@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
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,VoiceMailMain(@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *78,1,Answer() ; DND on
|
exten => *78,1,Answer() ; DND on
|
||||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||||
@@ -105,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
|||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||||
same => n,Playback(do-not-disturb&de-activated)
|
same => n,Playback(do-not-disturb&de-activated)
|
||||||
same => n,Hangup()
|
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,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||||
@@ -116,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
|||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||||
same => n,Hangup()
|
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,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||||
@@ -131,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
|||||||
exten => *65,1,Answer() ; say my extension
|
exten => *65,1,Answer() ; say my extension
|
||||||
same => n,SayDigits(${SP_EXT})
|
same => n,SayDigits(${SP_EXT})
|
||||||
same => n,Hangup()
|
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(Q=${FILTER(0-9,${EXTEN:3})})
|
||||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||||
@@ -149,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
same => n(deny),Playback(ss-noservice)
|
same => n(deny),Playback(ss-noservice)
|
||||||
same => n,Hangup()
|
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($["${T}" != "${EXTEN:3}"]?deny)
|
||||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
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,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||||
@@ -165,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-vm-direct]
|
[sp-vm-direct]
|
||||||
exten => _X.,1,Answer()
|
exten => _X!,1,Answer()
|
||||||
same => n,VoiceMail(${EXTEN}@default,u)
|
same => n,VoiceMail(${EXTEN}@default,u)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
@@ -175,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
|||||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||||
|
|
||||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
[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,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||||
same => n(skip),Hangup()
|
same => n(skip),Hangup()
|
||||||
|
|
||||||
[sp-queue-member] ; one queue agent (Local channel)
|
[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()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-from-ext]
|
[sp-from-ext]
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||||
|
rtpstart = 10000
|
||||||
|
rtpend = 20000
|
||||||
icesupport = yes
|
icesupport = yes
|
||||||
+1
@@ -2,5 +2,6 @@
|
|||||||
SP_DEFAULT_CID=
|
SP_DEFAULT_CID=
|
||||||
SP_DEFAULT_E911_CID=
|
SP_DEFAULT_E911_CID=
|
||||||
SP_REC_ANNOUNCE=
|
SP_REC_ANNOUNCE=
|
||||||
|
SP_REC_SIDES=
|
||||||
SP_CLASS_101=2
|
SP_CLASS_101=2
|
||||||
SP_TCADMIN_101=1
|
SP_TCADMIN_101=1
|
||||||
@@ -10,16 +10,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
|||||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||||
[sp-sub-dial-user]
|
[sp-sub-dial-user]
|
||||||
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
|
exten => s,1,Set(DS=)
|
||||||
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||||
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||||
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
|
|
||||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||||
same => n,Dial(${DS},${ARG2},tx)
|
same => n,Dial(${DS},${ARG2},tx)
|
||||||
same => n,Return()
|
same => n,Return()
|
||||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||||
same => n,Return()
|
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.
|
; ─── Call a user with all their personal rules.
|
||||||
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
||||||
[sp-sub-user]
|
[sp-sub-user]
|
||||||
@@ -61,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
|||||||
same => n(stop),Return()
|
same => n(stop),Return()
|
||||||
|
|
||||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
; ─── 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]
|
[sp-sub-record]
|
||||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
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,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,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()
|
same => n(done),Return()
|
||||||
|
|
||||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||||
@@ -87,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
|||||||
same => n,SayDigits(${EXTEN})
|
same => n,SayDigits(${EXTEN})
|
||||||
same => n,Hangup()
|
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).
|
; ─── Feature codes (03 §6.13).
|
||||||
[sp-featurecodes]
|
[sp-featurecodes]
|
||||||
exten => *97,1,Answer() ; my voicemail
|
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,VoiceMailMain(${SP_EXT}@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
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,VoiceMailMain(@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *78,1,Answer() ; DND on
|
exten => *78,1,Answer() ; DND on
|
||||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||||
@@ -105,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
|||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||||
same => n,Playback(do-not-disturb&de-activated)
|
same => n,Playback(do-not-disturb&de-activated)
|
||||||
same => n,Hangup()
|
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,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||||
@@ -116,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
|||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||||
same => n,Hangup()
|
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,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||||
@@ -131,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
|||||||
exten => *65,1,Answer() ; say my extension
|
exten => *65,1,Answer() ; say my extension
|
||||||
same => n,SayDigits(${SP_EXT})
|
same => n,SayDigits(${SP_EXT})
|
||||||
same => n,Hangup()
|
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(Q=${FILTER(0-9,${EXTEN:3})})
|
||||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||||
@@ -149,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
same => n(deny),Playback(ss-noservice)
|
same => n(deny),Playback(ss-noservice)
|
||||||
same => n,Hangup()
|
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($["${T}" != "${EXTEN:3}"]?deny)
|
||||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
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,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||||
@@ -165,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-vm-direct]
|
[sp-vm-direct]
|
||||||
exten => _X.,1,Answer()
|
exten => _X!,1,Answer()
|
||||||
same => n,VoiceMail(${EXTEN}@default,u)
|
same => n,VoiceMail(${EXTEN}@default,u)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
@@ -175,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
|||||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||||
|
|
||||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
[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,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||||
same => n(skip),Hangup()
|
same => n(skip),Hangup()
|
||||||
|
|
||||||
[sp-queue-member] ; one queue agent (Local channel)
|
[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()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-from-ext]
|
[sp-from-ext]
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||||
|
rtpstart = 10000
|
||||||
|
rtpend = 20000
|
||||||
icesupport = yes
|
icesupport = yes
|
||||||
+1
@@ -2,6 +2,7 @@
|
|||||||
SP_DEFAULT_CID=
|
SP_DEFAULT_CID=
|
||||||
SP_DEFAULT_E911_CID=
|
SP_DEFAULT_E911_CID=
|
||||||
SP_REC_ANNOUNCE=
|
SP_REC_ANNOUNCE=
|
||||||
|
SP_REC_SIDES=
|
||||||
SP_CLASS_103=2
|
SP_CLASS_103=2
|
||||||
SP_CLASS_104=2
|
SP_CLASS_104=2
|
||||||
SP_CLASS_105=2
|
SP_CLASS_105=2
|
||||||
+59
-11
@@ -10,16 +10,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
|||||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||||
[sp-sub-dial-user]
|
[sp-sub-dial-user]
|
||||||
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
|
exten => s,1,Set(DS=)
|
||||||
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||||
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||||
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
|
|
||||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||||
same => n,Dial(${DS},${ARG2},tx)
|
same => n,Dial(${DS},${ARG2},tx)
|
||||||
same => n,Return()
|
same => n,Return()
|
||||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||||
same => n,Return()
|
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.
|
; ─── Call a user with all their personal rules.
|
||||||
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
||||||
[sp-sub-user]
|
[sp-sub-user]
|
||||||
@@ -61,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
|||||||
same => n(stop),Return()
|
same => n(stop),Return()
|
||||||
|
|
||||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
; ─── 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]
|
[sp-sub-record]
|
||||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
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,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,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()
|
same => n(done),Return()
|
||||||
|
|
||||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||||
@@ -87,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
|||||||
same => n,SayDigits(${EXTEN})
|
same => n,SayDigits(${EXTEN})
|
||||||
same => n,Hangup()
|
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).
|
; ─── Feature codes (03 §6.13).
|
||||||
[sp-featurecodes]
|
[sp-featurecodes]
|
||||||
exten => *97,1,Answer() ; my voicemail
|
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,VoiceMailMain(${SP_EXT}@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
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,VoiceMailMain(@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *78,1,Answer() ; DND on
|
exten => *78,1,Answer() ; DND on
|
||||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||||
@@ -105,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
|||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||||
same => n,Playback(do-not-disturb&de-activated)
|
same => n,Playback(do-not-disturb&de-activated)
|
||||||
same => n,Hangup()
|
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,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||||
@@ -116,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
|||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||||
same => n,Hangup()
|
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,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||||
@@ -131,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
|||||||
exten => *65,1,Answer() ; say my extension
|
exten => *65,1,Answer() ; say my extension
|
||||||
same => n,SayDigits(${SP_EXT})
|
same => n,SayDigits(${SP_EXT})
|
||||||
same => n,Hangup()
|
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(Q=${FILTER(0-9,${EXTEN:3})})
|
||||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||||
@@ -149,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
same => n(deny),Playback(ss-noservice)
|
same => n(deny),Playback(ss-noservice)
|
||||||
same => n,Hangup()
|
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($["${T}" != "${EXTEN:3}"]?deny)
|
||||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
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,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||||
@@ -165,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-vm-direct]
|
[sp-vm-direct]
|
||||||
exten => _X.,1,Answer()
|
exten => _X!,1,Answer()
|
||||||
same => n,VoiceMail(${EXTEN}@default,u)
|
same => n,VoiceMail(${EXTEN}@default,u)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
@@ -175,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
|||||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||||
|
|
||||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
[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,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||||
same => n(skip),Hangup()
|
same => n(skip),Hangup()
|
||||||
|
|
||||||
[sp-queue-member] ; one queue agent (Local channel)
|
[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()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-from-ext]
|
[sp-from-ext]
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||||
|
rtpstart = 10000
|
||||||
|
rtpend = 20000
|
||||||
icesupport = yes
|
icesupport = yes
|
||||||
+1
@@ -2,6 +2,7 @@
|
|||||||
SP_DEFAULT_CID=
|
SP_DEFAULT_CID=
|
||||||
SP_DEFAULT_E911_CID=
|
SP_DEFAULT_E911_CID=
|
||||||
SP_REC_ANNOUNCE=
|
SP_REC_ANNOUNCE=
|
||||||
|
SP_REC_SIDES=
|
||||||
SP_CLASS_101=2
|
SP_CLASS_101=2
|
||||||
SP_CLASS_102=2
|
SP_CLASS_102=2
|
||||||
SP_CLASS_103=2
|
SP_CLASS_103=2
|
||||||
|
|||||||
+59
-11
@@ -10,16 +10,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
|||||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||||
[sp-sub-dial-user]
|
[sp-sub-dial-user]
|
||||||
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
|
exten => s,1,Set(DS=)
|
||||||
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||||
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||||
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
|
|
||||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||||
same => n,Dial(${DS},${ARG2},tx)
|
same => n,Dial(${DS},${ARG2},tx)
|
||||||
same => n,Return()
|
same => n,Return()
|
||||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||||
same => n,Return()
|
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.
|
; ─── Call a user with all their personal rules.
|
||||||
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
||||||
[sp-sub-user]
|
[sp-sub-user]
|
||||||
@@ -61,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
|||||||
same => n(stop),Return()
|
same => n(stop),Return()
|
||||||
|
|
||||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
; ─── 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]
|
[sp-sub-record]
|
||||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
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,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,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()
|
same => n(done),Return()
|
||||||
|
|
||||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||||
@@ -87,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
|||||||
same => n,SayDigits(${EXTEN})
|
same => n,SayDigits(${EXTEN})
|
||||||
same => n,Hangup()
|
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).
|
; ─── Feature codes (03 §6.13).
|
||||||
[sp-featurecodes]
|
[sp-featurecodes]
|
||||||
exten => *97,1,Answer() ; my voicemail
|
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,VoiceMailMain(${SP_EXT}@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
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,VoiceMailMain(@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *78,1,Answer() ; DND on
|
exten => *78,1,Answer() ; DND on
|
||||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||||
@@ -105,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
|||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||||
same => n,Playback(do-not-disturb&de-activated)
|
same => n,Playback(do-not-disturb&de-activated)
|
||||||
same => n,Hangup()
|
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,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||||
@@ -116,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
|||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||||
same => n,Hangup()
|
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,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||||
@@ -131,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
|||||||
exten => *65,1,Answer() ; say my extension
|
exten => *65,1,Answer() ; say my extension
|
||||||
same => n,SayDigits(${SP_EXT})
|
same => n,SayDigits(${SP_EXT})
|
||||||
same => n,Hangup()
|
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(Q=${FILTER(0-9,${EXTEN:3})})
|
||||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||||
@@ -149,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
same => n(deny),Playback(ss-noservice)
|
same => n(deny),Playback(ss-noservice)
|
||||||
same => n,Hangup()
|
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($["${T}" != "${EXTEN:3}"]?deny)
|
||||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
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,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||||
@@ -165,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-vm-direct]
|
[sp-vm-direct]
|
||||||
exten => _X.,1,Answer()
|
exten => _X!,1,Answer()
|
||||||
same => n,VoiceMail(${EXTEN}@default,u)
|
same => n,VoiceMail(${EXTEN}@default,u)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
@@ -175,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
|||||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||||
|
|
||||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
[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,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||||
same => n(skip),Hangup()
|
same => n(skip),Hangup()
|
||||||
|
|
||||||
[sp-queue-member] ; one queue agent (Local channel)
|
[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()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-from-ext]
|
[sp-from-ext]
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||||
|
rtpstart = 10000
|
||||||
|
rtpend = 20000
|
||||||
icesupport = yes
|
icesupport = yes
|
||||||
@@ -2,5 +2,6 @@
|
|||||||
SP_DEFAULT_CID=
|
SP_DEFAULT_CID=
|
||||||
SP_DEFAULT_E911_CID=
|
SP_DEFAULT_E911_CID=
|
||||||
SP_REC_ANNOUNCE=
|
SP_REC_ANNOUNCE=
|
||||||
|
SP_REC_SIDES=
|
||||||
SP_CLASS_101=2
|
SP_CLASS_101=2
|
||||||
SP_CLASS_102=2
|
SP_CLASS_102=2
|
||||||
@@ -10,16 +10,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
|||||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||||
[sp-sub-dial-user]
|
[sp-sub-dial-user]
|
||||||
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
|
exten => s,1,Set(DS=)
|
||||||
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||||
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||||
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
|
|
||||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||||
same => n,Dial(${DS},${ARG2},tx)
|
same => n,Dial(${DS},${ARG2},tx)
|
||||||
same => n,Return()
|
same => n,Return()
|
||||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||||
same => n,Return()
|
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.
|
; ─── Call a user with all their personal rules.
|
||||||
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
||||||
[sp-sub-user]
|
[sp-sub-user]
|
||||||
@@ -61,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
|||||||
same => n(stop),Return()
|
same => n(stop),Return()
|
||||||
|
|
||||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
; ─── 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]
|
[sp-sub-record]
|
||||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
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,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,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()
|
same => n(done),Return()
|
||||||
|
|
||||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||||
@@ -87,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
|||||||
same => n,SayDigits(${EXTEN})
|
same => n,SayDigits(${EXTEN})
|
||||||
same => n,Hangup()
|
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).
|
; ─── Feature codes (03 §6.13).
|
||||||
[sp-featurecodes]
|
[sp-featurecodes]
|
||||||
exten => *97,1,Answer() ; my voicemail
|
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,VoiceMailMain(${SP_EXT}@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
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,VoiceMailMain(@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *78,1,Answer() ; DND on
|
exten => *78,1,Answer() ; DND on
|
||||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||||
@@ -105,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
|||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||||
same => n,Playback(do-not-disturb&de-activated)
|
same => n,Playback(do-not-disturb&de-activated)
|
||||||
same => n,Hangup()
|
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,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||||
@@ -116,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
|||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||||
same => n,Hangup()
|
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,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||||
@@ -131,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
|||||||
exten => *65,1,Answer() ; say my extension
|
exten => *65,1,Answer() ; say my extension
|
||||||
same => n,SayDigits(${SP_EXT})
|
same => n,SayDigits(${SP_EXT})
|
||||||
same => n,Hangup()
|
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(Q=${FILTER(0-9,${EXTEN:3})})
|
||||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||||
@@ -149,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
same => n(deny),Playback(ss-noservice)
|
same => n(deny),Playback(ss-noservice)
|
||||||
same => n,Hangup()
|
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($["${T}" != "${EXTEN:3}"]?deny)
|
||||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
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,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||||
@@ -165,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-vm-direct]
|
[sp-vm-direct]
|
||||||
exten => _X.,1,Answer()
|
exten => _X!,1,Answer()
|
||||||
same => n,VoiceMail(${EXTEN}@default,u)
|
same => n,VoiceMail(${EXTEN}@default,u)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
@@ -175,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
|||||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||||
|
|
||||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
[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,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||||
same => n(skip),Hangup()
|
same => n(skip),Hangup()
|
||||||
|
|
||||||
[sp-queue-member] ; one queue agent (Local channel)
|
[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()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-from-ext]
|
[sp-from-ext]
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||||
|
rtpstart = 10000
|
||||||
|
rtpend = 20000
|
||||||
icesupport = yes
|
icesupport = yes
|
||||||
+1
@@ -2,5 +2,6 @@
|
|||||||
SP_DEFAULT_CID=13125551234
|
SP_DEFAULT_CID=13125551234
|
||||||
SP_DEFAULT_E911_CID=13125551234
|
SP_DEFAULT_E911_CID=13125551234
|
||||||
SP_REC_ANNOUNCE=
|
SP_REC_ANNOUNCE=
|
||||||
|
SP_REC_SIDES=
|
||||||
SP_CLASS_101=2
|
SP_CLASS_101=2
|
||||||
SP_TCADMIN_101=1
|
SP_TCADMIN_101=1
|
||||||
@@ -10,16 +10,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
|||||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||||
[sp-sub-dial-user]
|
[sp-sub-dial-user]
|
||||||
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
|
exten => s,1,Set(DS=)
|
||||||
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||||
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||||
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
|
|
||||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||||
same => n,Dial(${DS},${ARG2},tx)
|
same => n,Dial(${DS},${ARG2},tx)
|
||||||
same => n,Return()
|
same => n,Return()
|
||||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||||
same => n,Return()
|
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.
|
; ─── Call a user with all their personal rules.
|
||||||
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
||||||
[sp-sub-user]
|
[sp-sub-user]
|
||||||
@@ -61,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
|||||||
same => n(stop),Return()
|
same => n(stop),Return()
|
||||||
|
|
||||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
; ─── 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]
|
[sp-sub-record]
|
||||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
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,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,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()
|
same => n(done),Return()
|
||||||
|
|
||||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||||
@@ -87,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
|||||||
same => n,SayDigits(${EXTEN})
|
same => n,SayDigits(${EXTEN})
|
||||||
same => n,Hangup()
|
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).
|
; ─── Feature codes (03 §6.13).
|
||||||
[sp-featurecodes]
|
[sp-featurecodes]
|
||||||
exten => *97,1,Answer() ; my voicemail
|
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,VoiceMailMain(${SP_EXT}@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
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,VoiceMailMain(@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *78,1,Answer() ; DND on
|
exten => *78,1,Answer() ; DND on
|
||||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||||
@@ -105,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
|||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||||
same => n,Playback(do-not-disturb&de-activated)
|
same => n,Playback(do-not-disturb&de-activated)
|
||||||
same => n,Hangup()
|
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,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||||
@@ -116,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
|||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||||
same => n,Hangup()
|
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,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||||
@@ -131,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
|||||||
exten => *65,1,Answer() ; say my extension
|
exten => *65,1,Answer() ; say my extension
|
||||||
same => n,SayDigits(${SP_EXT})
|
same => n,SayDigits(${SP_EXT})
|
||||||
same => n,Hangup()
|
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(Q=${FILTER(0-9,${EXTEN:3})})
|
||||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||||
@@ -149,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
same => n(deny),Playback(ss-noservice)
|
same => n(deny),Playback(ss-noservice)
|
||||||
same => n,Hangup()
|
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($["${T}" != "${EXTEN:3}"]?deny)
|
||||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
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,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||||
@@ -165,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-vm-direct]
|
[sp-vm-direct]
|
||||||
exten => _X.,1,Answer()
|
exten => _X!,1,Answer()
|
||||||
same => n,VoiceMail(${EXTEN}@default,u)
|
same => n,VoiceMail(${EXTEN}@default,u)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
@@ -175,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
|||||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||||
|
|
||||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
[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,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||||
same => n(skip),Hangup()
|
same => n(skip),Hangup()
|
||||||
|
|
||||||
[sp-queue-member] ; one queue agent (Local channel)
|
[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()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-from-ext]
|
[sp-from-ext]
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||||
|
rtpstart = 10000
|
||||||
|
rtpend = 20000
|
||||||
icesupport = yes
|
icesupport = yes
|
||||||
stunaddr = stun.example.org:3478
|
stunaddr = stun.example.org:3478
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
SP_DEFAULT_CID=
|
SP_DEFAULT_CID=
|
||||||
SP_DEFAULT_E911_CID=
|
SP_DEFAULT_E911_CID=
|
||||||
SP_REC_ANNOUNCE=
|
SP_REC_ANNOUNCE=
|
||||||
|
SP_REC_SIDES=
|
||||||
SP_CLASS_99=2
|
SP_CLASS_99=2
|
||||||
SP_CLASS_101=2
|
SP_CLASS_101=2
|
||||||
SP_CLASS_102=2
|
SP_CLASS_102=2
|
||||||
@@ -10,16 +10,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
|||||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||||
[sp-sub-dial-user]
|
[sp-sub-dial-user]
|
||||||
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
|
exten => s,1,Set(DS=)
|
||||||
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||||
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||||
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
|
|
||||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||||
same => n,Dial(${DS},${ARG2},tx)
|
same => n,Dial(${DS},${ARG2},tx)
|
||||||
same => n,Return()
|
same => n,Return()
|
||||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||||
same => n,Return()
|
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.
|
; ─── Call a user with all their personal rules.
|
||||||
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
||||||
[sp-sub-user]
|
[sp-sub-user]
|
||||||
@@ -61,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
|||||||
same => n(stop),Return()
|
same => n(stop),Return()
|
||||||
|
|
||||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
; ─── 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]
|
[sp-sub-record]
|
||||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
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,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,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()
|
same => n(done),Return()
|
||||||
|
|
||||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||||
@@ -87,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
|||||||
same => n,SayDigits(${EXTEN})
|
same => n,SayDigits(${EXTEN})
|
||||||
same => n,Hangup()
|
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).
|
; ─── Feature codes (03 §6.13).
|
||||||
[sp-featurecodes]
|
[sp-featurecodes]
|
||||||
exten => *97,1,Answer() ; my voicemail
|
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,VoiceMailMain(${SP_EXT}@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
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,VoiceMailMain(@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *78,1,Answer() ; DND on
|
exten => *78,1,Answer() ; DND on
|
||||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||||
@@ -105,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
|||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||||
same => n,Playback(do-not-disturb&de-activated)
|
same => n,Playback(do-not-disturb&de-activated)
|
||||||
same => n,Hangup()
|
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,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||||
@@ -116,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
|||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||||
same => n,Hangup()
|
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,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||||
@@ -131,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
|||||||
exten => *65,1,Answer() ; say my extension
|
exten => *65,1,Answer() ; say my extension
|
||||||
same => n,SayDigits(${SP_EXT})
|
same => n,SayDigits(${SP_EXT})
|
||||||
same => n,Hangup()
|
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(Q=${FILTER(0-9,${EXTEN:3})})
|
||||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||||
@@ -149,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
same => n(deny),Playback(ss-noservice)
|
same => n(deny),Playback(ss-noservice)
|
||||||
same => n,Hangup()
|
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($["${T}" != "${EXTEN:3}"]?deny)
|
||||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
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,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||||
@@ -165,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-vm-direct]
|
[sp-vm-direct]
|
||||||
exten => _X.,1,Answer()
|
exten => _X!,1,Answer()
|
||||||
same => n,VoiceMail(${EXTEN}@default,u)
|
same => n,VoiceMail(${EXTEN}@default,u)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
@@ -175,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
|||||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||||
|
|
||||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
[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,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||||
same => n(skip),Hangup()
|
same => n(skip),Hangup()
|
||||||
|
|
||||||
[sp-queue-member] ; one queue agent (Local channel)
|
[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()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-from-ext]
|
[sp-from-ext]
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||||
|
rtpstart = 10000
|
||||||
|
rtpend = 20000
|
||||||
icesupport = yes
|
icesupport = yes
|
||||||
+1
@@ -2,4 +2,5 @@
|
|||||||
SP_DEFAULT_CID=
|
SP_DEFAULT_CID=
|
||||||
SP_DEFAULT_E911_CID=
|
SP_DEFAULT_E911_CID=
|
||||||
SP_REC_ANNOUNCE=
|
SP_REC_ANNOUNCE=
|
||||||
|
SP_REC_SIDES=
|
||||||
SP_CLASS_101=2
|
SP_CLASS_101=2
|
||||||
@@ -10,16 +10,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
|||||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||||
[sp-sub-dial-user]
|
[sp-sub-dial-user]
|
||||||
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
|
exten => s,1,Set(DS=)
|
||||||
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||||
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||||
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
|
|
||||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||||
same => n,Dial(${DS},${ARG2},tx)
|
same => n,Dial(${DS},${ARG2},tx)
|
||||||
same => n,Return()
|
same => n,Return()
|
||||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||||
same => n,Return()
|
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.
|
; ─── Call a user with all their personal rules.
|
||||||
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
||||||
[sp-sub-user]
|
[sp-sub-user]
|
||||||
@@ -61,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
|||||||
same => n(stop),Return()
|
same => n(stop),Return()
|
||||||
|
|
||||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
; ─── 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]
|
[sp-sub-record]
|
||||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
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,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,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()
|
same => n(done),Return()
|
||||||
|
|
||||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||||
@@ -87,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
|||||||
same => n,SayDigits(${EXTEN})
|
same => n,SayDigits(${EXTEN})
|
||||||
same => n,Hangup()
|
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).
|
; ─── Feature codes (03 §6.13).
|
||||||
[sp-featurecodes]
|
[sp-featurecodes]
|
||||||
exten => *97,1,Answer() ; my voicemail
|
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,VoiceMailMain(${SP_EXT}@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
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,VoiceMailMain(@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *78,1,Answer() ; DND on
|
exten => *78,1,Answer() ; DND on
|
||||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||||
@@ -105,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
|||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||||
same => n,Playback(do-not-disturb&de-activated)
|
same => n,Playback(do-not-disturb&de-activated)
|
||||||
same => n,Hangup()
|
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,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||||
@@ -116,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
|||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||||
same => n,Hangup()
|
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,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||||
@@ -131,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
|||||||
exten => *65,1,Answer() ; say my extension
|
exten => *65,1,Answer() ; say my extension
|
||||||
same => n,SayDigits(${SP_EXT})
|
same => n,SayDigits(${SP_EXT})
|
||||||
same => n,Hangup()
|
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(Q=${FILTER(0-9,${EXTEN:3})})
|
||||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||||
@@ -149,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
same => n(deny),Playback(ss-noservice)
|
same => n(deny),Playback(ss-noservice)
|
||||||
same => n,Hangup()
|
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($["${T}" != "${EXTEN:3}"]?deny)
|
||||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
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,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||||
@@ -165,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-vm-direct]
|
[sp-vm-direct]
|
||||||
exten => _X.,1,Answer()
|
exten => _X!,1,Answer()
|
||||||
same => n,VoiceMail(${EXTEN}@default,u)
|
same => n,VoiceMail(${EXTEN}@default,u)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
@@ -175,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
|||||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||||
|
|
||||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
[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,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||||
same => n(skip),Hangup()
|
same => n(skip),Hangup()
|
||||||
|
|
||||||
[sp-queue-member] ; one queue agent (Local channel)
|
[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()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-from-ext]
|
[sp-from-ext]
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||||
|
rtpstart = 10000
|
||||||
|
rtpend = 20000
|
||||||
icesupport = yes
|
icesupport = yes
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
SP_DEFAULT_CID=
|
SP_DEFAULT_CID=
|
||||||
SP_DEFAULT_E911_CID=
|
SP_DEFAULT_E911_CID=
|
||||||
SP_REC_ANNOUNCE=
|
SP_REC_ANNOUNCE=
|
||||||
|
SP_REC_SIDES=
|
||||||
SP_CLASS_101=2
|
SP_CLASS_101=2
|
||||||
SP_CLASS_102=2
|
SP_CLASS_102=2
|
||||||
SP_CLASS_103=2
|
SP_CLASS_103=2
|
||||||
@@ -10,16 +10,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
|||||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||||
[sp-sub-dial-user]
|
[sp-sub-dial-user]
|
||||||
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
|
exten => s,1,Set(DS=)
|
||||||
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||||
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||||
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
|
|
||||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||||
same => n,Dial(${DS},${ARG2},tx)
|
same => n,Dial(${DS},${ARG2},tx)
|
||||||
same => n,Return()
|
same => n,Return()
|
||||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||||
same => n,Return()
|
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.
|
; ─── Call a user with all their personal rules.
|
||||||
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
||||||
[sp-sub-user]
|
[sp-sub-user]
|
||||||
@@ -61,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
|||||||
same => n(stop),Return()
|
same => n(stop),Return()
|
||||||
|
|
||||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
; ─── 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]
|
[sp-sub-record]
|
||||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
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,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,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()
|
same => n(done),Return()
|
||||||
|
|
||||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||||
@@ -87,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
|||||||
same => n,SayDigits(${EXTEN})
|
same => n,SayDigits(${EXTEN})
|
||||||
same => n,Hangup()
|
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).
|
; ─── Feature codes (03 §6.13).
|
||||||
[sp-featurecodes]
|
[sp-featurecodes]
|
||||||
exten => *97,1,Answer() ; my voicemail
|
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,VoiceMailMain(${SP_EXT}@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
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,VoiceMailMain(@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *78,1,Answer() ; DND on
|
exten => *78,1,Answer() ; DND on
|
||||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||||
@@ -105,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
|||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||||
same => n,Playback(do-not-disturb&de-activated)
|
same => n,Playback(do-not-disturb&de-activated)
|
||||||
same => n,Hangup()
|
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,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||||
@@ -116,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
|||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||||
same => n,Hangup()
|
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,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||||
@@ -131,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
|||||||
exten => *65,1,Answer() ; say my extension
|
exten => *65,1,Answer() ; say my extension
|
||||||
same => n,SayDigits(${SP_EXT})
|
same => n,SayDigits(${SP_EXT})
|
||||||
same => n,Hangup()
|
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(Q=${FILTER(0-9,${EXTEN:3})})
|
||||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||||
@@ -149,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
same => n(deny),Playback(ss-noservice)
|
same => n(deny),Playback(ss-noservice)
|
||||||
same => n,Hangup()
|
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($["${T}" != "${EXTEN:3}"]?deny)
|
||||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
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,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||||
@@ -165,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-vm-direct]
|
[sp-vm-direct]
|
||||||
exten => _X.,1,Answer()
|
exten => _X!,1,Answer()
|
||||||
same => n,VoiceMail(${EXTEN}@default,u)
|
same => n,VoiceMail(${EXTEN}@default,u)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
@@ -175,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
|||||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||||
|
|
||||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
[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,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||||
same => n(skip),Hangup()
|
same => n(skip),Hangup()
|
||||||
|
|
||||||
[sp-queue-member] ; one queue agent (Local channel)
|
[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()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-from-ext]
|
[sp-from-ext]
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||||
[after-hours]
|
[after-hours]
|
||||||
mode = files
|
mode = playlist
|
||||||
directory = /var/lib/asterisk/moh/after-hours
|
entry = /var/lib/asterisk/moh/after-hours/5a1f0c3e-0000-4000-8000-000000000003
|
||||||
sort = random
|
sort = random
|
||||||
|
|
||||||
[support-jazz]
|
[support-jazz]
|
||||||
mode = files
|
mode = playlist
|
||||||
directory = /var/lib/asterisk/moh/support-jazz
|
entry = /var/lib/asterisk/moh/support-jazz/5a1f0c3e-0000-4000-8000-000000000002
|
||||||
sort = random
|
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.
|
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||||
|
rtpstart = 10000
|
||||||
|
rtpend = 20000
|
||||||
icesupport = yes
|
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": [
|
"extensions": [
|
||||||
{
|
{
|
||||||
"id": "00000000-0000-4000-8000-000000000101",
|
"id": "00000000-0000-4000-8000-000000000101",
|
||||||
|
|||||||
+1
@@ -2,6 +2,7 @@
|
|||||||
SP_DEFAULT_CID=
|
SP_DEFAULT_CID=
|
||||||
SP_DEFAULT_E911_CID=
|
SP_DEFAULT_E911_CID=
|
||||||
SP_REC_ANNOUNCE=
|
SP_REC_ANNOUNCE=
|
||||||
|
SP_REC_SIDES=
|
||||||
SP_CLASS_101=2
|
SP_CLASS_101=2
|
||||||
SP_CLASS_102=2
|
SP_CLASS_102=2
|
||||||
SP_CLASS_103=2
|
SP_CLASS_103=2
|
||||||
@@ -10,16 +10,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
|||||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||||
[sp-sub-dial-user]
|
[sp-sub-dial-user]
|
||||||
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
|
exten => s,1,Set(DS=)
|
||||||
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||||
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||||
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
|
|
||||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||||
same => n,Dial(${DS},${ARG2},tx)
|
same => n,Dial(${DS},${ARG2},tx)
|
||||||
same => n,Return()
|
same => n,Return()
|
||||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||||
same => n,Return()
|
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.
|
; ─── Call a user with all their personal rules.
|
||||||
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
||||||
[sp-sub-user]
|
[sp-sub-user]
|
||||||
@@ -61,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
|||||||
same => n(stop),Return()
|
same => n(stop),Return()
|
||||||
|
|
||||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
; ─── 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]
|
[sp-sub-record]
|
||||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
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,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,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()
|
same => n(done),Return()
|
||||||
|
|
||||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||||
@@ -87,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
|||||||
same => n,SayDigits(${EXTEN})
|
same => n,SayDigits(${EXTEN})
|
||||||
same => n,Hangup()
|
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).
|
; ─── Feature codes (03 §6.13).
|
||||||
[sp-featurecodes]
|
[sp-featurecodes]
|
||||||
exten => *97,1,Answer() ; my voicemail
|
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,VoiceMailMain(${SP_EXT}@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
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,VoiceMailMain(@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *78,1,Answer() ; DND on
|
exten => *78,1,Answer() ; DND on
|
||||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||||
@@ -105,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
|||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||||
same => n,Playback(do-not-disturb&de-activated)
|
same => n,Playback(do-not-disturb&de-activated)
|
||||||
same => n,Hangup()
|
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,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||||
@@ -116,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
|||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||||
same => n,Hangup()
|
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,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||||
@@ -131,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
|||||||
exten => *65,1,Answer() ; say my extension
|
exten => *65,1,Answer() ; say my extension
|
||||||
same => n,SayDigits(${SP_EXT})
|
same => n,SayDigits(${SP_EXT})
|
||||||
same => n,Hangup()
|
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(Q=${FILTER(0-9,${EXTEN:3})})
|
||||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||||
@@ -149,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
same => n(deny),Playback(ss-noservice)
|
same => n(deny),Playback(ss-noservice)
|
||||||
same => n,Hangup()
|
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($["${T}" != "${EXTEN:3}"]?deny)
|
||||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
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,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||||
@@ -165,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-vm-direct]
|
[sp-vm-direct]
|
||||||
exten => _X.,1,Answer()
|
exten => _X!,1,Answer()
|
||||||
same => n,VoiceMail(${EXTEN}@default,u)
|
same => n,VoiceMail(${EXTEN}@default,u)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
@@ -175,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
|||||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||||
|
|
||||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
[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,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||||
same => n(skip),Hangup()
|
same => n(skip),Hangup()
|
||||||
|
|
||||||
[sp-queue-member] ; one queue agent (Local channel)
|
[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()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-from-ext]
|
[sp-from-ext]
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||||
|
rtpstart = 10000
|
||||||
|
rtpend = 20000
|
||||||
icesupport = yes
|
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_CID=
|
||||||
SP_DEFAULT_E911_CID=
|
SP_DEFAULT_E911_CID=
|
||||||
SP_REC_ANNOUNCE=
|
SP_REC_ANNOUNCE=
|
||||||
|
SP_REC_SIDES=
|
||||||
+59
-11
@@ -10,16 +10,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
|||||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||||
[sp-sub-dial-user]
|
[sp-sub-dial-user]
|
||||||
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
|
exten => s,1,Set(DS=)
|
||||||
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||||
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||||
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
|
|
||||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||||
same => n,Dial(${DS},${ARG2},tx)
|
same => n,Dial(${DS},${ARG2},tx)
|
||||||
same => n,Return()
|
same => n,Return()
|
||||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||||
same => n,Return()
|
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.
|
; ─── Call a user with all their personal rules.
|
||||||
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
||||||
[sp-sub-user]
|
[sp-sub-user]
|
||||||
@@ -61,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
|||||||
same => n(stop),Return()
|
same => n(stop),Return()
|
||||||
|
|
||||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
; ─── 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]
|
[sp-sub-record]
|
||||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
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,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,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()
|
same => n(done),Return()
|
||||||
|
|
||||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||||
@@ -87,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
|||||||
same => n,SayDigits(${EXTEN})
|
same => n,SayDigits(${EXTEN})
|
||||||
same => n,Hangup()
|
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).
|
; ─── Feature codes (03 §6.13).
|
||||||
[sp-featurecodes]
|
[sp-featurecodes]
|
||||||
exten => *97,1,Answer() ; my voicemail
|
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,VoiceMailMain(${SP_EXT}@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
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,VoiceMailMain(@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *78,1,Answer() ; DND on
|
exten => *78,1,Answer() ; DND on
|
||||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||||
@@ -105,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
|||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||||
same => n,Playback(do-not-disturb&de-activated)
|
same => n,Playback(do-not-disturb&de-activated)
|
||||||
same => n,Hangup()
|
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,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||||
@@ -116,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
|||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||||
same => n,Hangup()
|
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,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||||
@@ -131,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
|||||||
exten => *65,1,Answer() ; say my extension
|
exten => *65,1,Answer() ; say my extension
|
||||||
same => n,SayDigits(${SP_EXT})
|
same => n,SayDigits(${SP_EXT})
|
||||||
same => n,Hangup()
|
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(Q=${FILTER(0-9,${EXTEN:3})})
|
||||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||||
@@ -149,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
same => n(deny),Playback(ss-noservice)
|
same => n(deny),Playback(ss-noservice)
|
||||||
same => n,Hangup()
|
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($["${T}" != "${EXTEN:3}"]?deny)
|
||||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
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,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||||
@@ -165,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-vm-direct]
|
[sp-vm-direct]
|
||||||
exten => _X.,1,Answer()
|
exten => _X!,1,Answer()
|
||||||
same => n,VoiceMail(${EXTEN}@default,u)
|
same => n,VoiceMail(${EXTEN}@default,u)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
@@ -175,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
|||||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||||
|
|
||||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
[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,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||||
same => n(skip),Hangup()
|
same => n(skip),Hangup()
|
||||||
|
|
||||||
[sp-queue-member] ; one queue agent (Local channel)
|
[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()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-from-ext]
|
[sp-from-ext]
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||||
|
rtpstart = 10000
|
||||||
|
rtpend = 20000
|
||||||
icesupport = yes
|
icesupport = yes
|
||||||
+1
@@ -2,4 +2,5 @@
|
|||||||
SP_DEFAULT_CID=13125551234
|
SP_DEFAULT_CID=13125551234
|
||||||
SP_DEFAULT_E911_CID=13125551234
|
SP_DEFAULT_E911_CID=13125551234
|
||||||
SP_REC_ANNOUNCE=custom/44444444-4444-4444-8444-444444444448
|
SP_REC_ANNOUNCE=custom/44444444-4444-4444-8444-444444444448
|
||||||
|
SP_REC_SIDES=
|
||||||
SP_CLASS_101=2
|
SP_CLASS_101=2
|
||||||
+59
-11
@@ -10,16 +10,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
|||||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||||
[sp-sub-dial-user]
|
[sp-sub-dial-user]
|
||||||
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
|
exten => s,1,Set(DS=)
|
||||||
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||||
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||||
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
|
|
||||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||||
same => n,Dial(${DS},${ARG2},tx)
|
same => n,Dial(${DS},${ARG2},tx)
|
||||||
same => n,Return()
|
same => n,Return()
|
||||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||||
same => n,Return()
|
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.
|
; ─── Call a user with all their personal rules.
|
||||||
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
||||||
[sp-sub-user]
|
[sp-sub-user]
|
||||||
@@ -61,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
|||||||
same => n(stop),Return()
|
same => n(stop),Return()
|
||||||
|
|
||||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
; ─── 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]
|
[sp-sub-record]
|
||||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
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,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,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()
|
same => n(done),Return()
|
||||||
|
|
||||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||||
@@ -87,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
|||||||
same => n,SayDigits(${EXTEN})
|
same => n,SayDigits(${EXTEN})
|
||||||
same => n,Hangup()
|
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).
|
; ─── Feature codes (03 §6.13).
|
||||||
[sp-featurecodes]
|
[sp-featurecodes]
|
||||||
exten => *97,1,Answer() ; my voicemail
|
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,VoiceMailMain(${SP_EXT}@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
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,VoiceMailMain(@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *78,1,Answer() ; DND on
|
exten => *78,1,Answer() ; DND on
|
||||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||||
@@ -105,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
|||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||||
same => n,Playback(do-not-disturb&de-activated)
|
same => n,Playback(do-not-disturb&de-activated)
|
||||||
same => n,Hangup()
|
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,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||||
@@ -116,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
|||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||||
same => n,Hangup()
|
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,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||||
@@ -131,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
|||||||
exten => *65,1,Answer() ; say my extension
|
exten => *65,1,Answer() ; say my extension
|
||||||
same => n,SayDigits(${SP_EXT})
|
same => n,SayDigits(${SP_EXT})
|
||||||
same => n,Hangup()
|
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(Q=${FILTER(0-9,${EXTEN:3})})
|
||||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||||
@@ -149,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
same => n(deny),Playback(ss-noservice)
|
same => n(deny),Playback(ss-noservice)
|
||||||
same => n,Hangup()
|
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($["${T}" != "${EXTEN:3}"]?deny)
|
||||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
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,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||||
@@ -165,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-vm-direct]
|
[sp-vm-direct]
|
||||||
exten => _X.,1,Answer()
|
exten => _X!,1,Answer()
|
||||||
same => n,VoiceMail(${EXTEN}@default,u)
|
same => n,VoiceMail(${EXTEN}@default,u)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
@@ -175,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
|||||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||||
|
|
||||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
[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,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||||
same => n(skip),Hangup()
|
same => n(skip),Hangup()
|
||||||
|
|
||||||
[sp-queue-member] ; one queue agent (Local channel)
|
[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()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-from-ext]
|
[sp-from-ext]
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||||
|
rtpstart = 10000
|
||||||
|
rtpend = 20000
|
||||||
icesupport = yes
|
icesupport = yes
|
||||||
+1
@@ -2,5 +2,6 @@
|
|||||||
SP_DEFAULT_CID=
|
SP_DEFAULT_CID=
|
||||||
SP_DEFAULT_E911_CID=
|
SP_DEFAULT_E911_CID=
|
||||||
SP_REC_ANNOUNCE=
|
SP_REC_ANNOUNCE=
|
||||||
|
SP_REC_SIDES=
|
||||||
SP_CLASS_99=0
|
SP_CLASS_99=0
|
||||||
SP_CLASS_102=1
|
SP_CLASS_102=1
|
||||||
+59
-11
@@ -10,16 +10,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
|||||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||||
[sp-sub-dial-user]
|
[sp-sub-dial-user]
|
||||||
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
|
exten => s,1,Set(DS=)
|
||||||
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||||
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
|
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||||
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
|
|
||||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||||
same => n,Dial(${DS},${ARG2},tx)
|
same => n,Dial(${DS},${ARG2},tx)
|
||||||
same => n,Return()
|
same => n,Return()
|
||||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||||
same => n,Return()
|
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.
|
; ─── Call a user with all their personal rules.
|
||||||
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
||||||
[sp-sub-user]
|
[sp-sub-user]
|
||||||
@@ -61,11 +83,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
|||||||
same => n(stop),Return()
|
same => n(stop),Return()
|
||||||
|
|
||||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
; ─── 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]
|
[sp-sub-record]
|
||||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
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,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,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()
|
same => n(done),Return()
|
||||||
|
|
||||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||||
@@ -87,14 +114,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
|||||||
same => n,SayDigits(${EXTEN})
|
same => n,SayDigits(${EXTEN})
|
||||||
same => n,Hangup()
|
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).
|
; ─── Feature codes (03 §6.13).
|
||||||
[sp-featurecodes]
|
[sp-featurecodes]
|
||||||
exten => *97,1,Answer() ; my voicemail
|
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,VoiceMailMain(${SP_EXT}@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
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,VoiceMailMain(@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *78,1,Answer() ; DND on
|
exten => *78,1,Answer() ; DND on
|
||||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||||
@@ -105,7 +153,7 @@ exten => *79,1,Answer() ; DND off
|
|||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||||
same => n,Playback(do-not-disturb&de-activated)
|
same => n,Playback(do-not-disturb&de-activated)
|
||||||
same => n,Hangup()
|
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,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||||
@@ -116,7 +164,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
|||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||||
same => n,Hangup()
|
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,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||||
@@ -131,7 +179,7 @@ exten => *43,1,Answer() ; echo test
|
|||||||
exten => *65,1,Answer() ; say my extension
|
exten => *65,1,Answer() ; say my extension
|
||||||
same => n,SayDigits(${SP_EXT})
|
same => n,SayDigits(${SP_EXT})
|
||||||
same => n,Hangup()
|
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(Q=${FILTER(0-9,${EXTEN:3})})
|
||||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||||
@@ -149,7 +197,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
same => n(deny),Playback(ss-noservice)
|
same => n(deny),Playback(ss-noservice)
|
||||||
same => n,Hangup()
|
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($["${T}" != "${EXTEN:3}"]?deny)
|
||||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
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,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||||
@@ -165,7 +213,7 @@ exten => _*3[123]X,1,Answer()
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-vm-direct]
|
[sp-vm-direct]
|
||||||
exten => _X.,1,Answer()
|
exten => _X!,1,Answer()
|
||||||
same => n,VoiceMail(${EXTEN}@default,u)
|
same => n,VoiceMail(${EXTEN}@default,u)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
@@ -175,12 +223,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
|||||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||||
|
|
||||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
[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,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||||
same => n(skip),Hangup()
|
same => n(skip),Hangup()
|
||||||
|
|
||||||
[sp-queue-member] ; one queue agent (Local channel)
|
[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()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-from-ext]
|
[sp-from-ext]
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||||
|
rtpstart = 10000
|
||||||
|
rtpend = 20000
|
||||||
icesupport = yes
|
icesupport = yes
|
||||||
@@ -73,6 +73,8 @@ export function renderGlobals(snap: Snapshot): string {
|
|||||||
`SP_DEFAULT_CID=${defaultCid}`,
|
`SP_DEFAULT_CID=${defaultCid}`,
|
||||||
`SP_DEFAULT_E911_CID=${didNumber(st['emergency.defaultCallerIdDidId']) || defaultCid}`,
|
`SP_DEFAULT_E911_CID=${didNumber(st['emergency.defaultCallerIdDidId']) || defaultCid}`,
|
||||||
`SP_REC_ANNOUNCE=${snap.recordingAnnouncement ? s.sound(snap.recordingAnnouncement) : ''}`,
|
`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);
|
const exts = [...snap.extensions].sort(byNumber);
|
||||||
for (const e of exts) lines.push(`SP_CLASS_${s.digits(e.number)}=${CLASS_RANK[e.outboundClass]}`);
|
for (const e of exts) lines.push(`SP_CLASS_${s.digits(e.number)}=${CLASS_RANK[e.outboundClass]}`);
|
||||||
|
|||||||
@@ -13,16 +13,38 @@ exten => s,1,Set(N=\${FILTER(0123456789+,\${ARG1})})
|
|||||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||||
[sp-sub-dial-user]
|
[sp-sub-dial-user]
|
||||||
exten => s,1,Set(DS=\${PJSIP_DIAL_CONTACTS(\${ARG1})})
|
exten => s,1,Set(DS=)
|
||||||
same => n,Set(WS=\${PJSIP_DIAL_CONTACTS(\${ARG1}-web)})
|
same => n,Gosub(sp-sub-contacts,s,1(\${ARG1}))
|
||||||
same => n,ExecIf($["\${DS}" != "" & "\${WS}" != ""]?Set(DS=\${DS}&\${WS}))
|
same => n,Gosub(sp-sub-contacts,s,1(\${ARG1}-web))
|
||||||
same => n,ExecIf($["\${DS}" = ""]?Set(DS=\${WS}))
|
|
||||||
same => n,GotoIf($["\${DS}" = ""]?unavail)
|
same => n,GotoIf($["\${DS}" = ""]?unavail)
|
||||||
same => n,Dial(\${DS},\${ARG2},tx)
|
same => n,Dial(\${DS},\${ARG2},tx)
|
||||||
same => n,Return()
|
same => n,Return()
|
||||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||||
same => n,Return()
|
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.
|
; ─── Call a user with all their personal rules.
|
||||||
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
||||||
[sp-sub-user]
|
[sp-sub-user]
|
||||||
@@ -64,11 +86,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[\${IF($["\${SP_FWDHOPS}" = ""]?0:\${SP_FWDHOPS})
|
|||||||
same => n(stop),Return()
|
same => n(stop),Return()
|
||||||
|
|
||||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
; ─── 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]
|
[sp-sub-record]
|
||||||
exten => s,1,GotoIf($["\${SP_REC}" != ""]?done)
|
exten => s,1,GotoIf($["\${SP_REC}" != ""]?done)
|
||||||
same => n,Set(__SP_REC=\${STRFTIME(\${EPOCH},,%Y/%m/%d)}/\${UNIQUEID}.wav)
|
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,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,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()
|
same => n(done),Return()
|
||||||
|
|
||||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||||
@@ -90,14 +117,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
|||||||
same => n,SayDigits(\${EXTEN})
|
same => n,SayDigits(\${EXTEN})
|
||||||
same => n,Hangup()
|
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).
|
; ─── Feature codes (03 §6.13).
|
||||||
[sp-featurecodes]
|
[sp-featurecodes]
|
||||||
exten => *97,1,Answer() ; my voicemail
|
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,VoiceMailMain(\${SP_EXT}@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
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,VoiceMailMain(@default)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
same => n(full),Playback(an-error-has-occurred)
|
||||||
|
same => n,Hangup()
|
||||||
exten => *78,1,Answer() ; DND on
|
exten => *78,1,Answer() ; DND on
|
||||||
same => n,Set(DB(SP/DND/\${SP_EXT})=1)
|
same => n,Set(DB(SP/DND/\${SP_EXT})=1)
|
||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: \${SP_EXT},Value: 1)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: \${SP_EXT},Value: 1)
|
||||||
@@ -108,7 +156,7 @@ exten => *79,1,Answer() ; DND off
|
|||||||
same => n,UserEvent(SpToggle,Key: DND,Ext: \${SP_EXT},Value: 0)
|
same => n,UserEvent(SpToggle,Key: DND,Ext: \${SP_EXT},Value: 0)
|
||||||
same => n,Playback(do-not-disturb&de-activated)
|
same => n,Playback(do-not-disturb&de-activated)
|
||||||
same => n,Hangup()
|
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,Gosub(sp-sub-normalize,s,1(\${EXTEN:3}))
|
||||||
same => n,Set(DB(SP/CFU/\${SP_EXT})=\${GOSUB_RETVAL})
|
same => n,Set(DB(SP/CFU/\${SP_EXT})=\${GOSUB_RETVAL})
|
||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: \${SP_EXT},Value: \${GOSUB_RETVAL})
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: \${SP_EXT},Value: \${GOSUB_RETVAL})
|
||||||
@@ -119,7 +167,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
|||||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: \${SP_EXT},Value: )
|
same => n,UserEvent(SpToggle,Key: CFU,Ext: \${SP_EXT},Value: )
|
||||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||||
same => n,Hangup()
|
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,PickupChan(PJSIP/\${T}-&PJSIP/\${T}-web-) ; directed pickup **101
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||||
@@ -134,7 +182,7 @@ exten => *43,1,Answer() ; echo test
|
|||||||
exten => *65,1,Answer() ; say my extension
|
exten => *65,1,Answer() ; say my extension
|
||||||
same => n,SayDigits(\${SP_EXT})
|
same => n,SayDigits(\${SP_EXT})
|
||||||
same => n,Hangup()
|
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(Q=\${FILTER(0-9,\${EXTEN:3})})
|
||||||
same => n,Set(K=SP/QPAUSE/\${Q}/\${SP_EXT})
|
same => n,Set(K=SP/QPAUSE/\${Q}/\${SP_EXT})
|
||||||
same => n,GotoIf($["\${DB(\${K})}" = "1"]?login)
|
same => n,GotoIf($["\${DB(\${K})}" = "1"]?login)
|
||||||
@@ -152,7 +200,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
same => n(deny),Playback(ss-noservice)
|
same => n(deny),Playback(ss-noservice)
|
||||||
same => n,Hangup()
|
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($["\${T}" != "\${EXTEN:3}"]?deny)
|
||||||
same => n,GotoIf($["\${PJSIP_ENDPOINT(\${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
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,Page(PJSIP/\${T},db(sp-sub-autoanswer^s^1),30)
|
||||||
@@ -168,7 +216,7 @@ exten => _*3[123]X,1,Answer()
|
|||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-vm-direct]
|
[sp-vm-direct]
|
||||||
exten => _X.,1,Answer()
|
exten => _X!,1,Answer()
|
||||||
same => n,VoiceMail(\${EXTEN}@default,u)
|
same => n,VoiceMail(\${EXTEN}@default,u)
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
@@ -178,12 +226,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
|||||||
same => n,Goto(sp-outbound-routes,\${EXTEN},1)
|
same => n,Goto(sp-outbound-routes,\${EXTEN},1)
|
||||||
|
|
||||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
[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,Gosub(sp-sub-dial-user,s,1(\${EXTEN},600))
|
||||||
same => n(skip),Hangup()
|
same => n(skip),Hangup()
|
||||||
|
|
||||||
[sp-queue-member] ; one queue agent (Local channel)
|
[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()
|
same => n,Hangup()
|
||||||
|
|
||||||
[sp-from-ext]
|
[sp-from-ext]
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function loadCase(name: string): Snapshot {
|
|||||||
timeConditions?: Partial<Snapshot['timeConditions'][number]>[];
|
timeConditions?: Partial<Snapshot['timeConditions'][number]>[];
|
||||||
holidays?: Snapshot['holidays'];
|
holidays?: Snapshot['holidays'];
|
||||||
announcements?: Snapshot['announcements'];
|
announcements?: Snapshot['announcements'];
|
||||||
mohClasses?: string[];
|
mohClasses?: { name: string; shuffle?: boolean; files: string[] }[];
|
||||||
};
|
};
|
||||||
const settings = Object.fromEntries(
|
const settings = Object.fromEntries(
|
||||||
(Object.keys(SettingsSchemas) as SettingKey[])
|
(Object.keys(SettingsSchemas) as SettingKey[])
|
||||||
@@ -117,7 +117,7 @@ export function loadCase(name: string): Snapshot {
|
|||||||
id: c.id!,
|
id: c.id!,
|
||||||
number: c.number!,
|
number: c.number!,
|
||||||
})),
|
})),
|
||||||
mohClasses: raw.mohClasses ?? [],
|
mohClasses: (raw.mohClasses ?? []).map((c) => ({ shuffle: false, ...c })),
|
||||||
pagingGroups: (raw.pagingGroups ?? []).map((g, i) => ({
|
pagingGroups: (raw.pagingGroups ?? []).map((g, i) => ({
|
||||||
name: `Paging ${i + 1}`,
|
name: `Paging ${i + 1}`,
|
||||||
duplex: false,
|
duplex: false,
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ export type GeneratorDeps = {
|
|||||||
log: Pick<FastifyBaseLogger, 'info' | 'error'>;
|
log: Pick<FastifyBaseLogger, 'info' | 'error'>;
|
||||||
onApplied?: (r: ApplyResult) => void;
|
onApplied?: (r: ApplyResult) => void;
|
||||||
debounceMs?: number;
|
debounceMs?: number;
|
||||||
|
/** RTP_PORT_START/END (config.ts). */
|
||||||
|
rtpPorts?: { start: number; end: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
|
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
|
||||||
@@ -165,7 +167,7 @@ export function createGenerator(deps: GeneratorDeps) {
|
|||||||
restartRequired: false,
|
restartRequired: false,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const snap = await loadSnapshot(db);
|
const snap = { ...(await loadSnapshot(db)), rtpPorts: deps.rtpPorts };
|
||||||
const files = render(snap);
|
const files = render(snap);
|
||||||
await mkdir(join(dir, '.prev'), { recursive: true });
|
await mkdir(join(dir, '.prev'), { recursive: true });
|
||||||
const current = new Map<GeneratedFileName, string | null>();
|
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
|
* 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).
|
* 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). */
|
/** Played before recording a call that a phone number records (Settings → Recording). */
|
||||||
recordingAnnouncement: string | null;
|
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 {
|
export function todayIn(timezone: string, now = new Date()): string {
|
||||||
// en-CA formats as YYYY-MM-DD.
|
// en-CA formats as YYYY-MM-DD.
|
||||||
return new Intl.DateTimeFormat('en-CA', { timeZone: timezone }).format(now);
|
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 holidayRows = await db.select().from(holidays);
|
||||||
const annRows = await db.select().from(announcements);
|
const annRows = await db.select().from(announcements);
|
||||||
const mohRows = await db
|
const mohRows = await db
|
||||||
.selectDistinct({ name: mohClasses.name })
|
.select({ name: mohClasses.name, shuffle: mohClasses.shuffle, file: soundFiles.id })
|
||||||
.from(mohClasses)
|
.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 optionRows = await db.select().from(ivrOptions);
|
||||||
const soundName = new Map(
|
const soundName = new Map(
|
||||||
(await db.select({ id: soundFiles.id, name: soundFiles.asteriskName }).from(soundFiles)).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,
|
maxMembers: c.maxMembers,
|
||||||
record: c.record,
|
record: c.record,
|
||||||
})),
|
})),
|
||||||
mohClasses: mohRows.map((m) => m.name),
|
mohClasses: [...moh.values()],
|
||||||
pagingGroups: pageRows.map((g) => ({
|
pagingGroups: pageRows.map((g) => ({
|
||||||
id: g.id,
|
id: g.id,
|
||||||
number: g.number,
|
number: g.number,
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
// musiconhold_selfphoneng.conf (03 §8): one class per moh_classes row, a folder Asterisk plays in
|
// musiconhold_selfphoneng.conf (03 §8): one class per moh_classes row that has music, as a
|
||||||
// random order. [default] lives in the static musiconhold.conf (01 §3.11).
|
// 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 type { Snapshot } from './load.ts';
|
||||||
import * as s from './sanitize.ts';
|
import * as s from './sanitize.ts';
|
||||||
|
|
||||||
export function renderMoh(snap: Snapshot): string {
|
export function renderMoh(snap: Snapshot): string {
|
||||||
return [...snap.mohClasses]
|
return [...snap.mohClasses]
|
||||||
.sort()
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
.map((name) =>
|
.map((c) =>
|
||||||
[
|
[
|
||||||
`[${s.ident(name)}]`,
|
`[${s.ident(c.name)}]`,
|
||||||
'mode = files',
|
'mode = playlist',
|
||||||
`directory = /var/lib/asterisk/moh/${s.ident(name)}`,
|
// No extension: Asterisk picks the format, as it does for files mode.
|
||||||
'sort = random',
|
...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'),
|
].join('\n'),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
// pjsip_selfphoneng.conf (03 §5) and rtp_selfphoneng.conf (03 §4).
|
// pjsip_selfphoneng.conf (03 §5) and rtp_selfphoneng.conf (03 §4).
|
||||||
import { CLASS_RANK } from '@selfphoneng/shared';
|
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';
|
import * as s from './sanitize.ts';
|
||||||
|
|
||||||
const section = (title: string, lines: (string | false | null | undefined)[]) =>
|
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 {
|
export function renderRtp(snap: Snapshot): string {
|
||||||
const st = snap.settings;
|
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.stunServer']) lines.push(`stunaddr = ${hostPort(st['network.stunServer'])}`);
|
||||||
if (st['network.lanIp'] && st['network.publicIp'])
|
if (st['network.lanIp'] && st['network.publicIp'])
|
||||||
lines.push(
|
lines.push(
|
||||||
|
|||||||
+26
-1
@@ -5,7 +5,8 @@ import { AmiClient } from './ami/client.ts';
|
|||||||
import { buildApp } from './app.ts';
|
import { buildApp } from './app.ts';
|
||||||
import { ConfigError, loadConfig } from './config.ts';
|
import { ConfigError, loadConfig } from './config.ts';
|
||||||
import { setSecretKey } from './crypto/secrets.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 { clearRestartIfRestarted, createGenerator } from './generator/index.ts';
|
||||||
import { ensureInitialAdmin } from './services/bootstrap.ts';
|
import { ensureInitialAdmin } from './services/bootstrap.ts';
|
||||||
import { LiveState } from './live/state.ts';
|
import { LiveState } from './live/state.ts';
|
||||||
@@ -45,6 +46,7 @@ const generator = createGenerator({
|
|||||||
db,
|
db,
|
||||||
ami,
|
ami,
|
||||||
dir: resolve(config.ASTERISK_CONFIG_DIR),
|
dir: resolve(config.ASTERISK_CONFIG_DIR),
|
||||||
|
rtpPorts: { start: config.RTP_PORT_START, end: config.RTP_PORT_END },
|
||||||
log: app.log,
|
log: app.log,
|
||||||
onApplied: (r) => {
|
onApplied: (r) => {
|
||||||
live.emit('message', {
|
live.emit('message', {
|
||||||
@@ -82,6 +84,29 @@ app.decorate('live', live);
|
|||||||
watchWebhooks(db, live, app.log);
|
watchWebhooks(db, live, app.log);
|
||||||
watchEvents(db, live, ami, 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);
|
await runMigrations(db);
|
||||||
app.log.info('database migrated');
|
app.log.info('database migrated');
|
||||||
const admin = await ensureInitialAdmin(
|
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 { todayIn } from './generator/load.ts';
|
||||||
import type { LiveState } from './live/state.ts';
|
import type { LiveState } from './live/state.ts';
|
||||||
import { cleanupRecordings } from './routes/recordings.ts';
|
import { cleanupRecordings } from './routes/recordings.ts';
|
||||||
|
import { transcribeCalls } from './ai/calls.ts';
|
||||||
import { audit } from './services/audit.ts';
|
import { audit } from './services/audit.ts';
|
||||||
import { backupDue, runBackup } from './services/backup.ts';
|
import { backupDue, runBackup } from './services/backup.ts';
|
||||||
import { logEvent } from './services/events.ts';
|
import { logEvent } from './services/events.ts';
|
||||||
@@ -258,6 +259,9 @@ export function startJobs(d: JobDeps): () => void {
|
|||||||
run('trunkWatch', j.trunkWatch);
|
run('trunkWatch', j.trunkWatch);
|
||||||
if (minute % 5 === 0) run('publicIpWatch', j.publicIpWatch);
|
if (minute % 5 === 0) run('publicIpWatch', j.publicIpWatch);
|
||||||
if (minute % 60 === 0) run('diskWatch', j.diskWatch);
|
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++;
|
minute++;
|
||||||
};
|
};
|
||||||
tick();
|
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. */
|
/** "101" and "101-web" both belong to extension 101; trunks and others don't match. */
|
||||||
export const extOfEndpoint = (endpoint: string): string | null =>
|
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. */
|
/** "trunk-<uuid>", "trunk-<uuid>-reg" → the trunk id. */
|
||||||
export const trunkOfObject = (name: string): string | null =>
|
export const trunkOfObject = (name: string): string | null =>
|
||||||
|
|||||||
@@ -58,6 +58,27 @@ linekey.30.type = 0
|
|||||||
linekey.31.type = 0
|
linekey.31.type = 0
|
||||||
linekey.32.type = 0
|
linekey.32.type = 0
|
||||||
linekey.33.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
|
## time
|
||||||
local_time.time_zone = -6
|
local_time.time_zone = -6
|
||||||
local_time.time_zone_name = United States-Central Time
|
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 type { Db } from '../db/client.ts';
|
||||||
import { contacts, devices, extensions } from '../db/schema.ts';
|
import { contacts, devices, extensions } from '../db/schema.ts';
|
||||||
import { getSetting } from '../services/settings.ts';
|
import { getSetting } from '../services/settings.ts';
|
||||||
|
import { loadDialRules, type DialRule } from './dialplan.ts';
|
||||||
|
|
||||||
export type ProvisionContext = {
|
export type ProvisionContext = {
|
||||||
mac: string;
|
mac: string;
|
||||||
@@ -26,6 +27,8 @@ export type ProvisionContext = {
|
|||||||
/** The phonebook with this phone's credentials in it (phones fetch it like the config). */
|
/** The phonebook with this phone's credentials in it (phones fetch it like the config). */
|
||||||
phonebookUrl: string;
|
phonebookUrl: string;
|
||||||
ntpServer: 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';
|
export const NTP_SERVER = 'pool.ntp.org';
|
||||||
@@ -77,6 +80,7 @@ export async function loadContext(
|
|||||||
provPassword,
|
provPassword,
|
||||||
phonebookUrl: `https://${d.mac}:${provPassword}@${config.PUBLIC_HOSTNAME}/prov/phonebook/${d.mac}.xml`,
|
phonebookUrl: `https://${d.mac}:${provPassword}@${config.PUBLIC_HOSTNAME}/prov/phonebook/${d.mac}.xml`,
|
||||||
ntpServer: NTP_SERVER,
|
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 { describe, expect, it } from 'vitest';
|
||||||
import { phoneTimezone } from '@selfphoneng/shared';
|
import { phoneTimezone } from '@selfphoneng/shared';
|
||||||
import type { PhonebookEntry, ProvisionContext } from './context.ts';
|
import type { PhonebookEntry, ProvisionContext } from './context.ts';
|
||||||
|
import { dialRules } from './dialplan.ts';
|
||||||
import { grandstream, grandstreamPhonebook } from './templates/grandstream.ts';
|
import { grandstream, grandstreamPhonebook } from './templates/grandstream.ts';
|
||||||
import { yealink, yealinkPhonebook } from './templates/yealink.ts';
|
import { yealink, yealinkPhonebook } from './templates/yealink.ts';
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ const ctx = (over: Partial<ProvisionContext> = {}): ProvisionContext => ({
|
|||||||
phonebookUrl:
|
phonebookUrl:
|
||||||
'https://001565abcdef:Pr0vPassw0rdXyZ12345@pbx.example.com/prov/phonebook/001565abcdef.xml',
|
'https://001565abcdef:Pr0vPassw0rdXyZ12345@pbx.example.com/prov/phonebook/001565abcdef.xml',
|
||||||
ntpServer: 'pool.ntp.org',
|
ntpServer: 'pool.ntp.org',
|
||||||
|
dialRules: dialRules(['NXXNXXXXXX', '1NXXNXXXXXX'], [3]),
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
const book: PhonebookEntry[] = [
|
const book: PhonebookEntry[] = [
|
||||||
@@ -53,6 +55,8 @@ describe('phone templates', () => {
|
|||||||
expect(out).toContain('linekey.4.type = 0');
|
expect(out).toContain('linekey.4.type = 0');
|
||||||
expect(out).toContain('linekey.33.type = 0');
|
expect(out).toContain('linekey.33.type = 0');
|
||||||
expect(out).not.toContain('linekey.34');
|
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 () => {
|
it('Grandstream config: XML-escaped values', async () => {
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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).
|
// 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 { 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;
|
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. */
|
/** Line key 1 is the line; BLF keys are 2…; keys past the last BLF up to here are cleared. */
|
||||||
const LAST_KEY = 33;
|
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 {
|
export function yealink(c: ProvisionContext): string {
|
||||||
const v = oneLine;
|
const v = oneLine;
|
||||||
|
const now = dialNowRules(c.dialRules).slice(0, LAST_DIALNOW);
|
||||||
return [
|
return [
|
||||||
'#!version:1.0.0.1',
|
'#!version:1.0.0.1',
|
||||||
'## account 1',
|
'## account 1',
|
||||||
@@ -40,6 +44,12 @@ export function yealink(c: ProvisionContext): string {
|
|||||||
{ length: LAST_KEY - c.blf.length - 1 },
|
{ length: LAST_KEY - c.blf.length - 1 },
|
||||||
(_, i) => `linekey.${i + c.blf.length + 2}.type = 0`,
|
(_, 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',
|
'## time',
|
||||||
`local_time.time_zone = ${c.timezone.yealinkOffset}`,
|
`local_time.time_zone = ${c.timezone.yealinkOffset}`,
|
||||||
`local_time.time_zone_name = ${c.timezone.yealinkName}`,
|
`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) => {
|
app.post('/api/auth/totp/enable', { preHandler: requireAuth }, async (req) => {
|
||||||
adminOnly(req.user!);
|
adminOnly(req.user!);
|
||||||
const { secret, code } = TotpEnableSchema.parse(req.body);
|
const { secret, code, password } = TotpEnableSchema.parse(req.body);
|
||||||
if ((await myRow(req.user!.id)).totpSecretEnc) throw conflict('Two-step sign-in is already on');
|
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);
|
const step = checkTotp(secret, code, null);
|
||||||
if (step === null)
|
if (step === null)
|
||||||
throw badRequest('That code doesn’t match. Check the phone’s clock and try again.');
|
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 };
|
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) {
|
async function confirmPassword(id: string, body: unknown) {
|
||||||
const { password } = PasswordConfirmSchema.parse(body);
|
const { password } = PasswordConfirmSchema.parse(body);
|
||||||
const u = await myRow(id);
|
const u = await myRow(id);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { assertMohClass } from './moh.ts';
|
|||||||
import { softphoneQr } from '../provisioning/linphone.ts';
|
import { softphoneQr } from '../provisioning/linphone.ts';
|
||||||
import { findReferences } from '../services/destinations.ts';
|
import { findReferences } from '../services/destinations.ts';
|
||||||
import { getSetting } from '../services/settings.ts';
|
import { getSetting } from '../services/settings.ts';
|
||||||
|
import { grandstreamDialPlan, loadDialRules } from '../provisioning/dialplan.ts';
|
||||||
|
|
||||||
type ExtRow = typeof extensions.$inferSelect;
|
type ExtRow = typeof extensions.$inferSelect;
|
||||||
|
|
||||||
@@ -243,6 +244,7 @@ export async function extensionRoutes(app: FastifyInstance) {
|
|||||||
username: e.number,
|
username: e.number,
|
||||||
password: decrypt(e.sipPasswordEnc),
|
password: decrypt(e.sipPasswordEnc),
|
||||||
webUsername: e.webrtcEnabled ? `${e.number}-web` : null,
|
webUsername: e.webrtcEnabled ? `${e.number}-web` : null,
|
||||||
|
dialPlan: grandstreamDialPlan(await loadDialRules(db)),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
describeDestination,
|
describeDestination,
|
||||||
findReferences,
|
findReferences,
|
||||||
} from '../services/destinations.ts';
|
} from '../services/destinations.ts';
|
||||||
import { assertNumberAvailable } from '../services/numbering.ts';
|
import { assertNumberAvailable, ownerOf } from '../services/numbering.ts';
|
||||||
|
|
||||||
type Row = typeof ivrs.$inferSelect;
|
type Row = typeof ivrs.$inferSelect;
|
||||||
type Tx = Parameters<Parameters<Db['transaction']>[0]>[0];
|
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)));
|
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) => {
|
app.post('/api/ivrs', async (req, reply) => {
|
||||||
const { options = [], ...body } = IvrCreateSchema.parse(req.body);
|
const { options = [], ...body } = IvrCreateSchema.parse(req.body);
|
||||||
await assertNumberAvailable(db, body.number, 'ivr');
|
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.greetingSoundId);
|
||||||
await assertPrompt(db, body.invalidSoundId);
|
await assertPrompt(db, body.invalidSoundId);
|
||||||
await assertDestinations(db, [
|
await assertDestinations(db, [
|
||||||
@@ -139,6 +156,15 @@ export async function ivrRoutes(app: FastifyInstance) {
|
|||||||
const before = await getIvr(id);
|
const before = await getIvr(id);
|
||||||
if (body.number && body.number !== before.number)
|
if (body.number && body.number !== before.number)
|
||||||
await assertNumberAvailable(db, body.number, 'ivr', id);
|
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.greetingSoundId);
|
||||||
await assertPrompt(db, body.invalidSoundId);
|
await assertPrompt(db, body.invalidSoundId);
|
||||||
// Pointing at itself is fine here: the IVR exists.
|
// Pointing at itself is fine here: the IVR exists.
|
||||||
|
|||||||
@@ -7,11 +7,12 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { mkdir, rm } from 'node:fs/promises';
|
import { mkdir, rm } from 'node:fs/promises';
|
||||||
import { resolve } from 'node:path';
|
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 type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import {
|
import {
|
||||||
MohClassCreateSchema,
|
MohClassCreateSchema,
|
||||||
|
MohClassUpdateSchema,
|
||||||
SOUND_MAX_BYTES,
|
SOUND_MAX_BYTES,
|
||||||
SoundName,
|
SoundName,
|
||||||
type ListResponse,
|
type ListResponse,
|
||||||
@@ -38,10 +39,11 @@ async function toDto(db: Db, c: ClassRow): Promise<MohClassDto> {
|
|||||||
.select()
|
.select()
|
||||||
.from(soundFiles)
|
.from(soundFiles)
|
||||||
.where(eq(soundFiles.mohClassId, c.id))
|
.where(eq(soundFiles.mohClassId, c.id))
|
||||||
.orderBy(asc(soundFiles.name));
|
.orderBy(asc(soundFiles.position), asc(soundFiles.createdAt));
|
||||||
return {
|
return {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
|
shuffle: c.shuffle,
|
||||||
files: files.map((f) => ({
|
files: files.map((f) => ({
|
||||||
id: f.id,
|
id: f.id,
|
||||||
name: f.name,
|
name: f.name,
|
||||||
@@ -79,6 +81,14 @@ export async function mohRoutes(app: FastifyInstance) {
|
|||||||
.catch((e: unknown) => app.log.warn({ err: e }, 'moh reload'));
|
.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) {
|
async function getClass(id: string) {
|
||||||
const [c] = await db.select().from(mohClasses).where(eq(mohClasses.id, id));
|
const [c] = await db.select().from(mohClasses).where(eq(mohClasses.id, id));
|
||||||
if (!c) throw notFound('Music on hold not found');
|
if (!c) throw notFound('Music on hold not found');
|
||||||
@@ -172,6 +182,8 @@ export async function mohRoutes(app: FastifyInstance) {
|
|||||||
source: 'upload',
|
source: 'upload',
|
||||||
originalFilename: filename ?? null,
|
originalFilename: filename ?? null,
|
||||||
asteriskName: `moh/${c.name}/${id}`,
|
asteriskName: `moh/${c.name}/${id}`,
|
||||||
|
// Plays last, after the ones already there.
|
||||||
|
position: await nextPosition(c.id),
|
||||||
durationMs: (await durationMs(file)) ?? 0,
|
durationMs: (await durationMs(file)) ?? 0,
|
||||||
});
|
});
|
||||||
await audit(db, {
|
await audit(db, {
|
||||||
@@ -187,6 +199,43 @@ export async function mohRoutes(app: FastifyInstance) {
|
|||||||
return reply.code(201).send(await toDto(db, c));
|
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) => {
|
app.get('/api/moh-classes/:id/files/:fileId/audio', async (req, reply) => {
|
||||||
const { id, fileId } = FileParams.parse(req.params);
|
const { id, fileId } = FileParams.parse(req.params);
|
||||||
const c = await getClass(id);
|
const c = await getClass(id);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
// GET /prov/... (07 §2): phones fetch their config with HTTP Basic auth (user = MAC, password =
|
// 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
|
// the device's provisioning password). No session. Unknown MAC or bad auth → 401, alike; wrong
|
||||||
// logged with the IP and count toward an IP block (provisioning/limiter.ts).
|
// credentials are logged with the IP and count toward an IP block (provisioning/limiter.ts).
|
||||||
import { timingSafeEqual } from 'node:crypto';
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
import { BlockList, isIPv4 } from 'node:net';
|
import { BlockList, isIPv4 } from 'node:net';
|
||||||
import { eq, sql } from 'drizzle-orm';
|
import { eq, sql } from 'drizzle-orm';
|
||||||
@@ -113,24 +113,28 @@ export async function provisioningRoutes(app: FastifyInstance) {
|
|||||||
if (!kind) return deny(reply, 404, 'Not found');
|
if (!kind) return deny(reply, 404, 'Not found');
|
||||||
const mac = kind.m![1]!;
|
const mac = kind.m![1]!;
|
||||||
|
|
||||||
const found = await loadContext(db, app.config, mac);
|
const loaded = await loadContext(db, app.config, mac);
|
||||||
if (
|
const found =
|
||||||
!found ||
|
loaded &&
|
||||||
found.ctx.vendor === 'generic' ||
|
loaded.ctx.vendor !== 'generic' &&
|
||||||
(kind.f.vendor && found.ctx.vendor !== kind.f.vendor)
|
(!kind.f.vendor || loaded.ctx.vendor === kind.f.vendor)
|
||||||
) {
|
? loaded
|
||||||
recordProvFailure(req.ip);
|
: null;
|
||||||
req.log.warn({ ip: req.ip, mac }, 'provisioning: unknown MAC');
|
|
||||||
return deny(reply, 404, 'Not found');
|
|
||||||
}
|
|
||||||
const auth = basicAuth(req);
|
const auth = basicAuth(req);
|
||||||
const ok =
|
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) {
|
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) {
|
if (auth) {
|
||||||
recordProvFailure(req.ip);
|
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
|
return reply
|
||||||
.code(401)
|
.code(401)
|
||||||
|
|||||||
@@ -3,18 +3,28 @@
|
|||||||
// routes. Admins see all; a user may play the recordings of calls they took part in.
|
// routes. Admins see all; a user may play the recordings of calls they took part in.
|
||||||
import { rm } from 'node:fs/promises';
|
import { rm } from 'node:fs/promises';
|
||||||
import { join, resolve } from 'node:path';
|
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 type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
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 { requireAdmin, requireAuth } from '../auth/guards.ts';
|
||||||
import type { Db } from '../db/client.ts';
|
import type { Db } from '../db/client.ts';
|
||||||
import { callExtensions, calls, extensions, recordings } from '../db/schema.ts';
|
import { callExtensions, calls, extensions, recordings } from '../db/schema.ts';
|
||||||
import { badRequest, forbidden, notFound } from '../errors.ts';
|
import { badRequest, forbidden, notFound } from '../errors.ts';
|
||||||
import { audit } from '../services/audit.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 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). */
|
/** 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> {
|
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 })
|
.select({ id: recordings.id, filePath: recordings.filePath })
|
||||||
.from(recordings)
|
.from(recordings)
|
||||||
.where(lt(recordings.startedAt, sql`now() - make_interval(days => ${days})`));
|
.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)
|
if (old.length)
|
||||||
await db.delete(recordings).where(
|
await db.delete(recordings).where(
|
||||||
inArray(
|
inArray(
|
||||||
@@ -34,6 +44,9 @@ export async function cleanupRecordings(db: Db, dir: string, days: number): Prom
|
|||||||
return old.length;
|
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) {
|
export async function recordingRoutes(app: FastifyInstance) {
|
||||||
const { db } = app;
|
const { db } = app;
|
||||||
const dir = resolve(app.config.RECORDINGS_DIR);
|
const dir = resolve(app.config.RECORDINGS_DIR);
|
||||||
@@ -48,14 +61,24 @@ export async function recordingRoutes(app: FastifyInstance) {
|
|||||||
'/api/recordings',
|
'/api/recordings',
|
||||||
{ preHandler: requireAdmin },
|
{ preHandler: requireAdmin },
|
||||||
async (req): Promise<ListResponse<RecordingDto>> => {
|
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;
|
const before = cursor ? new Date(Buffer.from(cursor, 'base64url').toString()) : null;
|
||||||
if (before && Number.isNaN(before.getTime())) throw badRequest('Bad cursor');
|
if (before && Number.isNaN(before.getTime())) throw badRequest('Bad cursor');
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ r: recordings, c: calls })
|
.select({ r: recordings, c: calls })
|
||||||
.from(recordings)
|
.from(recordings)
|
||||||
.leftJoin(calls, eq(calls.recordingId, recordings.id))
|
.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))
|
.orderBy(desc(recordings.startedAt))
|
||||||
.limit(51);
|
.limit(51);
|
||||||
const items = rows.slice(0, 50);
|
const items = rows.slice(0, 50);
|
||||||
@@ -65,6 +88,9 @@ export async function recordingRoutes(app: FastifyInstance) {
|
|||||||
startedAt: r.startedAt.toISOString(),
|
startedAt: r.startedAt.toISOString(),
|
||||||
durationMs: r.durationMs,
|
durationMs: r.durationMs,
|
||||||
sizeBytes: r.sizeBytes,
|
sizeBytes: r.sizeBytes,
|
||||||
|
summary: r.summary,
|
||||||
|
hasTranscript: r.transcript !== null,
|
||||||
|
aiError: r.aiError,
|
||||||
call: c
|
call: c
|
||||||
? {
|
? {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
@@ -83,19 +109,32 @@ export async function recordingRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
app.get('/api/recordings/:id/audio', { preHandler: requireAuth }, async (req, reply) => {
|
/** Admins, or a participant: their extension is on the call this recording belongs to. */
|
||||||
const r = await getRecording(IdParams.parse(req.params).id);
|
async function mayHear(user: { id: string; role: string }, recordingId: string) {
|
||||||
if (req.user!.role !== 'admin') {
|
if (user.role === 'admin') return;
|
||||||
// Only a participant: their extension is on the call this recording belongs to.
|
|
||||||
const [mine] = await db
|
const [mine] = await db
|
||||||
.select({ id: calls.id })
|
.select({ id: calls.id })
|
||||||
.from(calls)
|
.from(calls)
|
||||||
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
|
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
|
||||||
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
|
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
|
||||||
.where(and(eq(calls.recordingId, r.id), eq(extensions.userId, req.user!.id)))
|
.where(and(eq(calls.recordingId, recordingId), eq(extensions.userId, user.id)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!mine) throw forbidden('Not your call');
|
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);
|
||||||
|
await mayHear(req.user!, r.id);
|
||||||
if ((req.query as { download?: string }).download)
|
if ((req.query as { download?: string }).download)
|
||||||
reply.header(
|
reply.header(
|
||||||
'content-disposition',
|
'content-disposition',
|
||||||
@@ -106,7 +145,7 @@ export async function recordingRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
app.delete('/api/recordings/:id', { preHandler: requireAdmin }, async (req) => {
|
app.delete('/api/recordings/:id', { preHandler: requireAdmin }, async (req) => {
|
||||||
const r = await getRecording(IdParams.parse(req.params).id);
|
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 db.delete(recordings).where(eq(recordings.id, r.id)); // calls.recording_id → null
|
||||||
await audit(db, {
|
await audit(db, {
|
||||||
actorUserId: req.user!.id,
|
actorUserId: req.user!.id,
|
||||||
|
|||||||
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user