Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .omp/rules/asterisk.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,9 @@ globs:
- REST endpoints use Basic Auth on every request; credentials are not session-based.
- Resources: channels, bridges, endpoints, device states, mailboxes, sounds, recordings, playbacks.
- Stasis application model: channels enter stasis via dialplan `Stasis(appname)`, controlled via REST.


## Testing

- No inline tests (`#[cfg(test)]`) in any protocol crate. All tests live in `tests/` (`asterisk-rs-tests` crate).
- Mock servers for each protocol are in `tests/src/mock/` (MockAmiServer, MockAriServer, MockAgiClient).
6 changes: 6 additions & 0 deletions .omp/rules/rust.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ globs:
- Each protocol crate (ami, agi, ari) is independently usable.
- `asterisk-rs` is the umbrella re-export. It adds no logic, only pub use.

## Testing

- No `#[cfg(test)]` or inline test modules in production crates. All tests live in the external `tests/` crate (`asterisk-rs-tests`).
- Unit, mock integration, and live integration tests are separate binaries in `tests/`.
- Run tests with `cargo test -p asterisk-rs-tests`, never with per-crate `cargo test -p asterisk-rs-ami`.

## Build

```bash
Expand Down
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,9 +339,15 @@ Located in each crate's `examples/` directory (not workspace root):
|---------|-------|-------------|
| `ami_originate.rs` | asterisk-rs-ami | Builder, OriginateAction, response handling |
| `ami_events.rs` | asterisk-rs-ami | Event subscription loop |
| `ami_call_tracker.rs` | asterisk-rs-ami | CallTracker, CompletedCall records, background task |
| `agi_server.rs` | asterisk-rs-agi | AgiHandler impl, channel operations |
| `agi_ivr.rs` | asterisk-rs-agi | IVR menu, DTMF, AstDB, variables, branching |
| `ari_stasis_app.rs` | asterisk-rs-ari | Stasis event loop, ChannelHandle |
| `ari_bridge.rs` | asterisk-rs-ari | Bridge creation, channel origination |
| `ari_recording.rs` | asterisk-rs-ari | Channel recording and playback |
| `ari_pending.rs` | asterisk-rs-ari | Race-free PendingChannel origination |
| `ari_websocket_transport.rs` | asterisk-rs-ari | Unified WebSocket transport mode |
| `pbx_dial.rs` | asterisk-rs | Pbx high-level dial, wait_for_answer, CompletedCall |

All examples require a running Asterisk instance and use `tracing_subscriber` (dev-dependency).

Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

214 changes: 200 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ channels, bridges, queues, and recordings across all three Asterisk interfaces.
- **AGI** -- run dialplan logic from your Rust service. FastAGI server with typed async commands.
- **ARI** -- full call control via REST + WebSocket. Resource handles, typed events with metadata.

## Example
## Quick Example

```rust,ignore
use asterisk_rs::ami::{AmiClient, AmiEvent};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt::init();

