Call-2-Code Communicator
  • JavaScript 95.8%
  • Shell 4.2%
Find a file
2026-08-29 05:39:31 -05:00
app remi speak 2026-08-29 05:39:31 -05:00
asterisk remi speak 2026-08-29 05:39:31 -05:00
nginx remi speak 2026-08-29 05:39:31 -05:00
systemd remi speak 2026-08-29 05:39:31 -05:00
tests remi speak 2026-08-29 05:39:31 -05:00
.env.example remi speak 2026-08-29 05:39:31 -05:00
.gitignore remi speak 2026-08-29 05:39:31 -05:00
package.json remi speak 2026-08-29 05:39:31 -05:00
README.md remi speak 2026-08-29 05:39:31 -05:00

Pixels Chat Calling API

Places outbound phone-verification calls through an existing FreePBX 17 / Asterisk 21 system over a BulkVS SIP trunk, speaking a verification code via local TTS (Piper).

This version runs on the same machine as Asterisk and uses Asterisk call files instead of AMI/ARI — no manager credentials, no extra network service. The API drops a small text file into Asterisk's spool directory; Asterisk originates the call itself and reports the outcome by moving that file into a "done" folder with a status line, which the API polls.

1. Architecture

Client --HTTPS--> Pixels Chat Calling API --call file--> Asterisk/FreePBX --BulkVS--> PSTN

The API authenticates the request, validates and normalizes the phone number and code, generates a TTS audio file with Piper, writes a .call file naming a small custom dialplan context, and moves it into /var/spool/asterisk/outgoing/. Asterisk picks it up immediately, dials out through BulkVS, and on answer runs the dialplan context that plays the audio and hangs up.

2. Requirements

  • Node.js 18+
  • ffmpeg on PATH
  • Piper (local TTS)
  • FreePBX 17 / Asterisk 21 already installed with a working BulkVS trunk, on this same machine
  • Root/sudo access to configure Asterisk and set up the service user

3. Installing the API

sudo useradd --system --home /opt/pixels-chat-calling --shell /usr/sbin/nologin pixelschat
sudo mkdir -p /opt/pixels-chat-calling
sudo cp -r app tests package.json /opt/pixels-chat-calling/
cd /opt/pixels-chat-calling
sudo npm install --omit=dev
sudo cp /path/to/.env.example /opt/pixels-chat-calling/.env
sudo mkdir -p /var/lib/pixels-chat-calling/audio /var/lib/pixels-chat-calling/callfiles
sudo chown -R pixelschat:pixelschat /opt/pixels-chat-calling /var/lib/pixels-chat-calling
sudo chmod 750 /var/lib/pixels-chat-calling /var/lib/pixels-chat-calling/audio /var/lib/pixels-chat-calling/callfiles

Edit /opt/pixels-chat-calling/.env:

  • API_KEYS — one or more comma-separated long random secrets (generate with openssl rand -hex 32). Clients send one as Authorization: Bearer <key>.
  • ASTERISK_TRUNK — must match the trunk name FreePBX shows for BulkVS.
  • OUTBOUND_DID — the number presented as caller ID. Never accepted from clients.
  • TTS_PROVIDER=local, plus PIPER_BINARY / PIPER_MODEL_PATH (section 8).

4. Give the API user access to the Asterisk spool

Asterisk's spool directories are normally owned by the asterisk user/group. Add pixelschat to that group so it can drop files in without running as root or as asterisk itself:

sudo usermod -aG asterisk pixelschat
sudo mkdir -p /var/spool/asterisk/outgoing_done
sudo chown asterisk:asterisk /var/spool/asterisk/outgoing_done
sudo chmod 770 /var/spool/asterisk/outgoing
sudo chmod 770 /var/spool/asterisk/outgoing_done

Restart the service (once installed) for the new group membership to apply.

5. systemd Service

sudo cp systemd/pixels-chat-calling.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now pixels-chat-calling
sudo systemctl status pixels-chat-calling

The unit runs as pixelschat:asterisk and only allows writes to /var/lib/pixels-chat-calling, /var/spool/asterisk/outgoing, and /var/spool/asterisk/outgoing_done — everything else on the filesystem is read-only to it.

