Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10b499e263 | ||
|
|
34f443385d | ||
|
|
f435b475ed | ||
|
|
1979a02827 | ||
|
|
08bc611dec | ||
|
|
63286a1954 | ||
|
|
6bd2aaec56 | ||
|
|
80e97b4118 | ||
|
|
0a18d5354d | ||
|
|
a0121d4975 | ||
|
|
678d45e4f9 | ||
|
|
60348cf0c5 | ||
|
|
958405651b | ||
|
|
db244b116a | ||
|
|
1db5aa22dc | ||
|
|
8a1f17099d | ||
|
|
a75113b6ac | ||
|
|
2bc7d5cde5 | ||
|
|
19ca4ffc91 | ||
|
|
1bc0fe8811 | ||
|
|
add90204c0 | ||
|
|
4369dd5ae1 | ||
|
|
3d0f091bba | ||
|
|
5db4914fa7 | ||
|
|
fd3f06c167 | ||
|
|
79bb1fa78b | ||
|
|
11b531d5b4 | ||
|
|
70083f8f83 | ||
|
|
6b0c5aa827 | ||
|
|
e05065599e | ||
|
|
49d529e185 | ||
|
|
70f14288ca | ||
|
|
6f87d6f663 | ||
|
|
a3dfcb6b14 | ||
|
|
dc5965617e | ||
|
|
a614319990 | ||
|
|
33d5f1a5f5 | ||
|
|
431ada51aa | ||
|
|
7da0611a98 | ||
|
|
0cdac0bcdc | ||
|
|
adb6d52ddf | ||
|
|
7722f33639 | ||
|
|
97e67e347d | ||
|
|
19610777a4 | ||
|
|
b06335586b | ||
|
|
73963ee3a3 | ||
|
|
06b3a73edc | ||
|
|
3a9423af1f | ||
|
|
ba293f8703 | ||
|
|
95561f651f | ||
|
|
53aefaa5d7 | ||
|
|
604e40adf1 | ||
|
|
e6a1373f36 | ||
|
|
b05361e840 | ||
|
|
692dd59552 | ||
|
|
a754974a90 | ||
|
|
32f4590369 | ||
|
|
20d27232ac | ||
|
|
4c006f2388 | ||
|
|
46cc8c2002 | ||
|
|
aaa294ae6a | ||
|
|
4d331fb70e | ||
|
|
d9f9c6e444 | ||
|
|
a26a5ca740 | ||
|
|
274242ce32 | ||
|
|
0deff2a637 | ||
|
|
eacc571093 | ||
|
|
4a175a0d77 | ||
|
|
f2868cf5d3 | ||
|
|
6893c55e1b | ||
|
|
e54e8a1576 | ||
|
|
338ab8d7f1 | ||
|
|
175d5a2c73 | ||
|
|
aa6681da03 | ||
|
|
4143f6d064 | ||
|
|
65a0438dde | ||
|
|
90b3081368 | ||
|
|
22b3ef9a8c | ||
|
|
e2518b03d8 | ||
|
|
ff59bc7799 | ||
|
|
4d9b5dab8a | ||
|
|
54e1e201a1 | ||
|
|
8d5475cfbf |
No files matched your search
@@ -39,3 +39,4 @@
|
||||
**/*.key
|
||||
**/*.log
|
||||
**/*.pid
|
||||
docs/voip.ms-api
|
||||
@@ -7,6 +7,7 @@ INITIAL_ADMIN_EMAIL=admin@example.com
|
||||
INITIAL_ADMIN_PASSWORD=change-me # only used if no admin exists yet
|
||||
|
||||
# --- optional ---
|
||||
CONTRACK_HOSTNAME= # Contrack contacts (spec 11 §4): its own DNS name, e.g. contacts.example.com
|
||||
TZ=America/Chicago
|
||||
API_PORT=8080
|
||||
POSTGRES_PORT=5433
|
||||
@@ -14,3 +15,18 @@ APP_UID=1000
|
||||
APP_GID=1000
|
||||
LOG_LEVEL=info
|
||||
TRUST_PROXY=true # true when behind Caddy/nginx
|
||||
RATE_LIMIT_PER_MINUTE=600 # requests per minute per client IP, every route; 0 = off
|
||||
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
|
||||
# A private CA your identity provider or directory uses (admin guide: Sign-in). Put the PEM file
|
||||
# in data/tls/; the api reads it at start, so restart the api after changing it.
|
||||
#NODE_EXTRA_CA_CERTS=/var/lib/selfphoneng/tls/idp-ca.pem
|
||||
@@ -1,17 +1,18 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# Container images, built only when a release is published (08 §3.1). Runs on GitHub Actions and
|
||||
# Gitea Actions (both read .github/workflows); each pushes to its own registry:
|
||||
# GitHub: ghcr.io/<owner>/selfphoneng-{api,asterisk} (the job's GITHUB_TOKEN)
|
||||
# Gitea: <gitea host>/<owner>/selfphoneng-{api,asterisk} (secrets REGISTRY_USER + REGISTRY_TOKEN:
|
||||
# GitHub: ghcr.io/<owner>/selfphoneng-{api,asterisk,probe} (the job's GITHUB_TOKEN)
|
||||
# Gitea: <gitea host>/<owner>/selfphoneng-{api,asterisk,probe} (secrets REGISTRY_USER + REGISTRY_TOKEN:
|
||||
# a personal access token with write:package; Gitea's job token can't push packages)
|
||||
# Tags: the version (0.0.1), plus <major>.<minor> and latest; a pre-release gets the version and dev.
|
||||
# The release also gets the probe as downloads (selfphoneng-probe-linux-{x64,arm64}, .cjs, SHA256SUMS).
|
||||
name: release
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write # attaching the probe downloads to the release
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
@@ -79,6 +80,7 @@ jobs:
|
||||
)
|
||||
docker build "${labels[@]}" -f apps/api/Dockerfile -t "$p-api:build" .
|
||||
docker build "${labels[@]}" -t "$p-asterisk:build" asterisk
|
||||
docker build "${labels[@]}" -f apps/probe/Dockerfile -t "$p-probe:build" .
|
||||
- name: Check
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -88,15 +90,41 @@ jobs:
|
||||
docker run --rm --entrypoint node "$p-api:build" --input-type=module -e "await import('/app/dist/app.js')"
|
||||
test "$(docker run --rm --entrypoint cat "$p-api:build" /app/VERSION)" = "${{ steps.v.outputs.version }}"
|
||||
docker run --rm --entrypoint asterisk "$p-asterisk:build" -V
|
||||
docker run --rm "$p-probe:build" node --input-type=module -e "await import('/app/src/server.ts')"
|
||||
- name: Push
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
p="${{ steps.v.outputs.prefix }}"
|
||||
for image in api asterisk; do
|
||||
for image in api asterisk probe; do
|
||||
for t in ${{ steps.v.outputs.tags }}; do
|
||||
docker tag "$p-$image:build" "$p-$image:$t"
|
||||
docker push "$p-$image:$t"
|
||||
done
|
||||
done
|
||||
echo "pushed: $p-{api,asterisk}:{${{ steps.v.outputs.tags }}}" | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"
|
||||
echo "pushed: $p-{api,asterisk,probe}:{${{ steps.v.outputs.tags }}}" | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"
|
||||
- name: Probe downloads
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The probe without Docker (08 §2.6): a .cjs for Node 22+, and Linux executables with Node
|
||||
# inside. Built in BuildKit and handed back as files, so it works on Gitea's runner too.
|
||||
docker build -f apps/probe/binary.Dockerfile --output type=local,dest=probe-dist .
|
||||
id="${{ github.event.release.id }}"
|
||||
for f in probe-dist/*; do
|
||||
name=$(basename "$f")
|
||||
if [ "${{ steps.v.outputs.registry }}" = ghcr.io ]; then
|
||||
curl -fsS -o /dev/null -X POST -H "Authorization: Bearer $GH_TOKEN" \
|
||||
-H 'Content-Type: application/octet-stream' --data-binary "@$f" \
|
||||
"https://uploads.github.com/repos/$GITHUB_REPOSITORY/releases/$id/assets?name=$name"
|
||||
else
|
||||
# The REGISTRY_TOKEN personal access token needs write:repository for this.
|
||||
curl -fsS -o /dev/null -X POST -H "Authorization: token ${REGISTRY_TOKEN:-$GH_TOKEN}" \
|
||||
-F "attachment=@$f" \
|
||||
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/$id/assets?name=$name"
|
||||
fi
|
||||
echo "attached $name" | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"
|
||||
done
|
||||
@@ -6,3 +6,7 @@ data/
|
||||
coverage/
|
||||
.pnpm-store/
|
||||
*.pid
|
||||
# The mobile app is its own repo (13), checked out here beside the PBX.
|
||||
mobile/
|
||||
# voip.ms's API example bundle (06 §5.1): their copyright, kept locally for reference.
|
||||
docs/voip.ms-api/
|
||||
@@ -6,3 +6,4 @@ dist/
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
apps/api/src/db/migrations/
|
||||
mobile/
|
||||
@@ -27,6 +27,10 @@ write the software around it: database, config generator, API, web UI.
|
||||
| `docs/spec/07-provisioning.md` | Desk phone auto-provisioning (Yealink, Grandstream, Polycom) and mobile apps | Working on provisioning |
|
||||
| `docs/spec/08-security-ops.md` | Security, backups, monitoring, legal requirements | Before any release, and when touching auth or networking |
|
||||
| `docs/spec/09-roadmap.md` | Milestones, ordered tasks, acceptance criteria | Picking your next task |
|
||||
| `docs/spec/11-contacts.md` | Contacts: the builtin phonebook or Contrack, lookup, the Contrack timeline | Working on contacts or caller names |
|
||||
| `docs/spec/12-sso.md` | Single sign-on: OpenID Connect (Keycloak and others) and LDAP/LDAPS | Working on sign-in, users or roles |
|
||||
| `docs/spec/13-mobile.md` | The Android/iPhone app (`mobile` repo): pairing, device tokens, its tasks | Working on the mobile app or its API |
|
||||
| `docs/spec/14-spam.md` | Spam check: IPQualityScore and the FTC complaint list for unknown callers | Working on spam check or caller information |
|
||||
|
||||
## Hard rules
|
||||
|
||||
|
||||
+269
-1
@@ -1,7 +1,275 @@
|
||||
# Changelog
|
||||
|
||||
Versions follow [semver](https://semver.org). Each release's container images are built from its
|
||||
tag (08 §3.1): `selfphoneng-api` and `selfphoneng-asterisk`, on ghcr.io and on the Gitea registry.
|
||||
tag (08 §3.1): `selfphoneng-api`, `selfphoneng-asterisk` and `selfphoneng-probe`, on ghcr.io and on
|
||||
the Gitea registry. From v0.0.3 the release also has the probe as downloads for machines without
|
||||
Docker.
|
||||
|
||||
## v0.0.13 — 2026-10-29
|
||||
|
||||
**Updating:** no database changes. Portainer: change both tags to `0.0.13` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.13`.
|
||||
|
||||
Hardening after the attacks on FreePBX servers (spec 08 §8), where stolen trunk passwords were used
|
||||
for toll fraud:
|
||||
|
||||
- **Fraud alerts:** every admin gets an email, and the System page a warning, when the phone system
|
||||
calls an international or premium number (including Caribbean area codes, which are dialed like
|
||||
a US call but cost like abroad), when voip.ms bills calls on the trunk that this phone system
|
||||
didn't make, or when the voip.ms balance drops more than 10 in an hour. The last two need the
|
||||
voip.ms API login on the trunk. All three can be changed on Settings → Security → **Fraud
|
||||
alerts**.
|
||||
- **voip.ms IP restriction** (strongly recommended): the setup checklist on Trunks now says to
|
||||
turn it on for the sub-account, with only this server's public IP. A stolen trunk password is
|
||||
then useless anywhere else ([voip.ms](docs/admin-guide/voipms.md#1-in-the-voipms-portal)).
|
||||
- **Asterisk:** the api's manager login (AMI) can only do what SelfPhoneNG uses, and the image no
|
||||
longer has the modules that run commands on the server.
|
||||
|
||||
## v0.0.12 — 2026-10-28
|
||||
|
||||
**Before you update:** this version changes the database (spam reports); the api backs up first,
|
||||
by itself. Portainer: change both tags to `0.0.12` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.12`. If you run the network test probe, update it too (see
|
||||
**Probe key** below).
|
||||
|
||||
- **My reports:** everyone with an extension gets a Reports tab: the calls that rang them, how
|
||||
many they answered and missed, talk time, calls and missed calls per day, and their busiest
|
||||
hours.
|
||||
- **Report spam:** on the Calls page, an incoming outside call has a **Report spam** button. When
|
||||
the spam check flagged the call, its finding is the reason; otherwise the person types one.
|
||||
Reports wait at the top of **Blocked numbers** for an admin to Block or Dismiss, and the
|
||||
Dashboard counts them.
|
||||
- **Second security review** (spec 08 §7), eight findings fixed, none critical:
|
||||
- Signing in by email (OpenID or directory) no longer matches admins or anyone who already has
|
||||
a connected sign-in, and connecting an OpenID account only finishes in the browser that
|
||||
started it.
|
||||
- **Recordings, transcripts and AI summaries** only go to whoever made or answered the call.
|
||||
Ring group and queue members whose phone only rang no longer get a coworker's recording, nor
|
||||
its summary in their call report email or on their Contrack timeline.
|
||||
- The `voicemail.new` webhook no longer carries the voicemail's transcript.
|
||||
- Changing the Contrack address asks for the company token again, and everyone reconnects
|
||||
their own Contrack token.
|
||||
- A directory account can only be swapped for another by an admin.
|
||||
- **Probe key** (strongly recommended): Settings → Network → **Generate key**, then set it as
|
||||
`PROBE_KEY` on the probe and restart it. The probe then only answers your server. Without a key
|
||||
it still works, and Diagnostics warns. Behind your own reverse proxy (`TRUST_PROXY=true`), the
|
||||
proxy must run on the probe's machine.
|
||||
- **Asterisk 24.0.0-rc3.** TLS trunks get a health check again, and Test checks them too (rc2
|
||||
couldn't check them over TLS). `pnpm test:asterisk-upgrade` now checks what SelfPhoneNG relies
|
||||
on Asterisk doing before any version change.
|
||||
- **Docs:** a CrowdSec rule for slow SIP scanners, and how to check that CrowdSec bans
|
||||
([network](docs/admin-guide/network.md#crowdsec)).
|
||||
|
||||
## v0.0.11 — 2026-10-28
|
||||
|
||||
**Updating:** no database changes. Portainer: change both tags to `0.0.11` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.11`.
|
||||
|
||||
- **Phones:** on an iPhone a ringing call dimmed the screen but showed no Answer button (iOS
|
||||
zoomed into the small sign-in fields and stayed zoomed, so the call dialog was off screen).
|
||||
Fields are now 16px on touch screens and buttons at least 44px. A ringing call gets its own
|
||||
screen: full screen on a phone, with big round Decline and Answer buttons. My Phone's tabs fold
|
||||
into a menu button on narrow screens.
|
||||
- **New look:** colors from the logo (blue, and its navy for dark mode), cards on an off-white
|
||||
page, and text that is easy to read in both themes (all text at least WCAG AA). Icons
|
||||
throughout.
|
||||
- **Admin sidebar** grouped into Call routing, People, Features, History and System.
|
||||
- **My Phone Home** is a set of cards: My status, Missed calls (with call back) and New
|
||||
voicemails.
|
||||
- **Browser phone:** a status pill (Ready, Connecting…), phone-style call buttons with icons
|
||||
(Mute, Hold, Keypad, Transfer, Hang up) and a round keypad with the letters under the digits.
|
||||
|
||||
## v0.0.10 — 2026-10-27
|
||||
|
||||
**Before you update:** this version changes the database (spam check); the api backs up first,
|
||||
by itself. Portainer: change both tags to `0.0.10` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.10`.
|
||||
|
||||
- **Spam check** (spec 14, off until you turn it on in Settings → Spam check): when an outside
|
||||
US caller isn't in the company's or your contacts, the number is looked up and what's known
|
||||
shows while it rings. The browser phone shows a **Likely spam** or **Possible spam** badge with
|
||||
a line such as "Fraud score 92 · spammer · VOIP"; desk phones show the caller's name as
|
||||
**SPAM LIKELY** or **Spam risk** (you can change these) when the answer is quick; call
|
||||
history keeps the badge. It's information only: calls are never blocked or delayed, and a
|
||||
failed lookup just shows nothing. Two sources, either or both:
|
||||
- **IPQualityScore** (1,000 lookups a month free): sends callers' numbers to IPQualityScore,
|
||||
so turning it on asks you to confirm. Answers are kept 14 days for flagged numbers and 3 for
|
||||
the rest. When the lookups run out the dashboard says so, checks fall back to the FTC list,
|
||||
and it carries on by itself when lookups are back.
|
||||
- **FTC complaint list** (free api.data.gov key): downloaded to the server, so nothing leaves
|
||||
it. The first download takes about 7 hours; then it fetches what's new every 24 hours.
|
||||
- See the [admin guide](docs/admin-guide/spam-check.md), including privacy and GDPR.
|
||||
- Database changes: `spam_lookups`, `ftc_complaints` (the api backs up first).
|
||||
|
||||
## v0.0.9 — 2026-10-27
|
||||
|
||||
**Before you update:** this version changes the database (paired phones); the api backs up first,
|
||||
by itself. Portainer: change both tags to `0.0.9` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.9`.
|
||||
|
||||
- **Answer from the notification**: the softphone's incoming call notification has **Answer** and
|
||||
**Decline** buttons (Chrome and Edge; Firefox and Safari show it without buttons). It now shows
|
||||
whenever the softphone tab isn't in front, not only when it's hidden, stays up while the call
|
||||
rings and goes away once it stops. If notifications are blocked, the softphone panel says how
|
||||
to allow them.
|
||||
- **Pair the mobile app** (spec 13): My settings → Mobile app shows a one-time code (QR, or
|
||||
server + code, good for 10 minutes) that our own Android/iPhone app trades for a device token.
|
||||
A paired phone acts as its user, never as an admin, and is listed there to remove. Changing or
|
||||
resetting a password, or disabling a user, removes their paired phones. The app itself is
|
||||
still in testing and not in the app stores.
|
||||
- Database changes: `mobile_devices` (the api backs up first).
|
||||
|
||||
## v0.0.8 — 2026-10-26
|
||||
|
||||
**Before you update:** this version changes the database (sign-in); the api backs up first, by
|
||||
itself. Portainer: change both tags to `0.0.8` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.8`. Sign-in works as before until you set up single sign-on in
|
||||
Settings → Sign-in (admin guide: [Sign-in](docs/admin-guide/sign-in.md)). Every route is now rate
|
||||
limited, 600 requests a minute per client IP by default (`RATE_LIMIT_PER_MINUTE`, 0 turns it off).
|
||||
|
||||
- **Sign in with OpenID Connect** (spec 12, admin guide: [Sign-in](docs/admin-guide/sign-in.md)):
|
||||
Keycloak (tested), and any provider that follows the standard: Authentik, Authelia, Zitadel,
|
||||
Entra ID and others are expected to work. People connect their
|
||||
account on My settings → Sign-in and then sign in with either it or their password. The admin
|
||||
picks passwords only, OpenID only, or both. Provider groups can decide who may sign in and who
|
||||
is an admin. Signing out also signs out at the provider, and someone disabled there loses their
|
||||
session here within 20 minutes. No accounts are made automatically: an admin still creates each
|
||||
user. Admins keep their password, so the phone system stays reachable if the provider is down.
|
||||
- **Sign in with the company directory** (LDAP / LDAPS): presets for Active Directory and Samba
|
||||
AD, OpenLDAP, FreeIPA and lldap (AD and lldap tested), and a Test button. People use their directory username or email
|
||||
and password. New users are matched by email; anyone who chose their own password, and every
|
||||
admin, connects once on My settings → Sign-in. An admin filter can make people admins (nested AD
|
||||
groups count).
|
||||
- **Leavers**: every hour, someone removed from the directory (or disabled there) is disabled
|
||||
here, password included. A dashboard warning shows when the directory can't be reached, or when
|
||||
a check would disable most people (it then disables nobody).
|
||||
- **Security** (a two-reviewer audit of the new sign-in and all 207 API routes): rate limits on
|
||||
every route, stricter on Test buttons; a password asked again (change password, two-step on and
|
||||
off) counts like a sign-in; a saved directory or provider password is never sent to a new
|
||||
address; and a role set by the provider is read-only on Users.
|
||||
- Database changes: `user_identities`; `sessions` records how each session was made; a user may
|
||||
have no password (the api backs up first).
|
||||
|
||||
## v0.0.7 — 2026-10-23
|
||||
|
||||
**Before you update:** this version changes the database (call reports, contacts); the api backs
|
||||
up first, by itself. Portainer: change both tags to `0.0.7` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.7`. Contacts start in Simple mode with empty address books;
|
||||
Contrack is optional and off until you set it up (admin guide: [Contacts](docs/admin-guide/contacts.md)).
|
||||
Desk phones pick up their new directory at their next daily check (or reboot them).
|
||||
|
||||
- **Contacts** (spec 11, admin guide: [Contacts](docs/admin-guide/contacts.md)): each person's own
|
||||
contacts and the company's, in My Phone → Contacts and Admin → Contacts. Import `.vcf` files
|
||||
(iPhone, Android, Google, Outlook, Apple) or CSV, with a preview; duplicates are skipped. Share
|
||||
contacts with colleagues or everyone: they Accept or Deny each one, and nothing lands in their
|
||||
contacts without it. Contact names show in call history, voicemail, Home, the browser phone,
|
||||
and the call report email, with **Add to contacts** on calls and voicemails from numbers that
|
||||
aren't contacts yet.
|
||||
- **Caller names on desk phones while they ring**: the caller's name from the ringing person's
|
||||
own contacts (or the company's), per phone, so nobody sees a colleague's private contact names;
|
||||
ring group and queue prefixes ("Sales:") stay in front.
|
||||
- **Desk phone directories** now have the phone owner's own contacts, after the extensions and
|
||||
before the company's.
|
||||
- **Contrack** (optional): the setup wizard (new step 8, Contacts) and Settings → Contacts offer
|
||||
[Contrack](https://github.com/arvarik/contrack), a self-hosted contact and relationship manager,
|
||||
as a second container (`deploy/contrack/compose.yml`, pinned to 2.0.2). Each person connects
|
||||
their own Contrack account in My settings → Contacts, and the company has one everyone sees.
|
||||
Calls go on the contact's Contrack timeline, optionally with the call summary and its action
|
||||
items (the first becomes a follow-up); contacts from before Contrack can be copied over.
|
||||
- **Call report email** (My settings → Call report): your calls every hour, day or week, at the
|
||||
time you pick (company time zone), with whether each was recorded and, optionally, each
|
||||
recorded call's summary. No calls, no email.
|
||||
- **Voicemail search**: search your messages by what was said (the transcript) or by the caller.
|
||||
- Database changes: call report columns on `extensions`; `contact_shares`, `contrack_call_log`,
|
||||
and Contrack columns on `users` (the api backs up first).
|
||||
|
||||
## v0.0.6 — 2026-10-21
|
||||
|
||||
**Before you update:** this version changes the database (call transcripts); the api backs up
|
||||
first, by itself. Portainer: change both tags to `0.0.6` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.6`. Call transcripts are off until you turn them on in Settings → AI;
|
||||
calls recorded before that get no transcript.
|
||||
|
||||
- **Call transcripts and summaries** (Settings → AI, admin guide: [Local AI](docs/admin-guide/ai.md)):
|
||||
recorded calls are written out a minute or two after they end, caller and other side apart, with
|
||||
a summary and action items from the language model. The Recordings page shows them and searches
|
||||
what was said; the people on a call can read its transcript. Database change: four columns on
|
||||
`recordings` (the api backs up first).
|
||||
|
||||
## v0.0.5 — 2026-10-21
|
||||
|
||||
**Before you update:** this version changes the database (voicemail transcripts); the api backs
|
||||
up first, by itself. Portainer: change both tags to `0.0.5` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.5`. The AI is optional and off until you set it up: a second stack
|
||||
from `deploy/ai/compose.yml`, then Settings → AI (admin guide: [Local AI](docs/admin-guide/ai.md)).
|
||||
After updating, phones set up automatically pick up the dial plan at their next daily check (or
|
||||
reboot them).
|
||||
|
||||
- Admin guide: [Analog phones (ATAs)](docs/admin-guide/ata.md), setting up a Grandstream HT802 V2
|
||||
by hand.
|
||||
- **Dial plan:** a user's Credentials tab shows a dial plan for analog phone adapters, made from
|
||||
your outbound routes and internal number lengths: 911 and full outside numbers go out the moment
|
||||
they're dialed instead of after a pause. Yealink desk phones get the same as dial-now rules.
|
||||
- New internal numbers can no longer start with 911 or 9911, so 911 can go out at once (numbers
|
||||
you already have keep working).
|
||||
- **Local AI** (admin guide: [Local AI](docs/admin-guide/ai.md)): Settings → AI sets a language
|
||||
model, speech to text and text to speech, each with a Test, on this server or the local network
|
||||
only (anything else is refused). `deploy/ai/compose.yml` is a second stack with Speaches
|
||||
(Whisper and Kokoro) on the GPU. `AI_*` in `.env` sets them on a new install.
|
||||
- **Voicemail transcripts:** with AI on, a new message's words are in the voicemail email and on the
|
||||
voicemail page. Database change: `voicemail_messages.transcript` (the api backs up first).
|
||||
|
||||
## v0.0.4 — 2026-10-21
|
||||
|
||||
**Updating:** no database changes. Portainer: change both tags to `0.0.4` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.4`.
|
||||
|
||||
- Settings → Security has the **SIP port** and **Accept SIP over TCP** (5060 and on by default,
|
||||
as before). Both were settings with no field. Another port, such as 5080, helps when a router or
|
||||
the ISP keeps UDP 5060 for itself: a Complete network test shows UDP getting through while SIP
|
||||
on UDP 5060 doesn't. Changing it needs a telephony restart, a new router forward and setting the
|
||||
phones up again; trunks keep working. A port that another part of SelfPhoneNG listens on is
|
||||
refused. The admin guide (Network) explains when to change it.
|
||||
|
||||
## v0.0.3 — 2026-10-21
|
||||
|
||||
**Before you update:** this version changes the database (music-on-hold order). The api backs up
|
||||
first, by itself (see the admin guide, Updating). Compose installs from v0.0.2 get
|
||||
`scripts/update.sh` with the release: `git fetch --tags && git checkout v0.0.3`, then
|
||||
`sudo scripts/update.sh v0.0.3`. Portainer: change both tags to `0.0.3` and redeploy. Optional:
|
||||
`RTP_PORT_START`/`RTP_PORT_END` on the api, then open that range instead of 10000–20000.
|
||||
|
||||
- **Any internal number:** users, ring groups, queues, digital receptionists, conference rooms and
|
||||
paging groups take any free number of 1 to 6 digits, for example **1** for the first user (the
|
||||
setup wizard asks for it too). Settings → **Numbering** sets where **Next free** looks. A
|
||||
one-digit number and a menu key of a digital receptionist that lets callers dial numbers can't
|
||||
be the same. Ring groups, queues, voicemail, pickup (`**`), queue login (`*45`), forwarding
|
||||
(`*72`) and intercom (`*80`) now work with one-digit numbers.
|
||||
- **Music on hold order:** a set plays in the order shown on Sounds & music (move files up and
|
||||
down; new uploads go last), or **Shuffle** for random order. Before, the order was Asterisk's.
|
||||
- **Reports:** totals now include answered calls, talk time and **outside minutes** (in and out,
|
||||
roughly what voip.ms bills), with outside minutes per day, and the **voip.ms balance** of each
|
||||
trunk that has the API login.
|
||||
- **System → Diagnostics:** checks what usually keeps phones off the network (public IP, DNS,
|
||||
local networks, the phone network limit, SIP listening, free audio ports), with how to fix each.
|
||||
A **Quick** or **Complete network test** checks the ports from the internet through a network
|
||||
test probe you run outside your network; set its URL in Settings → Network. The probe comes as
|
||||
a Docker image (`selfphoneng-probe`), and, for a machine without Docker, as a single Linux
|
||||
executable (x64 and arm64) attached to each release, with a systemd unit.
|
||||
- The call audio port range is configurable: `RTP_PORT_START` and `RTP_PORT_END` (default
|
||||
10000–20000).
|
||||
- Updating (Docker only): a new version backs up before it changes the database, and stops
|
||||
instead if the backup fails. `scripts/update.sh` updates a compose install to a release, checks
|
||||
the result and goes back by itself if the check fails. The admin guide's "Updating" section
|
||||
covers compose and Portainer.
|
||||
- Container logs are capped (3 × 10 MB) in `docker-compose.yml`.
|
||||
- Security leftovers from the review (08 §6), all fixed:
|
||||
- Asterisk no longer loads the modules that can run host commands (`app_system`, `func_shell`,
|
||||
`app_disa`, `res_agi`, `app_externalivr`).
|
||||
- Turning two-step sign-in on asks for your password again.
|
||||
- `*97`/`*98` voicemail logins: one at a time per phone, 10 an hour.
|
||||
- Phone setup files: an unknown MAC gets the same answer as a known one.
|
||||
- The login limiter no longer keeps every email ever tried until a restart.
|
||||
|
||||
## v0.0.2 — 2026-10-21
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
A fully open source, self-hosted business phone system. Bring your own SIP trunk and numbers
|
||||
(reference provider: voip.ms). Built on Asterisk 24 + TypeScript + PostgreSQL 18, runs in Docker.
|
||||
|
||||
**Status:** pre-1.0 (v0.0.2), for testing. See [what's left for v1](docs/v1-checklist.md).
|
||||
**Status:** pre-1.0 (v0.0.13), for testing. See [what's left for v1](docs/v1-checklist.md).
|
||||
|
||||
## What it does today
|
||||
|
||||
@@ -17,24 +17,33 @@ A fully open source, self-hosted business phone system. Bring your own SIP trunk
|
||||
outbound rules with calling permissions, 911 with E911 caller ID and alerts.
|
||||
- **Call flows:** digital receptionists, office hours and holidays, ring groups, queues, parking,
|
||||
pickup, conference rooms, paging and intercom, music on hold.
|
||||
- **Voicemail** with email, **call history**, **recordings**, a **live dashboard** (listen,
|
||||
- **Voicemail** with email (and transcripts by local AI), **call history**, **recordings**, a **live dashboard** (listen,
|
||||
whisper, barge) and reports.
|
||||
- **Running it:** backups and restore, background health checks, webhooks and API tokens,
|
||||
- **Spam check** (off until you turn it on): outside callers who aren't contacts are looked up
|
||||
with IPQualityScore or the FTC complaint list, and flagged in the browser phone, on desk phones
|
||||
and in call history. Calls are never blocked.
|
||||
- **Running it:** updates (`scripts/update.sh` for compose installs; a backup before every
|
||||
database change), backups and restore, background health checks, webhooks and API tokens,
|
||||
two-step sign-in for admins, limits on outside calls at once.
|
||||
|
||||
## Wanted
|
||||
|
||||
- More SIP providers: **Telnyx, Flowroute and Twilio** first.
|
||||
- More local AI: call transcripts and summaries, typed prompts, type-to-speak, an AI
|
||||
receptionist ([spec](docs/spec/10-ai.md)).
|
||||
- Analog phone adapters set up automatically: **Grandstream HT802 V2** first (two ports, a dial
|
||||
plan from your numbers), then Cisco ATA 191/192 and SPA112/122. Today they're set up by hand
|
||||
([how](docs/admin-guide/ata.md)).
|
||||
- SMS (built for voip.ms, parked until wanted).
|
||||
- Push notifications, so phone apps ring in the background.
|
||||
- An update script for running installs.
|
||||
- Text-to-speech prompts, recording prompts by phone, a queue wallboard, Polycom phones.
|
||||
|
||||
## Docs
|
||||
|
||||
- [Admin guide](docs/admin-guide/README.md): install, [voip.ms setup](docs/admin-guide/voipms.md),
|
||||
[network and firewall](docs/admin-guide/network.md), [E911 and recording law](docs/admin-guide/legal.md),
|
||||
[troubleshooting](docs/admin-guide/troubleshooting.md), [updating and backups](docs/admin-guide/maintenance.md)
|
||||
[troubleshooting](docs/admin-guide/troubleshooting.md), [updating and backups](docs/admin-guide/maintenance.md),
|
||||
[analog phones (ATAs)](docs/admin-guide/ata.md), [local AI](docs/admin-guide/ai.md)
|
||||
- [Changelog](CHANGELOG.md)
|
||||
- [Ready for v1 checklist](docs/v1-checklist.md)
|
||||
- Writing code (human or AI): start with [`AGENTS.md`](AGENTS.md), then the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfphoneng/api",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.13",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
@@ -12,13 +12,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.1.3",
|
||||
"@fastify/rate-limit": "^11.2.1",
|
||||
"@fastify/static": "^10.1.6",
|
||||
"@fastify/websocket": "^11.3.4",
|
||||
"@node-rs/argon2": "^2.2.2",
|
||||
"@selfphoneng/shared": "workspace:*",
|
||||
"drizzle-orm": "^0.45.4",
|
||||
"fastify": "^5.12.7",
|
||||
"ldapts": "^9.2.0",
|
||||
"nodemailer": "10.0.11",
|
||||
"openid-client": "^6.8.8",
|
||||
"pg": "^8.23.1",
|
||||
"qrcode": "1.5.4",
|
||||
"zod": "^4.6.7"
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
+18
-2
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import Fastify from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyRateLimit from '@fastify/rate-limit';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
import { sql } from 'drizzle-orm';
|
||||
@@ -11,9 +12,10 @@ import type { Db } from './db/client.ts';
|
||||
import type { Generator } from './generator/index.ts';
|
||||
import type { AmiClient } from './ami/client.ts';
|
||||
import { settingsRoutes } from './routes/settings.ts';
|
||||
import { errorHandler, forbidden, notFound, sendError } from './errors.ts';
|
||||
import { HttpError, errorHandler, forbidden, notFound, sendError } from './errors.ts';
|
||||
import { authRoutes } from './routes/auth.ts';
|
||||
import { userRoutes } from './routes/users.ts';
|
||||
import { oidcRoutes } from './routes/oidc.ts';
|
||||
import { systemRoutes } from './routes/system.ts';
|
||||
import { extensionRoutes } from './routes/extensions.ts';
|
||||
import { trunkRoutes } from './routes/trunks.ts';
|
||||
@@ -21,6 +23,7 @@ import { didRoutes } from './routes/dids.ts';
|
||||
import { outboundRouteRoutes } from './routes/outbound.ts';
|
||||
import { liveRoutes } from './live/hub.ts';
|
||||
import { meRoutes } from './routes/me.ts';
|
||||
import { mobileRoutes } from './routes/mobile.ts';
|
||||
import { soundRoutes } from './routes/sounds.ts';
|
||||
import { ringGroupRoutes } from './routes/ringgroups.ts';
|
||||
import { queueRoutes } from './routes/queues.ts';
|
||||
@@ -39,6 +42,7 @@ import { liveCallRoutes } from './routes/livecalls.ts';
|
||||
import { integrationRoutes } from './routes/integrations.ts';
|
||||
import { reportRoutes } from './routes/reports.ts';
|
||||
import { aboutRoutes } from './routes/about.ts';
|
||||
import { contactRoutes } from './routes/contacts.ts';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
@@ -47,7 +51,7 @@ declare module 'fastify' {
|
||||
/** Decorated by the caller (index.ts, tests) before ready(): it needs app.log. */
|
||||
generator: Generator;
|
||||
/** Decorated by the caller too (tests pass a fake). */
|
||||
ami: Pick<AmiClient, 'action' | 'isConnected'>;
|
||||
ami: Pick<AmiClient, 'action' | 'command' | 'isConnected'>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +94,15 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
||||
app.decorateRequest('sessionId', null);
|
||||
app.register(fastifyCookie);
|
||||
app.register(fastifyWebsocket);
|
||||
// Every route, per client IP (08 §2.4). Before the routes, so each one gets it; routes with
|
||||
// their own limits (login, /prov) or a stricter one say so in `config.rateLimit`.
|
||||
if (config.RATE_LIMIT_PER_MINUTE > 0)
|
||||
app.register(fastifyRateLimit, {
|
||||
max: config.RATE_LIMIT_PER_MINUTE,
|
||||
timeWindow: 60_000,
|
||||
errorResponseBuilder: (_req, ctx) =>
|
||||
new HttpError(429, 'rate_limited', `Too many requests, try again in ${ctx.after}`),
|
||||
});
|
||||
app.setErrorHandler(errorHandler);
|
||||
|
||||
// CSRF (04 §2.2): browsers can't add this header cross-site without CORS, and CORS is off.
|
||||
@@ -138,6 +151,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
||||
|
||||
app.register(authRoutes);
|
||||
app.register(userRoutes);
|
||||
app.register(oidcRoutes);
|
||||
app.register(systemRoutes);
|
||||
app.register(extensionRoutes);
|
||||
app.register(trunkRoutes);
|
||||
@@ -146,6 +160,8 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
||||
app.register(settingsRoutes);
|
||||
app.register(liveRoutes);
|
||||
app.register(meRoutes);
|
||||
app.register(mobileRoutes);
|
||||
app.register(contactRoutes);
|
||||
app.register(soundRoutes);
|
||||
app.register(ringGroupRoutes);
|
||||
app.register(queueRoutes);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Paired phones (13 §2): a one-time pairing code from the web, traded by the app for a device token
|
||||
// `Authorization: Bearer spm_<40 base62>`, stored as sha256, acting as the phone's user.
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { and, eq, gt, isNull } from 'drizzle-orm';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { mobileDevices, users } from '../db/schema.ts';
|
||||
import { generateToken, hashToken } from './tokens.ts';
|
||||
|
||||
export const DEVICE_TOKEN_RE = /^spm_[A-Za-z0-9]{40}$/;
|
||||
/** No 0/O/1/I: people type it from a screen. */
|
||||
export const PAIR_CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
export const PAIR_CODE_TTL_MS = 10 * 60_000;
|
||||
|
||||
export const generateDeviceToken = () => `spm_${generateToken().slice(3)}`;
|
||||
|
||||
export function generatePairCode(): string {
|
||||
let s = '';
|
||||
for (let i = 0; i < 12; i++) s += PAIR_CODE_CHARS[randomInt(PAIR_CODE_CHARS.length)];
|
||||
return s;
|
||||
}
|
||||
|
||||
/** The device's (active) user, or null for an unknown, removed or malformed token. */
|
||||
export async function getDeviceUser(db: Db, token: string) {
|
||||
if (!DEVICE_TOKEN_RE.test(token)) return null;
|
||||
const [row] = await db
|
||||
.select({ id: mobileDevices.id, user: users })
|
||||
.from(mobileDevices)
|
||||
.innerJoin(users, eq(users.id, mobileDevices.userId))
|
||||
.where(and(eq(mobileDevices.tokenHash, hashToken(token)), eq(users.isActive, true)));
|
||||
if (!row) return null;
|
||||
await db
|
||||
.update(mobileDevices)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(mobileDevices.id, row.id));
|
||||
return { user: row.user, deviceId: row.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Trades a pairing code for a device token, once. Null for a code that's unknown, used, expired,
|
||||
* or whose user was disabled since.
|
||||
*/
|
||||
export async function pairDevice(
|
||||
db: Db,
|
||||
code: string,
|
||||
device: { name: string; platform: 'android' | 'ios' },
|
||||
) {
|
||||
const [row] = await db
|
||||
.select({ id: mobileDevices.id, user: users })
|
||||
.from(mobileDevices)
|
||||
.innerJoin(users, eq(users.id, mobileDevices.userId))
|
||||
.where(
|
||||
and(
|
||||
eq(mobileDevices.pairCodeHash, hashToken(code)),
|
||||
gt(mobileDevices.pairExpiresAt, new Date()),
|
||||
eq(users.isActive, true),
|
||||
),
|
||||
);
|
||||
if (!row) return null;
|
||||
const token = generateDeviceToken();
|
||||
// The code is cleared in the same statement: two apps racing with one code can't both win.
|
||||
const [won] = await db
|
||||
.update(mobileDevices)
|
||||
.set({
|
||||
...device,
|
||||
tokenPrefix: token.slice(0, 8),
|
||||
tokenHash: hashToken(token),
|
||||
pairCodeHash: null,
|
||||
pairExpiresAt: null,
|
||||
})
|
||||
.where(and(eq(mobileDevices.id, row.id), isNull(mobileDevices.tokenHash)))
|
||||
.returning({ id: mobileDevices.id });
|
||||
return won ? { token, deviceId: won.id, user: row.user } : null;
|
||||
}
|
||||
+23
-10
@@ -1,9 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// preHandler guards (04 §2.4). Routes list them in `preHandler`.
|
||||
// preHandler guards (04 §2.4). Routes list them in `preHandler`; the rate limit presets go in
|
||||
// `config`.
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import type { users } from '../db/schema.ts';
|
||||
import { forbidden, unauthorized } from '../errors.ts';
|
||||
import { COOKIE, getSessionUser, setSessionCookie } from './sessions.ts';
|
||||
import { COOKIE, getSessionUser, mustChange, setSessionCookie } from './sessions.ts';
|
||||
import { getDeviceUser } from './devices.ts';
|
||||
import { getTokenUser } from './tokens.ts';
|
||||
|
||||
export type CurrentUser = typeof users.$inferSelect;
|
||||
@@ -14,22 +16,28 @@ declare module 'fastify' {
|
||||
sessionId: string | null;
|
||||
/** Set when the request came with an API token instead of a session. */
|
||||
apiTokenId?: string;
|
||||
/** Set when the request came from a paired phone (13 §2). */
|
||||
mobileDeviceId?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets req.user from an API token (Authorization: Bearer, 04 §2.3) or else the session cookie.
|
||||
* Enforces must_change_password outside /api/auth/*.
|
||||
* Sets req.user from a device token (`spm_`, 13 §2) or API token (Authorization: Bearer, 04 §2.3),
|
||||
* or else the session cookie. Enforces must_change_password outside /api/auth/*.
|
||||
*/
|
||||
export async function requireAuth(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const auth = req.headers.authorization;
|
||||
if (auth?.startsWith('Bearer ')) {
|
||||
const t = await getTokenUser(req.server.db, auth.slice(7).trim());
|
||||
if (!t) throw unauthorized();
|
||||
req.user = t.user;
|
||||
const raw = auth.slice(7).trim();
|
||||
const d = raw.startsWith('spm_') ? await getDeviceUser(req.server.db, raw) : null;
|
||||
const t = d ? null : await getTokenUser(req.server.db, raw);
|
||||
if (!d && !t) throw unauthorized();
|
||||
// A phone is never an admin (13 §2): a lost one mustn't open the admin panel or recordings.
|
||||
req.user = d ? { ...d.user, role: 'user' } : t!.user;
|
||||
req.sessionId = null;
|
||||
req.apiTokenId = t.tokenId;
|
||||
if (t.user.mustChangePassword)
|
||||
if (d) req.mobileDeviceId = d.deviceId;
|
||||
else req.apiTokenId = t!.tokenId;
|
||||
if (req.user.mustChangePassword)
|
||||
throw forbidden('Change your password first', 'password_change_required');
|
||||
return;
|
||||
}
|
||||
@@ -39,7 +47,7 @@ export async function requireAuth(req: FastifyRequest, reply: FastifyReply): Pro
|
||||
if (found.extended) setSessionCookie(reply, token!);
|
||||
req.user = found.user;
|
||||
req.sessionId = found.sessionId;
|
||||
if (found.user.mustChangePassword && !req.url.startsWith('/api/auth/'))
|
||||
if (mustChange(found) && !req.url.startsWith('/api/auth/'))
|
||||
throw forbidden('Change your password first', 'password_change_required');
|
||||
}
|
||||
|
||||
@@ -47,3 +55,8 @@ export async function requireAdmin(req: FastifyRequest, reply: FastifyReply): Pr
|
||||
await requireAuth(req, reply);
|
||||
if (req.user!.role !== 'admin') throw forbidden('Admins only');
|
||||
}
|
||||
|
||||
/** Route `config` for what calls out or does real work (Test buttons, downloads): 10 a minute. */
|
||||
export const SLOW = { rateLimit: { max: 10, timeWindow: 60_000 } };
|
||||
/** Route `config` for routes with their own limit (/prov) or only Asterisk on this machine. */
|
||||
export const NO_LIMIT = { rateLimit: false as const };
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { expect, it } from 'vitest';
|
||||
import {
|
||||
isLoginBlocked,
|
||||
loginLimiterSize,
|
||||
recordLoginFailure,
|
||||
resetLoginLimiter,
|
||||
} from './login-limiter.ts';
|
||||
|
||||
it('forgets emails tried long ago instead of growing until restart', () => {
|
||||
resetLoginLimiter();
|
||||
const t0 = 1_000_000_000;
|
||||
for (let i = 0; i < 100; i++) recordLoginFailure(`10.0.0.${i}`, `user${i}@x.test`, t0);
|
||||
expect(loginLimiterSize()).toBe(200);
|
||||
for (let i = 0; i < 5; i++) recordLoginFailure('10.9.9.9', 'kept@x.test', t0 + 61 * 60_000);
|
||||
expect(loginLimiterSize()).toBe(2);
|
||||
expect(isLoginBlocked('10.9.9.9', 'other@x.test', t0 + 61 * 60_000)).toBe(true);
|
||||
});
|
||||
@@ -3,8 +3,10 @@
|
||||
// ponytail: in-process memory, resets on restart; fine for one api process (v1 has no HA).
|
||||
const MIN = 60_000;
|
||||
const RULES = { ip: { max: 5, windowMs: 15 * MIN }, email: { max: 10, windowMs: 60 * MIN } };
|
||||
const LONGEST = Math.max(RULES.ip.windowMs, RULES.email.windowMs);
|
||||
|
||||
const failures = new Map<string, number[]>();
|
||||
let lastSweep = 0;
|
||||
|
||||
function recent(key: string, windowMs: number, now: number): number[] {
|
||||
const list = (failures.get(key) ?? []).filter((t) => now - t < windowMs);
|
||||
@@ -13,20 +15,34 @@ function recent(key: string, windowMs: number, now: number): number[] {
|
||||
return list;
|
||||
}
|
||||
|
||||
export function isLoginBlocked(ip: string, email: string, now = Date.now()): boolean {
|
||||
/** `email` null: by IP only (an OpenID callback names no account, 12 §3.2). */
|
||||
export function isLoginBlocked(ip: string, email: string | null, now = Date.now()): boolean {
|
||||
return (
|
||||
recent(`ip:${ip}`, RULES.ip.windowMs, now).length >= RULES.ip.max ||
|
||||
recent(`email:${email.toLowerCase()}`, RULES.email.windowMs, now).length >= RULES.email.max
|
||||
(email !== null &&
|
||||
recent(`email:${email.toLowerCase()}`, RULES.email.windowMs, now).length >= RULES.email.max)
|
||||
);
|
||||
}
|
||||
|
||||
export function recordLoginFailure(ip: string, email: string, now = Date.now()): void {
|
||||
for (const key of [`ip:${ip}`, `email:${email.toLowerCase()}`]) {
|
||||
export function recordLoginFailure(ip: string, email: string | null, 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 === null ? [] : [`email:${email.toLowerCase()}`])]) {
|
||||
failures.set(key, [...(failures.get(key) ?? []), now]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Tests only. */
|
||||
export function loginLimiterSize(): number {
|
||||
return failures.size;
|
||||
}
|
||||
|
||||
/** Tests only. */
|
||||
export function resetLoginLimiter(): void {
|
||||
failures.clear();
|
||||
lastSweep = 0;
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import { createHash, randomBytes } from 'node:crypto';
|
||||
import { and, eq, gt, ne } from 'drizzle-orm';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { sessions, users } from '../db/schema.ts';
|
||||
import { encrypt } from '../crypto/secrets.ts';
|
||||
import { mobileDevices, sessions, users } from '../db/schema.ts';
|
||||
|
||||
export const COOKIE = 'sp_session';
|
||||
const DAY = 86_400_000;
|
||||
@@ -32,6 +33,9 @@ export async function createSession(
|
||||
userId: string,
|
||||
ip: string | null,
|
||||
userAgent: string | null,
|
||||
method: 'password' | 'ldap' | 'oidc' = 'password',
|
||||
/** OpenID sessions (12 §3.5): kept encrypted for the leaver check and signing out there. */
|
||||
idp?: { refreshToken: string | null; idToken: string },
|
||||
): Promise<string> {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
await db.insert(sessions).values({
|
||||
@@ -40,6 +44,10 @@ export async function createSession(
|
||||
expiresAt: new Date(Date.now() + LIFETIME),
|
||||
ip,
|
||||
userAgent: userAgent?.slice(0, 500) ?? null,
|
||||
method,
|
||||
idpRefreshEnc: idp?.refreshToken ? encrypt(idp.refreshToken) : null,
|
||||
idpIdTokenEnc: idp ? encrypt(idp.idToken) : null,
|
||||
idpCheckedAt: idp ? new Date() : null,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
@@ -51,7 +59,7 @@ export async function createSession(
|
||||
export async function getSessionUser(db: Db, token: string, slide = true) {
|
||||
const id = sha256(token);
|
||||
const [row] = await db
|
||||
.select({ expiresAt: sessions.expiresAt, user: users })
|
||||
.select({ expiresAt: sessions.expiresAt, method: sessions.method, user: users })
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(users.id, sessions.userId))
|
||||
.where(and(eq(sessions.id, id), gt(sessions.expiresAt, new Date()), eq(users.isActive, true)));
|
||||
@@ -64,15 +72,23 @@ export async function getSessionUser(db: Db, token: string, slide = true) {
|
||||
.where(eq(sessions.id, id));
|
||||
extended = true;
|
||||
}
|
||||
return { user: row.user, sessionId: id, extended };
|
||||
return { user: row.user, sessionId: id, method: row.method, extended };
|
||||
}
|
||||
|
||||
/** A password to change only blocks sessions made with it (OpenID sessions skip it, 12). */
|
||||
export const mustChange = (f: { user: { mustChangePassword: boolean }; method: string }) =>
|
||||
f.user.mustChangePassword && f.method === 'password';
|
||||
|
||||
export async function deleteSession(db: Db, token: string) {
|
||||
await db.delete(sessions).where(eq(sessions.id, sha256(token)));
|
||||
}
|
||||
|
||||
/** Logs a user out everywhere except (optionally) the current session. */
|
||||
/**
|
||||
* Logs a user out everywhere except (optionally) the current session, paired phones included (13 §2:
|
||||
* a password change, a reset, or disabling them).
|
||||
*/
|
||||
export async function deleteUserSessions(db: Db, userId: string, keepSessionId?: string) {
|
||||
await db.delete(mobileDevices).where(eq(mobileDevices.userId, userId));
|
||||
await db
|
||||
.delete(sessions)
|
||||
.where(
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// `node dist/backup-cli.js` in the api container: one backup like the one before migrating
|
||||
// (database, voicemail, custom sounds, music on hold; no recordings), its file name printed.
|
||||
// scripts/update.sh runs it in the running (old) api before updating.
|
||||
import { loadConfig } from './config.ts';
|
||||
import { createDb } from './db/client.ts';
|
||||
import { backupBeforeMigrate } from './services/backup.ts';
|
||||
|
||||
const config = loadConfig();
|
||||
const { db, pool } = createDb(config.DATABASE_URL);
|
||||
try {
|
||||
console.log((await backupBeforeMigrate(config, db)).name);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
@@ -19,6 +19,16 @@ describe('config', () => {
|
||||
expect(() => loadConfig({ ...env, APP_SECRET: undefined })).toThrow(ConfigError);
|
||||
expect(() => loadConfig({ ...env, APP_SECRET: 'c2hvcnQ=' })).toThrow(/APP_SECRET/);
|
||||
});
|
||||
it('call audio ports: 10000–20000 by default; the end above the start', () => {
|
||||
expect(loadConfig(env)).toMatchObject({ RTP_PORT_START: 10000, RTP_PORT_END: 20000 });
|
||||
expect(loadConfig({ ...env, RTP_PORT_START: '40000', RTP_PORT_END: '40199' })).toMatchObject({
|
||||
RTP_PORT_START: 40000,
|
||||
RTP_PORT_END: 40199,
|
||||
});
|
||||
expect(() => loadConfig({ ...env, RTP_PORT_START: '40000', RTP_PORT_END: '30000' })).toThrow(
|
||||
/RTP_PORT_END/,
|
||||
);
|
||||
});
|
||||
it('rejects a non-postgres DATABASE_URL', () => {
|
||||
expect(() => loadConfig({ ...env, DATABASE_URL: 'mysql://x' })).toThrow(/DATABASE_URL/);
|
||||
});
|
||||
|
||||
+14
-2
@@ -1,7 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { z } from 'zod';
|
||||
|
||||
const EnvSchema = z.object({
|
||||
const EnvSchema = z
|
||||
.object({
|
||||
APP_SECRET: z
|
||||
.string()
|
||||
.refine((v) => Buffer.from(v, 'base64').length === 32, 'must be 32 random bytes, base64'),
|
||||
@@ -34,8 +35,19 @@ const EnvSchema = z.object({
|
||||
API_PORT: z.coerce.number().int().min(1).max(65535).default(8080),
|
||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||
TRUST_PROXY: z.stringbool().default(true),
|
||||
/** Requests per minute per client IP, every route (08 §2.4); stricter ones per route. 0: off. */
|
||||
RATE_LIMIT_PER_MINUTE: z.coerce.number().int().min(0).default(600),
|
||||
/** Call audio (RTP) ports, both ends included: the firewall opens this UDP range (01 §3.6). */
|
||||
RTP_PORT_START: z.coerce.number().int().min(1024).max(65534).default(10000),
|
||||
RTP_PORT_END: z.coerce.number().int().min(1025).max(65535).default(20000),
|
||||
/** Migrate even when the backup before migrating fails (index.ts). For a fix-it-now moment only. */
|
||||
SKIP_BACKUP_BEFORE_MIGRATE: z.stringbool().default(false),
|
||||
TZ: z.string().default('America/Chicago'),
|
||||
});
|
||||
})
|
||||
.refine((c) => c.RTP_PORT_END > c.RTP_PORT_START, {
|
||||
path: ['RTP_PORT_END'],
|
||||
message: 'must be above RTP_PORT_START',
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof EnvSchema>;
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Address books (11 §2): a user's (owner = their id) and the company's (owner null), kept in the
|
||||
// `contacts` table (builtin) or in Contrack (contacts.provider = 'contrack', 11 §4). Every caller
|
||||
// goes through these functions and doesn't care which.
|
||||
import { asc, eq, isNull, or, sql } from 'drizzle-orm';
|
||||
import {
|
||||
normalizeNumber,
|
||||
type ContactBookDto,
|
||||
type ContactCopyResult,
|
||||
type ContactData,
|
||||
type ContactDto,
|
||||
type ContactImportResult,
|
||||
} from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { contacts } from '../db/schema.ts';
|
||||
import { HttpError, badRequest } from '../errors.ts';
|
||||
import {
|
||||
ContrackError,
|
||||
contrackBook,
|
||||
contrackCreate,
|
||||
contrackBulk,
|
||||
contrackSettings,
|
||||
needToken,
|
||||
type Contrack,
|
||||
} from './contrack.ts';
|
||||
|
||||
/** null = the company book. */
|
||||
export type Owner = string | null;
|
||||
const inBook = (owner: Owner) =>
|
||||
owner ? eq(contacts.ownerUserId, owner) : isNull(contacts.ownerUserId);
|
||||
|
||||
type Row = typeof contacts.$inferSelect;
|
||||
export const toDto = (r: Row): ContactDto => ({
|
||||
id: r.id,
|
||||
book: r.ownerUserId ? 'mine' : 'company',
|
||||
name: r.name,
|
||||
company: r.company,
|
||||
email: r.email,
|
||||
numbers: r.numbers as ContactData['numbers'],
|
||||
notes: r.notes,
|
||||
url: null,
|
||||
});
|
||||
|
||||
/** Contrack's errors as API answers (409 for "connect first", 502 for Contrack itself). */
|
||||
const asHttp = (err: unknown): never => {
|
||||
if (err instanceof ContrackError)
|
||||
throw new HttpError(err.status === 409 ? 409 : 502, 'contrack', err.message);
|
||||
throw err;
|
||||
};
|
||||
|
||||
/** Editing, deleting and importing happen in Contrack itself in Contrack mode (11 §3.1, §4.3). */
|
||||
export async function assertBuiltin(db: Db) {
|
||||
if (await contrackSettings(db))
|
||||
throw new HttpError(409, 'contrack', 'Contacts live in Contrack: change them there');
|
||||
}
|
||||
|
||||
/** Normalized numbers (00 §8); `bad` gets the ones that aren't phone numbers. */
|
||||
export function cleanNumbers(c: ContactData, bad?: (n: string) => void): ContactData['numbers'] {
|
||||
const out: ContactData['numbers'] = [];
|
||||
for (const n of c.numbers)
|
||||
try {
|
||||
const number = normalizeNumber(n.number);
|
||||
if (!out.some((o) => o.number === number)) out.push({ label: n.label, number });
|
||||
} catch {
|
||||
if (!bad) throw badRequest(`"${n.number}" isn't a phone number`);
|
||||
bad(n.number);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const byName = (a: ContactDto, b: ContactDto) => a.name.localeCompare(b.name);
|
||||
|
||||
async function contrackList(db: Db, c: Contrack, owner: Owner) {
|
||||
const b = await contrackBook(db, c, owner);
|
||||
return { items: (b?.contacts ?? []).toSorted(byName), book: b };
|
||||
}
|
||||
|
||||
/**
|
||||
* The books someone sees: my contacts, then the company's (userId), or only the company's (null,
|
||||
* Admin → Contacts). `contrack` says whether that person's (or the company's) token works.
|
||||
*/
|
||||
export async function readBooks(db: Db, userId: string | null): Promise<ContactBookDto> {
|
||||
const c = await contrackSettings(db);
|
||||
if (!c) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(
|
||||
userId
|
||||
? or(eq(contacts.ownerUserId, userId), isNull(contacts.ownerUserId))
|
||||
: isNull(contacts.ownerUserId),
|
||||
)
|
||||
.orderBy(sql`${contacts.ownerUserId} is null`, asc(contacts.name));
|
||||
return { items: rows.map(toDto), provider: 'builtin', contrack: null };
|
||||
}
|
||||
const company = await contrackList(db, c, null);
|
||||
const mine = userId ? await contrackList(db, c, userId) : null;
|
||||
const own = mine ?? company; // whose token the page is about
|
||||
return {
|
||||
items: [...(mine?.items ?? []), ...company.items],
|
||||
provider: 'contrack',
|
||||
contrack: {
|
||||
publicUrl: c.publicUrl,
|
||||
connected: !!own.book && !own.book.error,
|
||||
error: own.book?.error ?? null,
|
||||
toCopy:
|
||||
own.book && !own.book.error
|
||||
? (await notInContrack(db, userId, own.book.contacts)).length
|
||||
: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** This book's builtin contacts that its Contrack account doesn't have yet. */
|
||||
async function notInContrack(db: Db, owner: Owner, have: ContactDto[]): Promise<ContactData[]> {
|
||||
const rows = (await db.select().from(contacts).where(inBook(owner))).map(toDto);
|
||||
return rows.filter((r) => !have.some((h) => isDuplicate(h, r)));
|
||||
}
|
||||
|
||||
/**
|
||||
* "Copy my contacts to Contrack" (11 §5, C6): the builtin book's contacts that aren't there yet,
|
||||
* in one Contrack import. The builtin ones stay, for switching back.
|
||||
*/
|
||||
export async function copyToContrack(db: Db, owner: Owner): Promise<ContactCopyResult> {
|
||||
const c = await contrackSettings(db);
|
||||
if (!c)
|
||||
throw new HttpError(409, 'contrack', 'Contacts are not in Contrack (Settings → Contacts)');
|
||||
const b = await contrackBook(db, c, owner);
|
||||
if (!b) asHttp(needToken(owner));
|
||||
if (b!.error) throw new HttpError(502, 'contrack', b!.error);
|
||||
const all = (await db.select().from(contacts).where(inBook(owner))).length;
|
||||
const fresh = await notInContrack(db, owner, b!.contacts);
|
||||
if (!fresh.length) return { added: 0, duplicates: all, failed: 0 };
|
||||
const r = await contrackBulk(db, c, owner, fresh).catch(asHttp);
|
||||
return { added: r.count, duplicates: all - fresh.length, failed: r.failed };
|
||||
}
|
||||
|
||||
/** One contact added by hand (or from a call): a row, or a Contrack contact. */
|
||||
export async function createContact(db: Db, owner: Owner, d: ContactData): Promise<ContactDto> {
|
||||
const clean = { ...d, numbers: cleanNumbers(d) };
|
||||
const c = await contrackSettings(db);
|
||||
if (c) return contrackCreate(db, c, owner, clean).catch(asHttp);
|
||||
const [row] = await db
|
||||
.insert(contacts)
|
||||
.values({ ...clean, ownerUserId: owner })
|
||||
.returning();
|
||||
return toDto(row!);
|
||||
}
|
||||
|
||||
/** Same contact: same name (any case) and a number in common, or neither has a number. */
|
||||
const sameKey = (c: ContactData) => c.name.trim().toLowerCase();
|
||||
const isDuplicate = (a: ContactData, b: ContactData) =>
|
||||
sameKey(a) === sameKey(b) &&
|
||||
((!a.numbers.length && !b.numbers.length) ||
|
||||
a.numbers.some((n) => b.numbers.some((m) => m.number === n.number)));
|
||||
|
||||
/**
|
||||
* Adds contacts to a book, skipping ones it already has (11 §3.1): an import or accepted shares.
|
||||
* With `commit` false it only counts. Bad numbers are dropped and reported.
|
||||
*/
|
||||
export async function addToBook(
|
||||
db: Db,
|
||||
owner: Owner,
|
||||
list: ContactData[],
|
||||
commit: boolean,
|
||||
errors: string[] = [],
|
||||
): Promise<ContactImportResult> {
|
||||
const c = await contrackSettings(db);
|
||||
let have: ContactDto[];
|
||||
if (c) {
|
||||
const b = await contrackBook(db, c, owner);
|
||||
if (!b) asHttp(needToken(owner));
|
||||
if (b!.error) throw new HttpError(502, 'contrack', b!.error);
|
||||
have = b!.contacts;
|
||||
} else have = (await db.select().from(contacts).where(inBook(owner))).map(toDto);
|
||||
const fresh: ContactData[] = [];
|
||||
let duplicates = 0;
|
||||
for (const d of list) {
|
||||
const numbers = cleanNumbers(d, (n) =>
|
||||
errors.push(`${d.name}: "${n}" isn't a phone number, left out`),
|
||||
);
|
||||
const clean = { ...d, numbers };
|
||||
if ([...have, ...fresh].some((h) => isDuplicate(h, clean))) duplicates++;
|
||||
else fresh.push(clean);
|
||||
}
|
||||
if (commit && fresh.length) {
|
||||
// ponytail: one Contrack request per contact; imports go to Contrack's own importer, so
|
||||
// this is only accepted shares.
|
||||
if (c) for (const d of fresh) await contrackCreate(db, c, owner, d).catch(asHttp);
|
||||
// ponytail: one insert of up to 5,000 rows (the import cap).
|
||||
else await db.insert(contacts).values(fresh.map((d) => ({ ...d, ownerUserId: owner })));
|
||||
}
|
||||
return {
|
||||
committed: commit,
|
||||
added: fresh.length,
|
||||
duplicates,
|
||||
noNumber: fresh.filter((d) => !d.numbers.length).length,
|
||||
errors: errors.slice(0, 20),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Names for numbers, from my book first, then the company's (11 §1). Numbers as calls store them
|
||||
* (normalized). userId null: the company book only.
|
||||
*/
|
||||
export async function contactNames(
|
||||
db: Db,
|
||||
userId: string | null,
|
||||
numbers: (string | null)[],
|
||||
opts: { wait?: boolean } = {},
|
||||
): Promise<Map<string, string>> {
|
||||
const want = [...new Set(numbers.filter((n): n is string => !!n))];
|
||||
const out = new Map<string, string>();
|
||||
if (!want.length) return out;
|
||||
const c = await contrackSettings(db);
|
||||
if (c) {
|
||||
const books = [
|
||||
userId ? await contrackBook(db, c, userId, opts) : null,
|
||||
await contrackBook(db, c, null, opts),
|
||||
];
|
||||
for (const b of books)
|
||||
for (const ct of b?.contacts ?? [])
|
||||
for (const n of ct.numbers)
|
||||
if (want.includes(n.number) && !out.has(n.number)) out.set(n.number, ct.name);
|
||||
return out;
|
||||
}
|
||||
// ponytail: a scan of the two books' numbers; a GIN index when books reach tens of thousands.
|
||||
const { rows } = await db.execute<{ name: string; number: string; mine: boolean }>(sql`
|
||||
select c.name, n->>'number' as number, c.owner_user_id is not null as mine
|
||||
from ${contacts} c, jsonb_array_elements(c.numbers) n
|
||||
where ${userId ? sql`(c.owner_user_id = ${userId} or c.owner_user_id is null)` : sql`c.owner_user_id is null`}
|
||||
and n->>'number' in ${want}`);
|
||||
for (const r of rows.sort((a, b) => Number(b.mine) - Number(a.mine)))
|
||||
if (!out.has(r.number)) out.set(r.number, r.name);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Caller names on desk phones while they ring (11 §6 C4): before ringing a user's phones, the
|
||||
// dialplan asks GET /api/internal/caller-name (func_curl, 2 s at most) for that user's name for
|
||||
// the caller, and the phones show it.
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
/** The dialplan's key for the route, derived from APP_SECRET (stable across restarts). */
|
||||
export const callerNameToken = (appSecret: string) =>
|
||||
createHmac('sha256', appSecret).update('selfphoneng caller-name').digest('hex');
|
||||
|
||||
export const sameToken = (a: string, b: string) =>
|
||||
a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
||||
|
||||
/**
|
||||
* What a phone can show and the dialplan can carry: letters, digits, spaces and . ' & @ -, at
|
||||
* most 40 characters. Nothing that Dial's options (, ^ ( ) |) or a SIP header (" < > \ ;) could
|
||||
* read as syntax.
|
||||
*/
|
||||
export const phoneSafeName = (name: string) =>
|
||||
name
|
||||
.normalize('NFC')
|
||||
.replace(/[^\p{L}\p{M}\p{N} .'&@-]+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 40)
|
||||
.trim();
|
||||
@@ -0,0 +1,265 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Contrack (11 §4): a self-hosted contact manager beside the PBX. We call a handful of its REST
|
||||
// routes with a person's (or the company account's) personal token, and keep each book in memory
|
||||
// for 5 minutes, so name lookups don't wait on it.
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { normalizeNumber, type ContactData, type ContactDto } from '@selfphoneng/shared';
|
||||
import { AiError, assertLocalUrl } from '../ai/client.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { users } from '../db/schema.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
|
||||
export class ContrackError extends Error {
|
||||
status: number | undefined;
|
||||
constructor(message: string, status?: number) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** One call to Contrack's API (base: the local address in Settings → Contacts). */
|
||||
async function ctk<T>(
|
||||
base: string,
|
||||
token: string,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
try {
|
||||
assertLocalUrl(base);
|
||||
} catch (err) {
|
||||
throw new ContrackError((err as AiError).message);
|
||||
}
|
||||
const url = new URL(path, base.endsWith('/') ? base : `${base}/`);
|
||||
let r: Response;
|
||||
try {
|
||||
r = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers as Record<string, string> | undefined),
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(init.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
} catch (err) {
|
||||
const e = err as Error & { cause?: { code?: string } };
|
||||
throw new ContrackError(
|
||||
e.name === 'TimeoutError'
|
||||
? `Contrack (${url.host}) didn't answer within 10 s`
|
||||
: `Can't reach Contrack at ${url.host}${e.cause?.code ? ` (${e.cause.code})` : ''}`,
|
||||
);
|
||||
}
|
||||
if (r.status === 401) throw new ContrackError('Contrack refused the token', 401);
|
||||
if (!r.ok) {
|
||||
const body = (await r.json().catch(() => null)) as { error?: { message?: string } } | null;
|
||||
throw new ContrackError(`Contrack: ${body?.error?.message ?? `HTTP ${r.status}`}`, r.status);
|
||||
}
|
||||
return (await r.json()) as T;
|
||||
}
|
||||
|
||||
type Slim = {
|
||||
id: string;
|
||||
name: string;
|
||||
company: string | null;
|
||||
emails: { email: string }[];
|
||||
phones: { phone: string }[];
|
||||
};
|
||||
|
||||
export type Contrack = { url: string; publicUrl: string | null; companyToken: string | null };
|
||||
|
||||
/** Settings → Contacts, or null in builtin mode. */
|
||||
export async function contrackSettings(db: Db): Promise<Contrack | null> {
|
||||
if ((await getSetting(db, 'contacts.provider')) !== 'contrack') return null;
|
||||
const c = await getSetting(db, 'contacts.contrack');
|
||||
if (!c) return null;
|
||||
return {
|
||||
url: c.url,
|
||||
publicUrl: c.publicUrl,
|
||||
companyToken: c.companyTokenEnc ? decrypt(c.companyTokenEnc) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The token for a book: a user's own, or the company account's (owner null). */
|
||||
export async function tokenFor(db: Db, c: Contrack, owner: string | null): Promise<string | null> {
|
||||
if (owner === null) return c.companyToken;
|
||||
const [u] = await db
|
||||
.select({ enc: users.contrackTokenEnc })
|
||||
.from(users)
|
||||
.where(eq(users.id, owner));
|
||||
return u?.enc ? decrypt(u.enc) : null;
|
||||
}
|
||||
|
||||
const toDto = (c: Contrack, s: Slim, book: ContactDto['book']): ContactDto => {
|
||||
const numbers: ContactData['numbers'] = [];
|
||||
for (const p of s.phones)
|
||||
try {
|
||||
const number = normalizeNumber(p.phone);
|
||||
if (!numbers.some((n) => n.number === number)) numbers.push({ label: '', number });
|
||||
} catch {
|
||||
// Not a number a phone can dial ("ask reception"): left out.
|
||||
}
|
||||
return {
|
||||
id: s.id,
|
||||
book,
|
||||
name: s.name,
|
||||
company: s.company,
|
||||
email: s.emails[0]?.email ?? null,
|
||||
numbers,
|
||||
notes: null,
|
||||
url: c.publicUrl ? `${c.publicUrl}/contact/${encodeURIComponent(s.id)}` : null,
|
||||
};
|
||||
};
|
||||
|
||||
type Book = { contacts: ContactDto[]; at: number; error: string | null };
|
||||
/** By owner: a user id, or '' for the company account. Keyed by token too, so a new one refetches. */
|
||||
const cache = new Map<string, Book & { token: string }>();
|
||||
const TTL = 5 * 60_000;
|
||||
|
||||
/** Forget a book (after we add to it, or a token changes). */
|
||||
export const forgetBook = (owner: string | null) => cache.delete(owner ?? '');
|
||||
|
||||
const inflight = new Map<string, Promise<Book>>();
|
||||
|
||||
/**
|
||||
* A Contrack book, from the cache when it's under 5 minutes old. Null: no token. A failing
|
||||
* Contrack keeps the last good list and says why. `wait: false` (a phone ringing, 11 §6 C4)
|
||||
* never waits on Contrack: it answers what the cache has and refreshes in the background.
|
||||
*/
|
||||
export async function contrackBook(
|
||||
db: Db,
|
||||
c: Contrack,
|
||||
owner: string | null,
|
||||
{ now = Date.now(), wait = true }: { now?: number; wait?: boolean } = {},
|
||||
): Promise<Book | null> {
|
||||
const token = await tokenFor(db, c, owner);
|
||||
if (!token) return null;
|
||||
const key = owner ?? '';
|
||||
const hit = cache.get(key);
|
||||
const usable = hit && hit.token === token ? hit : undefined;
|
||||
if (usable && now - usable.at < TTL) return usable;
|
||||
if (!inflight.has(key))
|
||||
inflight.set(
|
||||
key,
|
||||
fetchBook(c, owner, token, usable, now).finally(() => inflight.delete(key)),
|
||||
);
|
||||
if (!wait) return usable ?? null;
|
||||
return inflight.get(key)!;
|
||||
}
|
||||
|
||||
async function fetchBook(
|
||||
c: Contrack,
|
||||
owner: string | null,
|
||||
token: string,
|
||||
hit: Book | undefined,
|
||||
now: number,
|
||||
): Promise<Book> {
|
||||
const key = owner ?? '';
|
||||
const book = owner === null ? 'company' : 'mine';
|
||||
try {
|
||||
const rows = await ctk<Slim[]>(c.url, token, 'api/contacts?view=slim');
|
||||
const fresh = { contacts: rows.map((r) => toDto(c, r, book)), at: now, error: null, token };
|
||||
cache.set(key, fresh);
|
||||
return fresh;
|
||||
} catch (err) {
|
||||
const stale = {
|
||||
contacts: hit?.contacts ?? [],
|
||||
at: now, // tried now: the next try is in 5 minutes, not on every lookup
|
||||
error: (err as Error).message,
|
||||
token,
|
||||
};
|
||||
cache.set(key, stale);
|
||||
return stale;
|
||||
}
|
||||
}
|
||||
|
||||
/** Writing to a book whose owner hasn't connected Contrack. */
|
||||
export const needToken = (owner: string | null) =>
|
||||
new ContrackError(
|
||||
owner
|
||||
? 'Connect your Contrack account first (My settings → Contacts)'
|
||||
: 'The company Contrack account has no token (Settings → Contacts)',
|
||||
409,
|
||||
);
|
||||
|
||||
/** Adds a contact to a Contrack book (11 §4.3: "Add to contacts", an accepted share). */
|
||||
export async function contrackCreate(
|
||||
db: Db,
|
||||
c: Contrack,
|
||||
owner: string | null,
|
||||
d: ContactData,
|
||||
): Promise<ContactDto> {
|
||||
const token = await tokenFor(db, c, owner);
|
||||
if (!token) throw needToken(owner);
|
||||
const row = await ctk<Slim>(c.url, token, 'api/contacts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: d.name,
|
||||
...(d.company ? { company: d.company } : {}),
|
||||
...(d.email ? { emails: [{ email: d.email }] } : {}),
|
||||
phones: d.numbers.map((n) => ({ phone: n.number, ...(n.label ? { label: n.label } : {}) })),
|
||||
}),
|
||||
});
|
||||
forgetBook(owner);
|
||||
return toDto(c, row, owner === null ? 'company' : 'mine');
|
||||
}
|
||||
|
||||
/** Many contacts into a Contrack book in one import (11 §5, C6); 5,000 at most. */
|
||||
export async function contrackBulk(
|
||||
db: Db,
|
||||
c: Contrack,
|
||||
owner: string | null,
|
||||
list: ContactData[],
|
||||
): Promise<{ count: number; failed: number }> {
|
||||
const token = await tokenFor(db, c, owner);
|
||||
if (!token) throw needToken(owner);
|
||||
const r = await ctk<{ count: number; failed: number }>(c.url, token, 'api/contacts/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Import-Id': randomUUID() },
|
||||
body: JSON.stringify(
|
||||
list.slice(0, 5000).map((d) => ({
|
||||
name: d.name,
|
||||
...(d.company ? { company: d.company } : {}),
|
||||
...(d.email ? { emails: [{ email: d.email }] } : {}),
|
||||
phones: d.numbers.map((n) => ({ phone: n.number, ...(n.label ? { label: n.label } : {}) })),
|
||||
})),
|
||||
),
|
||||
});
|
||||
forgetBook(owner);
|
||||
return { count: r.count, failed: typeof r.failed === 'number' ? r.failed : 0 };
|
||||
}
|
||||
|
||||
/** A timeline entry on a contact (11 §4.4); answers its id. */
|
||||
export async function contrackInteraction(
|
||||
c: Contrack,
|
||||
token: string,
|
||||
contactId: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const r = await ctk<{ id: string }>(
|
||||
c.url,
|
||||
token,
|
||||
`api/contacts/${encodeURIComponent(contactId)}/interactions`,
|
||||
{ method: 'POST', body: JSON.stringify(body) },
|
||||
);
|
||||
return r.id;
|
||||
}
|
||||
|
||||
/** Settings → Contacts → Test, and connecting a token: reachable, and the token reads contacts. */
|
||||
export async function contrackCheck(url: string, token: string | null): Promise<string> {
|
||||
try {
|
||||
assertLocalUrl(url);
|
||||
} catch (err) {
|
||||
throw new ContrackError((err as AiError).message);
|
||||
}
|
||||
const health = await fetch(new URL('healthz', url.endsWith('/') ? url : `${url}/`), {
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
}).catch(() => null);
|
||||
if (!health?.ok) throw new ContrackError(`Can't reach Contrack at ${url}`);
|
||||
if (!token) return 'Contrack answers.';
|
||||
const rows = await ctk<Slim[]>(url, token, 'api/contacts?view=slim');
|
||||
return `Contrack answers; the token reads ${rows.length} contact${rows.length === 1 ? '' : 's'}.`;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// CSV to contacts (04 §7.4): name,company,email,label1,number1,label2,number2,… with a header row
|
||||
// (any order, any case; notes too).
|
||||
import type { ContactData } from '@selfphoneng/shared';
|
||||
|
||||
/** RFC 4180 rows (blank ones too): quoted cells may hold commas, quotes ("") and line breaks. */
|
||||
export function csvRows(input: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let cell = '';
|
||||
let quoted = false;
|
||||
const s = input.replace(/^\uFEFF/, '');
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s[i]!;
|
||||
if (quoted) {
|
||||
if (c === '"' && s[i + 1] === '"') {
|
||||
cell += '"';
|
||||
i++;
|
||||
} else if (c === '"') quoted = false;
|
||||
else cell += c;
|
||||
} else if (c === '"') quoted = true;
|
||||
else if (c === ',') {
|
||||
row.push(cell);
|
||||
cell = '';
|
||||
} else if (c === '\n' || c === '\r') {
|
||||
if (c === '\r' && s[i + 1] === '\n') i++;
|
||||
row.push(cell);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
cell = '';
|
||||
} else cell += c;
|
||||
}
|
||||
if (cell || row.length) rows.push([...row, cell]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function parseCsv(input: string): { contacts: ContactData[]; errors: string[] } {
|
||||
const [header, ...rows] = csvRows(input);
|
||||
if (!header) return { contacts: [], errors: [] };
|
||||
const col = new Map(header.map((h, i) => [h.trim().toLowerCase(), i]));
|
||||
if (!col.has('name'))
|
||||
return { contacts: [], errors: ['The first row must name the columns, with a "name" column'] };
|
||||
const contacts: ContactData[] = [];
|
||||
const errors: string[] = [];
|
||||
rows.forEach((r, i) => {
|
||||
if (!r.some((c) => c.trim())) return;
|
||||
const get = (k: string) => {
|
||||
const at = col.get(k);
|
||||
return at === undefined ? '' : (r[at] ?? '').trim();
|
||||
};
|
||||
const numbers: ContactData['numbers'] = [];
|
||||
for (let n = 1; col.has(`number${n}`); n++)
|
||||
if (get(`number${n}`)) numbers.push({ label: get(`label${n}`), number: get(`number${n}`) });
|
||||
const name = get('name');
|
||||
if (!name) {
|
||||
errors.push(`Row ${i + 2}: no name, skipped`);
|
||||
return;
|
||||
}
|
||||
contacts.push({
|
||||
name,
|
||||
company: get('company') || null,
|
||||
email: get('email') || null,
|
||||
numbers,
|
||||
notes: get('notes') || null,
|
||||
});
|
||||
});
|
||||
return { contacts, errors };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// C5: what a call looks like on a Contrack timeline (11 §4.4).
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { splitSummary, timelineEntry, type TimelineCall } from './timeline.ts';
|
||||
|
||||
const call = (c: Partial<TimelineCall>): TimelineCall => ({
|
||||
out: false,
|
||||
status: 'answered',
|
||||
fromNumber: '13125551234',
|
||||
toNumber: '13125550000',
|
||||
talkSeconds: 192,
|
||||
answeredBy: null,
|
||||
recorded: false,
|
||||
...c,
|
||||
});
|
||||
|
||||
describe('timelineEntry', () => {
|
||||
it('titles calls from where the person sat', () => {
|
||||
const t = (c: Partial<TimelineCall>) => timelineEntry(call(c), null).title;
|
||||
expect(t({})).toBe('Incoming call');
|
||||
expect(t({ answeredBy: 'Sue Smith' })).toBe('Incoming call, answered by Sue Smith');
|
||||
expect(t({ status: 'missed', talkSeconds: 0 })).toBe('Missed call');
|
||||
expect(t({ status: 'voicemail', talkSeconds: 0 })).toBe('Missed call, voicemail left');
|
||||
expect(t({ out: true })).toBe('Outgoing call');
|
||||
expect(t({ out: true, status: 'busy', talkSeconds: 0 })).toBe('Outgoing call, busy');
|
||||
expect(t({ out: true, status: 'failed', talkSeconds: 0 })).toBe('Outgoing call, no answer');
|
||||
});
|
||||
|
||||
it('writes the facts, the summary and its action items as escaped HTML', () => {
|
||||
const e = timelineEntry(
|
||||
call({ recorded: true }),
|
||||
'Acme <wants> a quote & a call back.\nAction items:\n- Dan sends the quote\n- Sue books a visit',
|
||||
);
|
||||
expect(e).toEqual({
|
||||
title: 'Incoming call',
|
||||
content:
|
||||
'<p>From (312) 555-1234 to (312) 555-0000 · 3:12 · recorded</p>' +
|
||||
'<p>Acme <wants> a quote & a call back.</p>' +
|
||||
'<p>Action items:</p><ul><li>Dan sends the quote</li><li>Sue books a visit</li></ul>',
|
||||
duration: 4,
|
||||
firstItem: 'Dan sends the quote',
|
||||
});
|
||||
const missed = timelineEntry(call({ status: 'missed', talkSeconds: 0 }), null);
|
||||
expect([missed.content, missed.duration, missed.firstItem]).toEqual([
|
||||
'<p>From (312) 555-1234 to (312) 555-0000</p>',
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
expect(timelineEntry(call({ out: true }), null).content).toBe(
|
||||
'<p>To (312) 555-0000 · 3:12</p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('reads "Action items: none" as none', () => {
|
||||
expect(splitSummary('Wrong number.\nAction items: none')).toEqual({
|
||||
text: ['Wrong number.'],
|
||||
items: [],
|
||||
});
|
||||
expect(splitSummary('Just chatted.')).toEqual({ text: ['Just chatted.'], items: [] });
|
||||
expect(splitSummary('X.\nAction items:\n- none')).toEqual({ text: ['X.'], items: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Calls on the Contrack timeline (11 §4.4): each call of a connected person with one of their
|
||||
// Contrack contacts becomes a "call" interaction on that contact, with the recorded call's
|
||||
// summary if they asked for it. Unknown numbers are not added to Contrack.
|
||||
import { and, asc, eq, gt, isNotNull, lt, ne, sql } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import { formatNumber } from '@selfphoneng/shared';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import {
|
||||
callExtensions,
|
||||
calls,
|
||||
contrackCallLog,
|
||||
extensions,
|
||||
recordings,
|
||||
users,
|
||||
} from '../db/schema.ts';
|
||||
import { todayIn } from '../generator/load.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { ContrackError, contrackBook, contrackInteraction, contrackSettings } from './contrack.ts';
|
||||
import { ON_THE_CALL } from '../services/cdr.ts';
|
||||
|
||||
/** A recorded call waits this long for its summary before it's logged without one. */
|
||||
const SUMMARY_WAIT_MS = 30 * 60_000;
|
||||
|
||||
const esc = (s: string) =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const num = (n: string | null) => (n ? (n.length > 6 ? formatNumber(n) : n) : 'an unknown number');
|
||||
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
|
||||
export type TimelineCall = {
|
||||
out: boolean;
|
||||
status: string;
|
||||
fromNumber: string | null;
|
||||
toNumber: string | null;
|
||||
talkSeconds: number;
|
||||
/** Someone else answered (a ring group or queue colleague). */
|
||||
answeredBy: string | null;
|
||||
recorded: boolean;
|
||||
};
|
||||
|
||||
/** "Action items:" lines of a summary (10 §3), and the text before them. */
|
||||
export function splitSummary(summary: string): { text: string[]; items: string[] } {
|
||||
const lines = summary
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const at = lines.findIndex((l) => /^action items:/i.test(l));
|
||||
if (at < 0) return { text: lines, items: [] };
|
||||
return {
|
||||
text: lines.slice(0, at),
|
||||
items: lines
|
||||
.slice(at + 1)
|
||||
.filter((l) => /^[-*•]\s+/.test(l))
|
||||
.map((l) => l.replace(/^[-*•]\s+/, ''))
|
||||
.filter((l) => !/^none\.?$/i.test(l)),
|
||||
};
|
||||
}
|
||||
|
||||
/** The interaction for one call: title, HTML content, minutes, the first action item. */
|
||||
export function timelineEntry(c: TimelineCall, summary: string | null) {
|
||||
const missed = ['missed', 'voicemail', 'abandoned'].includes(c.status);
|
||||
const title = c.out
|
||||
? c.status === 'answered'
|
||||
? 'Outgoing call'
|
||||
: `Outgoing call, ${c.status === 'busy' ? 'busy' : 'no answer'}`
|
||||
: c.status === 'voicemail'
|
||||
? 'Missed call, voicemail left'
|
||||
: missed || c.status !== 'answered'
|
||||
? 'Missed call'
|
||||
: c.answeredBy
|
||||
? `Incoming call, answered by ${c.answeredBy}`
|
||||
: 'Incoming call';
|
||||
const facts = [
|
||||
c.out ? `To ${num(c.toNumber)}` : `From ${num(c.fromNumber)} to ${num(c.toNumber)}`,
|
||||
...(c.talkSeconds ? [mmss(c.talkSeconds)] : []),
|
||||
...(c.recorded ? ['recorded'] : []),
|
||||
];
|
||||
const parts = [`<p>${esc(facts.join(' · '))}</p>`];
|
||||
const s = summary ? splitSummary(summary) : null;
|
||||
if (s?.text.length) parts.push(...s.text.map((l) => `<p>${esc(l)}</p>`));
|
||||
if (s?.items.length)
|
||||
parts.push(`<p>Action items:</p><ul>${s.items.map((i) => `<li>${esc(i)}</li>`).join('')}</ul>`);
|
||||
return {
|
||||
title,
|
||||
content: parts.join(''),
|
||||
duration: c.talkSeconds ? Math.ceil(c.talkSeconds / 60) : null,
|
||||
firstItem: s?.items[0] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const answerer = alias(extensions, 'answerer');
|
||||
|
||||
/**
|
||||
* One run of the contrackLog job: calls that ended over a minute ago (and within 2 days), of
|
||||
* people who connected Contrack and log calls, not looked at yet. Contrack down: tried again on
|
||||
* the next run.
|
||||
*/
|
||||
export async function logCallsToContrack(d: {
|
||||
db: Db;
|
||||
log: { error(o: object, m: string): void };
|
||||
now?: Date;
|
||||
}): Promise<number> {
|
||||
const c = await contrackSettings(d.db);
|
||||
if (!c) return 0;
|
||||
const now = d.now ?? new Date();
|
||||
const tz = await getSetting(d.db, 'general.timezone');
|
||||
const summariesOn =
|
||||
(await getSetting(d.db, 'ai.callTranscripts')) && (await getSetting(d.db, 'ai.callSummaries'));
|
||||
const rows = await d.db
|
||||
.select({
|
||||
call: calls,
|
||||
role: callExtensions.role,
|
||||
userId: users.id,
|
||||
withSummary: users.contrackLogSummaries,
|
||||
tokenEnc: users.contrackTokenEnc,
|
||||
extId: extensions.id,
|
||||
answeredById: answerer.id,
|
||||
answeredBy: answerer.name,
|
||||
summary: recordings.summary,
|
||||
aiDoneAt: recordings.aiDoneAt,
|
||||
})
|
||||
.from(calls)
|
||||
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
|
||||
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
|
||||
.innerJoin(users, eq(users.id, extensions.userId))
|
||||
.leftJoin(answerer, eq(answerer.id, calls.answeredByExtensionId))
|
||||
.leftJoin(recordings, eq(recordings.id, calls.recordingId))
|
||||
.where(
|
||||
and(
|
||||
isNotNull(users.contrackTokenEnc),
|
||||
eq(users.contrackLogCalls, true),
|
||||
eq(users.isActive, true),
|
||||
ne(calls.direction, 'internal'),
|
||||
gt(calls.endedAt, users.contrackLogSince),
|
||||
lt(calls.endedAt, new Date(now.getTime() - 60_000)),
|
||||
gt(calls.endedAt, new Date(now.getTime() - 2 * 24 * 3600_000)),
|
||||
sql`not exists (select 1 from ${contrackCallLog} l where l.call_id = ${calls.id} and l.user_id = ${users.id})`,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(calls.endedAt))
|
||||
.limit(500);
|
||||
|
||||
// One entry per call and person (a person can be in a call as caller, rang and answered).
|
||||
// `heard`: made or answered it; a phone that only rang gets no recording or summary (08 §7 #3).
|
||||
const todo = new Map<string, (typeof rows)[number] & { out: boolean; heard: boolean }>();
|
||||
for (const r of rows) {
|
||||
const k = `${r.call.id}|${r.userId}`;
|
||||
const out = r.role === 'caller' || todo.get(k)?.out === true;
|
||||
const heard = ON_THE_CALL.includes(r.role) || todo.get(k)?.heard === true;
|
||||
todo.set(k, { ...r, out, heard });
|
||||
}
|
||||
const skipUser = new Set<string>(); // Contrack down or token refused: try again next run
|
||||
let logged = 0;
|
||||
for (const r of todo.values()) {
|
||||
if (skipUser.has(r.userId)) continue;
|
||||
const record = (interactionId: string | null, error: string | null = null) =>
|
||||
d.db
|
||||
.insert(contrackCallLog)
|
||||
.values({ callId: r.call.id, userId: r.userId, interactionId, error })
|
||||
.onConflictDoNothing();
|
||||
const book = await contrackBook(d.db, c, r.userId);
|
||||
if (!book || book.error) {
|
||||
skipUser.add(r.userId);
|
||||
continue;
|
||||
}
|
||||
const other = r.out ? r.call.toNumber : r.call.fromNumber;
|
||||
const contact = other
|
||||
? book.contacts.find((x) => x.numbers.some((n) => n.number === other))
|
||||
: undefined;
|
||||
if (!contact) {
|
||||
await record(null);
|
||||
continue;
|
||||
}
|
||||
const recorded = r.heard && r.call.recordingId !== null;
|
||||
const wantSummary = r.withSummary && summariesOn && recorded;
|
||||
if (wantSummary && !r.aiDoneAt && now.getTime() - r.call.endedAt.getTime() < SUMMARY_WAIT_MS)
|
||||
continue; // the transcript job hasn't summarized it yet
|
||||
const e = timelineEntry(
|
||||
{
|
||||
out: r.out,
|
||||
status: r.call.status,
|
||||
fromNumber: r.call.fromNumber,
|
||||
toNumber: r.call.toNumber,
|
||||
talkSeconds: r.call.talkSeconds,
|
||||
answeredBy: r.answeredById && r.answeredById !== r.extId ? r.answeredBy : null,
|
||||
recorded,
|
||||
},
|
||||
wantSummary ? r.summary : null,
|
||||
);
|
||||
try {
|
||||
const id = await contrackInteraction(c, decrypt(r.tokenEnc!), contact.id, {
|
||||
type: 'call',
|
||||
title: e.title,
|
||||
content: e.content,
|
||||
date: r.call.startedAt.toISOString(),
|
||||
duration: e.duration,
|
||||
source: 'selfphoneng',
|
||||
...(e.firstItem
|
||||
? {
|
||||
actionItem: {
|
||||
title: e.firstItem.slice(0, 200),
|
||||
dueAt: todayIn(tz, new Date(r.call.startedAt.getTime() + 24 * 3600_000)),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
await record(id);
|
||||
logged++;
|
||||
} catch (err) {
|
||||
const status = err instanceof ContrackError ? err.status : undefined;
|
||||
// Refused (bad request, a read-only token, a deleted contact): noted, not retried.
|
||||
if (status && status >= 400 && status < 500 && status !== 401 && status !== 429)
|
||||
await record(null, (err as Error).message);
|
||||
else {
|
||||
skipUser.add(r.userId);
|
||||
d.log.error({ err, user: r.userId }, 'contrack: call not logged yet');
|
||||
}
|
||||
}
|
||||
}
|
||||
return logged;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// C1: vCard and CSV imports read what phones and address books export (11 §3.1).
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseCsv } from './csv.ts';
|
||||
import { parseVcards } from './vcard.ts';
|
||||
|
||||
const IPHONE = [
|
||||
'BEGIN:VCARD',
|
||||
'VERSION:3.0',
|
||||
'PRODID:-//Apple Inc.//iPhone OS 17.0//EN',
|
||||
'N:Doe;Jane;;;',
|
||||
'FN:Jane Doe',
|
||||
'ORG:Acme Corp;',
|
||||
'EMAIL;type=INTERNET;type=HOME;type=pref:jane@example.com',
|
||||
'TEL;type=CELL;type=VOICE;type=pref:(312) 555-1234',
|
||||
'item1.TEL:+1 312 555 0000',
|
||||
'item1.X-ABLabel:_$!<Main>!$_',
|
||||
'NOTE:Met at the fair\\, 2026\\nCall after 5',
|
||||
'PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD',
|
||||
' /2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJ',
|
||||
'END:VCARD',
|
||||
].join('\r\n');
|
||||
|
||||
const GOOGLE = `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:Bob Smith
|
||||
N:Smith;Bob;;;
|
||||
TEL;TYPE=WORK:+13125559999
|
||||
TEL;TYPE=HOME:3125558888
|
||||
EMAIL;TYPE=INTERNET:bob@example.com
|
||||
ORG:Widgets Inc.
|
||||
item1.URL:http\\://example.com
|
||||
END:VCARD
|
||||
BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
N:;;;;
|
||||
TEL;TYPE=CELL:3125550101
|
||||
END:VCARD
|
||||
`;
|
||||
|
||||
const V21 = `BEGIN:VCARD
|
||||
VERSION:2.1
|
||||
N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:M=C3=BCller;J=C3=BCrgen;;Dr.;
|
||||
TEL;CELL:+49301234567
|
||||
TEL;WORK;FAX:+49301234500
|
||||
NOTE;ENCODING=QUOTED-PRINTABLE:Line one=0D=0ALine tw=
|
||||
o
|
||||
END:VCARD`;
|
||||
|
||||
const V40 = `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:Carol Lee
|
||||
TEL;VALUE=uri;TYPE="voice,cell":tel:+1-312-555-7777
|
||||
END:VCARD
|
||||
BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
NOTE:nothing to call
|
||||
END:VCARD`;
|
||||
|
||||
describe('parseVcards', () => {
|
||||
it('reads an iPhone export: names, company, labels, note; skips the photo', () => {
|
||||
expect(parseVcards(IPHONE)).toEqual({
|
||||
contacts: [
|
||||
{
|
||||
name: 'Jane Doe',
|
||||
company: 'Acme Corp',
|
||||
email: 'jane@example.com',
|
||||
numbers: [
|
||||
{ label: 'mobile', number: '(312) 555-1234' },
|
||||
{ label: 'main', number: '+1 312 555 0000' },
|
||||
],
|
||||
notes: 'Met at the fair, 2026\nCall after 5',
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a Google export, and names a card with only a number after it', () => {
|
||||
const { contacts } = parseVcards(GOOGLE);
|
||||
expect(contacts.map((c) => [c.name, c.company, c.numbers])).toEqual([
|
||||
[
|
||||
'Bob Smith',
|
||||
'Widgets Inc.',
|
||||
[
|
||||
{ label: 'work', number: '+13125559999' },
|
||||
{ label: 'home', number: '3125558888' },
|
||||
],
|
||||
],
|
||||
['3125550101', null, [{ label: 'mobile', number: '3125550101' }]],
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads vCard 2.1: quoted-printable UTF-8, bare types, soft line breaks', () => {
|
||||
const [c] = parseVcards(V21).contacts;
|
||||
expect(c).toMatchObject({
|
||||
name: 'Dr. Jürgen Müller',
|
||||
numbers: [
|
||||
{ label: 'mobile', number: '+49301234567' },
|
||||
{ label: 'fax', number: '+49301234500' },
|
||||
],
|
||||
notes: 'Line one\r\nLine two',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads vCard 4.0 tel: URIs, and reports a card with nothing to call', () => {
|
||||
expect(parseVcards(V40)).toEqual({
|
||||
contacts: [
|
||||
{
|
||||
name: 'Carol Lee',
|
||||
company: null,
|
||||
email: null,
|
||||
numbers: [{ label: 'mobile', number: '+1-312-555-7777' }],
|
||||
notes: null,
|
||||
},
|
||||
],
|
||||
errors: ['Card 2: no name or number, skipped'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCsv', () => {
|
||||
it('reads the 04 §7.4 columns in any order, with quotes and line breaks', () => {
|
||||
const csv =
|
||||
'Number1,Name,label1,company,notes,number2,label2\r\n' +
|
||||
'312-555-1234,"Doe, Jane",mobile,Acme,"two\nlines",,\r\n' +
|
||||
',,,,,,\r\n' +
|
||||
'3125550000,,work,,,,\r\n' +
|
||||
'+44 20 7946 0958,"Bob ""B"" Smith",,,,101,desk\r\n';
|
||||
expect(parseCsv(csv)).toEqual({
|
||||
contacts: [
|
||||
{
|
||||
name: 'Doe, Jane',
|
||||
company: 'Acme',
|
||||
email: null,
|
||||
numbers: [{ label: 'mobile', number: '312-555-1234' }],
|
||||
notes: 'two\nlines',
|
||||
},
|
||||
{
|
||||
name: 'Bob "B" Smith',
|
||||
company: null,
|
||||
email: null,
|
||||
numbers: [
|
||||
{ label: '', number: '+44 20 7946 0958' },
|
||||
{ label: 'desk', number: '101' },
|
||||
],
|
||||
notes: null,
|
||||
},
|
||||
],
|
||||
errors: ['Row 4: no name, skipped'],
|
||||
});
|
||||
});
|
||||
|
||||
it('needs a header with a name column', () => {
|
||||
expect(parseCsv('Jane,3125551234\n').errors[0]).toMatch(/"name" column/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// vCard 2.1, 3.0 and 4.0 to contacts (11 §3.1): the name, company, first email, every phone and
|
||||
// the note. Everything else (photos, addresses, birthdays) is ignored.
|
||||
import type { ContactData } from '@selfphoneng/shared';
|
||||
|
||||
type Prop = { group: string; name: string; params: Map<string, string[]>; value: string };
|
||||
|
||||
/** "ENCODING=QUOTED-PRINTABLE;CHARSET=utf-8;TYPE=CELL,VOICE;WORK" (2.1 allows bare types). */
|
||||
function parseParams(parts: string[]): Map<string, string[]> {
|
||||
const out = new Map<string, string[]>();
|
||||
for (const p of parts) {
|
||||
const eq = p.indexOf('=');
|
||||
const key = eq < 0 ? 'TYPE' : p.slice(0, eq).toUpperCase();
|
||||
const raw = eq < 0 ? p : p.slice(eq + 1);
|
||||
const vals = raw.replace(/"/g, '').split(',').filter(Boolean);
|
||||
// 2.1's bare QUOTED-PRINTABLE is an encoding, not a type.
|
||||
const k = eq < 0 && /^(QUOTED-PRINTABLE|BASE64|8BIT)$/i.test(raw) ? 'ENCODING' : key;
|
||||
out.set(k, [...(out.get(k) ?? []), ...vals.map((v) => v.toUpperCase())]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** "[group.]NAME[;params]:value", with ':' allowed inside quoted params. */
|
||||
function parseLine(line: string): Prop | null {
|
||||
let quoted = false;
|
||||
let colon = -1;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
if (line[i] === '"') quoted = !quoted;
|
||||
else if (line[i] === ':' && !quoted) {
|
||||
colon = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (colon < 0) return null;
|
||||
const [head = '', ...params] = line.slice(0, colon).split(';');
|
||||
const dot = head.lastIndexOf('.');
|
||||
return {
|
||||
group: dot < 0 ? '' : head.slice(0, dot).toUpperCase(),
|
||||
name: head.slice(dot + 1).toUpperCase(),
|
||||
params: parseParams(params),
|
||||
value: line.slice(colon + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeQp(v: string, charset: string): string {
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < v.length; i++) {
|
||||
const hex = v.slice(i + 1, i + 3);
|
||||
if (v[i] === '=' && /^[0-9A-F]{2}$/i.test(hex)) {
|
||||
bytes.push(parseInt(hex, 16));
|
||||
i += 2;
|
||||
} else bytes.push(v.charCodeAt(i) & 0xff);
|
||||
}
|
||||
try {
|
||||
return new TextDecoder(charset).decode(new Uint8Array(bytes));
|
||||
} catch {
|
||||
return new TextDecoder('utf-8').decode(new Uint8Array(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
/** Splits on unescaped `sep`, then unescapes \n \, \; \\. */
|
||||
function components(v: string, sep: ';' | ','): string[] {
|
||||
const out = [''];
|
||||
for (let i = 0; i < v.length; i++) {
|
||||
const c = v[i]!;
|
||||
if (c === '\\' && i + 1 < v.length) {
|
||||
const n = v[++i]!;
|
||||
out[out.length - 1] += n === 'n' || n === 'N' ? '\n' : n;
|
||||
} else if (c === sep) out.push('');
|
||||
else out[out.length - 1] += c;
|
||||
}
|
||||
return out.map((s) => s.trim());
|
||||
}
|
||||
const text = (v: string) => components(v, ';').join(';').trim();
|
||||
|
||||
/** cell → mobile, work, home, fax, else other; an Apple X-ABLabel wins. */
|
||||
function telLabel(types: string[], abLabel: string | undefined): string {
|
||||
if (abLabel) {
|
||||
const l = abLabel.replace(/^_\$!<|>!\$_$/g, '').trim();
|
||||
if (l) return l.toLowerCase() === 'cell' ? 'mobile' : l.toLowerCase();
|
||||
}
|
||||
if (types.includes('FAX')) return 'fax';
|
||||
if (types.some((t) => t === 'CELL' || t === 'MOBILE' || t === 'IPHONE')) return 'mobile';
|
||||
if (types.includes('WORK')) return 'work';
|
||||
if (types.includes('HOME')) return 'home';
|
||||
if (types.includes('MAIN')) return 'main';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/** Every card in the text, in order. A card with no name, company or number is an error. */
|
||||
export function parseVcards(input: string): { contacts: ContactData[]; errors: string[] } {
|
||||
// Unfold: a line starting with a space or tab continues the one before (and 2.1
|
||||
// quoted-printable continues a line that ends with "=").
|
||||
const lines: string[] = [];
|
||||
for (const l of input.replace(/^\uFEFF/, '').split(/\r\n|\r|\n/)) {
|
||||
const prev = lines.at(-1);
|
||||
if (prev?.endsWith('=') && /QUOTED-PRINTABLE/i.test(prev.split(':')[0]!))
|
||||
lines[lines.length - 1] = prev.slice(0, -1) + l.replace(/^[ \t]/, '');
|
||||
else if (prev !== undefined && /^[ \t]/.test(l)) lines[lines.length - 1] = prev + l.slice(1);
|
||||
else lines.push(l);
|
||||
}
|
||||
|
||||
const contacts: ContactData[] = [];
|
||||
const errors: string[] = [];
|
||||
let card: Prop[] | null = null;
|
||||
for (const line of lines) {
|
||||
const p = parseLine(line);
|
||||
if (!p) continue;
|
||||
if (p.name === 'BEGIN' && p.value.toUpperCase() === 'VCARD') card = [];
|
||||
else if (p.name === 'END' && p.value.toUpperCase() === 'VCARD' && card) {
|
||||
const c = toContact(card);
|
||||
if (c) contacts.push(c);
|
||||
else errors.push(`Card ${contacts.length + errors.length + 1}: no name or number, skipped`);
|
||||
card = null;
|
||||
} else if (card) {
|
||||
const enc = p.params.get('ENCODING') ?? [];
|
||||
if (enc.includes('QUOTED-PRINTABLE'))
|
||||
p.value = decodeQp(p.value, p.params.get('CHARSET')?.[0]?.toLowerCase() ?? 'utf-8');
|
||||
card.push(p);
|
||||
}
|
||||
}
|
||||
return { contacts, errors };
|
||||
}
|
||||
|
||||
function toContact(props: Prop[]): ContactData | null {
|
||||
const first = (n: string) => props.find((p) => p.name === n)?.value;
|
||||
const fn = first('FN') ? text(first('FN')!) : '';
|
||||
const n = first('N');
|
||||
const [family = '', given = '', middle = '', prefix = '', suffix = ''] = n
|
||||
? components(n, ';')
|
||||
: [];
|
||||
const fromN = [prefix, given, middle, family, suffix].filter(Boolean).join(' ');
|
||||
const org = first('ORG') ? components(first('ORG')!, ';')[0] || null : null;
|
||||
const email = first('EMAIL') ? text(first('EMAIL')!) || null : null;
|
||||
const abLabels = new Map(
|
||||
props.filter((p) => p.name === 'X-ABLABEL' && p.group).map((p) => [p.group, text(p.value)]),
|
||||
);
|
||||
const numbers = props
|
||||
.filter((p) => p.name === 'TEL')
|
||||
.map((p) => ({
|
||||
label: telLabel(p.params.get('TYPE') ?? [], p.group ? abLabels.get(p.group) : undefined),
|
||||
number: text(p.value).replace(/^tel:/i, '').split(';')[0]!.trim(),
|
||||
}))
|
||||
.filter((t) => t.number);
|
||||
const note = first('NOTE') ? text(first('NOTE')!) || null : null;
|
||||
const name = fn || fromN || org || numbers[0]?.number;
|
||||
if (!name) return null;
|
||||
return { name, company: org, email, numbers, notes: note };
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
||||
import pg from 'pg';
|
||||
@@ -19,3 +22,26 @@ export type Db = ReturnType<typeof createDb>['db'];
|
||||
export async function runMigrations(db: Db): Promise<void> {
|
||||
await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrations this image has and the database hasn't run yet: how many are applied, of how many.
|
||||
* Null when none are, or for an empty database (a fresh install: nothing to lose). Reads drizzle's
|
||||
* own bookkeeping, `drizzle.__drizzle_migrations`.
|
||||
*/
|
||||
export async function migrationsPending(
|
||||
db: Db,
|
||||
folder = MIGRATIONS_DIR,
|
||||
): Promise<{ applied: number; total: number } | null> {
|
||||
const journal = JSON.parse(await readFile(join(folder, 'meta', '_journal.json'), 'utf8')) as {
|
||||
entries: unknown[];
|
||||
};
|
||||
const t = await db.execute<{ t: string | null }>(
|
||||
sql`select to_regclass('drizzle.__drizzle_migrations')::text as t`,
|
||||
);
|
||||
if (!t.rows[0]?.t) return null;
|
||||
const r = await db.execute<{ n: number }>(
|
||||
sql`select count(*)::int as n from drizzle.__drizzle_migrations`,
|
||||
);
|
||||
const applied = r.rows[0]!.n;
|
||||
return applied < journal.entries.length ? { applied, total: journal.entries.length } : null;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE "moh_classes" ADD COLUMN "shuffle" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "sound_files" ADD COLUMN "position" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
-- Existing music on hold keeps the order it was uploaded in (owner, 2026-10-21).
|
||||
UPDATE "sound_files" s SET "position" = o.n FROM (
|
||||
SELECT "id", row_number() OVER (PARTITION BY "moh_class_id" ORDER BY "created_at", "id") - 1 AS n
|
||||
FROM "sound_files" WHERE "moh_class_id" IS NOT NULL
|
||||
) o WHERE s."id" = o."id";
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "voicemail_messages" ADD COLUMN "transcript" text;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "recordings" ADD COLUMN "transcript" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "recordings" ADD COLUMN "summary" text;--> statement-breakpoint
|
||||
ALTER TABLE "recordings" ADD COLUMN "ai_done_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "recordings" ADD COLUMN "ai_error" text;
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TYPE "public"."call_report" AS ENUM('off', 'hourly', 'daily', 'weekly');--> statement-breakpoint
|
||||
ALTER TABLE "extensions" ADD COLUMN "call_report" "call_report" DEFAULT 'off' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "extensions" ADD COLUMN "call_report_time" text DEFAULT '08:00' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "extensions" ADD COLUMN "call_report_weekday" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "extensions" ADD COLUMN "call_report_summaries" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "extensions" ADD COLUMN "call_report_sent_at" timestamp with time zone;
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE "contact_shares" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"from_user_id" uuid,
|
||||
"to_user_id" uuid NOT NULL,
|
||||
"source_id" text NOT NULL,
|
||||
"contact" jsonb NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"decided_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "contact_shares_status_check" CHECK ("contact_shares"."status" in ('pending', 'accepted', 'denied'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "contact_shares" ADD CONSTRAINT "contact_shares_from_user_id_users_id_fk" FOREIGN KEY ("from_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "contact_shares" ADD CONSTRAINT "contact_shares_to_user_id_users_id_fk" FOREIGN KEY ("to_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "contact_shares_to_user_id_index" ON "contact_shares" USING btree ("to_user_id") WHERE "contact_shares"."status" = 'pending';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ADD COLUMN "contrack_token_enc" text;
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE "contrack_call_log" (
|
||||
"call_id" uuid NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"interaction_id" text,
|
||||
"error" text,
|
||||
"logged_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "contrack_call_log_call_id_user_id_pk" PRIMARY KEY("call_id","user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "contrack_log_calls" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "contrack_log_summaries" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "contrack_log_since" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "contrack_call_log" ADD CONSTRAINT "contrack_call_log_call_id_calls_id_fk" FOREIGN KEY ("call_id") REFERENCES "public"."calls"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "contrack_call_log" ADD CONSTRAINT "contrack_call_log_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
-- People already connected (C3) start logging now, not with the last 2 days of calls.
|
||||
UPDATE "users" SET "contrack_log_since" = now() WHERE "contrack_token_enc" IS NOT NULL;
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE TYPE "public"."identity_kind" AS ENUM('oidc', 'ldap');--> statement-breakpoint
|
||||
CREATE TABLE "user_identities" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"kind" "identity_kind" NOT NULL,
|
||||
"subject" text NOT NULL,
|
||||
"email" text,
|
||||
"groups" text[] DEFAULT '{}' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"last_used_at" timestamp with time zone,
|
||||
CONSTRAINT "user_identities_kind_subject_unique" UNIQUE("kind","subject"),
|
||||
CONSTRAINT "user_identities_userId_kind_unique" UNIQUE("user_id","kind")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "users" ALTER COLUMN "password_hash" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD COLUMN "method" text DEFAULT 'password' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD COLUMN "idp_refresh_enc" text;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD COLUMN "idp_id_token_enc" text;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD COLUMN "idp_checked_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "user_identities" ADD CONSTRAINT "user_identities_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_method_check" CHECK ("sessions"."method" in ('password', 'ldap', 'oidc'));
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE "mobile_devices" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"name" text,
|
||||
"platform" text,
|
||||
"token_prefix" text,
|
||||
"token_hash" text,
|
||||
"pair_code_hash" text,
|
||||
"pair_expires_at" timestamp with time zone,
|
||||
"last_used_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "mobile_devices_tokenHash_unique" UNIQUE("token_hash"),
|
||||
CONSTRAINT "mobile_devices_pairCodeHash_unique" UNIQUE("pair_code_hash"),
|
||||
CONSTRAINT "mobile_devices_platform_check" CHECK ("mobile_devices"."platform" in ('android', 'ios'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "mobile_devices" ADD CONSTRAINT "mobile_devices_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "mobile_devices_user_id_index" ON "mobile_devices" USING btree ("user_id");
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE "ftc_complaints" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"number" text NOT NULL,
|
||||
"reported_at" timestamp with time zone NOT NULL,
|
||||
"subject" text,
|
||||
"robocall" boolean,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "spam_lookups" (
|
||||
"number" text PRIMARY KEY NOT NULL,
|
||||
"provider" text NOT NULL,
|
||||
"risk" text NOT NULL,
|
||||
"info" jsonb NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "spam_lookups_risk_check" CHECK ("spam_lookups"."risk" in ('low', 'medium', 'high'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "calls" ADD COLUMN "spam_risk" text;--> statement-breakpoint
|
||||
ALTER TABLE "calls" ADD COLUMN "spam_info" jsonb;--> statement-breakpoint
|
||||
CREATE INDEX "ftc_complaints_number_index" ON "ftc_complaints" USING btree ("number");--> statement-breakpoint
|
||||
CREATE INDEX "ftc_complaints_reported_at_index" ON "ftc_complaints" USING btree ("reported_at");--> statement-breakpoint
|
||||
CREATE INDEX "spam_lookups_expires_at_index" ON "spam_lookups" USING btree ("expires_at");
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "spam_reports" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"number" text NOT NULL,
|
||||
"reason" text NOT NULL,
|
||||
"reported_by" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "spam_reports_number_reportedBy_unique" UNIQUE("number","reported_by")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "spam_reports" ADD CONSTRAINT "spam_reports_reported_by_users_id_fk" FOREIGN KEY ("reported_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
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
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
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
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -36,6 +36,83 @@
|
||||
"when": 1792512298128,
|
||||
"tag": "0004_system_events",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1792606712441,
|
||||
"tag": "0005_moh_order",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1792624567407,
|
||||
"tag": "0006_voicemail_transcript",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1792626762332,
|
||||
"tag": "0007_recording_transcripts",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1792781321577,
|
||||
"tag": "0008_call_reports",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1792784242837,
|
||||
"tag": "0009_contact_shares",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1792785318384,
|
||||
"tag": "0010_contrack_token",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1792788393381,
|
||||
"tag": "0011_contrack_call_log",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1792951905226,
|
||||
"tag": "0012_sso",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1793052019332,
|
||||
"tag": "0013_mobile_devices",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1793127210386,
|
||||
"tag": "0014_spam_check",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1793212840276,
|
||||
"tag": "0015_spam_reports",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+176
-2
@@ -19,6 +19,7 @@ import {
|
||||
smallint,
|
||||
text,
|
||||
timestamp,
|
||||
unique,
|
||||
uuid,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
@@ -53,7 +54,8 @@ export const users = pgTable('users', {
|
||||
id: id(),
|
||||
email: citext().notNull().unique(),
|
||||
displayName: text().notNull(),
|
||||
passwordHash: text().notNull(),
|
||||
/** argon2id; null: no password, signs in with OpenID only (12 §3.3). */
|
||||
passwordHash: text(),
|
||||
role: userRole().notNull().default('user'),
|
||||
isActive: boolean().notNull().default(true),
|
||||
totpSecretEnc: text(),
|
||||
@@ -63,6 +65,13 @@ export const users = pgTable('users', {
|
||||
totpLastStep: bigint({ mode: 'number' }),
|
||||
mustChangePassword: boolean().notNull().default(false),
|
||||
lastLoginAt: tstz(),
|
||||
/** Contrack mode (11 §4.3): this person's own Contrack personal token, encrypted. */
|
||||
contrackTokenEnc: text(),
|
||||
/** 11 §4.4: my calls on my Contrack contacts' timelines, from contrackLogSince on. */
|
||||
contrackLogCalls: boolean().notNull().default(true),
|
||||
/** …with each recorded call's summary (10 §3). */
|
||||
contrackLogSummaries: boolean().notNull().default(false),
|
||||
contrackLogSince: tstz(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
@@ -76,9 +85,40 @@ export const sessions = pgTable(
|
||||
expiresAt: tstz().notNull(),
|
||||
ip: inet(),
|
||||
userAgent: text(),
|
||||
/** How it was made (12 §2): password, ldap or oidc. */
|
||||
method: text().notNull().default('password'),
|
||||
/** OpenID sessions (12 §3.5): refresh token for the leaver check, ID token for signing out. */
|
||||
idpRefreshEnc: text(),
|
||||
idpIdTokenEnc: text(),
|
||||
idpCheckedAt: tstz(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [index().on(t.userId)],
|
||||
(t) => [
|
||||
index().on(t.userId),
|
||||
check('sessions_method_check', sql`${t.method} in ('password', 'ldap', 'oidc')`),
|
||||
],
|
||||
);
|
||||
|
||||
// One more way to sign in to a user (12 §2): a connected OpenID account or directory entry.
|
||||
export const identityKind = pgEnum('identity_kind', ['oidc', 'ldap']);
|
||||
export const userIdentities = pgTable(
|
||||
'user_identities',
|
||||
{
|
||||
id: id(),
|
||||
userId: uuid()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
kind: identityKind().notNull(),
|
||||
/** oidc: "<iss> <sub>"; ldap: the entry's UUID attribute. */
|
||||
subject: text().notNull(),
|
||||
/** As the provider last said. */
|
||||
email: text(),
|
||||
/** oidc: groupsClaim values at the last sign-in (12 §3.4). */
|
||||
groups: emptyTextArray(),
|
||||
createdAt: createdAt(),
|
||||
lastUsedAt: tstz(),
|
||||
},
|
||||
(t) => [unique().on(t.kind, t.subject), unique().on(t.userId, t.kind)],
|
||||
);
|
||||
|
||||
export const apiTokens = pgTable('api_tokens', {
|
||||
@@ -94,6 +134,29 @@ export const apiTokens = pgTable('api_tokens', {
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
/** Paired phones (13 §3): a row waits with a pairing code, then holds the device token. */
|
||||
export const mobileDevices = pgTable(
|
||||
'mobile_devices',
|
||||
{
|
||||
id: id(),
|
||||
userId: uuid()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text(),
|
||||
platform: text(),
|
||||
tokenPrefix: text(),
|
||||
tokenHash: text().unique(),
|
||||
pairCodeHash: text().unique(),
|
||||
pairExpiresAt: tstz(),
|
||||
lastUsedAt: tstz(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
index().on(t.userId),
|
||||
check('mobile_devices_platform_check', sql`${t.platform} in ('android', 'ios')`),
|
||||
],
|
||||
);
|
||||
|
||||
// ───────────── extensions ─────────────
|
||||
export const outboundClass = pgEnum('outbound_class', [
|
||||
'internal',
|
||||
@@ -102,6 +165,7 @@ export const outboundClass = pgEnum('outbound_class', [
|
||||
'international',
|
||||
]);
|
||||
export const recordMode = pgEnum('record_mode', ['never', 'inbound', 'outbound', 'all']);
|
||||
export const callReport = pgEnum('call_report', ['off', 'hourly', 'daily', 'weekly']);
|
||||
|
||||
export const extensions = pgTable('extensions', {
|
||||
id: id(),
|
||||
@@ -128,6 +192,13 @@ export const extensions = pgTable('extensions', {
|
||||
language: text().notNull().default('en'),
|
||||
mohClass: text().notNull().default('default'),
|
||||
blfNumbers: emptyTextArray(),
|
||||
/** The call report email (05 §4): how often, at what local time (HH:MM) and weekday (0 = Sunday). */
|
||||
callReport: callReport().notNull().default('off'),
|
||||
callReportTime: text().notNull().default('08:00'),
|
||||
callReportWeekday: integer().notNull().default(1),
|
||||
callReportSummaries: boolean().notNull().default(false),
|
||||
/** The end of the last period reported on (or when the schedule last changed). */
|
||||
callReportSentAt: tstz(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
@@ -334,6 +405,8 @@ export const queueCalls = pgTable(
|
||||
export const mohClasses = pgTable('moh_classes', {
|
||||
id: id(),
|
||||
name: text().notNull().unique(),
|
||||
/** Play the files in random order; otherwise in `sound_files.position` order (03 §8). */
|
||||
shuffle: boolean().notNull().default(false),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
@@ -349,6 +422,8 @@ export const soundFiles = pgTable(
|
||||
originalFilename: text(),
|
||||
asteriskName: text().notNull().unique(),
|
||||
durationMs: integer().notNull(),
|
||||
/** Music on hold: where it plays in its class's list (0 first). Prompts don't use it. */
|
||||
position: integer().notNull().default(0),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
@@ -529,6 +604,12 @@ export const recordings = pgTable('recordings', {
|
||||
startedAt: tstz().notNull(),
|
||||
durationMs: integer(),
|
||||
sizeBytes: bigint({ mode: 'number' }),
|
||||
/** Spec 10 §3: [{ side: 'caller' | 'other', at: seconds, text }], in time order. */
|
||||
transcript: jsonb().$type<{ side: 'caller' | 'other'; at: number; text: string }[]>(),
|
||||
summary: text(),
|
||||
/** When the transcript job finished with it (null: not yet); aiError: why it has none. */
|
||||
aiDoneAt: tstz(),
|
||||
aiError: text(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
@@ -549,11 +630,59 @@ export const calls = pgTable(
|
||||
answeredByExtensionId: uuid().references(() => extensions.id, { onDelete: 'set null' }),
|
||||
talkSeconds: integer().notNull().default(0),
|
||||
recordingId: uuid().references(() => recordings.id, { onDelete: 'set null' }),
|
||||
/** The spam check of an incoming call's number (14 §6); null = not checked. */
|
||||
spamRisk: text(),
|
||||
spamInfo: jsonb(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [index().on(t.startedAt.desc())],
|
||||
);
|
||||
|
||||
// ───────────── spam check (14 §7) ─────────────
|
||||
/** IPQualityScore answers, so a caller who rings again costs no credit (14 §4). */
|
||||
export const spamLookups = pgTable(
|
||||
'spam_lookups',
|
||||
{
|
||||
number: text().primaryKey(),
|
||||
provider: text().notNull(),
|
||||
risk: text().notNull(),
|
||||
info: jsonb().notNull(),
|
||||
expiresAt: tstz().notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
index().on(t.expiresAt),
|
||||
check('spam_lookups_risk_check', sql`${t.risk} in ('low', 'medium', 'high')`),
|
||||
],
|
||||
);
|
||||
|
||||
/** The local copy of the FTC's Do Not Call complaints (14 §2.2). */
|
||||
export const ftcComplaints = pgTable(
|
||||
'ftc_complaints',
|
||||
{
|
||||
id: text().primaryKey(),
|
||||
number: text().notNull(),
|
||||
reportedAt: tstz().notNull(),
|
||||
subject: text(),
|
||||
robocall: boolean(),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index().on(t.number), index().on(t.reportedAt)],
|
||||
);
|
||||
|
||||
/** "Report spam" from a user's call history, waiting for an admin (05 §2 Blocked numbers). */
|
||||
export const spamReports = pgTable(
|
||||
'spam_reports',
|
||||
{
|
||||
id: id(),
|
||||
number: text().notNull(),
|
||||
reason: text().notNull(),
|
||||
reportedBy: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [unique().on(t.number, t.reportedBy)],
|
||||
);
|
||||
|
||||
export const callExtensions = pgTable(
|
||||
'call_extensions',
|
||||
{
|
||||
@@ -586,6 +715,8 @@ export const voicemailMessages = pgTable('voicemail_messages', {
|
||||
filePath: text().notNull(),
|
||||
emailedAt: tstz(),
|
||||
deletedAt: tstz(),
|
||||
/** Speech to text (spec 10), when ai.voicemailTranscripts is on; null: none (yet). */
|
||||
transcript: text(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
@@ -600,6 +731,49 @@ export const contacts = pgTable('contacts', {
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
/** A call put on (or checked for) a person's Contrack timeline (11 §4.4): never twice. */
|
||||
export const contrackCallLog = pgTable(
|
||||
'contrack_call_log',
|
||||
{
|
||||
callId: uuid()
|
||||
.notNull()
|
||||
.references(() => calls.id, { onDelete: 'cascade' }),
|
||||
userId: uuid()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** Contrack's interaction id; null: no contact has that number, or it couldn't be written. */
|
||||
interactionId: text(),
|
||||
error: text(),
|
||||
loggedAt: tstz().notNull().defaultNow(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.callId, t.userId] })],
|
||||
);
|
||||
|
||||
/** A contact offered to a colleague, waiting for Accept or Deny (11 §2.1). */
|
||||
export const contactShares = pgTable(
|
||||
'contact_shares',
|
||||
{
|
||||
id: id(),
|
||||
fromUserId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
toUserId: uuid()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** The shared contact's id in the sender's book: offering it again replaces a pending offer. */
|
||||
sourceId: text().notNull(),
|
||||
/** A copy taken when shared: { name, company, email, numbers, notes }. */
|
||||
contact: jsonb().notNull(),
|
||||
status: text().notNull().default('pending'),
|
||||
decidedAt: tstz(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
index()
|
||||
.on(t.toUserId)
|
||||
.where(sql`${t.status} = 'pending'`),
|
||||
check('contact_shares_status_check', sql`${t.status} in ('pending', 'accepted', 'denied')`),
|
||||
],
|
||||
);
|
||||
|
||||
export const smsMessages = pgTable(
|
||||
'sms_messages',
|
||||
{
|
||||
|
||||
+3
@@ -2,4 +2,7 @@
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[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,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
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
|
||||
@@ -83,11 +105,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +136,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +175,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +186,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +201,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +219,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +235,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +245,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -2,3 +2,6 @@
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[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,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
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
|
||||
@@ -83,11 +105,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +136,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +175,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +186,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +201,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +219,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +235,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +245,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
+3
@@ -2,5 +2,8 @@
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
SP_TCADMIN_101=1
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[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,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
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
|
||||
@@ -83,11 +105,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +136,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +175,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +186,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +201,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +219,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +235,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +245,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
+3
@@ -2,6 +2,9 @@
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_103=2
|
||||
SP_CLASS_104=2
|
||||
SP_CLASS_105=2
|
||||
+56
-8
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[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,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
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
|
||||
@@ -83,11 +105,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +136,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +175,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +186,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +201,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +219,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +235,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +245,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
+3
@@ -2,6 +2,9 @@
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
SP_CLASS_103=2
|
||||
|
||||
+56
-8
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[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,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
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
|
||||
@@ -83,11 +105,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +136,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +175,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +186,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +201,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +219,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +235,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +245,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -2,5 +2,8 @@
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[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,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
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
|
||||
@@ -83,11 +105,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +136,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +175,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +186,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +201,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +219,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +235,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +245,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
+3
@@ -2,5 +2,8 @@
|
||||
SP_DEFAULT_CID=13125551234
|
||||
SP_DEFAULT_E911_CID=13125551234
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
SP_TCADMIN_101=1
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[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,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
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
|
||||
@@ -83,11 +105,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +136,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +175,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +186,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +201,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +219,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +235,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +245,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
stunaddr = stun.example.org:3478
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_99=2
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[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,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
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
|
||||
@@ -83,11 +105,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +136,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +175,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +186,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +201,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +219,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +235,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +245,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
+3
@@ -2,4 +2,7 @@
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[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,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
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
|
||||
@@ -83,11 +105,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +136,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +175,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +186,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +201,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +219,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +235,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +245,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -2,6 +2,9 @@
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
SP_CLASS_103=2
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[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,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
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
|
||||
@@ -83,11 +105,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +136,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +175,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +186,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +201,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +219,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +235,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +245,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
@@ -234,6 +282,7 @@ exten => 551,1,Goto(sp-queue-44444444-4444-4444-8444-444444444551,s,1)
|
||||
exten => s,1,Set(__SP_TARGET=550)
|
||||
same => n,Answer()
|
||||
same => n,Set(CALLERID(name)=Support: ${CALLERID(name)})
|
||||
same => n,Set(__SP_CNAM_PREFIX=Support: ${SP_CNAM_PREFIX})
|
||||
same => n,Gosub(sp-sub-record,s,1)
|
||||
same => n,Queue(550,t,,,300)
|
||||
same => n,GotoIf($["${QUEUESTATUS}" = "TIMEOUT" | "${QUEUESTATUS}" = "FULL" | "${QUEUESTATUS}" = "JOINEMPTY" | "${QUEUESTATUS}" = "LEAVEEMPTY" | "${QUEUESTATUS}" = "JOINUNAVAIL" | "${QUEUESTATUS}" = "LEAVEUNAVAIL"]?fail)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
[after-hours]
|
||||
mode = files
|
||||
directory = /var/lib/asterisk/moh/after-hours
|
||||
mode = playlist
|
||||
entry = /var/lib/asterisk/moh/after-hours/5a1f0c3e-0000-4000-8000-000000000003
|
||||
sort = random
|
||||
|
||||
[support-jazz]
|
||||
mode = files
|
||||
directory = /var/lib/asterisk/moh/support-jazz
|
||||
sort = random
|
||||
mode = playlist
|
||||
entry = /var/lib/asterisk/moh/support-jazz/5a1f0c3e-0000-4000-8000-000000000002
|
||||
entry = /var/lib/asterisk/moh/support-jazz/5a1f0c3e-0000-4000-8000-000000000001
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"mohClasses": ["support-jazz", "after-hours"],
|
||||
"mohClasses": [
|
||||
{
|
||||
"name": "support-jazz",
|
||||
"files": ["5a1f0c3e-0000-4000-8000-000000000002", "5a1f0c3e-0000-4000-8000-000000000001"]
|
||||
},
|
||||
{ "name": "after-hours", "shuffle": true, "files": ["5a1f0c3e-0000-4000-8000-000000000003"] }
|
||||
],
|
||||
"extensions": [
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000101",
|
||||
|
||||
+3
@@ -2,6 +2,9 @@
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
SP_CLASS_103=2
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[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,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
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
|
||||
@@ -83,11 +105,16 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
; This runs on the calling side, so with SP_REC_SIDES (call transcripts on) <uniqueid>-in.wav is
|
||||
; what the caller said and -out.wav what they heard (the other side).
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
same => n,GotoIf($["${GLOBAL(SP_REC_SIDES)}" = "1"]?sides)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n,Return()
|
||||
same => n(sides),MixMonitor(${SP_REC},br(${SP_REC:0:-4}-in.wav)t(${SP_REC:0:-4}-out.wav))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
@@ -109,14 +136,35 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Voicemail logins (*97, *98) from a phone: one at a time and 10 an hour, 3 PIN tries each
|
||||
; (voicemail.conf maxlogins), so a phone can't run through the PINs (08 §6). Returns "ok".
|
||||
[sp-sub-vmlimit]
|
||||
exten => s,1,Set(GROUP(spvm)=${SP_EXT})
|
||||
same => n,GotoIf($[${GROUP_COUNT(${SP_EXT}@spvm)} > 1]?no)
|
||||
same => n,Set(LOCAL(H)=${STRFTIME(${EPOCH},UTC,%Y%m%d%H)})
|
||||
same => n,Set(LOCAL(V)=${DB(SP/VMLOGIN/${SP_EXT})})
|
||||
same => n,Set(LOCAL(N)=${IF($["${CUT(V,:,1)}" = "${H}"]?${CUT(V,:,2)}:0)})
|
||||
same => n,GotoIf($[${N} >= 10]?no)
|
||||
same => n,Set(DB(SP/VMLOGIN/${SP_EXT})=${H}:$[${N} + 1])
|
||||
same => n,Return(ok)
|
||||
same => n(no),Return()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,Gosub(sp-sub-vmlimit,s,1)
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" != "ok"]?full)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
same => n(full),Playback(an-error-has-occurred)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
@@ -127,7 +175,7 @@ exten => *79,1,Answer() ; DND off
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
exten => _*72X!,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
@@ -138,7 +186,7 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
exten => _**X!,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
@@ -153,7 +201,7 @@ exten => *43,1,Answer() ; echo test
|
||||
exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
exten => _*45X!,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
@@ -171,7 +219,7 @@ exten => _*45X.,1,Answer() ; *45<queue> toggle
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
exten => _*80X!,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
@@ -187,7 +235,7 @@ exten => _*3[123]X,1,Answer()
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
exten => _X!,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -197,12 +245,12 @@ exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external desti
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
exten => _X!,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
exten => _X!,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
@@ -234,6 +282,7 @@ exten => 502,1,Goto(sp-rg-22222222-2222-4222-8222-222222222502,s,1)
|
||||
[sp-rg-22222222-2222-4222-8222-222222222500]
|
||||
exten => s,1,Set(__SP_TARGET=500)
|
||||
same => n,Set(CALLERID(name)=Sales: ${CALLERID(name)})
|
||||
same => n,Set(__SP_CNAM_PREFIX=Sales: ${SP_CNAM_PREFIX})
|
||||
same => n,Dial(Local/101@sp-rg-member&Local/102@sp-rg-member&Local/103@sp-rg-member,25,tm)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
|
||||
same => n,Goto(sp-vm-direct,101,1)
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
icesupport = yes
|
||||
@@ -0,0 +1,6 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
[sp_admin_user]
|
||||
type = user
|
||||
admin = yes
|
||||
marked = yes
|
||||
music_on_hold_when_empty = yes
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_1=2
|
||||
SP_CLASS_12=2
|
||||
@@ -0,0 +1,348 @@
|
||||
; 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).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[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,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
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"]
|
||||
]
|
||||
}
|
||||
+3
@@ -2,3 +2,6 @@
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
Loaded 100 of 351 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user