let client = AmiClient::builder()
.host("10.0.0.1")
.credentials("admin", "secret")
Expand All @@ -33,7 +35,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {

while let Some(event) = hangups.recv().await {
if let AmiEvent::Hangup { channel, cause, cause_txt, .. } = event {
println!("{channel} hung up: {cause} ({cause_txt})");
tracing::info!(%channel, %cause, %cause_txt, "channel hung up");
}
}

Expand All @@ -43,26 +45,56 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {

## Install

Use the umbrella crate to pull in whichever protocols you need:

```toml
[dependencies]
asterisk-rs = "0.1"
```

Or add individual protocol crates directly:

```toml
[dependencies]
asterisk-rs = "0.2"
asterisk-rs-ami = "0.4" # AMI only
asterisk-rs-agi = "0.2" # AGI only
asterisk-rs-ari = "0.4" # ARI only
```

Or pick individual protocols:
## Feature Selection

The umbrella crate enables all protocols by default. To select only what you need:

```toml
[dependencies]
asterisk-rs-ami = "0.2" # AMI only
asterisk-rs-agi = "0.1" # AGI only
asterisk-rs-ari = "0.2" # ARI only
asterisk-rs = { version = "0.1", default-features = false, features = ["ami"] }
# or: features = ["agi"]
# or: features = ["ari"]
# or: features = ["ami", "ari"]
```

Available features: `ami`, `agi`, `ari`. The `pbx` abstraction requires `ami`.

## Protocols

| Protocol | Default Port | Transport | Use Case |
|----------|-------------|-----------|----------|
| [AMI](https://docs.rs/asterisk-rs-ami) | 5038 | TCP | Monitoring, call control, system management |
| [AGI](https://docs.rs/asterisk-rs-agi) | 4573 | TCP | Dialplan logic, IVR, call routing |
| [ARI](https://docs.rs/asterisk-rs-ari) | 8088 | HTTP + WS | Stasis applications, full media control |

## Capabilities

- Typed actions, events, and commands for the full Asterisk 23 protocol surface
- Typed actions, events, and commands for the full Asterisk protocol surface
- Filtered event subscriptions -- receive only what you need
- Event-collecting actions -- `send_collecting()` gathers multi-event responses (Status, QueueStatus, etc.)
- Automatic reconnection with exponential backoff, jitter, and re-authentication
- **Call tracker** -- correlates AMI events into `CompletedCall` records (channel, duration, cause, full event log)
- **PBX abstraction** -- `Pbx::dial()` wraps originate + OriginateResponse correlation into one async call
- **Pending resources** -- ARI `PendingChannel`/`PendingBridge` pre-subscribe before REST to eliminate event races
- **Transport modes** -- ARI supports HTTP (request/response) or WebSocket (bidirectional streaming)
- **Outbound WebSocket server** -- `AriServer` accepts Asterisk 22+ outbound WS connections
- **Media channel** -- low-level audio I/O over WebSocket for external media applications
- Resource handles for ARI (ChannelHandle, BridgeHandle, PlaybackHandle, RecordingHandle)
- Domain types for hangup causes, channel states, device states, dial statuses, and more
- ARI event metadata (application, timestamp, asterisk_id) on every event
Expand All @@ -71,17 +103,171 @@ asterisk-rs-ari = "0.2" # ARI only
- `#[non_exhaustive]` enums -- new variants won't break your code
- Structured logging via `tracing`

## Protocols
## More Examples

| Protocol | Default Port | Transport | Use Case |
|----------|-------------|-----------|----------|
| [AMI](https://docs.rs/asterisk-rs-ami) | 5038 | TCP | Monitoring, call control, system management |
| [AGI](https://docs.rs/asterisk-rs-agi) | 4573 | TCP | Dialplan logic, IVR, call routing |
| [ARI](https://docs.rs/asterisk-rs-ari) | 8088 | HTTP + WS | Stasis applications, full media control |
### AMI: call tracker

```rust,ignore
use asterisk_rs::ami::AmiClient;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt::init();

let client = AmiClient::builder()
.host("127.0.0.1")
.credentials("admin", "secret")
.build()
.await?;

let (tracker, mut rx) = client.call_tracker();

while let Some(call) = rx.recv().await {
tracing::info!(
channel = %call.channel,
duration = ?call.duration,
cause = %call.cause_txt,
"call completed"
);
}

tracker.shutdown();
Ok(())
}
```

### AGI: IVR handler

```rust,ignore
use asterisk_rs::agi::{AgiChannel, AgiHandler, AgiRequest, AgiServer};

struct IvrHandler;

impl AgiHandler for IvrHandler {
async fn handle(&self, _request: AgiRequest, mut channel: AgiChannel)
-> asterisk_rs::agi::error::Result<()>
{
channel.answer().await?;
channel.stream_file("welcome", "#").await?;
let response = channel.get_data("press-ext", 5000, 4).await?;
tracing::info!(digits = response.result, "caller input");
channel.hangup(None).await?;
Ok(())
}
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt::init();

let (server, _shutdown) = AgiServer::builder()
.bind("0.0.0.0:4573")
.handler(IvrHandler)
.max_connections(100)
.build()
.await?;

server.run().await?;
Ok(())
}
```

### ARI: pending channel

```rust,ignore
use asterisk_rs::ari::config::AriConfigBuilder;
use asterisk_rs::ari::{AriClient, AriEvent, PendingChannel, TransportMode};
use asterisk_rs::ari::resources::channel::OriginateParams;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt::init();

let config = AriConfigBuilder::new("my-app")
.host("127.0.0.1")
.port(8088)
.username("asterisk")
.password("asterisk")
.build()?;

let client = AriClient::connect(config).await?;

// pre-subscribe before originate so no events are missed
let pending = client.channel();
let params = OriginateParams {
endpoint: "PJSIP/100".into(),
app: Some("my-app".into()),
..Default::default()
};
let (handle, mut events) = pending.originate(params).await?;

while let Some(msg) = events.recv().await {
match msg.event {
AriEvent::StasisStart { .. } => {
handle.answer().await?;
handle.play("sound:hello-world").await?;
handle.hangup(None).await?;
}
AriEvent::ChannelDestroyed { cause_txt, .. } => {
tracing::info!(%cause_txt, "channel destroyed");
break;
}
_ => {}
}
}

Ok(())
}
```

### PBX: dial and wait

```rust,ignore
use asterisk_rs::ami::AmiClient;
use asterisk_rs::pbx::{DialOptions, Pbx};
use std::time::Duration;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt::init();

let client = AmiClient::builder()
.host("127.0.0.1")
.credentials("admin", "secret")
.build()
.await?;

let mut pbx = Pbx::new(client);

let call = pbx.dial(
"PJSIP/100",
"200",
Some(
DialOptions::new()
.caller_id("Rust PBX <100>")
.timeout_ms(30000),
),
).await?;

call.wait_for_answer(Duration::from_secs(30)).await?;
tracing::info!("call answered");

call.hangup().await?;

if let Some(completed) = pbx.next_completed_call().await {
tracing::info!(duration = ?completed.duration, cause = %completed.cause_txt, "call record");
}

Ok(())
}
```

## Documentation

- [API Reference (docs.rs)](https://docs.rs/asterisk-rs)
- [AMI crate docs](https://docs.rs/asterisk-rs-ami)
- [AGI crate docs](https://docs.rs/asterisk-rs-agi)
- [ARI crate docs](https://docs.rs/asterisk-rs-ari)
- [User Guide](https://deadcode-walker.github.io/asterisk-rs/)

## MSRV
Expand Down
Loading
Loading