tgcalls · part of the ferogram ecosystem

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.

Start here

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.

This is a Rust-only guide
TgCalls has no Python bindings yet. For Rust API basics (client setup, login), see docs.ferogram.dev.
Start here

Installation

Cargo.toml
[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.

Debian / Ubuntu
sudo apt install ffmpeg
Start here

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:

main.rs
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:

shell
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.

Start here

Two layers: Call vs Calls

Pick based on how much control you need:

TypeScopeUse 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.

Guide

Building media

Media:: builders construct the MediaDescription / VideoDescription / AudioDescription both Call and Calls accept:

BuilderProduces
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_audioFrame-by-frame sources you feed manually, instead of a file
Media::microphone / camera / speakerLive device capture - see Media::list_devices()
Media::record_audio / record_videoRecording 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
Guide

Group calls

Through Calls, the control surface a bot's command handlers actually call - play, mute, volume, pause, resume:

Rust
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:

Rust
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);
Guide

Screen share & video

Video and screen share go through the lower-level Call type, since presentation streams are a second, independent track alongside the main one:

Rust
let mut call = Call::new(client, chat_id);

call.join(Media::audio(&audio_path)).await?;

let screen = Media::screen(1280, 720, 30);
call.join_presentation(screen).await?;

// ... later
call.stop_presentation().await?;
call.leave().await?;

Plain group video works the same way, just pass a video-capable Media straight to join:

Rust
let mut call = Call::new(client, chat_id);
call.join(Media::video(&path, 1280, 720, 30)).await?;
Guide

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:

Rust
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.

Guide

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.

Rust
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.

Verification emoji
ConferenceEvent::FingerprintUpdated gives you the same short emoji sequence Telegram's own clients show, so participants can visually confirm the call is end-to-end secure and not being intercepted.
Reference

Events & state

Register a handler with on_event on either Calls or Call to react to things beyond your own method calls:

EventMeaning
StreamEndedA stream (or recording target) reached EOF
ParticipantUpdateSomeone joined, left, or changed state (mute, video, etc.) - includes user_id, the ParticipantAction, and whether it was you
LeftYou were removed from (or otherwise left) the call without calling leave() yourself - state is already reset to Idle by the time this fires
EndedThe voice chat was ended for everyone

CallState is Idle → Joining → Joined → Leaving. Check it any time with call.state() or calls.status(chat_id).

Reference

Error handling

Every fallible call returns Result<T, TgCallsError>. The variants worth branching on specifically:

VariantMeaning
NotJoinedCalled something that needs an active join - call join() first
AlreadyJoinedAlready joined a call in this chat
NoActiveGroupCallNo active group call in this chat
P2PAlreadyActive / P2PNotActiveP2P call state mismatch for this user
TooManyConcurrentCalls(n)Hit the limit passed to with_concurrency_limit - leave one before joining another
WorkerGoneThis 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
Reference

Things to keep in mind

No automatic reconnection
If the network degrades, Call doesn't silently retry or fall back to a relay for you. Check call.connection_mode() to see if you've dropped to relay mode, and react to it yourself.
  • 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
Project

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.

Ankit Chaubey
Ankit Chaubey
Creator & maintainer - TgCalls, Ferogram, ferogram-py

Licensed under MIT OR Apache-2.0.

Project

Community & links