Logs: journalctl -u pixels-chat-calling -f

6. Nginx Reverse Proxy (optional, still worth it even on localhost)

sudo cp nginx/pixels-chat-calling.conf /etc/nginx/sites-available/pixels-chat-calling.conf
sudo ln -s /etc/nginx/sites-available/pixels-chat-calling.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Edit server_name and the TLS certificate paths first. With Nginx in front, keep APP_HOST=127.0.0.1 in .env so the Node process itself is never directly internet-reachable.

7. FreePBX / Asterisk Configuration

7.1 Custom dialplan context

Append the contents of asterisk/extensions_custom.conf to your PBX's existing /etc/asterisk/extensions_custom.conf (append — never edit extensions_additional.conf, which FreePBX regenerates):

sudo cat asterisk/extensions_custom.conf | sudo tee -a /etc/asterisk/extensions_custom.conf
sudo asterisk -rx "dialplan reload"

This creates a [pixels-chat-calling] context reachable only by a call file that explicitly names it — it has no inbound route, so nothing on the PSTN side can reach it.

7.2 Confirm the trunk name

In FreePBX GUI: Connectivity → Trunks, confirm the BulkVS trunk's PJSIP Trunk Name matches ASTERISK_TRUNK in .env. The call file dials <ASTERISK_CHANNEL_TECH>/<number>@<ASTERISK_TRUNK>, e.g. PJSIP/12145551234@BulkVS, which FreePBX resolves using that trunk's own registration/auth — the API never touches SIP credentials.

7.3 Confirm the spool module is loaded

Call files require pbx_spool.so, which is loaded by default:

sudo asterisk -rx "module show like pbx_spool"

7.4 Manual test, bypassing the API entirely

cat <<EOF | sudo tee /var/spool/asterisk/outgoing/manual-test.call
Channel: PJSIP/12145551234@BulkVS
CallerID: "Pixels Chat Calling" <12145559876>
Context: pixels-chat-calling
Extension: s
Priority: 1
WaitTime: 30
Archive: yes
Set: PCC_AUDIO_FILE=/var/lib/pixels-chat-calling/audio/some-existing-file
Set: PCC_CALL_ID=manual-test
Set: PCC_REPEAT=0
EOF

