Skip to content

Commit 086ad72

Browse files
committed
rename voip_bridge to bridge, add return_ivr [latest]
1 parent edb29c6 commit 086ad72

16 files changed

Lines changed: 365 additions & 159 deletions

docs/ivr_step_protocol_en.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ Every response is a JSON object with a `"type"` field. Two categories:
117117

118118
```json
119119
{ "type": "transfer", "target": "2001" }
120+
{ "type": "transfer", "target": "ivr:other_ivr", "params": {"order_id":"123"} }
120121
{ "type": "hangup", "prompt": null }
121122
{ "type": "queue", "target": "support" }
122123
{ "type": "voicemail", "target": "1001" }
@@ -198,15 +199,17 @@ RustPBX plays the prompt → on audio complete → automatically executes the dt
198199

199200
| type | Purpose | Required | Optional | Notes |
200201
|------|---------|----------|----------|-------|
201-
| `transfer` | Blind‑transfer the call to a SIP URI or extension | `target: string` | | The call leaves the IVR permanently |
202+
| `transfer` | Blind‑transfer the call to a SIP URI, extension, or another IVR | `target: string` | `params: Map<string,string>` | When `target` is an `ivr:` URI (e.g. `ivr:other_ivr`), `params` are encoded as query string and passed to the target IVR as session variables |
202203
| `queue` | Send the caller into an ACD queue | `target: string` | `return_to_ivr: bool` | When `return_to_ivr=true`, the call returns to IVR if no agent answers |
203204
| `voicemail` | Forward the caller to a user's voicemail | `target: string` || The call leaves the IVR |
204205
| `hangup` | Terminate the call || `prompt: string or null` | If `prompt` is set, plays audio before hanging up |
205206
| `play_and_hangup` | Play an announcement then hang up with a SIP status code || `prompt: string or null`, `code: u16 or null` | `code` is the SIP response code (e.g. 486 busy, 404 not found) |
206-
| `jump_ivr` | Jump to another named IVR route point | `route_point: string` | `params: Map<string,string>` | The new IVR receives `params` as session variables |
207+
| `jump_ivr` | Jump to another named IVR route point | `route_point: string` | `params: Map<string,string>` | Equivalent to `transfer` with `target="ivr:{route_point}"`. `params` are passed as session variables to the target IVR |
207208
| `route_to_agent` | Route the call to a human agent via skill‑based routing | `target: string` | `skill_group_id: string`, `key_id: string`, `channel_code: string` | Uses the contact‑center routing engine |
208209
| `voip_bridge` | Bridge call audio to an external WebSocket endpoint as raw PCM16 | `create_room_uri: string` | `headers: Map<string,string>`, `timeout_ms: u64` | See [voip_bridge_en.md](voip_bridge_en.md) |
209210

211+
> **Passing data between IVRs**: Both `transfer` (with `target="ivr:other_ivr"`) and `jump_ivr` support a `params` field. These params are URL-encoded as a query string on the target URI (e.g. `ivr:other_ivr?order_id=123`) and parsed on the receiving end. The target IVR merges them into session variables, making them available for `$var$` substitution and included in `ProviderContext.variables` sent to the external step provider. This enables seamless IVR-to-IVR context propagation without external state management.
212+
210213
### Non-terminal actions
211214

212215
| type | Purpose | Required | Optional | Notes |
@@ -424,6 +427,8 @@ Any `$var_name$` in string fields is replaced from session variables:
424427

425428
Any variables returned in `ProviderContext.variables` by the provider are also available.
426429

430+
When an IVR is entered via `transfer` (with `target="ivr:other_ivr"`) or `jump_ivr`, any `params` from the source action are merged into session variables before the first `ProviderEvent::SessionStart` is sent. This means the target IVR's provider immediately sees these values in `ProviderContext.variables` and can use `$param_name$` substitution in its responses.
431+
427432
---
428433

429434
## 8. Error Handling & Retry
Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,39 @@
11
#! /usr/bin/env python3
2-
"""VoipBridge IVR Provider + WebSocket PCM16 echo server — stdlib only.
2+
"""Bridge IVR Provider + WebSocket PCM16 echo server — stdlib only.
33
4-
Demonstrates the full IVR → voip_bridge transfer flow:
4+
Demonstrates the full IVR → bridge transfer flow:
55
66
1. IVR step provider exposes POST /ivr/step
77
- session_start → welcome prompt → dtmf_menu
8-
- press "1" → returns a TERMINAL ``voip_bridge`` ActionNode that bridges
8+
- press "1" → returns a TERMINAL ``bridge`` ActionNode that bridges
99
the call's audio to a WebSocket endpoint speaking raw PCM16
1010
- press "2" → announces current time (prompt)
1111
- press "0" → hangup
1212
2. A stdlib WebSocket server (raw socket, no third-party deps) acts as the
1313
"external VoIP service". It echoes any binary PCM16 frame back to the
1414
client — i.e. RustPBX receives its own audio. This is the same shape the
15-
Rust E2E test (src/proxy/tests/test_voip_bridge_e2e.rs) uses.
15+
Rust E2E test (src/proxy/tests/test_bridge_e2e.rs) uses.
1616
1717
Topology once the bridge is up:
1818
1919
Caller (WebRTC/RTP) ◄──► RustPBX SipSession ◄──► WebSocket (PCM16) ◄──► this echo server
2020
2121
Run (defaults: IVR HTTP on 8080, WS echo on 9090):
2222
23-
python3 examples/voip_bridge_ivr_provider.py
23+
python3 examples/bridge_ivr_provider.py
2424
2525
Run with custom ports:
2626
27-
python3 examples/voip_bridge_ivr_provider.py 8080 9090
27+
python3 examples/bridge_ivr_provider.py 8080 9090
2828
2929
Self-test (drives the IVR flow against the provider + pings the WS server
3030
with a PCM16 frame, printing a step-by-step trace — no SIP stack required):
3131
32-
python3 examples/voip_bridge_ivr_provider.py --self-test
32+
python3 examples/bridge_ivr_provider.py --self-test
3333
3434
Unit tests:
3535
36-
python3 -m unittest examples/voip_bridge_ivr_provider.py
36+
python3 -m unittest examples/bridge_ivr_provider.py
3737
"""
3838

3939
import argparse
@@ -63,7 +63,7 @@ class IvrSession:
6363
6464
rustpbx calls POST /ivr/step on every event; we reply with the next
6565
ActionNode. See docs/ivr_step_protocol_en.md for the protocol and
66-
docs/voip_bridge.md for the voip_bridge terminal action.
66+
docs/bridge.md for the bridge terminal action.
6767
"""
6868

6969
def __init__(self, caller: str, callee: str, ws_endpoint: str):
@@ -91,7 +91,7 @@ def next_action(self, event: dict) -> dict:
9191
"timeout_ms": 8000,
9292
"max_retries": 3,
9393
"entries": {
94-
"1": action_voip_bridge(self.ws_endpoint),
94+
"1": action_bridge(self.ws_endpoint),
9595
"2": {"type": "prompt", "tts_text": _time_text()},
9696
"0": action_hangup(),
9797
},
@@ -104,7 +104,7 @@ def next_action(self, event: dict) -> dict:
104104
if ev_type == "dtmf":
105105
digit = event.get("digit", "")
106106
if digit == "1":
107-
return action_voip_bridge(self.ws_endpoint)
107+
return action_bridge(self.ws_endpoint)
108108
if digit == "2":
109109
return action_prompt(tts_text=_time_text())
110110
if digit == "0":
@@ -136,19 +136,19 @@ def action_hangup():
136136
return {"type": "hangup"}
137137

138138

139-
def action_voip_bridge(ws_endpoint: str, headers=None, timeout_ms=10000):
139+
def action_bridge(ws_endpoint: str, headers=None, timeout_ms=10000):
140140
"""Terminal action: bridge call audio to a WebSocket endpoint as PCM16.
141141
142142
rustpbx turns this into a blind transfer target
143-
``voip_bridge:<ws_endpoint>?codec=pcm&samplerate=8000&_hdr_*=&timeout_ms=``
144-
and runs connect_voip_bridge() in src/proxy/proxy_call/sip_session/transfer.rs.
143+
``bridge:<ws_endpoint>?codec=pcm&samplerate=8000&_hdr_*=&timeout_ms=``
144+
and runs connect_bridge() in src/proxy/proxy_call/sip_session/transfer.rs.
145145
"""
146146
return {
147-
"type": "voip_bridge",
147+
"type": "bridge",
148148
"create_room_uri": ws_endpoint,
149149
"headers": headers or {"X-Source": "ivr-voip-bridge-example"},
150150
"timeout_ms": timeout_ms,
151-
"step_id": "voip_bridge",
151+
"step_id": "bridge",
152152
"step_name": "VoIP 桥接",
153153
}
154154

@@ -432,7 +432,7 @@ def serve(ivrr_port: int, ws_port: int):
432432
start_ws_echo(ws_port)
433433
server = _ThreadedHTTPServer(("0.0.0.0", ivrr_port), IvrStepHandler)
434434
print(f"[IVR] step provider on http://0.0.0.0:{ivrr_port}/ivr/step")
435-
print(f"[IVR] voip_bridge target = {IvrStepHandler.ws_endpoint}")
435+
print(f"[IVR] bridge target = {IvrStepHandler.ws_endpoint}")
436436
print("[IVR] Endpoints:")
437437
print(" POST /ivr/step — main provider endpoint")
438438
print(" POST /ivr/step/start — session start (fire-and-forget)")
@@ -507,7 +507,7 @@ def _ws_client_echo(url: str, samples: list) -> list:
507507
def self_test(ivrr_port: int, ws_port: int):
508508
"""Run provider + WS server in-process, drive the flow, print a trace."""
509509
print("=" * 72)
510-
print("VoipBridge IVR self-test — no SIP stack needed")
510+
print("Bridge IVR self-test — no SIP stack needed")
511511
print("=" * 72)
512512
IvrStepHandler.ws_endpoint = f"ws://127.0.0.1:{ws_port}"
513513
start_ws_echo(ws_port)
@@ -529,22 +529,22 @@ def step(label, event):
529529
trace.append((label, event.get("type"), resp.get("type"), resp))
530530
return resp
531531

532-
# 1. session_start → prompt + dtmf_menu (entries hold the voip_bridge)
532+
# 1. session_start → prompt + dtmf_menu (entries hold the bridge)
533533
resp = step("initial", {"type": "session_start"})
534534
assert resp["type"] == "prompt", resp
535535
menu = resp.get("next", {})
536536
assert menu.get("type") == "dtmf_menu", menu
537537
entry1 = menu.get("entries", {}).get("1", {})
538-
assert entry1.get("type") == "voip_bridge", entry1
538+
assert entry1.get("type") == "bridge", entry1
539539
print(f"[trace] session_start -> {resp['type']} (next={menu['type']}, "
540540
f"entry['1']={entry1['type']} uri={entry1.get('create_room_uri')})")
541541

542-
# 2. Verify the voip_bridge ActionNode shape matches the protocol doc.
542+
# 2. Verify the bridge ActionNode shape matches the protocol doc.
543543
uri = entry1.get("create_room_uri", "")
544544
if f":{ws_port}" not in uri:
545545
failures.append(f"ws port mismatch in create_room_uri: {uri}")
546546
if "timeout_ms" not in entry1:
547-
failures.append("timeout_ms missing on voip_bridge node")
547+
failures.append("timeout_ms missing on bridge node")
548548

549549
# 3. Drive dtmf "2" through a fresh session to exercise the time branch.
550550
sid2 = sid + "_b"
@@ -580,7 +580,7 @@ def step(label, event):
580580
print("-" * 72)
581581
for label, ev, act, node in trace:
582582
note = ""
583-
if act == "voip_bridge":
583+
if act == "bridge":
584584
note = "→ " + node.get("create_room_uri", "")
585585
elif act == "prompt":
586586
note = (node.get("tts_text") or node.get("file") or "")[:40]
@@ -620,10 +620,10 @@ def test_start_returns_prompt_chained_to_menu(self):
620620
self.assertEqual(nxt["type"], "dtmf_menu")
621621
self.assertIn("1", nxt["entries"])
622622

623-
def test_menu_entry_1_is_voip_bridge(self):
623+
def test_menu_entry_1_is_bridge(self):
624624
node = self.sess.next_action({"type": "session_start"})
625625
e1 = node["next"]["entries"]["1"]
626-
self.assertEqual(e1["type"], "voip_bridge")
626+
self.assertEqual(e1["type"], "bridge")
627627
self.assertEqual(e1["create_room_uri"], "ws://127.0.0.1:9090")
628628
self.assertGreater(e1["timeout_ms"], 0)
629629
self.assertIn("headers", e1)
@@ -632,7 +632,7 @@ def test_menu_entry_0_is_hangup(self):
632632
node = self.sess.next_action({"type": "session_start"})
633633
self.assertEqual(node["next"]["entries"]["0"]["type"], "hangup")
634634

635-
def test_voip_bridge_uri_from_env_override(self):
635+
def test_bridge_uri_from_env_override(self):
636636
s = IvrSession("1", "2", "wss://relay.example.com/ws")
637637
node = s.next_action({"type": "session_start"})
638638
self.assertEqual(
@@ -661,7 +661,7 @@ def sendall(self, b):
661661

662662

663663
def main():
664-
p = argparse.ArgumentParser(description="VoipBridge IVR provider + WS echo")
664+
p = argparse.ArgumentParser(description="Bridge IVR provider + WS echo")
665665
p.add_argument("ivrr_port", nargs="?", type=int, default=8080)
666666
p.add_argument("ws_port", nargs="?", type=int, default=9090)
667667
p.add_argument("--self-test", action="store_true",

src/addons/ivr_editor

Submodule ivr_editor updated from 9f45b77 to 32fd3ef

src/call/app/ivr/common.rs

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use crate::tts::TtsService;
66
use std::collections::HashMap;
77
use std::sync::Arc;
88
use std::time::Duration;
9+
use urlencoding;
910

1011
use super::config::{ActionNode, EntryAction};
1112

@@ -211,8 +212,17 @@ pub async fn execute_action(
211212
tts_service: Option<&Arc<TtsService>>,
212213
) -> anyhow::Result<ActionResult> {
213214
match action {
214-
EntryAction::Transfer { target } => {
215-
let t = substitute_vars(target, &sess.variables);
215+
EntryAction::Transfer { target, params } => {
216+
let mut t = substitute_vars(target, &sess.variables);
217+
if !params.is_empty() {
218+
t.push('?');
219+
for (i, (k, v)) in params.iter().enumerate() {
220+
if i > 0 {
221+
t.push('&');
222+
}
223+
t.push_str(&format!("{}={}", k, urlencoding::encode(v)));
224+
}
225+
}
216226
Ok(ActionResult::Terminal(TerminalAction::Transfer(t)))
217227
}
218228
EntryAction::Queue { target, .. } => {
@@ -490,7 +500,21 @@ pub async fn execute_action(
490500
params,
491501
} => {
492502
let rp = substitute_vars(route_point, &sess.variables);
493-
let target = format!("toivr:{}", rp);
503+
let mut target = format!("toivr:{}", rp);
504+
// Encode params as query string so the target IVR can receive them
505+
if !params.is_empty() {
506+
target.push('?');
507+
for (i, (k, v)) in params.iter().enumerate() {
508+
if i > 0 {
509+
target.push('&');
510+
}
511+
target.push_str(&format!(
512+
"{}={}",
513+
k,
514+
urlencoding::encode(v)
515+
));
516+
}
517+
}
494518
sess.variables.insert(
495519
"jump_params".into(),
496520
serde_json::to_string(params).unwrap_or_default(),
@@ -522,28 +546,34 @@ pub async fn execute_action(
522546
Ok(ActionResult::Terminal(TerminalAction::Transfer(t)))
523547
}
524548

525-
EntryAction::VoipBridge {
549+
EntryAction::Bridge {
526550
create_room_uri,
527551
headers,
552+
return_ivr,
528553
success,
529554
failure,
530555
..
531556
} => {
532557
let uri = substitute_vars(create_room_uri, &sess.variables);
533-
sess.variables.insert("voip_room_uri".into(), uri.clone());
558+
sess.variables.insert("bridge_room_uri".into(), uri.clone());
534559
for (k, v) in headers {
535-
sess.variables.insert(format!("voip_hdr_{}", k), v.clone());
560+
sess.variables.insert(format!("bridge_hdr_{}", k), v.clone());
561+
}
562+
let mut uri = uri;
563+
if let Some(ivr_name) = return_ivr {
564+
let sep = if uri.contains('?') { "&" } else { "?" };
565+
uri = format!("{}{}return_ivr={}", uri, sep, ivr_name);
536566
}
537567
if success.is_some() || failure.is_some() {
538568
sess.variables
539-
.insert("voip_bridge_branch".into(), "true".into());
569+
.insert("bridge_branch".into(), "true".into());
540570
Ok(ActionResult::Terminal(TerminalAction::Transfer(format!(
541-
"voip_bridge:{}",
571+
"bridge:{}",
542572
uri
543573
))))
544574
} else {
545575
Ok(ActionResult::Terminal(TerminalAction::Transfer(format!(
546-
"voip_bridge:{}",
576+
"bridge:{}",
547577
uri
548578
))))
549579
}

0 commit comments

Comments
 (0)