Voice & video calls for Telegram, in Rust.
TgCalls bridges Telegram's calling stack with a clean async API on top of ferogram and ntgcalls. Group calls, P2P calls, E2E-encrypted conferences, screen share - join a voice chat and stream a file in three lines.
What TgCalls is
Music bots, voice assistants, recording tools, anything that needs to participate in a Telegram call, lives on top of this crate. It handles joining or creating the group call, negotiating the WebRTC media connection, and streaming audio or video through FFmpeg, so you just provide the client, the chat, and the media.
It's a separate crate from core Ferogram - calling is a large, optional surface most bots never touch, so it isn't bundled by default. Add it only if you need it.
Installation
[dependencies] tgcalls = "0.2" ferogram = "0.6.5" tokio = { version = "1", features = ["full"] }
You'll also need FFmpeg on your PATH - it's what decodes your media files into the raw frames TgCalls streams into the call.
sudo apt install ffmpeg
Quick start: play audio in a group call
Joining a voice chat and playing a file takes a few lines. Calls handles join, voice-chat creation, and media detection for you:
use tgcalls::Calls; #[tokio::main] async fn main() -> anyhow::Result<()> { let api_id: i32 = std::env::var("API_ID")?.parse()?; let api_hash = std::env::var("API_HASH")?; let (client, shutdown) = ferogram::Client::quick_connect("tgcalls.session", api_id, &api_hash).await?; let calls = Calls::new(client); calls.play(chat_id, "song.mp3").await?; println!("Playing. Press Ctrl+C to stop."); tokio::signal::ctrl_c().await?; calls.leave(chat_id).await?; drop(shutdown); Ok(()) }
Try it against the real repo:
git clone https://github.com/ankit-chaubey/tgcalls.git cd tgcalls export API_ID=123456 export API_HASH=your_api_hash_here cargo run --example group_audio_call -- -1001234567890 /path/to/song.mp3
First run asks you to sign in with your phone number and login code, same as any ferogram client. The session is saved locally after that.
Two layers: Call vs Calls
Pick based on how much control you need:
| Type | Scope | Use it when |
|---|---|---|
| Calls | One manager, every chat your client is in | play, pause, seek, record, status - chat_id-keyed, auto-starts voice chats, auto-subscribes video, runs each call on its own thread. Register once with your Dispatcher and forget about ntgcalls' threading rules. Most bots only need this. |
| Call | One chat, you own it | Manual join/leave, direct access to every ntgcalls operation - screen share, external frames, broadcast parts, raw participant updates. Use it when managing calls yourself or when Calls doesn't expose something you need. |
Media builds the FFmpeg-backed (or device-backed) sources both layers stream. P2PCall is the separate private (1:1) call flow, and ConferenceCall is Telegram's newer E2E-encrypted call type - a genuinely different join/signaling flow built on signed block chains rather than participant broadcasts.
Building media
Media:: builders construct the MediaDescription / VideoDescription / AudioDescription both Call and Calls accept:
| Builder | Produces |
|---|---|
| Media::audio(path) | Audio-only stream from a file, decoded via FFmpeg |
| Media::audio_at(path, offset) | Audio starting at a given Duration offset |
| Media::video(path, w, h, fps) | Video-only stream at the given resolution and frame rate |
| Media::av(path, w, h, fps) | Combined audio + video from one file |
| Media::screen(w, h, fps) | Screen-share / presentation stream |
| Media::external_video / external_audio | Frame-by-frame sources you feed manually, instead of a file |
| Media::microphone / camera / speaker | Live device capture - see Media::list_devices() |
| Media::record_audio / record_video | Recording targets: writes the call's incoming audio/video to a file |
| auto_media(path, w, h, fps) | Probes the file and picks audio-only or A/V automatically |
Group calls
Through Calls, the control surface a bot's command handlers actually call - play, mute, volume, pause, resume:
let calls = Calls::new(client); calls.play(chat_id, path).await?; calls.mute(chat_id).await?; calls.unmute(chat_id).await?; // Telegram's raw 0..20000 scale: 10000 = 100%, 15000 = 150% calls.set_volume(chat_id, user_id, 15000).await?; calls.pause(chat_id).await?; calls.resume(chat_id).await?; calls.leave(chat_id).await?;
Other useful calls: seek, progress (returns a Progress with remaining() / percent()), status, get_participants, call_type, connection_mode, and is_joined.
Recording a call
Same API, aimed at a recording target instead of playback - joins silently first if you're not already in the call:
calls.record(chat_id, Media::record_audio("output.mp3")).await?;
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
calls.leave(chat_id).await?;
Multiple calls at once
By default Calls has no cap on concurrent chats. If you need one, cap it explicitly:
Calls::with_concurrency_limit(client, 10);P2P calls
Direct, one-on-one calls work the same way in spirit - ring someone, exchange keys, stream - though the setup is more involved than group calls, since P2P calls are end-to-end encrypted and need a live signaling loop while connecting:
let mut stream = client.stream_updates(); let mut call = P2PCall::new(client, user_id); let media = Media::audio(&path); let (servers, versions) = call.request(false, &mut stream).await?; let (mut sig_out_rx, mut conn_rx) = call.connect(&servers, &versions, true).await?; // configure audio before signaling completes, so the encoder is ready call.set_media(StreamMode::Capture, &media).await?; let connected = call.run_signaling(&mut sig_out_rx, &mut conn_rx, &mut stream).await?; if !connected { call.end().await; return Err(anyhow::anyhow!("WebRTC connection failed")); } // ... call.end().await; when done
The p2p_audio_call and p2p_video_call examples in the repo walk through the whole flow end to end, including the update stream plumbing.
Conference calls (E2E-encrypted)
Telegram's newer call type. Membership and the shared encryption key live in a signed, append-only block chain that clients build and verify themselves - the server just relays opaque blocks between participants instead of broadcasting a participant list. It's a genuinely different flow from the classic group call, which is why it's its own manager, ConferenceCalls.
let conferences = ConferenceCalls::new(client); conferences.on_event(|chat_id, event| match event { ConferenceEvent::FingerprintUpdated(emoji) => println!("verification: {emoji}"), ConferenceEvent::ParticipantsChanged => println!("participants changed"), ConferenceEvent::Left => println!("left"), _ => {} }); let mut dp = Dispatcher::new(); dp.middleware(conferences.clone()); // routes chain blocks for you conferences.create(chat_id, vec![invitee_user_id], None).await?; // wait for ParticipantsChanged before playing - the invitee can't // decrypt anything sent before the chain has rekeyed to include them conferences.play(chat_id, Media::audio(&path)).await?;
Incoming invites arrive as a system message; check for them on your update stream with incoming_conference_call(&update). Upgrading an existing P2P call into a conference instead of starting fresh is migrate_from_p2p.
Events & state
Register a handler with on_event on either Calls or Call to react to things beyond your own method calls:
| Event | Meaning |
|---|---|
| StreamEnded | A stream (or recording target) reached EOF |
| ParticipantUpdate | Someone joined, left, or changed state (mute, video, etc.) - includes user_id, the ParticipantAction, and whether it was you |
| Left | You were removed from (or otherwise left) the call without calling leave() yourself - state is already reset to Idle by the time this fires |
| Ended | The voice chat was ended for everyone |
CallState is Idle → Joining → Joined → Leaving. Check it any time with call.state() or calls.status(chat_id).
Error handling
Every fallible call returns Result<T, TgCallsError>. The variants worth branching on specifically:
| Variant | Meaning |
|---|---|
| NotJoined | Called something that needs an active join - call join() first |
| AlreadyJoined | Already joined a call in this chat |
| NoActiveGroupCall | No active group call in this chat |
| P2PAlreadyActive / P2PNotActive | P2P call state mismatch for this user |
| TooManyConcurrentCalls(n) | Hit the limit passed to with_concurrency_limit - leave one before joining another |
| WorkerGone | This chat's call worker thread is gone (panicked or already torn down) |
| Ferogram(..) / NtgCalls(..) | Wrapped errors bubbled up from the underlying RPC layer or the native ntgcalls layer |
Things to keep in mind
- FFmpeg must be reachable on PATH - media playback silently has nowhere to go without it
- Group video supports full HD, high-quality video and high-quality audio streaming
- P2P calls are still actively evolving compared to group and conference calls
- Calling is subject to the same responsible-use expectations as the rest of Ferogram - see session security & ToS on the main docs hub
About & credits
TgCalls is created and maintained by Ankit Chaubey, alongside Ferogram and ferogram-py. It wouldn't exist without ntgcalls, the native calling engine created by Laky-64, which TgCalls wraps in an ergonomic async Rust API and wires directly into ferogram's client.
Licensed under MIT OR Apache-2.0.
Community & links
- Examples: github.com/ankit-chaubey/tgcalls/examples - twelve working files covering every flow on this page
- API reference: docs.rs/tgcalls
- Crates.io: crates.io/crates/tgcalls
- Chat: @FerogramChat
- Ferogram core docs: docs.ferogram.dev