ringo
ringo
Make and test phone calls from your terminal.
Two tools that share one engine:
- ringo-phone — a terminal softphone: manage SIP accounts and place calls without leaving the keyboard.
- ringo-flow — a telephony scenario test runner: write call flows as Rhai scripts and run them headlessly in CI.
The source is on GitHub.
Introduction
ringophone
ringo is a terminal SIP softphone built on baresip, with a full ratatui TUI. It manages multiple accounts side by side — each with its own profile, call history and configuration — while keeping baresip running headless in the background.
It’s part of the ringo workspace; the
crate is ringo-phone, the binary is ringo.
Features
- Profile picker — fuzzy-search selector with inline create / edit / clone / delete.
- Headless baresip — spawns baresip without its stdio UI; no terminal clutter.
- ratatui TUI — status bar, command bar (
:with tab-completion), Normal/Dial split, call list, DTMF, hold/resume, mute. - Contact book — TOML contacts with fuzzy number matching and
$EDITORediting. - Blind & attended transfer with a contact picker.
- Call history (per-profile, redial) and dial history (global,
Ctrl+R). - MWI message-waiting indicator.
- Theming — every UI color configurable, with ready-made themes.
- Remote control — drive a running session from another terminal or a script.
- Multiple instances — each profile gets its own dynamically assigned port.
Next steps
- Getting started — install and launch your first profile.
- Profiles and Configuration — set up accounts and tune the UI / baresip.
- Using the TUI — modes and keybindings.
- Remote control — drive sessions from scripts.
For scripted, multi-agent telephony testing (assertions, audio, HTTP), see the companion tool ringo-flow.
Getting started
Install
1. baresip
ringo needs baresip ≥ 3.14 in your $PATH
(baresip -v to check). See
Supported platforms;
on most systems:
sudo pacman -S baresip # Arch
sudo apt install baresip # Debian/Ubuntu (may need >= 3.14 from a PPA/source)
brew install baresip # macOS
2. ringo
Pre-built binaries for Linux and macOS (x86_64 + arm64) are on the
releases page — download, extract
and put ringo on your $PATH.
From crates.io:
cargo install ringo-phone
From GitHub (no clone needed):
cargo install --git https://github.com/davidborzek/ringo ringo-phone
Quick start
ringo # open the profile picker → Ctrl+N to create your first profile
Fill in your SIP credentials in the form, press Enter to save, then select the profile and press Enter to launch. See Profiles for the fields.
Usage
ringo # open the profile picker (default)
ringo start <name> # launch a specific profile directly
ringo list # list all profiles
ringo list --plain # one name per line (for scripting)
ringo list --json # as a JSON array
From here, Using the TUI covers the keybindings, and Remote control covers driving a running session from a script.
Profiles
Each SIP account is a profile, stored as TOML at
~/.config/ringo/profiles/<name>/profile.toml. Create and edit profiles right in
the picker (Ctrl+N / Ctrl+E), or write the file by hand:
username = "user123"
password = "secret"
domain = "sip.example.com"
display_name = "My Name" # optional
transport = "tls" # optional: udp, tcp, tls
outbound = "sip:proxy.example.com" # optional
stun_server = "stun:stun.example.com" # optional
media_enc = "dtls_srtp" # optional
notify = true # desktop notifications (default: true)
mwi = true # message-waiting indicator (default: true)
Custom SIP headers
Add headers to every outgoing INVITE. Order is preserved and duplicate keys are
allowed (e.g. RFC 4244 History-Info). Values are percent-encoded for baresip’s
uaaddheader — write them as plain text, no manual escaping. The ${uuid}
placeholder is replaced per call with a fresh UUIDv4 (shared across all headers in
the same INVITE); use $$ for a literal $.
custom_headers = [
["History-Info", "<sip:1@example.com>;index=1"],
["History-Info", "<sip:2@example.com>;index=1.1"],
["X-Trace-Id", "call-${uuid}"],
]
File locations
| Path | Description |
|---|---|
~/.config/ringo/ringo.toml | Global config |
~/.config/ringo/contacts.toml | Contact book |
~/.config/ringo/profiles/<name>/profile.toml | Profile config |
~/.config/ringo/profiles/<name>/call_history | Per-profile call history (JSONL) |
~/.local/share/ringo/history | Global dial history |
/tmp/ringo-<name>-<ts>/ | Runtime temp dir (auto-cleaned) |
/tmp/ringo-<name>.log | Application log (hooks, TCP errors, lifecycle) |
Using the TUI
Launching a profile opens the ratatui interface: a status bar (registration, MWI), the call list, and a mode line. Below are the keybindings per mode and overlay.
Profile picker
| Key | Action |
|---|---|
Enter | Start selected profile |
Ctrl+N | Create new profile |
Ctrl+E | Edit selected profile |
Ctrl+Y | Clone selected profile |
Ctrl+D | Delete selected profile (confirmation) |
↑ / ↓ | Navigate (wrap-around) |
Esc | Quit |
Normal mode (default)
| Key | Action |
|---|---|
d | Enter Dial mode |
a | Accept incoming call |
b / Del | Hang up |
h / r | Hold / Resume |
m | Toggle mute |
t / T | Blind / attended transfer |
0-9 * # | DTMF tones (during a call) |
f / Tab | Open contacts (Tab switches calls when several are active) |
e / l / c | Event log / baresip log / call history |
Ctrl+R | Fuzzy-search dial history |
Ctrl+E | Edit profile (no active call) |
Ctrl+P | Switch profile (back to picker) |
: | Open the command bar |
q / Ctrl+C | Quit (with / without confirmation) |
Dial mode
| Key | Action |
|---|---|
Enter | Dial and return to Normal mode |
Esc | Cancel |
Backspace | Delete character / exit when empty |
← → / Home End | Move / jump the cursor |
↑ / ↓ | Navigate dial history |
Tab | Open contacts |
Ctrl+R | Fuzzy-search dial history |
Transfer mode
| Key | Action |
|---|---|
Enter | Execute the transfer |
Tab | Open contacts |
↑ / ↓ / Ctrl+R | Dial-history navigation / search |
Esc | Cancel |
Contacts overlay
| Key | Action |
|---|---|
↑ / ↓, g / G | Navigate / jump to top-bottom |
Enter | Select number (dial or transfer) |
/ | Search |
a / e / d | Add / edit / delete contact |
E | Open contacts in $EDITOR |
f / Esc | Close |
Command bar
Open with :. Tab-completes commands; Enter runs, Esc closes.
Commands: dial <n>, hangup, accept, hold, resume, mute,
dtmf <digits>, transfer <uri>, contacts, events, log, history, edit,
switch, help, quit.
Call history / log views
| Key | Action |
|---|---|
↑ / ↓, g / G | Navigate / jump |
Enter | (history) copy peer to dial input — redial |
/ | (history) search |
d / D | (history) delete entry / clear all |
e / l / c | Switch between event log / baresip log / call history |
Esc | Close |
Remote control
Drive a running session from another terminal — or a script — over a per-session
Unix socket. ctl is an alias for control.
ringo control list # running sessions: PID, profile, account
ringo control -t <target> <command> [args]
# examples
ringo control -t work dial 4711 # target by profile name
ringo control -t 215709 hangup # ...or by PID
ringo control -t work dtmf 123# # send DTMF into the active call
ringo control -t work status # registration + active calls
<target> is a profile name or a PID — use the PID (from ringo control list)
when a name is awkward to type or the same profile runs more than once.
Commands: dial <n>, hangup, accept, hold, resume, mute,
dtmf <digits>, transfer <uri>, status, shutdown.
Headless sessions
For scripting and automated testing, run a session without the TUI — it still
binds the control socket and registers, so you drive it entirely via
ringo control:
ringo start --headless work & # runs in the background, no terminal needed
ringo control -t work status # …drive it…
ringo control -t work shutdown # stop it cleanly (or Ctrl-C the process)
JSON output
Add --json (-j) for machine-readable output: list emits an array of sessions,
status a structured object (registration, active calls, and the most recently
closed call under last_call with its reason/duration), and every other command an
{ "ok", "data", "error" } envelope. The exit code reflects success.
ringo control list --json
ringo control -t work status --json
ringo control -t work dial 4711 --json # {"ok":true,"data":"Dialing 4711","error":null}
For full Rhai-scripted telephony test scenarios (multiple agents, assertions, audio verification), see ringo-flow.
Configuration
Global config lives at ~/.config/ringo/ringo.toml. Everything below is optional.
Picker subtitle
[picker]
# Fields shown next to each profile name in the picker. Available: aor, username,
# domain, display_name, transport, auth_user, outbound, stun_server, media_enc.
info = ["aor"] # default
Theme
All UI colors are configurable — named values or #rrggbb hex.
| Role | Default | Used for |
|---|---|---|
accent | cyan | Logo, picker selection, DTMF input, history popup |
subtle | dark_gray | Hints, log text, subtitles, unfocused labels |
success | green | Registered, established call, toggle on |
danger | red | Muted, missed calls, registration failed |
attention | yellow | Selected call, ringing, MWI, focused field |
transfer | magenta | Transfer-mode input |
[theme]
accent = "cyan"
subtle = "dark_gray"
success = "green"
danger = "red"
attention = "yellow"
transfer = "magenta"
Ready-made themes (Catppuccin Mocha, Gruvbox, Nord, Tokyo Night) live in
themes/.
baresip
ringo auto-detects the baresip module path and audio driver; override any of these
in ringo.toml:
[baresip]
module_path = "/usr/lib/baresip/modules" # baresip modules
audio_driver = "pipewire" # alsa | pulse | pipewire | coreaudio
audio_player_device = "default"
audio_source_device = "default"
audio_alert_device = "default"
sip_cafile = "/etc/ssl/certs/ca-certificates.crt" # SIP TLS CA file
sip_capath = "/etc/ssl/certs" # CA dir ("" to disable)
# Arbitrary baresip config overrides, appended last (last value wins).
# ⚠️ Incorrect values can break ringo. See the baresip Configuration wiki.
[baresip.extra]
dns_server = "10.0.0.1:53"
call_max_calls = "8"
Contacts
Contacts live at ~/.config/ringo/contacts.toml; names resolve in the call list
and history, and numbers match across formats (01555…, +491555…, 491555…).
[[contacts]]
name = "Alice"
numbers = ["+491555123456", "alice.work"]
Manage them in the TUI (contacts overlay → a/e/d) or with $EDITOR (E).
Hooks
Run shell commands on events; each hook gets context via environment variables and
runs in a background thread (errors go to /tmp/ringo-<name>.log).
[[hooks]]
event = "call_incoming"
command = "notify-send 'ringo' \"Call from $(echo $RINGO_EVENT_DATA | jq -r .number)\""
| Event | Trigger | Event data |
|---|---|---|
profile_loaded | Profile loaded, baresip spawned | — |
call_incoming | Incoming call | call_id, number, display_name |
call_outgoing | Outgoing call initiated | call_id, number |
call_ended | Call closed | call_id, number, direction, duration_secs, reason, error |
Each hook receives RINGO_EVENT, RINGO_PROFILE, RINGO_PROFILE_JSON (no
password) and RINGO_EVENT_DATA (JSON).
Integrations
Shell completions
Completions are dynamic — profile names complete from your actual profiles under
~/.config/ringo/profiles/.
# fish — ~/.config/fish/config.fish
COMPLETE=fish ringo | source
# bash — ~/.bashrc
source <(COMPLETE=bash ringo)
# zsh — ~/.zshrc
source <(COMPLETE=zsh ringo)
After sourcing, ringo start <Tab> completes profile names.
rofi
cp scripts/ringo-rofi ~/.local/bin/
# sway / i3
bindsym $mod+p exec ringo-rofi
ringo-rofi uses $TERMINAL if set, otherwise tries kitty, alacritty,
foot, wezterm, xterm.
tmux
cp scripts/ringo-tmux ~/.local/bin/
ringo-tmux
ringo-tmux uses fzf for multi-select profile picking and opens each selected
profile in its own pane within a ringo tmux session. Requires tmux and fzf.
Call history format
One JSON object per line:
{"ts":"2024-01-15 14:30:05","dir":"outgoing","peer":"sip:alice@example.com","duration":"02:05:13","duration_secs":7513}
cat ~/.config/ringo/profiles/<name>/call_history | jq .
Introduction
ringoflow
ringo-flow is a declarative telephony scenario test runner for baresip. You write a scenario as a small Rhai script — bring up SIP agents, place and answer calls, assert on call state, DTMF, audio and HTTP — and run it headlessly, e.g. in CI.
#![allow(unused)]
fn main() {
let dom = env("SIP_DOMAIN");
let a = agent("A", #{
username: env("A_USER"),
domain: dom,
password: env("A_PASS"),
});
let b = agent("B", #{
username: env("B_USER"),
domain: dom,
password: env("B_PASS"),
});
a.register();
b.register();
await_until(|| assert(b.registered).is_true(), "10s");
a.dial(b);
await_until(|| assert(b.state).equals(State::Ringing), "15s");
b.accept();
await_until(|| assert(a.state).equals(State::Established));
a.hangup();
}
Highlights
- Headless — virtual audio, no devices needed; runs on a build server.
- Suites —
setup/scenario/teardown, each scenario isolated with fresh agents. Select with--scenario, tag with--tag/--exclude-tag, disable withskip, focus withonly. - Audio — send tones / files and assert what the other side receives (Goertzel tone detection).
- HTTP — call backend APIs mid-scenario, and stand up a built-in mock server to test webhook-driven call control.
Next steps
- Getting started — install and run.
- Your first scenario — a guided, line-by-line walkthrough.
- Writing scenarios — suites, selection, and the patterns.
- Audio testing and HTTP & webhooks — the feature guides.
- The API section (in the sidebar) — every verb, getter and matcher, generated from the engine.
The Rust library API is on docs.rs.
Getting started
Install
1. baresip
ringo-flow needs baresip ≥ 3.14 in your
$PATH (baresip -v to check). See
Supported platforms
for install instructions (pacman -S baresip, brew install baresip, …).
2. ringo-flow
Pre-built binaries for Linux and macOS (x86_64 + arm64) are on the
releases page — download, extract
and put ringo-flow on your $PATH.
From crates.io:
cargo install ringo-flow
From GitHub (no clone needed):
cargo install --git https://github.com/davidborzek/ringo ringo-flow
From a workspace checkout (no install):
cargo run -p ringo-flow -- run scenario.rhai
Run a scenario
Credentials and the SIP domain come from the environment (via
env(...)), so nothing sensitive lives in the script:
SIP_DOMAIN=example.com A_USER=alice A_PASS=… B_USER=bob B_PASS=… \
ringo-flow run scenario.rhai
ringo-flow run scenario.rhai # one file
ringo-flow run scenarios/ # a directory (all *.rhai, recursively)
ringo-flow check scenario.rhai # syntax-check only (no baresip)
The exit code is non-zero if any scenario fails.
Useful flags
--scenario <pattern>— run a subset by name (re:for a regex).--tag <tag>/--exclude-tag <tag>— filter by tag (repeatable, comma-separated).--env-file FILE— load variables forenv(...)(a sibling<scenario>.envis layered on top per file).--logs— print each agent’s SIP signaling at the end.--save-audio— save sent/received WAVs to the working directory.--json— emit NDJSON events (for CI).-q/-v,--no-color.
See ringo-flow run --help for the full list.
Your first scenario
Let’s write a complete test: two agents place, answer and tear down a call. We’ll build it line by line — every concept you need for most scenarios is here.
You’ll need baresip in your $PATH and two SIP accounts.
The whole script
Save this as first.rhai:
#![allow(unused)]
fn main() {
let dom = env("SIP_DOMAIN");
let a = agent("A", #{
username: env("A_USER"),
domain: dom,
password: env("A_PASS"),
});
let b = agent("B", #{
username: env("B_USER"),
domain: dom,
password: env("B_PASS"),
});
a.register();
b.register();
await_until(|| assert(a.registered).is_true(), "10s");
await_until(|| assert(b.registered).is_true(), "10s");
a.dial(b);
await_until(|| assert(b.state).equals(State::Ringing), "15s");
b.accept();
await_until(|| assert(a.state).equals(State::Established));
wait(3); // the call must stay up
a.hangup();
await_until(|| assert(a.state).equals(State::Idle), "10s");
}
Run it:
SIP_DOMAIN=example.com A_USER=alice A_PASS=… B_USER=bob B_PASS=… \
ringo-flow run first.rhai
Line by line
Credentials from the environment. env("SIP_DOMAIN")
reads a variable, so no secrets live in the script. Pass them as shown above, or
from an --env-file.
Create the agents. agent(name, #{ … }) connects a
headless baresip instance and returns a handle you drive with verbs. name is just a label
used in the log. See the Agents reference for every config field.
Register, then wait for it. SIP is asynchronous: register()
only starts registration. await_until(|| <assertion>, "10s")
re-runs the assertion until it holds or the timeout elapses — never sleep and
hope. assert(a.registered) reads the
agent’s state; .is_true() checks it.
Place the call. a.dial(b) calls B at its address (you
can also dial a number or SIP URI as a string). We then wait until B is ringing
— b.state is one of State::Idle / State::Ringing /
State::Established.
Answer and connect. b.accept() answers; both sides
become Established. await_until without a timeout uses the default (overridable
with default_timeout(...)).
Hold, then hang up. wait(3) holds for three
seconds — and fails if an established call drops in that window, so it doubles as
a stability check. a.hangup() ends the call; we confirm
both return to Idle.
What failure looks like
Assertions report expect … — actual …, and the exit code is non-zero if any
assertion fails — so this runs cleanly in CI. Add -v to see every assertion, or
--logs to dump each agent’s SIP signaling when something’s off.
Next
- Writing scenarios — group several tests into a suite and select/tag/skip them.
- Audio testing — assert what the other side actually hears.
- HTTP & webhooks — drive and mock a backend API.
- The API reference — every verb, getter and matcher.
Writing scenarios
A scenario is a Rhai script. The top level can be the whole test, or you can register several named scenarios as a suite.
Agents and call control
agent(name, #{ … }) connects a headless baresip instance
and returns a handle you drive with verbs — register,
dial, accept,
hangup, hold, dtmf, transfer, … See
Agents for the full set, the config options and the readable
state (registered, state, …).
await_until
SIP is asynchronous, so assertions are polled:
await_until re-runs an
assert(...) until it holds or a timeout
elapses. Use it instead of sleeping.
#![allow(unused)]
fn main() {
a.dial(b);
await_until(|| assert(b.state).equals(State::Ringing), "15s");
}
The matchers — equals,
is_true,
contains, … — are all on the assertion
handle.
Suites: setup / scenario / teardown
setup() runs before each scenario and returns
the context passed to it; each
scenario(name, body) runs in isolation with
fresh agents; teardown() runs after each
(even on failure).
#![allow(unused)]
fn main() {
setup(|| {
let caller = agent("Caller", #{
username: env("A_USER"),
domain: env("SIP_DOMAIN"),
password: env("A_PASS"),
});
caller.register();
await_until(|| assert(caller.registered).is_true(), "10s");
#{ caller: caller }
});
scenario("answered call", #{ tags: ["smoke"] }, |ctx| {
ctx.caller.dial("+49301234567");
await_until(|| assert(ctx.caller.state).equals(State::Established), "15s");
});
}
Selecting, tagging and skipping
The scenario(name, #{ … }, body) options
control which scenarios run:
- Tags —
#{ tags: ["smoke"] }, then--tag smoke/--exclude-tag slow. - Skip —
#{ skip: true | "reason" }disables a scenario statically; or callskip("reason")at runtime (e.g. env-gated). - Focus —
#{ only: true }runs only the focused scenario(s), run-wide.
Skipped scenarios are reported but don’t fail the run.
More
- Assertions and matchers — the full matcher set.
- Audio testing — send tones/files and assert what’s received.
- HTTP & webhooks — call and mock a backend API.
Audio testing
ringo-flow runs baresip with virtual audio, so it can both play audio into a call and check what the other side receives — headless, no devices, CI-safe.
Send audio
agent.send_audio(source) switches the agent’s
active-call audio source:
#![allow(unused)]
fn main() {
a.send_audio(tone(440)); // a 440 Hz sine tone
a.send_audio(file("prompt.wav")); // a WAV file
a.send_audio(silent()); // stop sending
}
tone, file and
silent build an AudioSpec.
Verify what’s received
agent.verify_audio(freq, within) asserts the agent
is receiving a tone at freq Hz within the time window (detected with a Goertzel
filter):
#![allow(unused)]
fn main() {
a.send_audio(tone(440));
b.verify_audio(440, "5s"); // B hears A's tone within 5s
}
For a quick two-way check, verify_audio_connection(a, b)
sends a tone each way and asserts both arrive:
#![allow(unused)]
fn main() {
verify_audio_connection(a, b);
}
Debugging
Run with --save-audio to write each agent’s sent/received WAVs to the working
directory, so you can listen to what actually flowed.
See the Agents → Methods reference for the exact signatures.
HTTP & webhooks
Telephony rarely lives alone — there’s usually a backend that records calls or drives them. ringo-flow can both call an HTTP API mid-scenario and mock one your system under test calls back.
Call an API
http(method, url) makes a request and returns a response you
can assert on:
#![allow(unused)]
fn main() {
let res = http("GET", env("API_URL") + "/calls/last");
res.expect_status(200);
assert(res.json("from")).equals("+49301234567");
}
res.json("a.b.0.c") walks a dotted JSON path;
res.status / res.body /
res.header(name) are there too. For requests with headers
or a body, pass an options map — see HTTP:
#![allow(unused)]
fn main() {
http("POST", env("API_URL") + "/calls", #{
headers: #{ "Content-Type": "application/json" },
body: #{ to: "+49301234567" },
});
}
Mock a webhook (webhook-driven call control)
Some telephony APIs call your webhook for a call and expect you to answer with the actions to perform. Stand up a built-in mock server, point the API at it, and assert on what it received.
mock_server() starts the server;
on(...) answers a route dynamically,
json_response builds the body, and
last_request /
request_count inspect what arrived:
#![allow(unused)]
fn main() {
let hooks = mock_server();
// Answer the webhook with the call actions to perform.
hooks.on("POST", "/voice", |req| {
if req.json("event") == "incoming_call" {
json_response(#{ actions: [ #{ type: "answer" } ] })
} else {
json_response(#{ actions: [ #{ type: "hangup" } ] })
}
});
// Tell the system under test where to send its webhooks.
http("PUT", env("API_URL") + "/config?webhook=" + hooks.url + "/voice");
a.dial(env("API_NUMBER"));
// Wait for the webhook the same way you wait for anything else.
await_until(|| assert(hooks.request_count("/voice")).equals(1), "10s");
let req = hooks.last_request("/voice");
assert(req.json("event")).equals("incoming_call");
}
Notes:
- The
on(...)responder runs on a worker thread, so keep it pure (request → response): no agent verbs inside it. - Routes match by exact path or
regex("/calls/.*"), and by a method or any ("*"/ omit the method). Re-register a route withrespond(...)to stage the next answer between webhooks. - The server is stopped automatically at the end of the scenario.
See the HTTP mock server and Mock request reference for everything.
Running in CI
ringo-flow is built to run unattended on a build server: it’s headless (virtual audio), exits non-zero on failure, and can emit machine-readable output.
Exit code and output
The process exits non-zero if any scenario fails, so a CI step fails naturally.
Add --json for one JSON object per event (NDJSON) instead of the human log:
ringo-flow run scenarios/ --json
Other handy flags: -q (only failures + result), -v (show every assertion),
--logs (print each agent’s SIP signaling at the end), --save-audio (dump
sent/received WAVs), --no-color.
Credentials and environment
Scenarios read secrets via env(...). Provide them as
environment variables, or from a dotenv file:
ringo-flow run scenarios/ --env-file ci.env
A sibling <scenario>.env next to a file is layered on top automatically. Keep
real credentials in your CI secret store, not in the repo.
Selecting what to run
Run a whole directory (all *.rhai, recursively) or a subset:
ringo-flow run scenarios/ # everything
ringo-flow run scenarios/ --scenario "answered" # by name (re: for regex)
ringo-flow run scenarios/ --tag smoke # by tag
ringo-flow run scenarios/ --exclude-tag slow # drop tagged ones
See Writing scenarios for tags, skip and only.
Docker
A small image with baresip compiled in is published to GHCR on each release — nothing to install:
docker run --rm --network host \
-e SIP_DOMAIN=example.com -e A_USER=alice -e A_PASS=… -e B_USER=bob -e B_PASS=… \
-v "$PWD/scenarios:/scn:ro" \
ghcr.io/davidborzek/ringo-flow:latest run /scn
--network host is the simplest way to get working SIP/RTP and DNS. Use
:latest or pin :<version>. See the
README
for recordings, dotenv mounting and private-CA TLS.
Scenario structure
scenario(name: string, body: Fn)
Register a named scenario, run in isolation (fresh agents, torn down
after). The body may take the setup() context: |ctx| { … }.
Example
#![allow(unused)]
fn main() {
scenario("answered call", |ctx| {
ctx.caller.dial(ctx.callee);
await_until(|| assert(ctx.callee.state).equals(State::Ringing), "15s");
ctx.callee.accept();
});
}
scenario(name: string, options: map, body: Fn)
Register a scenario with options #{ tags: ["smoke"], skip: true|"reason", only: true }. --tag/--exclude-tag filter by tag; a skipped scenario is
reported but not run; if any scenario sets only, only those run.
setup(body: Fn)
Run before each scenario; its return value is passed to the scenario
(and teardown) as ctx. Typically creates and registers the agents.
Example
#![allow(unused)]
fn main() {
setup(|| {
let caller = agent("Caller", #{ username: env("A_USER"), domain: env("SIP_DOMAIN"), password: env("A_PASS") });
caller.register();
#{ caller: caller }
});
}
skip()
Skip the current scenario at runtime (reported, not failed).
skip(reason: string)
Skip the current scenario at runtime with a reason (reported, not failed);
e.g. if env("STAGE") != "prod" { skip("prod only") }.
Example
#![allow(unused)]
fn main() {
if env("STAGE") != "prod" { skip("prod only") }
}
teardown(body: Fn)
Run after each scenario (even on failure); receives the setup context.
Flow and timing
await_until(body: Fn)
Returns any
Re-run the expression until its assertion holds or the default timeout
elapses: await_until(|| assert(a.registered).is_true()). Returns the
body’s value, so .value() can bind a verified value.
await_until(body: Fn, within: string)
Returns any
Like await_until(body) but with an explicit timeout, e.g. "15s".
Example
#![allow(unused)]
fn main() {
await_until(|| assert(b.state).equals(State::Ringing), "15s");
}
default_timeout(duration: string)
Set the default await_until timeout for the rest of the script (e.g. "10s").
parallel(tasks: array)
Returns array
Run the given zero-arg closures concurrently and wait for all; returns
their results as an array, and fails if any task fails. Use it for
independent blocking work, e.g. verify_audio on several agents at once.
Tasks may share captured variables (each gets an independent snapshot,
so they can’t race). Don’t overlap await_until across tasks; its
silencing is global.
wait(seconds: int)
Hold for N seconds; FAILS if a call that is established at the start drops.
Example
#![allow(unused)]
fn main() {
wait(3); // the call must stay up for 3s
}
Agents
Constructor
agent(name: string, config: map)
Returns Agent
Connect a headless baresip agent and return a handle.
Config options — agent(name, #{ … }):
| Field | Type | Description |
|---|---|---|
username | string · required | SIP user (registration / auth) |
domain | string · required | SIP domain / registrar |
password | string | auth password |
display_name | string | caller display name |
transport | string | udp (default), tcp or tls |
auth_user | string | auth user, if it differs from username |
outbound | string | outbound proxy URI |
stun_server | string | STUN server, e.g. stun:host:port |
media_enc | string | media encryption, e.g. srtp, zrtp, dtls_srtp |
regint | int | re-registration interval (seconds); 0 disables |
mwi | bool | subscribe to message-waiting indication |
dtmf_mode | string | "info" for reliable headless DTMF (SIP INFO) |
headers | map | extra SIP headers on the INVITE, e.g. #{ "X-Foo": "bar" } |
Example
#![allow(unused)]
fn main() {
let a = agent("A", #{
username: env("A_USER"),
domain: env("SIP_DOMAIN"),
password: env("A_PASS"),
});
}
Methods
agent.abort_transfer()
Receiver Agent
Abort the pending attended transfer.
agent.accept()
Receiver Agent
Answer the agent’s incoming call.
Example
#![allow(unused)]
fn main() {
await_until(|| assert(b.state).equals(State::Ringing), "15s");
b.accept();
}
agent.attended_transfer(target: Agent)
Start an attended transfer: place a consultation call to another agent.
Complete it with complete_transfer() once that call is established.
agent.attended_transfer(target: string)
Receiver Agent
Start an attended transfer to a literal URI or bare number.
agent.complete_transfer()
Receiver Agent
Complete the pending attended transfer (REFER with Replaces).
agent.dial(target: Agent)
Dial another agent at its AOR.
Example
#![allow(unused)]
fn main() {
a.dial(b); // dial agent B at its AOR
a.dial("+49301234567"); // …or a number/URI in A's domain
await_until(|| assert(b.state).equals(State::Ringing), "15s");
}
agent.dial(target: string)
Receiver Agent
Dial a literal SIP URI, or a bare number/extension in the agent’s own domain.
agent.dtmf(digits: string)
Receiver Agent
Send DTMF tones (characters 0-9, *, #, A-D) back-to-back.
Example
#![allow(unused)]
fn main() {
a.dtmf("123#");
}
agent.dtmf(digits: string, gap: string)
Receiver Agent
Send DTMF tones with a pause between digits, e.g. dtmf("123#", "200ms").
agent.hangup()
Receiver Agent
Hang up the agent’s active call.
Example
#![allow(unused)]
fn main() {
a.hangup();
await_until(|| assert(a.state).equals(State::Idle), "10s");
}
agent.header(name: string)
Receiver Agent · Returns any
Value of a header on a received INVITE (string), or () if absent.
agent.headers()
Receiver Agent · Returns map
All received INVITE headers as a map (name → value); duplicates collapse,
use header(name) for a specific one.
agent.hold()
Receiver Agent
Put the active call on hold.
agent.info()
Receiver Agent · Returns map
A map of the agent’s current state: name, aor, registered, state,
reason, status_code, calls. Handy to print(...) or assert on.
agent.mute()
Receiver Agent
Toggle mute on the active call.
Example
#![allow(unused)]
fn main() {
a.mute(); // mute; call again to unmute
}
agent.register()
Receiver Agent
(Re-)register the agent’s account.
Example
#![allow(unused)]
fn main() {
a.register();
await_until(|| assert(a.registered).is_true(), "10s");
}
agent.resume()
Receiver Agent
Resume a held call.
agent.send_audio(source: AudioSpec)
Receiver Agent · Takes AudioSpec
Switch the agent’s active-call audio source: tone(Hz), file(path) or silent().
Example
#![allow(unused)]
fn main() {
a.send_audio(tone(440)); // play a 440 Hz tone
a.send_audio(file("prompt.wav"));
}
agent.to_json()
Receiver Agent · Returns string
The agent’s current state as a JSON string (for log(...)/debugging).
agent.transfer(target: Agent)
Blind-transfer (REFER) the active call to another agent’s AOR.
Example
#![allow(unused)]
fn main() {
callee.transfer(target); // hand the caller off to `target`
}
agent.transfer(target: string)
Receiver Agent
Blind-transfer (REFER) the active call to a literal URI or bare number.
agent.verify_audio(freq: int, within: string)
Receiver Agent
Assert the agent is receiving a tone at freq Hz within the window (Goertzel).
Example
#![allow(unused)]
fn main() {
a.send_audio(tone(440));
b.verify_audio(440, "5s"); // b hears A's 440 Hz tone
}
agent.verify_audio_connection(b: Agent)
Assert two-way audio between two agents (a→b then b→a) at 1000 Hz.
Fields
agent.peer
The current call’s remote party (the caller for an incoming call); read
peer.uri / peer.number / peer.name (each () if there’s no call).
agent.reason
Receiver Agent · Returns any
The last closed call’s reason (string), or () if none yet.
agent.registered
Receiver Agent · Returns bool
Whether the agent’s account is currently registered.
agent.state
Receiver Agent · Returns CallState
The agent’s current call phase: Idle, Ringing or Established.
agent.status_code
Receiver Agent · Returns any
SIP status code from the last closed call’s reason (int, e.g. 603),
or () if the reason isn’t a SIP response (local hangup, reset, …).
Peer
peer.name
Receiver Peer · Returns any
The remote party’s display name, or () if absent.
peer.number
Receiver Peer · Returns any
The remote party’s number (user-part of the URI), or ().
peer.uri
Receiver Peer · Returns any
The remote party’s full URI (e.g. sip:bob@example.com), or ().
AudioSpec
file(path: string)
Returns AudioSpec
A WAV-file audio source, for send_audio.
silent()
Returns AudioSpec
A silent audio source (stop sending), for send_audio.
tone(freq: int)
Returns AudioSpec
A sine-tone audio source at the given frequency (Hz), for send_audio.
Example
#![allow(unused)]
fn main() {
a.send_audio(tone(440));
}
Assertions and matchers
Constructor
assert(actual)
Returns Assertion
Begin a fluent assertion on a value: assert(x).equals(y), .is_true(),
.greater_than(n), etc. Matchers chain (.at_least(200).at_most(299))
and error (with a value-based message) on a mismatch. Asserting on a
getter auto-labels the log line (assert(caller.state) → Caller state,
assert(res.status) → HTTP status); .describe(…) overrides.
Methods
assertion.at_least(n: int)
Receiver Assertion · Returns Assertion
Assert the (numeric) value is >= n.
assertion.at_most(n: int)
Receiver Assertion · Returns Assertion
Assert the (numeric) value is <= n.
assertion.contains(needle: string)
Receiver Assertion · Returns Assertion
Assert the (string) value contains needle.
Example
#![allow(unused)]
fn main() {
assert(a.header("User-Agent")).contains("baresip");
}
assertion.describe(label: string)
Receiver Assertion · Returns Assertion
Label this assertion so the log line names it: assert(caller.registered) .describe("caller registered").is_true() → caller registered: ✓ expect ….
assertion.equals(expected)
Receiver Assertion · Returns Assertion
Assert the value equals expected (is is a reserved word in Rhai).
Example
#![allow(unused)]
fn main() {
assert(a.state).equals(State::Established);
}
assertion.greater_than(n: int)
Receiver Assertion · Returns Assertion
Assert the (numeric) value is > n.
assertion.is_absent()
Receiver Assertion · Returns Assertion
Assert the value is absent (()).
assertion.is_empty()
Receiver Assertion · Returns Assertion
Assert the string/array/map value is empty.
assertion.is_false()
Receiver Assertion · Returns Assertion
Assert the value is false.
assertion.is_not_empty()
Receiver Assertion · Returns Assertion
Assert the string/array/map value is not empty.
assertion.is_present()
Receiver Assertion · Returns Assertion
Assert the value is present (not ()), e.g. a received header.
assertion.is_true()
Receiver Assertion · Returns Assertion
Assert the value is true.
Example
#![allow(unused)]
fn main() {
assert(a.registered).is_true();
}
assertion.less_than(n: int)
Receiver Assertion · Returns Assertion
Assert the (numeric) value is < n.
assertion.matches(pattern: string)
Receiver Assertion · Returns Assertion
Assert the (string) value matches the regex pattern.
assertion.not_equals(expected)
Receiver Assertion · Returns Assertion
Assert the value does not equal expected.
assertion.value()
Receiver Assertion · Returns any
The value under assertion, so a verified value can be bound:
let id = await_until(|| assert(callee.header("X-Id")).is_present().value());.
HTTP
Constructor
http(method: string, url: string)
Returns HttpResponse
Make an HTTP request and return the response.
Example
#![allow(unused)]
fn main() {
let res = http("GET", env("API_URL") + "/calls");
res.expect_status(200);
}
http(method: string, url: string, options: map)
Returns HttpResponse
Make an HTTP request with options and return the response.
Options — http(method, url, #{ … }):
| Field | Type | Description |
|---|---|---|
headers | map | request headers, e.g. #{ "Content-Type": "application/json" } |
body | string or map | request body; a map is encoded to JSON |
Example
#![allow(unused)]
fn main() {
let res = http("POST", env("API_URL") + "/calls", #{
headers: #{ "Content-Type": "application/json" },
body: #{ to: "+49301234567" },
});
}
Methods
resp.expect_status(code: int)
Receiver HttpResponse
Assert and report the status; errors on mismatch.
resp.header(name: string)
Receiver HttpResponse · Returns any
A response header value (string), or () if absent.
resp.json()
Receiver HttpResponse · Returns any
The whole JSON body as a native value (object→map, array, …).
resp.json(path: string)
Receiver HttpResponse · Returns any
The value at a dotted JSON path (e.g. "data.id"), typed: object→map,
array, number, bool, null→(). Errors if the path is missing.
Example
#![allow(unused)]
fn main() {
assert(res.json("data.id")).equals(42);
}
Fields
resp.body
Receiver HttpResponse · Returns string
The HTTP response body as a string.
resp.status
Receiver HttpResponse · Returns int
The HTTP response status code.
HTTP mock server
Constructor
mock_server()
Returns HttpMock
Start a mock HTTP server on a free port and return a handle. Stopped
automatically at the end of the scenario. Use url to point the system
under test at it, respond/on to define routes.
Example
#![allow(unused)]
fn main() {
let hooks = mock_server();
hooks.on("POST", "/voice", |req| json_response(#{ actions: [ #{ type: "answer" } ] }));
http("PUT", env("API_URL") + "/config?webhook=" + hooks.url + "/voice");
}
mock_server(config: map)
Returns HttpMock
Start a mock HTTP server with config; stopped automatically at scenario
end. Omit port (or use mock_server()) for a free one.
Config — mock_server(#{ … }):
| Field | Type | Description |
|---|---|---|
port | int | port to bind (omit for a free one) |
Example
#![allow(unused)]
fn main() {
let hooks = mock_server(#{ port: 8080 });
}
Methods
mock.last_request(path: PathPattern)
Receiver HttpMock · Takes PathPattern · Returns MockRequest
The most recent request on a regex(...) path (errors if none yet).
mock.last_request(path: string)
Receiver HttpMock · Returns MockRequest
The most recent request on path (errors if none yet). Read it after
await_until confirms the webhook arrived.
Example
#![allow(unused)]
fn main() {
let req = hooks.last_request("/voice");
assert(req.json("event")).equals("incoming_call");
}
mock.on(method: string, path: PathPattern, responder: Fn)
Receiver HttpMock · Takes PathPattern
Dynamic responder for method and a regex(...) path.
mock.on(method: string, path: string, responder: Fn)
Receiver HttpMock
Answer method path dynamically: the |req| closure receives the
MockRequest and returns a response map (e.g. json_response(#{…})).
method may be "*" for any method. The closure runs on a runtime
worker, so keep it pure (request → response): no agent verbs, no wait
— those block a worker thread.
Example
#![allow(unused)]
fn main() {
hooks.on("POST", "/voice", |req| {
if req.json("event") == "incoming_call" {
json_response(#{ actions: [ #{ type: "answer" } ] })
} else {
json_response(#{ actions: [ #{ type: "hangup" } ] })
}
});
}
mock.on(path: PathPattern, responder: Fn)
Receiver HttpMock · Takes PathPattern
Dynamic responder for a regex(...) path on any HTTP method.
mock.on(path: string, responder: Fn)
Receiver HttpMock
Dynamic responder for path on any HTTP method.
mock.request_count(path: PathPattern)
Receiver HttpMock · Takes PathPattern · Returns int
How many requests arrived on a regex(...) path (any method).
mock.request_count(path: string)
Receiver HttpMock · Returns int
How many requests arrived on path (any method). Poll it with
await_until to wait for a webhook.
Example
#![allow(unused)]
fn main() {
await_until(|| assert(hooks.request_count("/voice")).equals(1), "10s");
}
mock.requests(path: PathPattern)
Receiver HttpMock · Takes PathPattern · Returns array
All requests on a regex(...) path, in arrival order, as MockRequests.
mock.requests(path: string)
Receiver HttpMock · Returns array
All requests received on path, in arrival order, as MockRequests.
mock.respond(method: string, path: PathPattern, response: map)
Receiver HttpMock · Takes PathPattern
Static response for method and a regex(...) path.
mock.respond(method: string, path: string, response: map)
Receiver HttpMock
Register a static response for method path: a map
#{ status: 200, content_type: "…", headers: #{…}, body: <string|map> }
(use json_response/text_response to build it). method may be "*"
for any method. Re-register to stage the next answer between webhooks.
Example
#![allow(unused)]
fn main() {
hooks.respond("POST", "/voice", json_response(#{ actions: [ #{ type: "hangup" } ] }));
}
mock.respond(path: PathPattern, response: map)
Receiver HttpMock · Takes PathPattern
Static response for a regex(...) path on any HTTP method.
mock.respond(path: string, response: map)
Receiver HttpMock
Static response for path on any HTTP method.
mock.stop()
Receiver HttpMock
Stop the server now (it otherwise stops automatically at scenario end).
Fields
mock.port
Receiver HttpMock · Returns int
The port the server is listening on.
mock.url
Receiver HttpMock · Returns string
The server’s base URL, e.g. http://127.0.0.1:8080.
Helpers
json_response(body)
Returns map
Build a 200 application/json response map from body (JSON-encoded),
for respond/on. body may be a map or an array, e.g.
json_response(#{ actions: [ … ] }) or json_response([ … ]).
Example
#![allow(unused)]
fn main() {
hooks.respond("POST", "/voice", json_response(#{ actions: [ #{ type: "answer" } ] }));
}
regex(pattern: string)
Returns PathPattern
A regex path matcher for respond/on/request_count/… anchored to the
whole path: regex("/calls/.*") matches /calls/123. Errors on a bad
pattern.
Example
#![allow(unused)]
fn main() {
hooks.on(regex("/calls/.*"), |req| text_response("ok"));
}
text_response(body: string)
Returns map
Build a 200 text/plain response map from body, for respond/on.
Mock request
Methods
req.header(name: string)
Receiver MockRequest · Returns any
A request header value (case-insensitive), or () if absent.
req.json(path: string)
Receiver MockRequest · Returns any
The value at a dotted JSON path in the body (object→map, array, number,
bool, null→()). Errors if the path is missing.
Example
#![allow(unused)]
fn main() {
assert(req.json("call.from")).equals("+49301234567");
}
req.query(name: string)
Receiver MockRequest · Returns any
A query-string parameter value, or () if absent.
Fields
req.body
Receiver MockRequest · Returns string
The raw request body.
req.method
Receiver MockRequest · Returns string
The request method (upper-case).
req.path
Receiver MockRequest · Returns string
The request path.
Environment
env(name: string)
Returns string
Read a variable: first from --env-file/<scenario>.env/load_env, then
the process environment. Errors if unset. Use it for per-env credentials.
Example
#![allow(unused)]
fn main() {
let dom = env("SIP_DOMAIN");
let a = agent("A", #{ username: env("A_USER"), domain: dom, password: env("A_PASS") });
}
load_env(path: string)
Load a dotenv file (KEY=VALUE lines) into env(...) for this scenario,
resolved relative to the scenario file. Later loads override earlier keys.
Utilities
log(message: string)
Print a timestamped note to the scenario log (and the --json stream),
unlike print which writes a bare line.
uuid()
Returns string
A fresh random UUID string.