ARI apps
The Asterisk REST Interface (ARI) is a low-level API that lets external applications take direct control of Asterisk channels, bridges, and media. Instead of configuring dialplan logic inside PBXware, you build a standalone application that reacts to real-time call events and drives call flow programmatically - giving you full flexibility over routing, media mixing, and call state.
What is ARI
ARI exposes two communication channels to your application:
- REST API - create and manipulate Asterisk objects (channels, bridges, playbacks, recordings) by issuing HTTP requests.
- WebSocket event stream - receive real-time events (channel state changes, DTMF, media end, hangup) as JSON messages over a persistent WebSocket connection.
Together they form a request/event loop: your application subscribes to events over the WebSocket, and responds to them by calling REST endpoints to move channels, play audio, join bridges, or hang up.
When a call enters a Stasis application (a special dialplan entry point that hands control to ARI), Asterisk pauses normal dialplan execution and waits for your application to drive the call. Your application is fully in control until it releases the channel or the call ends.
Key concepts
| Concept | Description |
|---|---|
| Channel | A single call leg - an inbound or outbound SIP/PJSIP call |
| Bridge | A mixing container that connects one or more channels so they can hear each other |
| Stasis app | A named entry point that transfers call control from dialplan to your ARI application |
| Playback | A media operation that plays audio into a channel or bridge |
| Recording | A media operation that captures audio from a channel or bridge |
Building a simple application
This section walks through a sample ARI application that demonstrates three patterns you will use in almost every real-world integration: identifying the caller, collecting multi-step input, and routing the call to the right destination. Use it as a reference while building your own application - each step explains not just what the code does, but why it is structured that way and what alternatives exist.
The code examples use Go. The ARI events and REST endpoints are the same regardless of language; only the HTTP and WebSocket libraries differ.
What it does
When a call arrives the application works through the following flow:
- Answer the inbound channel.
- Identify the caller - look up the caller ID in a database. If the caller is a VIP, play a personalised greeting and connect them directly to their account manager.
- Collect input - for regular callers, play a prompt and collect DTMF digits terminated by
#. Each digit resets an inactivity timer; if the caller stops entering digits before pressing#, the call is hung up afterinput_timeoutseconds. - Route the call - immediately look up the entered ID when
#is pressed. If found, play a confirmation tone and bridge the caller to that customer's destination; if not found, play an error tone and fall back to a default destination. Either side hanging up cleans up the other leg and destroys the bridge automatically.
Prerequisites
-
Any language that supports HTTP requests and WebSocket connections
-
ARI enabled on PBXware
-
ARI credentials (
username/password) and a Stasis application name configured in PBXware -
A
config.jsonfile in the working directory (or environment variables as a fallback - see Run the application){"ari": {"host": "your-pbxware-host","username": "your-ari-username","password": "your-ari-password","app": "your-app-name"},"db": "user:pass@tcp(host:3306)/dbname","default_destination": "PJSIP/1000","ring_timeout": 30,"input_timeout": 30,"max_digits": 20}Field Default Description default_destinationPJSIP/1000Fallback endpoint when no customer record matches the entered ID ring_timeout30Seconds to wait for the outbound leg to answer before giving up input_timeout30Seconds of DTMF inactivity before the call is hung up max_digits20Maximum digits a caller can enter before # -
A database with the two tables below
CREATE TABLE vip_callers (
caller_id VARCHAR(64) PRIMARY KEY,
name VARCHAR(128),
account_manager_ext VARCHAR(64)
);
CREATE TABLE customers (
customer_id VARCHAR(64) PRIMARY KEY,
name VARCHAR(128),
destination_ext VARCHAR(64)
);
Discovering what ARI can do
Before writing any code it is worth knowing how to browse the full ARI surface. Your PBXware instance exposes a live API reference at:
http://yourpbxwaredomain/ari/api-docs
This lists every REST resource (channels, bridges, recordings, sounds, endpoints, and more) along with their parameters and response schemas. It is the authoritative source for what operations are available - consult it when you need something beyond what this guide covers.
On the event side, every JSON message arriving over the WebSocket has a type field. The most relevant events are:
| Event | When it fires |
|---|---|
StasisStart | A call enters your Stasis application |
StasisEnd | A call leaves your application (hung up or transferred out) |
ChannelDtmfReceived | The caller pressed a key |
PlaybackStarted | A media playback began |
PlaybackFinished | A media playback completed |
ChannelHangupRequest | The caller hung up |
ChannelStateChange | A channel's state changed (ringing, up, etc.) |
The full event catalogue is in the API docs under the events resource. During development, logging every incoming event type is a quick way to observe what Asterisk sends and when.
Step 1 - Connect to ARI
Open a WebSocket connection to the ARI events endpoint. Pass your application name as the app query parameter and your credentials in an Authorization: Basic header - keeping them out of URLs prevents them from appearing in logs or proxy traces.
wsURL := url.URL{
Scheme: "ws",
Host: cfg.ARI.Host,
Path: "/ari/events",
RawQuery: url.Values{"app": {cfg.ARI.App}}.Encode(),
}
authHeader := http.Header{
"Authorization": {"Basic " + base64.StdEncoding.EncodeToString(
[]byte(cfg.ARI.Username + ":" + cfg.ARI.Password),
)},
}
conn, _, err := websocket.DefaultDialer.Dial(wsURL.String(), authHeader)
The app value must match the Stasis application name configured in the PBXware dialplan. This is how Asterisk knows which calls to hand to your application - only calls that pass through a Stasis(<app-name>) dialplan instruction will produce StasisStart events for your app.
Read messages in a loop and dispatch each event to your handler:
for {
_, msg, err := conn.ReadMessage()
// ...
var event ARIEvent
json.Unmarshal(msg, &event)
app.HandleEvent(event)
}
Events are dispatched synchronously rather than in separate goroutines. ARI delivers events for a channel serially, so synchronous dispatch preserves that ordering without coordination overhead. Spawning a goroutine per event leads to data races when two events for the same channel overlap - for example, a PlaybackFinished arriving while a DTMF handler is still mutating the same channel state.
For production use, wrap the connection in a reconnect loop with exponential backoff so a temporary network interruption does not take down the application. On reconnect, discard any per-channel state from the previous connection - Asterisk has already torn down those channels.
The ARI REST calls your application makes throughout the call use HTTP Basic Auth against the same host:
| Operation | Method | Endpoint |
|---|---|---|
| Answer a channel | POST | /channels/{id}/answer |
| Hang up a channel | DELETE | /channels/{id} |
| Play audio | POST | /channels/{id}/play |
| Stop a playback | DELETE | /playbacks/{id} |
| Create a bridge | POST | /bridges |
| Destroy a bridge | DELETE | /bridges/{id} |
| Add channel to bridge | POST | /bridges/{id}/addChannel |
| Originate an outbound call | POST | /channels |
Step 2 - Identify the caller
The StasisStart event fires when an inbound call enters your Stasis application. Answer the channel immediately - Asterisk will time out an unanswered channel, and the caller hears silence until you answer - then use the caller ID to decide how to handle the call.
func (a *App) onStasisStart(event ARIEvent) {
channelID := event.Channel.ID
callerID := event.Channel.Caller.Number
s := a.newState(channelID, PhaseAnswering)
a.ari.Answer(channelID)
vip, _ := a.lookupVIP(callerID)
if vip != nil {
// VIP path: play a personalised greeting, then connect directly.
s.Phase = PhaseVIP
a.enqueue(channelID, s, []string{"sound:custom/vip-greeting"}, func() error {
return a.bridge(channelID, vip.AccountManagerExt)
})
} else {
// Regular path: collect caller input.
a.startIVR(channelID, s)
}
}
Caller ID is the only information available before the caller has interacted with the application, making it the natural place to apply pre-interaction routing logic. In addition to caller ID, StasisStart also carries the dialled number, channel variables set in the dialplan, and the initial channel state - all of which you can use to branch call handling before playing a single prompt. For example, you could route calls differently based on which number was dialled, or pass context from the dialplan into your application via channel variables.
Step 3 - Collect caller input
For regular callers, play a prompt and start accumulating DTMF digits. The application tracks a Phase and a digit buffer per channel, so concurrent calls never share state.
PhaseCollecting
│
digit received ──────────────── append to buffer
│ (reset inactivity timer; drop if > max_digits)
input timeout ──────────────── hang up
│
'#' received ────────────────── look up customer ID immediately
│
┌────────────┴────────────┐
found not found
│ │
play confirmation play "not recognised"
bridge to extension bridge to default destination
A per-channel state machine is the standard approach for call-flow logic in ARI. Because all calls share the same event handler, the phase tells the handler what a given DTMF digit means for that specific call - without it, state from one caller could be misread as belonging to another.
The ChannelDtmfReceived event fires once per key press. Append each digit to the buffer and reset the inactivity timer. When the caller presses #, perform the database lookup and route immediately without an extra confirmation step:
case PhaseCollecting:
if digit == "#" {
entered := s.Digits
s.Digits = ""
s.Phase = PhaseRouting
// Stop the inactivity timer - the caller has committed their input.
s.inputTimer.Stop()
s.mu.Unlock()
customer, _ := a.lookupCustomer(entered)
if customer != nil {
a.enqueue(channelID, s, []string{"sound:custom/id-recognized"}, func() error {
return a.bridge(channelID, customer.DestinationExt)
})
} else {
a.enqueue(channelID, s, []string{"sound:custom/id-not-recognized"}, func() error {
return a.bridge(channelID, a.cfg.DefaultDestination)
})
}
} else {
if len(s.Digits) >= a.cfg.MaxDigits {
s.mu.Unlock()
return // silently drop digits beyond the limit
}
s.Digits += digit
// Reset inactivity timer on every digit.
s.inputTimer.Reset(time.Duration(a.cfg.InputTimeout) * time.Second)
s.mu.Unlock()
}
Audio files are played sequentially using a media queue. The reason for the queue is that POST /channels/{id}/play is asynchronous - it returns immediately and fires a PlaybackFinished event when done. You cannot simply call it multiple times in a row; the second call would start before the first file finishes. The queue holds the list of files and starts the next one only after PlaybackFinished arrives for the current one. When the queue empties, the registered callback fires - here that triggers the bridge call.
Loading a new queue (enqueue) while audio is already playing calls DELETE /playbacks/{id} to stop the current file immediately, so the new sequence starts without waiting for the old one to finish. This matters if a DTMF digit arrives while a long prompt is still playing - the application can interrupt it and respond right away.
The media parameter in the play request accepts a URI scheme that controls what is played:
| Scheme | Example | What it plays |
|---|---|---|
sound: | sound:custom/welcome | A sound file from the sounds directory |
digits: | digits:1234 | Each digit spoken individually |
number: | number:42 | A number spoken as a word ("forty-two") |
characters: | characters:abc | Each character spoken individually |
tone: | tone:busy | A named tone |
Step 4 - Route the call
The routing lookup and bridge setup happen immediately when the caller presses #. The database result determines which audio file plays and where the call goes; if no customer record exists for the entered ID, the call falls back to a default destination so it is never dropped.
Bridging is done by creating a mixing bridge, adding the inbound channel, and then originating an outbound call back into the same Stasis application. The bridge ID is passed as an argument to the origination so the outbound leg knows which bridge to join when its own StasisStart fires. A ring_timeout is passed as a query parameter so Asterisk automatically abandons the dial attempt if the destination does not answer within the configured limit.
func (a *App) bridge(channelID, endpoint string) error {
bridgeID, err := a.ari.CreateBridge()
if err != nil {
return fmt.Errorf("create bridge: %w", err)
}
if err := a.ari.AddChannelToBridge(bridgeID, channelID); err != nil {
_ = a.ari.DestroyBridge(bridgeID)
return fmt.Errorf("add to bridge: %w", err)
}
outboundID, err := a.ari.Originate(endpoint, a.cfg.ARI.App, "outbound,"+bridgeID, a.cfg.RingTimeout)
if err != nil {
_ = a.ari.DestroyBridge(bridgeID)
return fmt.Errorf("originate: %w", err)
}
// Store the bridge ID on the inbound leg's state so it can be destroyed on hangup.
if s := a.state(channelID); s != nil {
s.mu.Lock()
s.BridgeID = bridgeID
s.mu.Unlock()
}
// Track both directions so each side can hang up the other on disconnect.
a.outbound.Store(outboundID, channelID)
a.inbound.Store(channelID, outboundID)
return nil
}
// Handling the outbound leg in onStasisStart:
if len(event.Args) > 1 && event.Args[0] == "outbound" {
bridgeID := event.Args[1]
a.ari.AddChannelToBridge(bridgeID, channelID)
return
}
If AddChannelToBridge or Originate fails after the bridge has been created, the bridge is destroyed immediately to avoid leaking an empty bridge object in Asterisk.
The App keeps two maps - inbound (inbound ID → outbound ID) and outbound (outbound ID → inbound ID). When StasisEnd fires for either leg, the application looks up the partner channel, hangs it up, and then destroys the bridge. ARI does not auto-destroy a mixing bridge when it empties, so destroyBridge must be called explicitly once both legs are gone. Without this cleanup, a caller who hangs up before the agent answers would leave the outbound leg ringing indefinitely, and an empty bridge object would persist in Asterisk.
func (a *App) onStasisEnd(event ARIEvent) {
channelID := event.Channel.ID
// Inbound caller hung up - clean up the outbound leg and the bridge.
if v, ok := a.inbound.LoadAndDelete(channelID); ok {
outboundID := v.(string)
a.outbound.Delete(outboundID)
a.ari.Hangup(outboundID)
a.destroyBridge(channelID)
}
// Outbound leg ended - hang up the caller and destroy the bridge.
if v, ok := a.outbound.LoadAndDelete(channelID); ok {
inboundID := v.(string)
a.inbound.Delete(inboundID)
a.ari.Hangup(inboundID)
a.destroyBridge(inboundID)
a.channels.Delete(inboundID)
}
a.channels.Delete(channelID)
}
Using a bridge rather than a simple transfer keeps both call legs under your application's control for the lifetime of the call. This means you can add further behaviour on top - playing hold music while the agent is being connected, recording the conversation via POST /bridges/{id}/record, or allowing a supervisor to barge in by adding a third channel to the same bridge.
Complete example
The following tabs contain the full source for the sample application described in this guide.
- Node.js
- Python
- Go
- Rust
Install: npm install ws mysql2
app.mjs
import http from 'node:http';
import { createConnection } from 'mysql2/promise';
import WebSocket from 'ws';
// ---------- Config ----------
const cfg = {
ari: {
host: 'your-pbxware-host',
username: 'your-ari-username',
password: 'your-ari-password',
app: 'your-app-name',
},
db: { host: 'your-db-host', port: 3306, user: 'your-db-user', password: 'your-db-password', database: 'your-db-name' },
defaultDestination: 'PJSIP/1000',
ringTimeout: 30,
inputTimeout: 30,
maxDigits: 20,
};
// ---------- ARI REST ----------
function ariCall(method, path, body) {
return new Promise((resolve, reject) => {
const data = body ? JSON.stringify(body) : null;
const options = {
hostname: cfg.ari.host,
path: '/ari' + path,
method,
auth: `${cfg.ari.username}:${cfg.ari.password}`,
headers: data
? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
: {},
};
const req = http.request(options, (res) => {
let buf = '';
res.on('data', (chunk) => (buf += chunk));
res.on('end', () => {
if (res.statusCode >= 400)
return reject(new Error(`ARI ${method} ${path} → ${res.statusCode}: ${buf}`));
try { resolve(JSON.parse(buf)); } catch { resolve({}); }
});
});
req.on('error', reject);
if (data) req.write(data);
req.end();
});
}
const ari = {
answer: (id) => ariCall('POST', `/channels/${id}/answer`),
hangup: (id) => ariCall('DELETE', `/channels/${id}`),
play: (id, media) => ariCall('POST', `/channels/${id}/play`, { media }).then((r) => r.id ?? ''),
stopPlay: (pbId) => ariCall('DELETE', `/playbacks/${pbId}`),
createBridge: () => ariCall('POST', '/bridges', { type: 'mixing' }).then((r) => r.id ?? ''),
destroyBridge:(bId) => ariCall('DELETE', `/bridges/${bId}`),
addToBridge: (bId, chId) => ariCall('POST', `/bridges/${bId}/addChannel`, { channel: chId }),
originate: (ep, appName, args, tOut) => ariCall('POST', `/channels?timeout=${tOut}`, {
endpoint: ep, app: appName, appArgs: args, callerId: 'ARI App',
}).then((r) => r.id ?? ''),
};
// ---------- State machine ----------
const Phase = Object.freeze({
Answering: 'answering',
VIP: 'vip_greeting',
Collecting: 'collecting_id',
Routing: 'routing',
});
const channels = new Map(); // channelID → state
const outbound = new Map(); // outboundID → inboundID
const inbound = new Map(); // inboundID → outboundID
// ---------- Media queue ----------
async function enqueue(id, s, media, onDone) {
if (s.pbId) {
try { await ari.stopPlay(s.pbId); } catch { /* already gone */ }
s.pbId = '';
}
s.queue = [...media];
s.onDone = onDone ?? null;
await playNext(id, s);
}
async function playNext(id, s) {
if (s.queue.length === 0) {
const cb = s.onDone;
s.onDone = null;
if (cb) { try { await cb(); } catch (e) { console.error(`[${id}] queue callback:`, e); } }
return;
}
const media = s.queue.shift();
try {
s.pbId = await ari.play(id, media);
} catch (e) {
console.error(`[${id}] play ${media} failed - skipping:`, e);
await playNext(id, s);
}
}
// ---------- Input timer ----------
function resetInputTimer(id, s) {
if (s.inputTimer) clearTimeout(s.inputTimer);
s.inputTimer = setTimeout(() => {
if (s.phase === Phase.Collecting) {
console.log(`[${id}] input timeout - hanging up`);
ari.hangup(id).catch(() => {});
}
}, cfg.inputTimeout * 1000);
}
function clearInputTimer(s) {
if (s.inputTimer) { clearTimeout(s.inputTimer); s.inputTimer = null; }
}
// ---------- Bridging ----------
async function bridgeCall(id, endpoint) {
const bridgeId = await ari.createBridge();
try { await ari.addToBridge(bridgeId, id); }
catch (e) { await ari.destroyBridge(bridgeId).catch(() => {}); throw e; }
let outboundId;
try { outboundId = await ari.originate(endpoint, cfg.ari.app, `outbound,${bridgeId}`, cfg.ringTimeout); }
catch (e) { await ari.destroyBridge(bridgeId).catch(() => {}); throw e; }
const s = channels.get(id);
if (s) s.bridgeId = bridgeId;
outbound.set(outboundId, id);
inbound.set(id, outboundId);
console.log(`[${id}] bridging to ${endpoint} via ${bridgeId} (outbound: ${outboundId})`);
}
async function destroyBridgeFor(channelId) {
const s = channels.get(channelId);
if (!s || !s.bridgeId) return;
const bId = s.bridgeId;
s.bridgeId = '';
await ari.destroyBridge(bId).catch((e) => console.error(`[${channelId}] destroy bridge:`, e));
}
// ---------- DB ----------
let db;
async function lookupVIP(callerId) {
const [rows] = await db.execute(
'SELECT name, account_manager_ext FROM vip_callers WHERE caller_id = ?', [callerId]);
return rows[0] ?? null;
}
async function lookupCustomer(customerId) {
const [rows] = await db.execute(
'SELECT name, destination_ext FROM customers WHERE customer_id = ?', [customerId]);
return rows[0] ?? null;
}
// ---------- Event handlers ----------
async function onStasisStart(event) {
const id = event.channel?.id;
if (!id) return;
if (event.args?.[0] === 'outbound') {
const bridgeId = event.args[1];
console.log(`[${id}] outbound leg → bridge ${bridgeId}`);
await ari.addToBridge(bridgeId, id).catch((e) => console.error(`[${id}] add outbound to bridge:`, e));
return;
}
const callerId = event.channel.caller?.number ?? '';
console.log(`[${id}] incoming call from "${callerId}"`);
const s = { phase: Phase.Answering, digits: '', queue: [], onDone: null, pbId: '', bridgeId: '', inputTimer: null };
channels.set(id, s);
try { await ari.answer(id); } catch (e) { console.error(`[${id}] answer:`, e); return; }
let vip = null;
try { vip = await lookupVIP(callerId); } catch (e) { console.error(`[${id}] VIP lookup:`, e); }
if (vip) {
console.log(`[${id}] VIP: ${vip.name} → ${vip.account_manager_ext}`);
s.phase = Phase.VIP;
await enqueue(id, s, ['sound:custom/vip-greeting'], () => bridgeCall(id, vip.account_manager_ext));
} else {
await startIVR(id, s);
}
}
async function startIVR(id, s) {
s.phase = Phase.Collecting;
s.digits = '';
resetInputTimer(id, s);
await enqueue(id, s, ['sound:custom/welcome'], null);
}
async function onPlaybackFinished(event) {
const targetURI = event.playback?.target_uri ?? '';
const id = targetURI.replace(/^channel:/, '');
const s = channels.get(id);
if (!s) return;
if (s.pbId === event.playback?.id) await playNext(id, s);
}
async function onDTMF(event) {
const id = event.channel?.id;
if (!id) return;
const digit = event.digit ?? '';
const s = channels.get(id);
if (!s || s.phase !== Phase.Collecting) return;
if (digit === '#') {
const entered = s.digits;
s.digits = '';
s.phase = Phase.Routing;
clearInputTimer(s);
if (!entered) { await startIVR(id, s); return; }
console.log(`[${id}] customer ID entered: "${entered}"`);
let customer = null;
try { customer = await lookupCustomer(entered); }
catch (e) { console.error(`[${id}] customer lookup:`, e); await startIVR(id, s); return; }
if (customer) {
console.log(`[${id}] customer found: ${customer.name} → ${customer.destination_ext}`);
await enqueue(id, s, ['sound:custom/id-recognized'], () => bridgeCall(id, customer.destination_ext));
} else {
console.log(`[${id}] customer not found → ${cfg.defaultDestination}`);
await enqueue(id, s, ['sound:custom/id-not-recognized'], () => bridgeCall(id, cfg.defaultDestination));
}
} else {
if (s.digits.length >= cfg.maxDigits) {
console.log(`[${id}] DTMF '${digit}' ignored - digit limit reached`);
return;
}
s.digits += digit;
console.log(`[${id}] DTMF '${digit}' collected (so far: "${s.digits}")`);
resetInputTimer(id, s);
}
}
async function onStasisEnd(event) {
const id = event.channel?.id;
if (!id) return;
console.log(`[${id}] channel ended`);
const s = channels.get(id);
if (s) clearInputTimer(s);
if (inbound.has(id)) {
const outId = inbound.get(id); inbound.delete(id); outbound.delete(outId);
await ari.hangup(outId).catch((e) => console.error(`[${id}] hangup outbound:`, e));
await destroyBridgeFor(id);
}
if (outbound.has(id)) {
const inId = outbound.get(id); outbound.delete(id); inbound.delete(inId);
console.log(`[${id}] outbound ended, hanging up caller ${inId}`);
await ari.hangup(inId).catch((e) => console.error(`[${id}] hangup caller:`, e));
await destroyBridgeFor(inId);
channels.delete(inId);
}
channels.delete(id);
}
async function dispatch(event) {
switch (event.type) {
case 'StasisStart': await onStasisStart(event); break;
case 'PlaybackFinished': await onPlaybackFinished(event); break;
case 'ChannelDtmfReceived': await onDTMF(event); break;
case 'StasisEnd': await onStasisEnd(event); break;
}
}
// ---------- Main ----------
async function main() {
db = await createConnection(cfg.db);
console.log('DB connected');
const wsURL = `ws://${cfg.ari.host}/ari/events?${new URLSearchParams({ app: cfg.ari.app })}`;
const authHeader = 'Basic ' + Buffer.from(`${cfg.ari.username}:${cfg.ari.password}`).toString('base64');
const ws = new WebSocket(wsURL, { headers: { Authorization: authHeader } });
// Chain each event onto the previous one so they are handled strictly in
// arrival order - ws emits 'message' as soon as a frame arrives, so without
// this a slow async handler for one event could still be running when the
// next event for the same channel comes in.
let dispatchQueue = Promise.resolve();
ws.on('open', () => console.log('Connected to Asterisk ARI'));
ws.on('message', (data) => {
let event;
try { event = JSON.parse(data.toString()); } catch { return; }
console.log('←', event.type);
dispatchQueue = dispatchQueue.then(() => dispatch(event)).catch((e) => console.error('dispatch error:', e));
});
ws.on('close', () => { console.log('Connection closed'); process.exit(0); });
ws.on('error', (e) => { console.error('WebSocket error:', e); process.exit(1); });
process.on('SIGINT', () => { console.log('Shutting down'); process.exit(0); });
}
main().catch((e) => { console.error(e); process.exit(1); });
Install: pip install "websockets>=13" aiohttp aiomysql
app.py
import asyncio
import base64
import logging
import aiomysql
import aiohttp
import websockets
import json
logging.basicConfig(level=logging.DEBUG, format='%(levelname)s %(message)s')
log = logging.getLogger(__name__)
# ---------- Config ----------
ARI_HOST = 'your-pbxware-host'
ARI_USERNAME = 'your-ari-username'
ARI_PASSWORD = 'your-ari-password'
ARI_APP = 'your-app-name'
DB_HOST = 'your-db-host'
DB_PORT = 3306
DB_USER = 'your-db-user'
DB_PASSWORD = 'your-db-password'
DB_NAME = 'your-db-name'
DEFAULT_DESTINATION = 'PJSIP/1000'
RING_TIMEOUT = 30
INPUT_TIMEOUT = 30
MAX_DIGITS = 20
_http: aiohttp.ClientSession = None
_pool: aiomysql.Pool = None
# ---------- ARI REST ----------
async def _ari(method, path, body=None):
url = f'http://{ARI_HOST}/ari{path}'
auth = aiohttp.BasicAuth(ARI_USERNAME, ARI_PASSWORD)
async with _http.request(method, url, auth=auth, json=body) as resp:
if resp.status >= 400:
text = await resp.text()
raise RuntimeError(f'ARI {method} {path} → {resp.status}: {text}')
try:
return await resp.json(content_type=None)
except Exception:
return {}
async def answer(ch): await _ari('POST', f'/channels/{ch}/answer')
async def hangup(ch): await _ari('DELETE', f'/channels/{ch}')
async def play(ch, media): return (await _ari('POST', f'/channels/{ch}/play', {'media': media})).get('id', '')
async def stop_play(pb_id): await _ari('DELETE', f'/playbacks/{pb_id}')
async def create_bridge(): return (await _ari('POST', '/bridges', {'type': 'mixing'})).get('id', '')
async def destroy_bridge(b_id): await _ari('DELETE', f'/bridges/{b_id}')
async def add_to_bridge(b, ch): await _ari('POST', f'/bridges/{b}/addChannel', {'channel': ch})
async def originate(ep, app, args, timeout):
return (await _ari('POST', f'/channels?timeout={timeout}',
{'endpoint': ep, 'app': app, 'appArgs': args, 'callerId': 'ARI App'})).get('id', '')
# ---------- State machine ----------
class Phase:
Answering = 'answering'
VIP = 'vip_greeting'
Collecting = 'collecting_id'
Routing = 'routing'
class ChannelState:
def __init__(self):
self.phase = Phase.Answering
self.digits = ''
self.queue = []
self.on_done = None
self.pb_id = ''
self.bridge_id = ''
self.input_timer = None
channels: dict = {} # channelID → ChannelState
outbound: dict = {} # outboundID → inboundID
inbound: dict = {} # inboundID → outboundID
# ---------- Media queue ----------
async def enqueue(ch, s, media, on_done=None):
if s.pb_id:
try:
await stop_play(s.pb_id)
except Exception:
pass
s.pb_id = ''
s.queue = list(media)
s.on_done = on_done
await _play_next(ch, s)
async def _play_next(ch, s):
if not s.queue:
cb = s.on_done
s.on_done = None
if cb:
try:
result = cb()
if asyncio.iscoroutine(result):
await result
except Exception as e:
log.error('[%s] queue callback: %s', ch, e)
return
media = s.queue.pop(0)
try:
s.pb_id = await play(ch, media)
except Exception as e:
log.error('[%s] play %s failed - skipping: %s', ch, media, e)
await _play_next(ch, s)
# ---------- Input timer ----------
def reset_input_timer(ch, s):
clear_input_timer(s)
async def _timeout():
await asyncio.sleep(INPUT_TIMEOUT)
if s.phase == Phase.Collecting:
log.info('[%s] input timeout - hanging up', ch)
try:
await hangup(ch)
except Exception:
pass
s.input_timer = asyncio.create_task(_timeout())
def clear_input_timer(s):
if s.input_timer:
s.input_timer.cancel()
s.input_timer = None
# ---------- Bridging ----------
async def bridge_call(ch, endpoint):
bridge_id = await create_bridge()
try:
await add_to_bridge(bridge_id, ch)
except Exception:
await destroy_bridge(bridge_id)
raise
try:
outbound_id = await originate(endpoint, ARI_APP, f'outbound,{bridge_id}', RING_TIMEOUT)
except Exception:
await destroy_bridge(bridge_id)
raise
s = channels.get(ch)
if s:
s.bridge_id = bridge_id
outbound[outbound_id] = ch
inbound[ch] = outbound_id
log.info('[%s] bridging to %s via %s (outbound: %s)', ch, endpoint, bridge_id, outbound_id)
async def destroy_bridge_for(ch):
s = channels.get(ch)
if not s or not s.bridge_id:
return
b_id = s.bridge_id
s.bridge_id = ''
try:
await destroy_bridge(b_id)
except Exception as e:
log.error('[%s] destroy bridge %s: %s', ch, b_id, e)
# ---------- DB ----------
async def lookup_vip(caller_id):
async with _pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(
'SELECT name, account_manager_ext FROM vip_callers WHERE caller_id = %s',
(caller_id,))
return await cur.fetchone()
async def lookup_customer(customer_id):
async with _pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(
'SELECT name, destination_ext FROM customers WHERE customer_id = %s',
(customer_id,))
return await cur.fetchone()
# ---------- Event handlers ----------
async def on_stasis_start(event):
ch = (event.get('channel') or {}).get('id')
if not ch:
return
args = event.get('args', [])
if len(args) > 1 and args[0] == 'outbound':
bridge_id = args[1]
log.info('[%s] outbound leg → bridge %s', ch, bridge_id)
try:
await add_to_bridge(bridge_id, ch)
except Exception as e:
log.error('[%s] add outbound to bridge: %s', ch, e)
return
caller_id = (event.get('channel') or {}).get('caller', {}).get('number', '')
log.info('[%s] incoming call from "%s"', ch, caller_id)
s = ChannelState()
channels[ch] = s
try:
await answer(ch)
except Exception as e:
log.error('[%s] answer: %s', ch, e)
return
vip = None
try:
vip = await lookup_vip(caller_id)
except Exception as e:
log.error('[%s] VIP lookup: %s', ch, e)
if vip:
log.info('[%s] VIP: %s → %s', ch, vip['name'], vip['account_manager_ext'])
s.phase = Phase.VIP
ext = vip['account_manager_ext']
await enqueue(ch, s, ['sound:custom/vip-greeting'], lambda: bridge_call(ch, ext))
else:
await start_ivr(ch, s)
async def start_ivr(ch, s):
s.phase = Phase.Collecting
s.digits = ''
reset_input_timer(ch, s)
await enqueue(ch, s, ['sound:custom/welcome'])
async def on_playback_finished(event):
target_uri = (event.get('playback') or {}).get('target_uri', '')
ch = target_uri.removeprefix('channel:')
s = channels.get(ch)
if not s:
return
if s.pb_id == (event.get('playback') or {}).get('id'):
await _play_next(ch, s)
async def on_dtmf(event):
ch = (event.get('channel') or {}).get('id')
if not ch:
return
digit = event.get('digit', '')
s = channels.get(ch)
if not s or s.phase != Phase.Collecting:
return
log.debug("[%s] DTMF '%s' phase '%s'", ch, digit, s.phase)
if digit == '#':
entered = s.digits
s.digits = ''
s.phase = Phase.Routing
clear_input_timer(s)
if not entered:
await start_ivr(ch, s)
return
log.info('[%s] customer ID entered: "%s"', ch, entered)
customer = None
try:
customer = await lookup_customer(entered)
except Exception as e:
log.error('[%s] customer lookup: %s', ch, e)
await start_ivr(ch, s)
return
if customer:
log.info('[%s] customer found: %s → %s', ch, customer['name'], customer['destination_ext'])
ext = customer['destination_ext']
await enqueue(ch, s, ['sound:custom/id-recognized'], lambda: bridge_call(ch, ext))
else:
log.info('[%s] customer not found → %s', ch, DEFAULT_DESTINATION)
await enqueue(ch, s, ['sound:custom/id-not-recognized'], lambda: bridge_call(ch, DEFAULT_DESTINATION))
else:
if len(s.digits) >= MAX_DIGITS:
log.debug("[%s] DTMF '%s' ignored - digit limit reached", ch, digit)
return
s.digits += digit
log.debug("[%s] DTMF '%s' collected (so far: %r)", ch, digit, s.digits)
reset_input_timer(ch, s)
async def on_stasis_end(event):
ch = (event.get('channel') or {}).get('id')
if not ch:
return
log.info('[%s] channel ended', ch)
s = channels.get(ch)
if s:
clear_input_timer(s)
if ch in inbound:
out_id = inbound.pop(ch)
outbound.pop(out_id, None)
try:
await hangup(out_id)
except Exception as e:
log.error('[%s] hangup outbound: %s', ch, e)
await destroy_bridge_for(ch)
if ch in outbound:
in_id = outbound.pop(ch)
inbound.pop(in_id, None)
log.info('[%s] outbound ended, hanging up caller %s', ch, in_id)
try:
await hangup(in_id)
except Exception as e:
log.error('[%s] hangup caller: %s', in_id, e)
await destroy_bridge_for(in_id)
channels.pop(in_id, None)
channels.pop(ch, None)
async def dispatch(event):
t = event.get('type')
if t == 'StasisStart': await on_stasis_start(event)
elif t == 'PlaybackFinished': await on_playback_finished(event)
elif t == 'ChannelDtmfReceived': await on_dtmf(event)
elif t == 'StasisEnd': await on_stasis_end(event)
# ---------- Main ----------
async def main():
global _http, _pool
_pool = await aiomysql.create_pool(
host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD, db=DB_NAME)
log.info('DB connected')
_http = aiohttp.ClientSession()
ws_url = f'ws://{ARI_HOST}/ari/events?app={ARI_APP}'
auth = base64.b64encode(f'{ARI_USERNAME}:{ARI_PASSWORD}'.encode()).decode()
async with websockets.connect(
ws_url, additional_headers={'Authorization': f'Basic {auth}'}
) as ws:
log.info('Connected to Asterisk ARI')
async for message in ws:
try:
event = json.loads(message)
except Exception:
continue
log.debug('← %s', event.get('type'))
# Await directly (not create_task) so events for the same channel
# are always fully handled in arrival order - see the note on
# synchronous dispatch above.
await dispatch(event)
await _http.close()
_pool.close()
await _pool.wait_closed()
if __name__ == '__main__':
asyncio.run(main())
Initialize the module first:
go mod init ari-sample-app
Then add the three files below. Running go mod tidy (see Run the application) fills in go.mod's require block from their imports - github.com/go-sql-driver/mysql, github.com/gorilla/websocket, and the transitive filippo.io/edwards25519.
main.go
package main
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"net/url"
"os"
"os/signal"
"syscall"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/gorilla/websocket"
)
// ---------- Config ----------
type ARIConfig struct {
Host string `json:"host"`
Username string `json:"username"`
Password string `json:"password"`
App string `json:"app"`
}
type Config struct {
ARI ARIConfig `json:"ari"`
DB string `json:"db"` // DSN: "user:pass@tcp(host:3306)/dbname"
DefaultDestination string `json:"default_destination"` // fallback endpoint when customer ID is unknown
RingTimeout int `json:"ring_timeout"` // seconds to wait for outbound answer (default 30)
InputTimeout int `json:"input_timeout"` // seconds of DTMF inactivity before hangup (default 30)
MaxDigits int `json:"max_digits"` // maximum digits a caller can enter (default 20)
}
func loadConfig() Config {
cfgPath := flag.String("config", "config.json", "path to JSON config file")
flag.Parse()
if _, err := os.Stat(*cfgPath); err == nil {
f, err := os.Open(*cfgPath)
if err != nil {
log.Fatalf("open config: %v", err)
}
defer f.Close()
var cfg Config
if err := json.NewDecoder(f).Decode(&cfg); err != nil {
log.Fatalf("parse config: %v", err)
}
if cfg.RingTimeout == 0 {
cfg.RingTimeout = 30
}
if cfg.InputTimeout == 0 {
cfg.InputTimeout = 30
}
if cfg.MaxDigits == 0 {
cfg.MaxDigits = 20
}
log.Printf("config loaded from %s", *cfgPath)
return cfg
}
// Fall back to environment variables when no config file is present.
var cfg Config
cfg.ARI.Host = getenv("ARI_HOST", "")
cfg.ARI.Username = getenv("ARI_USERNAME", "")
cfg.ARI.Password = getenv("ARI_PASSWORD", "")
cfg.ARI.App = getenv("ARI_APP", "")
cfg.DB = getenv("DB_DSN", "")
cfg.DefaultDestination = getenv("DEFAULT_DESTINATION", "PJSIP/1000")
cfg.RingTimeout = 30
cfg.InputTimeout = 30
cfg.MaxDigits = 20
return cfg
}
func getenv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// ---------- Main ----------
func main() {
cfg := loadConfig()
// Cancelled on SIGINT or SIGTERM; propagates to HTTP calls and WebSocket reads.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
db, err := sql.Open("mysql", cfg.DB)
if err != nil {
log.Fatal("DB open:", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
log.Fatal("DB ping:", err)
}
log.Println("DB connected")
app := newApp(cfg, db, ctx)
wsURL := url.URL{
Scheme: "ws",
Host: cfg.ARI.Host,
Path: "/ari/events",
RawQuery: url.Values{"app": {cfg.ARI.App}}.Encode(),
}
// Credentials go in the Authorization header, not the URL, so they don't
// appear in logs or proxy traces.
authHeader := http.Header{
"Authorization": {"Basic " + base64.StdEncoding.EncodeToString(
[]byte(cfg.ARI.Username + ":" + cfg.ARI.Password),
)},
}
connectLoop(ctx, app, wsURL, authHeader)
log.Println("Shutdown complete")
}
// ---------- WebSocket connection loop ----------
func connectLoop(ctx context.Context, app *App, wsURL url.URL, header http.Header) {
backoff := time.Second
for {
conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL.String(), header)
if err != nil {
if ctx.Err() != nil {
return
}
log.Printf("WebSocket dial: %v; retrying in %s", err, backoff)
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
if backoff < 30*time.Second {
backoff *= 2
}
continue
}
backoff = time.Second
log.Println("Connected to Asterisk ARI")
readLoop(ctx, app, conn)
conn.Close()
// Asterisk channel objects are gone after a reconnect; discard stale state.
app.channels.Range(func(k, _ any) bool { app.channels.Delete(k); return true })
app.outbound.Range(func(k, _ any) bool { app.outbound.Delete(k); return true })
app.inbound.Range(func(k, _ any) bool { app.inbound.Delete(k); return true })
if ctx.Err() != nil {
return
}
log.Printf("Reconnecting in %s…", backoff)
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
if backoff < 30*time.Second {
backoff *= 2
}
}
}
func readLoop(ctx context.Context, app *App, conn *websocket.Conn) {
// Close the connection when the context is cancelled so ReadMessage unblocks.
connClosed := make(chan struct{})
go func() {
select {
case <-ctx.Done():
conn.Close()
case <-connClosed:
}
}()
defer close(connClosed)
for {
_, msg, err := conn.ReadMessage()
if err != nil {
if ctx.Err() == nil {
log.Println("WS read error:", err)
}
return
}
var event ARIEvent
if err := json.Unmarshal(msg, &event); err != nil {
continue
}
log.Printf("← %s", event.Type)
// Synchronous dispatch: ARI events for a channel arrive serially, and
// processing them without spawning goroutines prevents unbounded growth
// and the data races that came with per-event goroutines.
app.HandleEvent(event)
}
}
ari.go
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
)
type ARIClient struct {
baseURL string
username string
password string
http *http.Client
ctx context.Context
}
func newARIClient(host string, username, password string, ctx context.Context) *ARIClient {
return &ARIClient{
baseURL: fmt.Sprintf("http://%s/ari", host),
username: username,
password: password,
http: &http.Client{Timeout: 10 * time.Second},
ctx: ctx,
}
}
func (c *ARIClient) do(method, path string, body map[string]string) (map[string]interface{}, error) {
var bodyReader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal body: %w", err)
}
bodyReader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(c.ctx, method, c.baseURL+path, bodyReader)
if err != nil {
return nil, err
}
req.SetBasicAuth(c.username, c.password)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("ARI %s %s → %d: %s", method, path, resp.StatusCode, b)
}
if resp.StatusCode == http.StatusNoContent {
return nil, nil
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("ARI %s %s: decode response: %w", method, path, err)
}
return result, nil
}
func (c *ARIClient) Answer(channelID string) error {
_, err := c.do("POST", "/channels/"+channelID+"/answer", nil)
return err
}
func (c *ARIClient) Hangup(channelID string) error {
_, err := c.do("DELETE", "/channels/"+channelID, nil)
return err
}
func (c *ARIClient) Play(channelID, media string) (string, error) {
res, err := c.do("POST", "/channels/"+channelID+"/play", map[string]string{"media": media})
if err != nil {
return "", err
}
id, _ := res["id"].(string)
return id, nil
}
func (c *ARIClient) StopPlayback(playbackID string) error {
_, err := c.do("DELETE", "/playbacks/"+playbackID, nil)
return err
}
func (c *ARIClient) CreateBridge() (string, error) {
res, err := c.do("POST", "/bridges", map[string]string{"type": "mixing"})
if err != nil {
return "", err
}
id, _ := res["id"].(string)
return id, nil
}
func (c *ARIClient) DestroyBridge(bridgeID string) error {
_, err := c.do("DELETE", "/bridges/"+bridgeID, nil)
return err
}
func (c *ARIClient) AddChannelToBridge(bridgeID, channelID string) error {
_, err := c.do("POST", "/bridges/"+bridgeID+"/addChannel", map[string]string{"channel": channelID})
return err
}
// Originate dials endpoint and joins it to the named Stasis app.
// timeoutSecs controls how long Asterisk waits for the destination to answer.
func (c *ARIClient) Originate(endpoint, app, appArgs string, timeoutSecs int) (string, error) {
params := url.Values{"timeout": {strconv.Itoa(timeoutSecs)}}
res, err := c.do("POST", "/channels?"+params.Encode(), map[string]string{
"endpoint": endpoint,
"app": app,
"appArgs": appArgs,
"callerId": "ARI App",
})
if err != nil {
return "", err
}
id, _ := res["id"].(string)
return id, nil
}
handler.go
package main
import (
"context"
"database/sql"
"fmt"
"log"
"strings"
"sync"
"time"
)
type VIPCaller struct {
Name string
AccountManagerExt string
}
type Customer struct {
Name string
DestinationExt string
}
type Phase string
const (
PhaseAnswering Phase = "answering"
PhaseVIP Phase = "vip_greeting"
PhaseCollecting Phase = "collecting_id"
PhaseRouting Phase = "routing"
)
type ChannelState struct {
mu sync.Mutex
Phase Phase
Digits string
MediaQueue []string
OnQueueDone func() error
CurrentPbID string
BridgeID string
inputTimer *time.Timer
}
// ---------- ARI events ----------
type ARIEvent struct {
Type string `json:"type"`
Args []string `json:"args"`
Channel *struct {
ID string `json:"id"`
Caller struct {
Number string `json:"number"`
} `json:"caller"`
} `json:"channel"`
Playback *struct {
ID string `json:"id"`
TargetURI string `json:"target_uri"`
} `json:"playback"`
Digit string `json:"digit"`
}
// ---------- App ----------
type App struct {
cfg Config
ari *ARIClient
db *sql.DB
channels sync.Map // inboundChannelID -> *ChannelState
outbound sync.Map // outboundChannelID -> inboundChannelID
inbound sync.Map // inboundChannelID -> outboundChannelID
}
func newApp(cfg Config, db *sql.DB, ctx context.Context) *App {
return &App{
cfg: cfg,
ari: newARIClient(cfg.ARI.Host, cfg.ARI.Username, cfg.ARI.Password, ctx),
db: db,
}
}
func (a *App) state(channelID string) *ChannelState {
v, _ := a.channels.Load(channelID)
if v == nil {
return nil
}
return v.(*ChannelState)
}
func (a *App) newState(channelID string, phase Phase) *ChannelState {
s := &ChannelState{Phase: phase}
a.channels.Store(channelID, s)
return s
}
// ---------- Media queue ----------
func (a *App) enqueue(channelID string, s *ChannelState, media []string, onDone func() error) {
s.mu.Lock()
prevPbID := s.CurrentPbID
s.CurrentPbID = ""
s.MediaQueue = append([]string{}, media...)
s.OnQueueDone = onDone
s.mu.Unlock()
// Stop any in-progress playback so the new queue starts immediately.
if prevPbID != "" {
if err := a.ari.StopPlayback(prevPbID); err != nil {
log.Printf("[%s] stop playback %s: %v", channelID, prevPbID, err)
}
}
a.playNext(channelID, s)
}
func (a *App) playNext(channelID string, s *ChannelState) {
s.mu.Lock()
if len(s.MediaQueue) == 0 {
cb := s.OnQueueDone
s.OnQueueDone = nil
s.CurrentPbID = ""
s.mu.Unlock()
if cb != nil {
if err := cb(); err != nil {
log.Printf("[%s] queue callback: %v", channelID, err)
}
}
return
}
media := s.MediaQueue[0]
s.MediaQueue = s.MediaQueue[1:]
s.mu.Unlock()
pbID, err := a.ari.Play(channelID, media)
if err != nil {
log.Printf("[%s] play %q: %v - skipping", channelID, media, err)
a.playNext(channelID, s) // skip failed item and continue the queue
return
}
s.mu.Lock()
s.CurrentPbID = pbID
s.mu.Unlock()
}
// ---------- Bridging ----------
func (a *App) bridge(channelID, endpoint string) error {
bridgeID, err := a.ari.CreateBridge()
if err != nil {
return fmt.Errorf("create bridge: %w", err)
}
if err := a.ari.AddChannelToBridge(bridgeID, channelID); err != nil {
_ = a.ari.DestroyBridge(bridgeID)
return fmt.Errorf("add to bridge: %w", err)
}
outboundID, err := a.ari.Originate(endpoint, a.cfg.ARI.App, "outbound,"+bridgeID, a.cfg.RingTimeout)
if err != nil {
_ = a.ari.DestroyBridge(bridgeID)
return fmt.Errorf("originate: %w", err)
}
if s := a.state(channelID); s != nil {
s.mu.Lock()
s.BridgeID = bridgeID
s.mu.Unlock()
}
// Track both directions so each side can hang up the other on disconnect.
a.outbound.Store(outboundID, channelID)
a.inbound.Store(channelID, outboundID)
log.Printf("[%s] bridging to %s via %s (outbound: %s)", channelID, endpoint, bridgeID, outboundID)
return nil
}
// destroyBridge tears down the bridge tracked on the inbound leg's state.
// ARI does not auto-destroy a mixing bridge when it empties.
func (a *App) destroyBridge(inboundChannelID string) {
s := a.state(inboundChannelID)
if s == nil {
return
}
s.mu.Lock()
bridgeID := s.BridgeID
s.BridgeID = ""
s.mu.Unlock()
if bridgeID == "" {
return
}
if err := a.ari.DestroyBridge(bridgeID); err != nil {
log.Printf("[%s] destroy bridge %s: %v", inboundChannelID, bridgeID, err)
}
}
// ---------- DB ----------
func (a *App) lookupVIP(callerID string) (*VIPCaller, error) {
row := a.db.QueryRow("SELECT name, account_manager_ext FROM vip_callers WHERE caller_id = ?", callerID)
var v VIPCaller
if err := row.Scan(&v.Name, &v.AccountManagerExt); err == sql.ErrNoRows {
return nil, nil
} else if err != nil {
return nil, err
}
return &v, nil
}
func (a *App) lookupCustomer(customerID string) (*Customer, error) {
row := a.db.QueryRow("SELECT name, destination_ext FROM customers WHERE customer_id = ?", customerID)
var c Customer
if err := row.Scan(&c.Name, &c.DestinationExt); err == sql.ErrNoRows {
return nil, nil
} else if err != nil {
return nil, err
}
return &c, nil
}
// ---------- Event handlers ----------
func (a *App) HandleEvent(event ARIEvent) {
switch event.Type {
case "StasisStart":
a.onStasisStart(event)
case "PlaybackFinished":
a.onPlaybackFinished(event)
case "ChannelDtmfReceived":
a.onDTMF(event)
case "StasisEnd":
a.onStasisEnd(event)
}
}
func (a *App) onStasisStart(event ARIEvent) {
if event.Channel == nil {
return
}
channelID := event.Channel.ID
// Outbound leg we originated - add it to the waiting bridge.
if len(event.Args) > 1 && event.Args[0] == "outbound" {
bridgeID := event.Args[1]
log.Printf("[%s] outbound leg → bridge %s", channelID, bridgeID)
if err := a.ari.AddChannelToBridge(bridgeID, channelID); err != nil {
log.Printf("[%s] add outbound to bridge: %v", channelID, err)
}
return
}
callerID := event.Channel.Caller.Number
log.Printf("[%s] incoming call from %q", channelID, callerID)
s := a.newState(channelID, PhaseAnswering)
if err := a.ari.Answer(channelID); err != nil {
log.Printf("[%s] answer: %v", channelID, err)
return
}
vip, err := a.lookupVIP(callerID)
if err != nil {
log.Printf("[%s] VIP lookup: %v", channelID, err)
}
if vip != nil {
log.Printf("[%s] VIP caller: %s → %s", channelID, vip.Name, vip.AccountManagerExt)
s.mu.Lock()
s.Phase = PhaseVIP
ext := vip.AccountManagerExt
s.mu.Unlock()
a.enqueue(channelID, s, []string{"sound:custom/vip-greeting"}, func() error {
return a.bridge(channelID, ext)
})
} else {
a.startIVR(channelID, s)
}
}
func (a *App) startIVR(channelID string, s *ChannelState) {
s.mu.Lock()
s.Phase = PhaseCollecting
s.Digits = ""
if s.inputTimer != nil {
s.inputTimer.Stop()
}
s.inputTimer = a.newInputTimer(channelID, s)
s.mu.Unlock()
a.enqueue(channelID, s, []string{"sound:custom/welcome"}, nil)
}
func (a *App) newInputTimer(channelID string, s *ChannelState) *time.Timer {
d := time.Duration(a.cfg.InputTimeout) * time.Second
return time.AfterFunc(d, func() {
s.mu.Lock()
isCollecting := s.Phase == PhaseCollecting
s.mu.Unlock()
if isCollecting {
log.Printf("[%s] input timeout - hanging up", channelID)
_ = a.ari.Hangup(channelID)
}
})
}
func (a *App) onPlaybackFinished(event ARIEvent) {
if event.Playback == nil {
return
}
uri := event.Playback.TargetURI
if !strings.HasPrefix(uri, "channel:") {
return
}
channelID := strings.TrimPrefix(uri, "channel:")
s := a.state(channelID)
if s == nil {
return
}
s.mu.Lock()
match := s.CurrentPbID == event.Playback.ID
s.mu.Unlock()
if match {
a.playNext(channelID, s)
}
}
func (a *App) onDTMF(event ARIEvent) {
if event.Channel == nil {
return
}
channelID := event.Channel.ID
digit := event.Digit
s := a.state(channelID)
if s == nil {
return
}
// Lock once for the full read-decide-mutate sequence to avoid TOCTOU races.
s.mu.Lock()
phase := s.Phase
switch phase {
case PhaseCollecting:
if digit == "#" {
entered := s.Digits
s.Digits = ""
s.Phase = PhaseRouting
if s.inputTimer != nil {
s.inputTimer.Stop()
s.inputTimer = nil
}
s.mu.Unlock()
log.Printf("[%s] customer ID entered: %q", channelID, entered)
if entered == "" {
a.startIVR(channelID, s)
return
}
customer, err := a.lookupCustomer(entered)
if err != nil {
log.Printf("[%s] customer lookup: %v", channelID, err)
a.startIVR(channelID, s)
return
}
if customer != nil {
log.Printf("[%s] customer found: %s → %s", channelID, customer.Name, customer.DestinationExt)
ext := customer.DestinationExt
a.enqueue(channelID, s, []string{"sound:custom/id-recognized"}, func() error {
return a.bridge(channelID, ext)
})
} else {
log.Printf("[%s] customer %q not found → %s", channelID, entered, a.cfg.DefaultDestination)
dest := a.cfg.DefaultDestination
a.enqueue(channelID, s, []string{"sound:custom/id-not-recognized"}, func() error {
return a.bridge(channelID, dest)
})
}
} else {
if len(s.Digits) >= a.cfg.MaxDigits {
s.mu.Unlock()
log.Printf("[%s] DTMF %q ignored - digit limit reached", channelID, digit)
return
}
s.Digits += digit
digits := s.Digits
if s.inputTimer != nil {
s.inputTimer.Stop()
}
s.inputTimer = a.newInputTimer(channelID, s)
s.mu.Unlock()
log.Printf("[%s] DTMF %q collected (so far: %q)", channelID, digit, digits)
}
default:
s.mu.Unlock()
log.Printf("[%s] DTMF %q in phase %q (ignored)", channelID, digit, phase)
}
}
func (a *App) onStasisEnd(event ARIEvent) {
if event.Channel == nil {
return
}
channelID := event.Channel.ID
log.Printf("[%s] channel ended", channelID)
// Cancel any pending input timeout before cleaning up state.
if s := a.state(channelID); s != nil {
s.mu.Lock()
if s.inputTimer != nil {
s.inputTimer.Stop()
s.inputTimer = nil
}
s.mu.Unlock()
}
// Inbound caller hung up - clean up the outbound leg and the bridge.
if v, ok := a.inbound.LoadAndDelete(channelID); ok {
outboundID := v.(string)
a.outbound.Delete(outboundID)
if err := a.ari.Hangup(outboundID); err != nil {
log.Printf("[%s] hangup outbound partner: %v", channelID, err)
}
a.destroyBridge(channelID)
}
// Outbound leg ended - hang up the caller and destroy the bridge.
if v, ok := a.outbound.LoadAndDelete(channelID); ok {
inboundID := v.(string)
a.inbound.Delete(inboundID)
log.Printf("[%s] outbound ended, hanging up caller %s", channelID, inboundID)
if err := a.ari.Hangup(inboundID); err != nil {
log.Printf("[%s] hangup caller: %v", inboundID, err)
}
a.destroyBridge(inboundID)
a.channels.Delete(inboundID)
}
a.channels.Delete(channelID)
}
Cargo.toml (dependencies section)
[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = "0.24"
futures-util = "0.3"
reqwest = { version = "0.12", features = ["json"] }
sqlx = { version = "0.8", features = ["mysql", "runtime-tokio"] }
serde_json = "1"
dashmap = "6"
anyhow = "1"
log = "0.4"
env_logger = "0.11"
base64 = "0.22"
src/main.rs
use std::sync::Arc;
use std::time::Duration;
use base64::Engine;
use dashmap::DashMap;
use futures_util::StreamExt;
use serde_json::Value;
use sqlx::mysql::MySqlPool;
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
// ---------- Config ----------
struct Config {
ari_host: String,
ari_user: String,
ari_pass: String,
ari_app: String,
db_dsn: String,
default_dest: String,
ring_timeout: u64,
input_timeout: u64,
max_digits: usize,
}
impl Default for Config {
fn default() -> Self {
Self {
ari_host: "your-pbxware-host".into(),
ari_user: "your-ari-username".into(),
ari_pass: "your-ari-password".into(),
ari_app: "your-app-name".into(),
db_dsn: "mysql://your-db-user:your-db-password@your-db-host:3306/your-db-name".into(),
default_dest: "PJSIP/1000".into(),
ring_timeout: 30,
input_timeout: 30,
max_digits: 20,
}
}
}
// ---------- Phase ----------
#[derive(Clone, PartialEq, Debug)]
enum Phase { Answering, Vip, Collecting, Routing }
// ---------- NextAction ----------
// Encodes the callback that runs when the media queue drains.
// Using an enum avoids async function pointers in structs.
#[derive(Clone, Debug)]
enum NextAction {
StartIvr,
BridgeTo(String),
}
// ---------- ChannelState ----------
struct ChannelState {
phase: Phase,
digits: String,
media_queue: Vec<String>,
current_pb: String,
bridge_id: String,
next_action: Option<NextAction>,
input_timer: Option<JoinHandle<()>>,
}
impl ChannelState {
fn new() -> Self {
Self {
phase: Phase::Answering, digits: String::new(),
media_queue: Vec::new(), current_pb: String::new(),
bridge_id: String::new(), next_action: None, input_timer: None,
}
}
fn stop_timer(&mut self) {
if let Some(h) = self.input_timer.take() { h.abort(); }
}
}
fn set_queue(s: &mut ChannelState, media: Vec<String>, action: Option<NextAction>) {
s.media_queue = media;
s.next_action = action;
}
// ---------- App ----------
struct App {
cfg: Config,
http: reqwest::Client,
db: MySqlPool,
channels: DashMap<String, Arc<Mutex<ChannelState>>>,
outbound: DashMap<String, String>, // outboundID → inboundID
inbound: DashMap<String, String>, // inboundID → outboundID
}
type AppArc = Arc<App>;
fn get_chan(app: &AppArc, ch: &str) -> Option<Arc<Mutex<ChannelState>>> {
app.channels.get(ch).map(|r| r.value().clone())
}
// ---------- ARI REST ----------
async fn ari_call(app: &App, method: &str, path: &str, body: Option<Value>) -> anyhow::Result<Value> {
let url = format!("http://{}/ari{}", app.cfg.ari_host, path);
let req = match method {
"GET" => app.http.get(&url),
"POST" => app.http.post(&url),
"DELETE" => app.http.delete(&url),
m => anyhow::bail!("unsupported method {m}"),
};
let req = req.basic_auth(&app.cfg.ari_user, Some(&app.cfg.ari_pass));
let req = if let Some(b) = body { req.json(&b) } else { req };
let resp = req.send().await?;
let status = resp.status();
if !status.is_success() {
anyhow::bail!("ARI {method} {path} → {status}: {}", resp.text().await.unwrap_or_default());
}
let text = resp.text().await.unwrap_or_default();
Ok(serde_json::from_str(&text).unwrap_or(Value::Null))
}
async fn ari_answer(app: &App, ch: &str) -> anyhow::Result<()> {
ari_call(app, "POST", &format!("/channels/{ch}/answer"), None).await.map(|_| ())
}
async fn ari_hangup(app: &App, ch: &str) -> anyhow::Result<()> {
ari_call(app, "DELETE", &format!("/channels/{ch}"), None).await.map(|_| ())
}
async fn ari_play(app: &App, ch: &str, media: &str) -> anyhow::Result<String> {
let res = ari_call(app, "POST", &format!("/channels/{ch}/play"),
Some(serde_json::json!({ "media": media }))).await?;
Ok(res["id"].as_str().unwrap_or("").to_string())
}
async fn ari_create_bridge(app: &App) -> anyhow::Result<String> {
let res = ari_call(app, "POST", "/bridges",
Some(serde_json::json!({ "type": "mixing" }))).await?;
Ok(res["id"].as_str().unwrap_or("").to_string())
}
async fn ari_destroy_bridge(app: &App, bridge_id: &str) -> anyhow::Result<()> {
ari_call(app, "DELETE", &format!("/bridges/{bridge_id}"), None).await.map(|_| ())
}
async fn ari_add_to_bridge(app: &App, bridge_id: &str, ch: &str) -> anyhow::Result<()> {
ari_call(app, "POST", &format!("/bridges/{bridge_id}/addChannel"),
Some(serde_json::json!({ "channel": ch }))).await.map(|_| ())
}
async fn ari_originate(app: &App, endpoint: &str, app_args: &str) -> anyhow::Result<String> {
let timeout = app.cfg.ring_timeout;
let res = ari_call(app, "POST", &format!("/channels?timeout={timeout}"), Some(serde_json::json!({
"endpoint": endpoint, "app": &app.cfg.ari_app,
"appArgs": app_args, "callerId": "ARI App",
}))).await?;
Ok(res["id"].as_str().unwrap_or("").to_string())
}
// ---------- Input timer ----------
fn spawn_input_timer(app: AppArc, ch: String, arc: Arc<Mutex<ChannelState>>) -> JoinHandle<()> {
let timeout = app.cfg.input_timeout;
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(timeout)).await;
let is_collecting = arc.lock().await.phase == Phase::Collecting;
if is_collecting {
log::info!("[{ch}] input timeout - hanging up");
if let Err(e) = ari_hangup(&app, &ch).await {
log::error!("[{ch}] hangup on timeout: {e}");
}
}
})
}
// ---------- Bridge ----------
async fn do_bridge(app: &AppArc, ch: &str, endpoint: &str) -> anyhow::Result<()> {
let bridge_id = ari_create_bridge(app).await?;
if let Err(e) = ari_add_to_bridge(app, &bridge_id, ch).await {
let _ = ari_destroy_bridge(app, &bridge_id).await;
return Err(e);
}
let outbound_id = match ari_originate(app, endpoint, &format!("outbound,{bridge_id}")).await {
Ok(id) => id,
Err(e) => { let _ = ari_destroy_bridge(app, &bridge_id).await; return Err(e); }
};
if let Some(arc) = get_chan(app, ch) { arc.lock().await.bridge_id = bridge_id.clone(); }
app.outbound.insert(outbound_id.clone(), ch.to_string());
app.inbound.insert(ch.to_string(), outbound_id.clone());
log::info!("[{ch}] bridging to {endpoint} via {bridge_id} (outbound: {outbound_id})");
Ok(())
}
async fn destroy_bridge_for(app: &AppArc, ch: &str) {
let bridge_id = match get_chan(app, ch) {
Some(arc) => std::mem::take(&mut arc.lock().await.bridge_id),
None => return,
};
if bridge_id.is_empty() { return; }
if let Err(e) = ari_destroy_bridge(app, &bridge_id).await {
log::error!("[{ch}] destroy bridge {bridge_id}: {e}");
}
}
// ---------- Media queue ----------
async fn play_next(app: &AppArc, ch: &str) {
let arc = match get_chan(app, ch) { Some(a) => a, None => return };
let (media_opt, action_opt) = {
let mut s = arc.lock().await;
if s.media_queue.is_empty() {
(None, s.next_action.take())
} else {
(Some(s.media_queue.remove(0)), None)
}
};
if let Some(media) = media_opt {
match ari_play(app, ch, &media).await {
Ok(pb_id) => { arc.lock().await.current_pb = pb_id; }
Err(e) => {
log::error!("[{ch}] play {media}: {e} - skipping");
// Recurse to advance past the failed item.
let app = app.clone(); let ch = ch.to_string();
tokio::spawn(async move { play_next(&app, &ch).await; });
}
}
return;
}
if let Some(action) = action_opt {
let app = app.clone();
let ch = ch.to_string();
tokio::spawn(async move { execute_action(&app, &ch, action).await; });
}
}
async fn execute_action(app: &AppArc, ch: &str, action: NextAction) {
match action {
NextAction::StartIvr => { start_ivr(app, ch).await; }
NextAction::BridgeTo(endpoint) => {
if let Err(e) = do_bridge(app, ch, &endpoint).await {
log::error!("[{ch}] bridge to {endpoint}: {e}");
}
}
}
}
// ---------- DB ----------
async fn lookup_vip(app: &App, caller_id: &str) -> anyhow::Result<Option<(String, String)>> {
Ok(sqlx::query_as(
"SELECT name, account_manager_ext FROM vip_callers WHERE caller_id = ?")
.bind(caller_id).fetch_optional(&app.db).await?)
}
async fn lookup_customer(app: &App, customer_id: &str) -> anyhow::Result<Option<(String, String)>> {
Ok(sqlx::query_as(
"SELECT name, destination_ext FROM customers WHERE customer_id = ?")
.bind(customer_id).fetch_optional(&app.db).await?)
}
// ---------- Event handlers ----------
async fn start_ivr(app: &AppArc, ch: &str) {
let arc = match get_chan(app, ch) { Some(a) => a, None => return };
let handle = spawn_input_timer(app.clone(), ch.to_string(), arc.clone());
{
let mut s = arc.lock().await;
s.stop_timer();
s.phase = Phase::Collecting;
s.digits = String::new();
s.input_timer = Some(handle);
set_queue(&mut s, vec!["sound:custom/welcome".into()], None);
}
play_next(app, ch).await;
}
async fn on_stasis_start(app: &AppArc, event: &Value) {
let ch = match event["channel"]["id"].as_str() { Some(c) => c.to_string(), None => return };
if let Some(args) = event["args"].as_array() {
if args.len() > 1 && args[0].as_str() == Some("outbound") {
let bridge_id = args[1].as_str().unwrap_or("").to_string();
log::info!("[{ch}] outbound leg → bridge {bridge_id}");
if let Err(e) = ari_add_to_bridge(app, &bridge_id, &ch).await {
log::error!("[{ch}] add outbound to bridge: {e}");
}
return;
}
}
let caller_id = event["channel"]["caller"]["number"].as_str().unwrap_or("").to_string();
log::info!("[{ch}] incoming call from \"{caller_id}\"");
app.channels.insert(ch.clone(), Arc::new(Mutex::new(ChannelState::new())));
if let Err(e) = ari_answer(app, &ch).await {
log::error!("[{ch}] answer: {e}"); return;
}
let vip = match lookup_vip(app, &caller_id).await {
Ok(v) => v,
Err(e) => { log::error!("[{ch}] VIP lookup: {e}"); None }
};
if let Some((name, ext)) = vip {
log::info!("[{ch}] VIP caller: {name} → {ext}");
if let Some(arc) = get_chan(app, &ch) {
let mut s = arc.lock().await;
s.phase = Phase::Vip;
set_queue(&mut s, vec!["sound:custom/vip-greeting".into()],
Some(NextAction::BridgeTo(ext)));
}
play_next(app, &ch).await;
} else {
start_ivr(app, &ch).await;
}
}
async fn on_playback_finished(app: &AppArc, event: &Value) {
let target_uri = event["playback"]["target_uri"].as_str().unwrap_or("");
let ch = target_uri.trim_start_matches("channel:").to_string();
let pb_id = event["playback"]["id"].as_str().unwrap_or("").to_string();
let arc = match get_chan(app, &ch) { Some(a) => a, None => return };
let matches = arc.lock().await.current_pb == pb_id;
if matches { play_next(app, &ch).await; }
}
async fn on_dtmf(app: &AppArc, event: &Value) {
let ch = match event["channel"]["id"].as_str() { Some(c) => c.to_string(), None => return };
let digit = event["digit"].as_str().unwrap_or("").to_string();
let arc = match get_chan(app, &ch) { Some(a) => a, None => return };
let phase = arc.lock().await.phase.clone();
if phase != Phase::Collecting { return; }
log::debug!("[{ch}] DTMF '{digit}'");
if digit == "#" {
let entered = {
let mut s = arc.lock().await;
s.stop_timer();
s.phase = Phase::Routing;
std::mem::take(&mut s.digits)
};
if entered.is_empty() { start_ivr(app, &ch).await; return; }
log::info!("[{ch}] customer ID entered: \"{entered}\"");
let customer = match lookup_customer(app, &entered).await {
Ok(c) => c,
Err(e) => { log::error!("[{ch}] customer lookup: {e}"); start_ivr(app, &ch).await; return; }
};
if let Some((name, ext)) = customer {
log::info!("[{ch}] customer found: {name} → {ext}");
if let Some(arc) = get_chan(app, &ch) {
let mut s = arc.lock().await;
set_queue(&mut s, vec!["sound:custom/id-recognized".into()],
Some(NextAction::BridgeTo(ext)));
}
} else {
let dest = app.cfg.default_dest.clone();
log::info!("[{ch}] customer not found → {dest}");
if let Some(arc) = get_chan(app, &ch) {
let mut s = arc.lock().await;
set_queue(&mut s, vec!["sound:custom/id-not-recognized".into()],
Some(NextAction::BridgeTo(dest)));
}
}
play_next(app, &ch).await;
} else {
let (at_limit, new_digits) = {
let mut s = arc.lock().await;
if s.digits.len() >= app.cfg.max_digits {
(true, String::new())
} else {
s.digits.push_str(&digit);
let d = s.digits.clone();
// Reset the inactivity timer.
let handle = spawn_input_timer(app.clone(), ch.clone(), arc.clone());
s.stop_timer();
s.input_timer = Some(handle);
(false, d)
}
};
if at_limit {
log::debug!("[{ch}] DTMF '{digit}' ignored - digit limit reached");
} else {
log::debug!("[{ch}] DTMF '{digit}' collected (so far: \"{new_digits}\")");
}
}
}
async fn on_stasis_end(app: &AppArc, event: &Value) {
let ch = match event["channel"]["id"].as_str() { Some(c) => c.to_string(), None => return };
log::info!("[{ch}] channel ended");
if let Some(arc) = get_chan(app, &ch) { arc.lock().await.stop_timer(); }
if let Some((_, out_id)) = app.inbound.remove(&ch) {
app.outbound.remove(&out_id);
if let Err(e) = ari_hangup(app, &out_id).await {
log::error!("[{ch}] hangup outbound: {e}");
}
destroy_bridge_for(app, &ch).await;
}
if let Some((_, in_id)) = app.outbound.remove(&ch) {
app.inbound.remove(&in_id);
log::info!("[{ch}] outbound ended, hanging up caller {in_id}");
if let Err(e) = ari_hangup(app, &in_id).await {
log::error!("[{ch}] hangup caller: {e}");
}
destroy_bridge_for(app, &in_id).await;
app.channels.remove(&in_id);
}
app.channels.remove(&ch);
}
async fn handle_event(app: AppArc, event: Value) {
match event["type"].as_str().unwrap_or("") {
"StasisStart" => on_stasis_start(&app, &event).await,
"PlaybackFinished" => on_playback_finished(&app, &event).await,
"ChannelDtmfReceived" => on_dtmf(&app, &event).await,
"StasisEnd" => on_stasis_end(&app, &event).await,
_ => {}
}
}
// ---------- Main ----------
#[tokio::main]
async fn main() -> anyhow::Result<()> {
env_logger::init();
let cfg = Config::default();
let db = MySqlPool::connect(&cfg.db_dsn).await?;
log::info!("DB connected");
let ws_url = format!(
"ws://{}/ari/events?app={}",
cfg.ari_host, cfg.ari_app,
);
let auth = base64::engine::general_purpose::STANDARD
.encode(format!("{}:{}", cfg.ari_user, cfg.ari_pass));
let app: AppArc = Arc::new(App {
cfg, http: reqwest::Client::new(), db,
channels: DashMap::new(), outbound: DashMap::new(), inbound: DashMap::new(),
});
// Credentials go in the Authorization header, not the URL, so they don't
// appear in logs or proxy traces.
let mut request = ws_url.into_client_request()?;
request.headers_mut().insert("Authorization", format!("Basic {auth}").parse()?);
let (ws_stream, _) = tokio_tungstenite::connect_async(request).await?;
log::info!("Connected to Asterisk ARI");
let (_, mut read) = ws_stream.split();
while let Some(msg_res) = read.next().await {
let msg = match msg_res { Ok(m) => m, Err(e) => { log::error!("WS error: {e}"); break; } };
let text = match msg.into_text() { Ok(t) => t, Err(_) => continue };
let event: Value = match serde_json::from_str(&text) { Ok(v) => v, Err(_) => continue };
log::debug!("← {}", event["type"].as_str().unwrap_or("?"));
// Await directly (not tokio::spawn) so events for the same channel
// are always fully handled in arrival order - see the note on
// synchronous dispatch above.
handle_event(app.clone(), event).await;
}
log::info!("Connection closed");
Ok(())
}
Run the application
- Node.js
- Python
- Go
- Rust
node app.mjs
python app.py
go mod tidy
ARI_HOST=your-pbxware-host \
ARI_USERNAME=your-ari-username \
ARI_PASSWORD=your-ari-password \
ARI_APP=your-app-name \
DB_DSN="user:pass@tcp(host:3306)/dbname" \
DEFAULT_DESTINATION=PJSIP/1000 \
go run .
Or with a config file:
go run . -config config.json
RUST_LOG=info cargo run --release
The application connects to Asterisk, logs Connected to Asterisk ARI, and begins processing calls. Any inbound call routed to the configured Stasis application will be handled by the application.