use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use ferogram::middleware::{BoxFuture, Middleware, Next};
use ferogram::tl;
use ferogram::Update;
use ntgcalls::{
ConnectionInfo, ConnectionState, Frame, MediaDescription, NTgCalls, RemoteSource, SsrcMapping,
StreamDevice, StreamMode, StreamType, SubchainRequest,
};
use tokio::sync::{mpsc, oneshot};
use tracing::{debug, info, warn};
use crate::call::{ntg_chat_id, peer_user_id};
use crate::{error::TgCallsError, signaling};
type Reply<T> = oneshot::Sender<Result<T, TgCallsError>>;
type ConnectSlot = Arc<Mutex<Option<oneshot::Sender<Result<(), ntgcalls::Error>>>>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConferenceState {
Idle,
Joining,
Joined,
Leaving,
}
#[derive(Debug, Clone)]
pub enum ConferenceEvent {
StreamEnded(StreamType, StreamDevice),
FingerprintUpdated(String),
Left,
ParticipantsChanged,
Frames {
mode: StreamMode,
device: StreamDevice,
frames: Vec<Frame>,
},
RemoteSourceChanged(RemoteSource),
}
pub enum ConferenceTarget {
Create { invite: Vec<i64> },
Join { last_block: Option<Vec<u8>> },
JoinBySlug {
slug: String,
last_block: Option<Vec<u8>>,
},
JoinByInviteMessage {
msg_id: i32,
last_block: Option<Vec<u8>>,
},
}
fn media_has_video(media: &Option<MediaDescription>) -> bool {
media
.as_ref()
.is_some_and(|m| m.camera.is_some() || m.screen.is_some())
}
enum Command {
Start {
target: ConferenceTarget,
media: Option<MediaDescription>,
reply: Reply<()>,
},
Leave(Reply<()>),
FingerprintEmojis(Reply<String>),
IsJoined(oneshot::Sender<bool>),
InviteLink(oneshot::Sender<Option<String>>),
Invite {
user_id: i64,
video: Option<bool>,
reply: Reply<()>,
},
Play {
media: MediaDescription,
reply: Reply<()>,
},
ApplyBlocks {
call: tl::enums::InputGroupCall,
sub_chain_id: i32,
blocks: Vec<Vec<u8>>,
next_offset: i32,
},
OutboundBlock(Vec<u8>),
SubchainRequest(SubchainRequest),
RequestParticipants,
EmojisUpdated(String),
StreamEnded(StreamType, StreamDevice),
Frames {
mode: StreamMode,
device: StreamDevice,
frames: Vec<Frame>,
},
RemoteSourceChanged(RemoteSource),
}
pub struct ConferenceCall {
tx: mpsc::UnboundedSender<Command>,
chat_id: i64,
}
impl ConferenceCall {
pub fn new(
client: ferogram::Client,
chat_id: i64,
handler: impl Fn(ConferenceEvent) + Send + Sync + 'static,
) -> Self {
let (tx, mut rx) = mpsc::unbounded_channel::<Command>();
let actor_tx = tx.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tgcalls conference worker: failed to start runtime");
rt.block_on(async move {
let mut ntg = NTgCalls::new();
let connect_slot: ConnectSlot = Arc::new(Mutex::new(None));
let mut state = ConferenceState::Idle;
let mut active_call: Option<tl::enums::InputGroupCall> = None;
let mut has_video = false;
let mut invite_link: Option<String> = None;
let ntg_id = ntg_chat_id(chat_id);
{
let connect_slot = connect_slot.clone();
ntg.on_connection_change(move |_chat_id, info: ConnectionInfo| {
let mut slot = connect_slot.lock().unwrap();
match info.state {
ConnectionState::Connected => {
if let Some(tx) = slot.take() {
let _ = tx.send(Ok(()));
}
}
ConnectionState::Failed
| ConnectionState::Timeout
| ConnectionState::Closed => {
if let Some(tx) = slot.take() {
let _ = tx.send(Err(ntgcalls::Error {
kind: ntgcalls::ErrorKind::Connection,
message: format!("connection ended: {:?}", info.state),
}));
}
}
ConnectionState::Connecting => {}
}
});
}
{
let tx = actor_tx.clone();
ntg.on_stream_end(move |_chat_id, stream_type, device| {
let _ = tx.send(Command::StreamEnded(stream_type, device));
});
}
{
let tx = actor_tx.clone();
ntg.on_frames(move |_chat_id, mode, device, frames| {
let _ = tx.send(Command::Frames {
mode,
device,
frames,
});
});
}
{
let tx = actor_tx.clone();
ntg.on_remote_source_change(move |_chat_id, source| {
let _ = tx.send(Command::RemoteSourceChanged(source));
});
}
{
let tx = actor_tx.clone();
ntg.on_outbound_block(move |_chat_id, block| {
let _ = tx.send(Command::OutboundBlock(block));
});
}
{
let tx = actor_tx.clone();
ntg.on_subchain_request(move |_chat_id, req| {
let _ = tx.send(Command::SubchainRequest(req));
});
}
{
let tx = actor_tx.clone();
ntg.on_request_participants(move |_chat_id| {
let _ = tx.send(Command::RequestParticipants);
});
}
{
let tx = actor_tx.clone();
ntg.on_update_emojis(move |_chat_id, emoji| {
let _ = tx.send(Command::EmojisUpdated(emoji));
});
}
while let Some(cmd) = rx.recv().await {
match cmd {
Command::Start {
target,
media,
reply,
} => {
has_video = media_has_video(&media);
let ctx = StartCtx {
client: &client,
ntg_id,
chat_id,
connect_slot: &connect_slot,
};
let result =
start_conference(&ctx, &mut ntg, target, media, !has_video).await;
match result {
Ok((call, link)) => {
active_call = Some(call);
invite_link = link;
state = ConferenceState::Joined;
let _ = reply.send(Ok(()));
}
Err(e) => {
state = ConferenceState::Idle;
let _ = reply.send(Err(e));
}
}
}
Command::Leave(reply) => {
let result = ntg.stop(ntg_id).await.map_err(TgCallsError::from);
active_call = None;
invite_link = None;
state = ConferenceState::Idle;
let _ = reply.send(result);
}
Command::FingerprintEmojis(reply) => {
let result = ntg
.get_emojis_fingerprint(ntg_id)
.await
.map_err(TgCallsError::from);
let _ = reply.send(result);
}
Command::IsJoined(reply) => {
let _ = reply.send(state == ConferenceState::Joined);
}
Command::InviteLink(reply) => {
let _ = reply.send(invite_link.clone());
}
Command::Invite {
user_id,
video,
reply,
} => {
let video = video.unwrap_or(has_video);
let result = match &active_call {
Some(call) => {
invite_participant(&client, call, user_id, video).await
}
None => Err(TgCallsError::NotJoined),
};
let _ = reply.send(result);
}
Command::Play { media, reply } => {
has_video = media.camera.is_some() || media.screen.is_some();
let result = ntg
.set_stream_sources(ntg_id, StreamMode::Capture, &media)
.await
.map_err(TgCallsError::from);
let _ = reply.send(result);
}
Command::ApplyBlocks {
call,
sub_chain_id,
blocks,
next_offset,
} => {
let matches = match (&active_call, &call) {
(
Some(tl::enums::InputGroupCall::InputGroupCall(a)),
tl::enums::InputGroupCall::InputGroupCall(b),
) => a.id == b.id,
_ => false,
};
if matches {
if let Err(e) = ntg
.apply_blocks(ntg_id, sub_chain_id, next_offset, &blocks, false)
.await
{
warn!("tgcalls: apply_blocks (pushed) failed: {}", e);
}
}
}
Command::OutboundBlock(block) => {
if let Some(call) = &active_call {
if let Err(e) = signaling::send_conference_call_broadcast(
&client,
call.clone(),
block,
)
.await
{
warn!("tgcalls: failed to broadcast outbound block: {}", e);
}
}
}
Command::SubchainRequest(req) => {
if let Some(call) = &active_call {
match signaling::get_conference_chain_blocks(
&client,
call.clone(),
req.subchain,
req.height,
req.limit,
)
.await
{
Ok(Some(cb)) => {
if let Err(e) = ntg
.apply_blocks(
ntg_id,
cb.sub_chain_id,
cb.next_offset,
&cb.blocks,
true,
)
.await
{
warn!(
"tgcalls: apply_blocks (short poll) failed: {}",
e
);
}
}
Ok(None) => {}
Err(e) => {
warn!("tgcalls: getGroupCallChainBlocks failed: {}", e)
}
}
if let Err(e) =
ntg.finish_subchain_request(ntg_id, req.subchain).await
{
warn!("tgcalls: finish_subchain_request failed: {}", e);
}
}
}
Command::RequestParticipants => {
if let Some(call) = &active_call {
match signaling::get_participants(&client, call.clone()).await {
Ok(participants) => {
let mappings: Vec<SsrcMapping> = participants
.iter()
.filter_map(|p| {
peer_user_id(&p.peer).map(|user_id| SsrcMapping {
user_id,
ssrc: p.source,
})
})
.collect();
if let Err(e) =
ntg.update_audio_ssrc_mappings(ntg_id, &mappings).await
{
warn!(
"tgcalls: update_audio_ssrc_mappings failed: {}",
e
);
}
handler(ConferenceEvent::ParticipantsChanged);
}
Err(e) => warn!("tgcalls: getGroupParticipants failed: {}", e),
}
}
}
Command::EmojisUpdated(emoji) => {
handler(ConferenceEvent::FingerprintUpdated(emoji));
}
Command::StreamEnded(stream_type, device) => {
handler(ConferenceEvent::StreamEnded(stream_type, device));
}
Command::Frames {
mode,
device,
frames,
} => {
handler(ConferenceEvent::Frames {
mode,
device,
frames,
});
}
Command::RemoteSourceChanged(source) => {
handler(ConferenceEvent::RemoteSourceChanged(source));
}
}
}
let _ = ntg.stop(ntg_id).await;
});
});
Self { tx, chat_id }
}
pub async fn start(
&self,
target: ConferenceTarget,
media: Option<MediaDescription>,
) -> Result<(), TgCallsError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(Command::Start {
target,
media,
reply,
})
.map_err(|_| TgCallsError::WorkerGone)?;
rx.await.map_err(|_| TgCallsError::WorkerGone)?
}
pub async fn leave(&self) -> Result<(), TgCallsError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(Command::Leave(reply))
.map_err(|_| TgCallsError::WorkerGone)?;
rx.await.map_err(|_| TgCallsError::WorkerGone)?
}
pub async fn fingerprint_emojis(&self) -> Result<String, TgCallsError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(Command::FingerprintEmojis(reply))
.map_err(|_| TgCallsError::WorkerGone)?;
rx.await.map_err(|_| TgCallsError::WorkerGone)?
}
pub async fn is_joined(&self) -> bool {
let (reply, rx) = oneshot::channel();
if self.tx.send(Command::IsJoined(reply)).is_err() {
return false;
}
rx.await.unwrap_or(false)
}
pub async fn invite_link(&self) -> Option<String> {
let (reply, rx) = oneshot::channel();
if self.tx.send(Command::InviteLink(reply)).is_err() {
return None;
}
rx.await.ok().flatten()
}
pub async fn invite(&self, user_id: i64, video: Option<bool>) -> Result<(), TgCallsError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(Command::Invite {
user_id,
video,
reply,
})
.map_err(|_| TgCallsError::WorkerGone)?;
rx.await.map_err(|_| TgCallsError::WorkerGone)?
}
pub async fn play(&self, media: MediaDescription) -> Result<(), TgCallsError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(Command::Play { media, reply })
.map_err(|_| TgCallsError::WorkerGone)?;
rx.await.map_err(|_| TgCallsError::WorkerGone)?
}
pub fn apply_chain_blocks(&self, u: &tl::types::UpdateGroupCallChainBlocks) {
let _ = self.tx.send(Command::ApplyBlocks {
call: u.call.clone(),
sub_chain_id: u.sub_chain_id,
blocks: u.blocks.clone(),
next_offset: u.next_offset,
});
}
pub fn chat_id(&self) -> i64 {
self.chat_id
}
}
fn public_key_array(bytes: &[u8]) -> Result<[u8; 32], TgCallsError> {
bytes.try_into().map_err(|_| {
TgCallsError::TransportParse(format!(
"conference public key was {} bytes, expected 32",
bytes.len()
))
})
}
async fn invite_participant(
client: &ferogram::Client,
call: &tl::enums::InputGroupCall,
user_id: i64,
video: bool,
) -> Result<(), TgCallsError> {
debug!("tgcalls: resolving access hash for {}", user_id);
let access_hash = signaling::get_user_access_hash(client, user_id).await?;
debug!(
"tgcalls: inviting {} (access_hash={}) into conference",
user_id, access_hash
);
signaling::invite_conference_call_participant(
client,
call.clone(),
user_id,
access_hash,
video,
)
.await?;
info!("tgcalls: invited {} into conference", user_id);
Ok(())
}
struct StartCtx<'a> {
client: &'a ferogram::Client,
ntg_id: i64,
chat_id: i64,
connect_slot: &'a ConnectSlot,
}
async fn start_conference(
ctx: &StartCtx<'_>,
ntg: &mut NTgCalls,
target: ConferenceTarget,
media: Option<MediaDescription>,
video_stopped: bool,
) -> Result<(tl::enums::InputGroupCall, Option<String>), TgCallsError> {
let client = ctx.client;
let ntg_id = ctx.ntg_id;
let chat_id = ctx.chat_id;
let connect_slot = ctx.connect_slot;
let last_block = match &target {
ConferenceTarget::Join { last_block }
| ConferenceTarget::JoinBySlug { last_block, .. }
| ConferenceTarget::JoinByInviteMessage { last_block, .. } => last_block.clone(),
ConferenceTarget::Create { .. } => None,
};
ntg.create_p2p_call(ntg_id).await?;
let my_id = signaling::get_self_user_id(client).await?;
let params = ntg
.init_conference(ntg_id, my_id, last_block.as_deref())
.await?;
let public_key = public_key_array(¶ms.public_key)?;
let (tx, rx) = oneshot::channel();
*connect_slot.lock().unwrap() = Some(tx);
let (call, transport_json, invite_link) = match target {
ConferenceTarget::Create { invite } => {
let (call, transport, invite_link) = signaling::create_conference_call(
client,
¶ms.payload,
video_stopped,
params.block.clone(),
public_key,
)
.await?;
for user_id in invite {
if let Err(e) = invite_participant(client, &call, user_id, !video_stopped).await {
warn!(
"tgcalls: failed to invite {} into conference: {}",
user_id, e
);
}
}
(call, transport, invite_link)
}
ConferenceTarget::Join { .. } => {
let call = signaling::resolve_call(client, chat_id).await?;
join_conference(
client,
ntg,
ntg_id,
call,
¶ms,
video_stopped,
public_key,
)
.await?
}
ConferenceTarget::JoinBySlug { slug, .. } => {
let call = tl::enums::InputGroupCall::Slug(tl::types::InputGroupCallSlug { slug });
join_conference(
client,
ntg,
ntg_id,
call,
¶ms,
video_stopped,
public_key,
)
.await?
}
ConferenceTarget::JoinByInviteMessage { msg_id, .. } => {
let call =
tl::enums::InputGroupCall::InviteMessage(tl::types::InputGroupCallInviteMessage {
msg_id,
});
join_conference(
client,
ntg,
ntg_id,
call,
¶ms,
video_stopped,
public_key,
)
.await?
}
};
ntg.connect(ntg_id, &transport_json, false).await?;
rx.await.expect("connection callback never fired")?;
if let Some(media) = &media {
ntg.set_stream_sources(ntg_id, StreamMode::Capture, media)
.await?;
}
Ok((call, invite_link))
}
async fn join_conference(
client: &ferogram::Client,
ntg: &mut NTgCalls,
ntg_id: i64,
call: tl::enums::InputGroupCall,
params: &ntgcalls::ConferenceJoinParams,
video_stopped: bool,
public_key: [u8; 32],
) -> Result<(tl::enums::InputGroupCall, String, Option<String>), TgCallsError> {
let (transport, chain_blocks) = signaling::join_conference_call(
client,
call.clone(),
¶ms.payload,
video_stopped,
params.block.clone(),
public_key,
)
.await?;
if let Some(cb) = chain_blocks {
let _ = ntg
.apply_blocks(ntg_id, cb.sub_chain_id, cb.next_offset, &cb.blocks, false)
.await;
}
Ok((call, transport, None))
}
type ManagerEventHandler = Arc<dyn Fn(i64, ConferenceEvent) + Send + Sync>;
#[derive(Clone)]
pub struct ConferenceCalls {
client: ferogram::Client,
active: Arc<tokio::sync::Mutex<HashMap<i64, Arc<ConferenceCall>>>>,
event_handler: Arc<Mutex<Option<ManagerEventHandler>>>,
max_concurrent: Option<usize>,
}
impl ConferenceCalls {
pub fn new(client: ferogram::Client) -> Self {
Self {
client,
active: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
event_handler: Arc::new(Mutex::new(None)),
max_concurrent: None,
}
}
pub fn with_concurrency_limit(client: ferogram::Client, max_concurrent: usize) -> Self {
Self {
max_concurrent: Some(max_concurrent),
..Self::new(client)
}
}
pub fn on_event(&self, handler: impl Fn(i64, ConferenceEvent) + Send + Sync + 'static) {
*self.event_handler.lock().unwrap() = Some(Arc::new(handler));
}
async fn get_or_create(&self, chat_id: i64) -> Result<Arc<ConferenceCall>, TgCallsError> {
if let Some(conference) = self.active.lock().await.get(&chat_id) {
return Ok(conference.clone());
}
if let Some(max) = self.max_concurrent {
if self.active.lock().await.len() >= max {
return Err(TgCallsError::TooManyConcurrentCalls(max));
}
}
let handler = self.event_handler.lock().unwrap().clone();
let conference = Arc::new(ConferenceCall::new(
self.client.clone(),
chat_id,
move |event| {
if let Some(handler) = &handler {
handler(chat_id, event);
}
},
));
let mut active = self.active.lock().await;
Ok(active.entry(chat_id).or_insert(conference).clone())
}
async fn get(&self, chat_id: i64) -> Result<Arc<ConferenceCall>, TgCallsError> {
self.active
.lock()
.await
.get(&chat_id)
.cloned()
.ok_or(TgCallsError::NotJoined)
}
pub async fn start(
&self,
chat_id: i64,
target: ConferenceTarget,
media: Option<MediaDescription>,
) -> Result<(), TgCallsError> {
let conference = self.get_or_create(chat_id).await?;
conference.start(target, media).await
}
pub async fn create(
&self,
chat_id: i64,
invite: Vec<i64>,
media: Option<MediaDescription>,
) -> Result<(), TgCallsError> {
self.start(chat_id, ConferenceTarget::Create { invite }, media)
.await
}
pub async fn join(
&self,
chat_id: i64,
last_block: Option<Vec<u8>>,
media: Option<MediaDescription>,
) -> Result<(), TgCallsError> {
self.start(chat_id, ConferenceTarget::Join { last_block }, media)
.await
}
pub async fn invite(
&self,
chat_id: i64,
user_id: i64,
video: Option<bool>,
) -> Result<(), TgCallsError> {
self.get(chat_id).await?.invite(user_id, video).await
}
pub async fn play(&self, chat_id: i64, media: MediaDescription) -> Result<(), TgCallsError> {
self.get(chat_id).await?.play(media).await
}
pub async fn leave(&self, chat_id: i64) -> Result<(), TgCallsError> {
let conference = self.get(chat_id).await?;
let result = conference.leave().await;
self.active.lock().await.remove(&chat_id);
result
}
pub async fn fingerprint_emojis(&self, chat_id: i64) -> Result<String, TgCallsError> {
self.get(chat_id).await?.fingerprint_emojis().await
}
pub async fn is_joined(&self, chat_id: i64) -> bool {
match self.active.lock().await.get(&chat_id) {
Some(conference) => conference.is_joined().await,
None => false,
}
}
pub async fn invite_link(&self, chat_id: i64) -> Option<String> {
self.get(chat_id).await.ok()?.invite_link().await
}
pub async fn shutdown(&self) -> Vec<(i64, Result<(), TgCallsError>)> {
let chat_ids: Vec<i64> = self.active.lock().await.keys().copied().collect();
let mut tasks = tokio::task::JoinSet::new();
for chat_id in chat_ids {
let conferences = self.clone();
tasks.spawn(async move {
let result = conferences.leave(chat_id).await;
(chat_id, result)
});
}
let mut results = Vec::new();
while let Some(joined) = tasks.join_next().await {
if let Ok(pair) = joined {
results.push(pair);
}
}
results
}
async fn route_chain_blocks(&self, u: &tl::types::UpdateGroupCallChainBlocks) {
let conferences: Vec<_> = self.active.lock().await.values().cloned().collect();
for conference in conferences {
conference.apply_chain_blocks(u);
}
}
}
impl Middleware for ConferenceCalls {
fn call(&self, update: Update, next: Next) -> BoxFuture {
let conferences = self.clone();
Box::pin(async move {
if let Update::Raw(raw) = &update {
if let tl::enums::Update::GroupCallChainBlocks(u) = &raw.inner {
conferences.route_chain_blocks(u).await;
}
}
next.run(update).await
})
}
}
pub async fn migrate_from_p2p(
client: &ferogram::Client,
conferences: &ConferenceCalls,
chat_id: i64,
reason: &Option<tl::enums::PhoneCallDiscardReason>,
media: Option<MediaDescription>,
) -> Result<bool, TgCallsError> {
let Some(tl::enums::PhoneCallDiscardReason::MigrateConferenceCall(_)) = reason else {
return Ok(false);
};
let call = signaling::resolve_call(client, chat_id).await?;
let last_block = signaling::get_conference_chain_blocks(client, call, 0, -1, 1)
.await?
.and_then(|cb| cb.blocks.into_iter().next_back());
conferences.join(chat_id, last_block, media).await?;
Ok(true)
}
#[derive(Debug, Clone)]
pub struct ConferenceInvite {
pub chat_id: i64,
pub call_id: i64,
pub msg_id: i32,
pub video: bool,
pub missed: bool,
pub active: bool,
}
impl ConferenceInvite {
pub fn should_join(&self) -> bool {
self.active && !self.missed
}
pub fn target(&self, last_block: Option<Vec<u8>>) -> ConferenceTarget {
ConferenceTarget::JoinByInviteMessage {
msg_id: self.msg_id,
last_block,
}
}
}
pub fn incoming_conference_call(update: &Update) -> Option<ConferenceInvite> {
let Update::NewMessage(msg) = update else {
return None;
};
let tl::enums::Message::Service(svc) = &msg.raw else {
return None;
};
let tl::enums::MessageAction::ConferenceCall(action) = &svc.action else {
return None;
};
Some(ConferenceInvite {
chat_id: msg.chat_id(),
call_id: action.call_id,
msg_id: svc.id,
video: action.video,
missed: action.missed,
active: action.active,
})
}