(Swap in a real audio file path first — the pixels-chat-calling context will log a Playback warning and hang up if it's missing, but the call itself should still ring.) Check the result:

ls /var/spool/asterisk/outgoing_done/
cat /var/spool/asterisk/outgoing_done/manual-test.call

You'll see a Status: Completed (answered and ran the dialplan), Status: Expired (no answer/busy/timeout), or Status: Failed (channel/trunk problem) line appended.

8. TTS Setup (local Piper)

sudo apt install -y python3-pip python3-venv ffmpeg
sudo python3 -m venv /opt/piper-venv
sudo /opt/piper-venv/bin/pip install piper-tts
sudo ln -s /opt/piper-venv/bin/piper /usr/local/bin/piper

sudo mkdir -p /opt/piper/voices
cd /opt/piper/voices
sudo /opt/piper-venv/bin/python3 -m piper.download_voices en_US-amy-medium
sudo chown -R pixelschat:pixelschat /opt/piper

Test it standalone first:

echo "This is a test" | piper --model /opt/piper/voices/en_US-amy-medium.onnx --output_file /tmp/test.wav
aplay /tmp/test.wav

In .env:

TTS_PROVIDER=local
TTS_VOICE=en_US-amy-medium
PIPER_BINARY=/usr/local/bin/piper
PIPER_MODEL_PATH=/opt/piper/voices/en_US-amy-medium.onnx

Piper's raw output is converted with ffmpeg to 8 kHz mono 16-bit PCM WAV, the format Asterisk plays reliably.

9. Making a Call

curl -H "Authorization: Bearer YOUR_API_KEY" \
  "http://127.0.0.1:8443/api/v1.0/phone?tn=12145551234&code=58392014"
{
  "success": true,
  "message": "Call queued",
  "call_id": "550e8400-e29b-41d4-a716-446655440000",
  "destination": "12145551234"
}

Check its status:

curl -H "Authorization: Bearer YOUR_API_KEY" \
  "http://127.0.0.1:8443/api/v1.0/phone/550e8400-e29b-41d4-a716-446655440000"

status moves queuedoriginatingcompleted / no_answer / failed (polled from the spool every ASTERISK_POLL_INTERVAL_MS, 2 seconds by default).

Interactive docs: http://127.0.0.1:8443/docs

10. Testing

npm test

runs the validation unit tests (phone number/code normalization and rejection).

BASE_URL=http://127.0.0.1:8443 \
API_KEY=your_key \
TEST_TN=12145551234 \
bash tests/manual_test.sh

exercises health, auth, validation, a real call, status polling, and rate limiting. Use a number you actually control.

11. Firewall Recommendations

  • Only expose the API's port if you actually need remote access; for a same-machine-only setup, keep APP_HOST=127.0.0.1 and nothing needs to be opened externally at all.
  • If you do put Nginx in front for remote access, only open 443 — the app itself should stay bound to loopback.
  • No AMI/ARI port is used by this deployment, so there's nothing extra to firewall on the Asterisk side.

12. Troubleshooting

Symptom Check
503 from /health, checks.asterisk_spool: false pixelschat isn't in the asterisk group yet, or spool dir permissions/ownership are off — recheck section 4
Call stuck in originating forever Confirm pbx_spool.so is loaded (asterisk -rx "module show like pbx_spool") and the .call file actually left /var/spool/asterisk/outgoing/ (Asterisk should pick it up within seconds)
Call goes failed Check the archived file in outgoing_done/ for its Status: line, and asterisk -rx "pjsip show endpoint BulkVS" for trunk registration
No audio on answer Confirm PCC_AUDIO_FILE path exists and is readable by the asterisk user; check tail -f /var/log/asterisk/full during the call
401 Unauthorized on valid-looking key Confirm no trailing whitespace/newline in the key, and it's listed in API_KEYS
429 on first request MAX_CONCURRENT_CALLS/DAILY_CALL_LIMIT may be exhausted from earlier testing — check the calls table in the SQLite DB

Asterisk-side logs: asterisk -rvvv or tail -f /var/log/asterisk/full.

API logs: journalctl -u pixels-chat-calling -f — verification codes and API keys are never written to logs; destinations are masked (+1*******1234).

13. Known Limitations

  • Call-file status reporting only distinguishes Completed / Expired / Failed (mapped here to completed / no_answer / failed) — there's no separate busy or ringing state without AMI/ARI, since Asterisk doesn't expose that level of detail through the spool mechanism.
  • Status is polled from the filesystem every ASTERISK_POLL_INTERVAL_MS (default 2s), not pushed instantly.
  • Rate limiting is in-process (in-memory); running multiple API instances would need a shared store (e.g. Redis) for express-rate-limit.
  • The included Piper integration assumes a working piper binary and voice model already installed; only the invocation is wired up here.

14. Security Notes

  • API keys are compared with crypto.timingSafeEqual against SHA-256 hashes, never stored or logged in plaintext.
  • The verification code is never persisted — only its SHA-256 hash, used solely for duplicate-request detection.
  • The destination is stored as both a SHA-256 hash (for lookups) and a masked display form; the full number is never persisted in the audit table.
  • tn and code are strictly validated before touching the filesystem, TTS engine, or Asterisk — the code is checked against ^[0-9]+$ before it is ever passed to a subprocess or written into a call file, and no user input is concatenated into a shell string (execFile uses argument arrays; the call file's Set: lines only ever contain the validated code/call ID/path).
  • Generated audio files use random UUID filenames, never the code or number.
  • The outbound caller ID is fixed from server-side config; there is no request parameter that can influence it.
  • The custom dialplan context is only reachable via a call file that explicitly names it — it has no inbound route.
  • The service account only has write access to its own data directory and the two Asterisk spool folders (systemd ReadWritePaths + ProtectSystem=strict).