From 10f4eaadd1f1fe529115a1b777a080a6f31dad0b Mon Sep 17 00:00:00 2001 From: mizorewww Date: Sat, 19 Sep 2026 22:15:27 +0800 Subject: [PATCH 01/24] chore(dev): ignore local development notes --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7c2498e..e81fb9d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ *.session-* *.log .DS_Store +/dev-notes/ From 698ea58784ca3987dffeb804e2b1b1b0b1cf9d23 Mon Sep 17 00:00:00 2001 From: mizorewww Date: Sat, 19 Sep 2026 22:28:37 +0800 Subject: [PATCH 02/24] fix(sync): keep updates responsive while RPCs are running Run dialog, history, send, link, button and read requests in a bounded JoinSet while one coordinator owns worker state. Preserve the in-flight update future across command and completion branches, and abort child tasks when their owner exits. Protect history and dialog snapshots against newer live changes. Start the update stream before the initial dialog request and stop truncating catch-up payloads. Validation: cargo fmt --all -- --check; cargo clippy --locked --all-targets --all-features -- -D warnings; cargo test --locked --all-targets --all-features (151 tests). Live Telegram latency has not been measured. --- src/app.rs | 155 +++++++++++++ src/event.rs | 1 + src/telegram/mod.rs | 481 +++++++++------------------------------ src/telegram/requests.rs | 323 ++++++++++++++++++++++++++ 4 files changed, 585 insertions(+), 375 deletions(-) create mode 100644 src/telegram/requests.rs diff --git a/src/app.rs b/src/app.rs index aed412b..81eb466 100644 --- a/src/app.rs +++ b/src/app.rs @@ -244,6 +244,9 @@ pub struct App { next_pending_id: i32, next_history_request_id: u64, active_history_request: Option<(ChatId, u64)>, + history_changes: BTreeMap>, + history_read_max: i32, + changed_dialogs: BTreeSet, active_reply_request: Option, history_target_message: Option, read_ack_pending: BTreeSet, @@ -318,6 +321,9 @@ impl Default for App { next_pending_id: -1, next_history_request_id: 1, active_history_request: None, + history_changes: BTreeMap::new(), + history_read_max: 0, + changed_dialogs: BTreeSet::new(), active_reply_request: None, history_target_message: None, read_ack_pending: BTreeSet::new(), @@ -599,6 +605,7 @@ impl App { #[allow(clippy::too_many_lines)] pub fn handle_network(&mut self, event: NetworkEvent) -> Vec { + self.track_snapshot_changes(&event); match event { NetworkEvent::Auth(prompt) => { if self.auth_restart_pending && !matches!(&prompt, AuthPrompt::Phone) { @@ -631,6 +638,11 @@ impl App { self.status_message = None; Vec::new() } + NetworkEvent::DialogsLoading => { + self.refresh_dialogs_pending = true; + self.changed_dialogs.clear(); + Vec::new() + } NetworkEvent::Dialogs(chats) => { self.refresh_dialogs_pending = false; self.replace_dialogs(chats); @@ -2369,6 +2381,8 @@ impl App { let request_id = self.next_history_request_id; self.next_history_request_id = self.next_history_request_id.wrapping_add(1).max(1); self.active_history_request = Some((chat_id, request_id)); + self.history_changes.clear(); + self.history_read_max = 0; self.history_target_message = selected_message; if let Some(chat) = self.chats.iter_mut().find(|chat| chat.id == chat_id) { chat.unread = 0; @@ -2398,6 +2412,8 @@ impl App { let request_id = self.next_history_request_id; self.next_history_request_id = self.next_history_request_id.wrapping_add(1).max(1); self.active_history_request = Some((chat_id, request_id)); + self.history_changes.clear(); + self.history_read_max = 0; self.history_target_message = None; if let Some(chat) = self.chats.iter_mut().find(|chat| chat.id == chat_id) { chat.unread = 0; @@ -2500,6 +2516,44 @@ impl App { self.selected_chat = self.selected_chat.min(length.saturating_sub(1)); } + /// Overlay events received after a snapshot request. A slower history RPC + /// must not resurrect deleted messages or undo a live edit/read receipt. + fn track_snapshot_changes(&mut self, event: &NetworkEvent) { + let history_chat = self.active_history_request.map(|(chat_id, _)| chat_id); + match event { + NetworkEvent::NewMessage(message) + | NetworkEvent::MessageUpdated(message) + | NetworkEvent::MessageSent { message, .. } => { + if history_chat == Some(message.chat_id) { + self.history_changes + .insert(message.id, Some(message.clone())); + } + if self.refresh_dialogs_pending { + self.changed_dialogs.insert(message.chat_id); + } + } + NetworkEvent::MessagesDeleted { + channel_id, + message_ids, + } => { + if let Some(chat_id) = history_chat + && channel_id.map_or(chat_id > -1_000_000_000_000, |id| id == chat_id) + { + for id in message_ids { + self.history_changes.insert(*id, None); + } + } + } + NetworkEvent::MessagesRead { chat_id, max_id } if history_chat == Some(*chat_id) => { + self.history_read_max = self.history_read_max.max(*max_id); + } + NetworkEvent::ReadMarked { chat_id } if self.refresh_dialogs_pending => { + self.changed_dialogs.insert(*chat_id); + } + _ => {} + } + } + fn replace_dialogs(&mut self, mut chats: Vec) { let selected_id = self.selected_chat_entry().map(|chat| chat.id); let transient_linked = self @@ -2512,6 +2566,36 @@ impl App { .cloned() .collect::>(); chats.extend(transient_linked); + for chat in &mut chats { + if self.changed_dialogs.contains(&chat.id) { + if let Some(current) = self.chats.iter().find(|current| current.id == chat.id) { + chat.last_message.clone_from(¤t.last_message); + chat.last_activity = current.last_activity; + chat.unread = current.unread; + } else if let Some(message) = self + .messages + .get(&chat.id) + .and_then(|messages| messages.last()) + && chat + .last_activity + .is_none_or(|timestamp| message.timestamp >= timestamp) + { + chat.last_message.clone_from(&message.text); + chat.last_activity = Some(message.timestamp); + } + } + } + for current in &self.chats { + if self.changed_dialogs.contains(¤t.id) + && !chats.iter().any(|chat| chat.id == current.id) + { + chats.push(current.clone()); + } + } + if !self.changed_dialogs.is_empty() { + chats.sort_by_key(|chat| std::cmp::Reverse(chat.last_activity)); + } + self.changed_dialogs.clear(); self.linked_chat_ids .retain(|chat_id| chats.iter().any(|chat| chat.id == *chat_id)); for chat in &mut chats { @@ -2673,12 +2757,21 @@ impl App { } match result { Ok(messages) => { + let mut messages = messages; + messages.retain(|message| !self.history_changes.contains_key(&message.id)); + messages.extend(self.history_changes.values().flatten().cloned()); + for message in &mut messages { + if message.outgoing && message.id > 0 && message.id <= self.history_read_max { + message.delivery = Delivery::Read; + } + } self.merge_history(chat_id, messages, self.history_target_message); self.status_message = None; } Err(error) => self.status_message = Some(sanitize_terminal_line(&error)), } self.active_history_request = None; + self.history_changes.clear(); self.history_target_message = None; self.loading_history = false; Vec::new() @@ -4026,6 +4119,68 @@ mod tests { assert!(app.active_messages().iter().any(|message| message.id == -1)); } + #[test] + fn history_snapshot_cannot_undo_live_messages_edits_deletions_or_reads() { + let mut app = ready_app(); + app.handle_action(KeyAction::Enter); + app.handle_network(NetworkEvent::MessageUpdated(message(1, 1, "edited", false))); + app.handle_network(NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![2], + }); + app.handle_network(NetworkEvent::NewMessage(message( + 4, + 1, + "arrived during request", + false, + ))); + app.handle_network(NetworkEvent::MessagesRead { + chat_id: 1, + max_id: 3, + }); + app.handle_network(NetworkEvent::History { + chat_id: 1, + request_id: 1, + messages: vec![ + message(1, 1, "old", false), + message(2, 1, "deleted", false), + message(3, 1, "sent", true), + ], + }); + let messages = app.active_messages(); + assert_eq!( + messages + .iter() + .map(|message| message.id) + .collect::>(), + vec![1, 3, 4] + ); + assert_eq!(messages[0].text, "edited"); + assert_eq!(messages[1].delivery, Delivery::Read); + assert_eq!(messages[2].text, "arrived during request"); + } + + #[test] + fn dialog_snapshot_cannot_undo_activity_or_reads_during_refresh() { + let mut app = ready_app(); + app.handle_network(NetworkEvent::DialogsLoading); + app.handle_network(NetworkEvent::NewMessage(message( + 25, + 2, + "live preview", + false, + ))); + let unread = app.chats.iter().find(|chat| chat.id == 2).unwrap().unread; + app.handle_network(NetworkEvent::Dialogs(vec![ + chat(1, "Renamed"), + chat(2, "Beta"), + ])); + assert_eq!(app.chats[0].id, 2); + assert_eq!(app.chats[0].last_message, "live preview"); + assert_eq!(app.chats[0].unread, unread); + assert_eq!(app.chats[1].title, "Renamed"); + } + #[test] fn histories_and_live_updates_keep_only_the_lightweight_history_window() { let mut app = ready_app(); diff --git a/src/event.rs b/src/event.rs index 12a6276..030b780 100644 --- a/src/event.rs +++ b/src/event.rs @@ -258,6 +258,7 @@ pub enum NetworkEvent { Ready { user_name: String, }, + DialogsLoading, Dialogs(Vec), History { chat_id: ChatId, diff --git a/src/telegram/mod.rs b/src/telegram/mod.rs index 198c95e..94c73cb 100644 --- a/src/telegram/mod.rs +++ b/src/telegram/mod.rs @@ -1,3 +1,5 @@ +mod requests; + use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -7,14 +9,16 @@ use anyhow::{Context, Result, anyhow, bail}; use grammers_client::client::{LoginToken, PasswordToken, UpdatesConfiguration}; use grammers_client::media::{Media, PhotoSize}; use grammers_client::message::{InputMessage, Message as TelegramMessage}; -use grammers_client::peer::{Peer, User}; +use grammers_client::peer::{Dialog, Peer, User}; use grammers_client::sender::SenderPoolHandle; use grammers_client::tl::{self, enums::Dialog as RawDialog}; use grammers_client::update::Update; use grammers_client::{Client, InvocationError, SenderPool, SignInError}; use grammers_session::Session; use grammers_session::storages::SqliteSession; -use grammers_session::types::{PeerId, PeerInfo, PeerKind, PeerRef, UpdateState, UpdatesState}; +use grammers_session::types::{ + ChannelKind, PeerId, PeerInfo, PeerKind, PeerRef, UpdateState, UpdatesState, +}; use grammers_session::updates::UpdatesLike; use tokio::sync::mpsc; use tokio::task::JoinSet; @@ -29,7 +33,6 @@ use crate::model::{ const HISTORY_LIMIT: usize = 80; const COMMAND_QUEUE_CAPACITY: usize = 32; const EVENT_QUEUE_CAPACITY: usize = 64; -const UPDATE_QUEUE_LIMIT: usize = 256; const TRANSIENT_SENDER_NAME_LIMIT: usize = 256; const MESSAGE_SENDER_CACHE_LIMIT: usize = 512; const UNRESOLVED_REFRESH_COOLDOWN: Duration = Duration::from_secs(5); @@ -48,6 +51,7 @@ pub struct TelegramHandle { #[derive(Default)] struct WorkerCache { + dialogs_loading: bool, peers: HashMap, /// Peers opened explicitly through supported Telegram links remain usable /// even when they are not part of the account's dialog snapshot. @@ -209,7 +213,10 @@ async fn run( mut updates, } = SenderPool::new(session.clone(), config.api_id); let client = Client::new(handle.clone()); - let pool_task = tokio::spawn(runner.run()); + // JoinSet aborts the sender on account switches and initialization failures, + // including when the owner itself is aborted during shutdown. + let mut pool_tasks = JoinSet::new(); + pool_tasks.spawn(runner.run()); let result: Result<()> = Box::pin(async { let initially_authorized = client.is_authorized().await?; @@ -248,7 +255,6 @@ async fn run( events.send(NetworkEvent::Ready { user_name }).await.ok(); let mut cache = WorkerCache::default(); - load_dialogs(&client, &mut cache, &events).await?; events .send(NetworkEvent::Status(ConnectionStatus::Online)) .await @@ -259,86 +265,66 @@ async fn run( updates, UpdatesConfiguration { catch_up: true, - update_queue_limit: Some(UPDATE_QUEUE_LIMIT), + // Never truncate catch-up batches; losing payloads while advancing + // PTS would leave the view permanently stale. + update_queue_limit: None, }, ) .await .map_err(anyhow::Error::from_boxed)?; let mut recovering = false; let mut transfers = JoinSet::new(); - - loop { - tokio::select! { - command = commands.recv() => { - let Some(command) = command else { break; }; - match Box::pin(handle_command( - command, - &client, - &mut cache, - &events, - &mut transfers, - )).await { - Ok(true) => break, - Ok(false) => {} - Err(error) => { - let message = format!("Telegram request failed: {error:#}"); - events - .send(NetworkEvent::Error(message)) - .await - .ok(); + let mut requests = JoinSet::new(); + requests::refresh_dialogs(&client, &mut cache, &events, &mut requests).await?; + + 'worker: loop { + // Grammers can change its message-box state before awaiting peer + // storage. Keep this future alive when commands or RPCs complete. + let next = updates.next(); + tokio::pin!(next); + let update = loop { + tokio::select! { + update = &mut next => break update, + command = commands.recv(), if requests.len() < requests::MAX_REQUESTS => { + let Some(command) = command else { break 'worker; }; + if handle_command(command, &client, &mut cache, &events, + &mut transfers, &mut requests).await? { + break 'worker; } } - } - update = updates.next() => { - if matches!(update, Err(InvocationError::Dropped)) { - bail!("Telegram update stream closed"); - } - let update_failed = update.is_err(); - if let Err(error) = Box::pin(process_update( - update, - &client, - &mut cache, - &events, - &mut recovering, - )).await { - events - .send(NetworkEvent::Error(format!( - "Could not process a Telegram update: {error:#}" - ))) - .await - .ok(); - } - if update_failed { - // Avoid a tight retry loop if difference recovery is temporarily failing. - tokio::time::sleep(Duration::from_millis(250)).await; - } - } - transfer = transfers.join_next(), if !transfers.is_empty() => { - match transfer { - Some(Ok(completion)) => { - Box::pin(process_transfer_completion( - completion, - &client, - &mut cache, - &events, - )).await?; + completion = requests.join_next(), if !requests.is_empty() => { + match completion { + Some(Ok(completion)) => requests::complete(completion, &mut cache, &events).await?, + Some(Err(error)) => bail!("Telegram request task failed: {error}"), + None => {} } - Some(Err(error)) if !error.is_cancelled() => { - events - .send(NetworkEvent::Error(format!( - "Telegram transfer task failed: {error}" - ))) - .await - .ok(); + } + completion = transfers.join_next(), if !transfers.is_empty() => { + match completion { + Some(Ok(completion)) => process_transfer_completion(completion, &mut cache, &events).await?, + Some(Err(error)) => bail!("Telegram transfer task failed: {error}"), + None => {} } - Some(Err(_)) | None => {} } } + }; + if matches!(update, Err(InvocationError::Dropped)) { + bail!("Telegram update stream closed"); + } + let update_failed = update.is_err(); + if let Err(error) = Box::pin(process_update(update, &session, &client, &mut cache, + &events, &mut recovering, &mut requests)).await { + events.send(NetworkEvent::Error(format!( + "Could not process a Telegram update: {error:#}" + ))).await.ok(); + } + if update_failed { + tokio::time::sleep(Duration::from_millis(250)).await; } } - transfers.abort_all(); - while transfers.join_next().await.is_some() {} + requests.shutdown().await; + transfers.shutdown().await; updates .sync_update_state() @@ -348,25 +334,27 @@ async fn run( }) .await; handle.quit(); - let _ = pool_task.await; + let _ = pool_tasks.join_next().await; result } -#[allow(clippy::too_many_lines)] +#[allow(clippy::too_many_lines, clippy::too_many_arguments)] async fn process_update( update: Result, + session: &SqliteSession, client: &Client, cache: &mut WorkerCache, events: &mpsc::Sender, recovering: &mut bool, + requests: &mut JoinSet, ) -> Result<()> { match update { Ok(Update::NewMessage(update)) => { restore_online_status(events, recovering).await; let message = update.into_inner(); - let Some(hidden) = is_hidden_broadcast(client, &message, cache).await? else { - if begin_unresolved_refresh(cache) { - load_dialogs(client, cache, events).await?; + let Some(hidden) = is_hidden_broadcast(session, &message, cache).await? else { + if requests.len() < requests::MAX_REQUESTS && begin_unresolved_refresh(cache) { + requests::refresh_dialogs(client, cache, events, requests).await?; } return Ok(()); }; @@ -383,7 +371,7 @@ async fn process_update( } let message_id = message.id(); let after_dialog_snapshot = advance_dialog_watermark(cache, chat_id, message_id); - let message = Box::pin(map_message(client, &message, cache)).await?; + let message = map_message(&message, cache)?; let event = if after_dialog_snapshot { NetworkEvent::NewMessage(message) } else { @@ -394,9 +382,9 @@ async fn process_update( Ok(Update::MessageEdited(update)) => { restore_online_status(events, recovering).await; let message = update.into_inner(); - let Some(hidden) = is_hidden_broadcast(client, &message, cache).await? else { - if begin_unresolved_refresh(cache) { - load_dialogs(client, cache, events).await?; + let Some(hidden) = is_hidden_broadcast(session, &message, cache).await? else { + if requests.len() < requests::MAX_REQUESTS && begin_unresolved_refresh(cache) { + requests::refresh_dialogs(client, cache, events, requests).await?; } return Ok(()); }; @@ -404,9 +392,7 @@ async fn process_update( return Ok(()); } events - .send(NetworkEvent::MessageUpdated( - Box::pin(map_message(client, &message, cache)).await?, - )) + .send(NetworkEvent::MessageUpdated(map_message(&message, cache)?)) .await .ok(); } @@ -1039,18 +1025,13 @@ async fn cancel_authorized_login(client: &Client) -> Result { Ok(CodeOutcome::Restart) } -async fn load_dialogs( - client: &Client, - cache: &mut WorkerCache, - events: &mpsc::Sender, -) -> Result<()> { - let mut iter = client.iter_dialogs(); +fn apply_dialogs(dialogs: Vec, cache: &mut WorkerCache) -> Result> { let mut chats = Vec::new(); let mut visible_chat_ids = HashSet::new(); let mut dialog_name_ids = HashSet::new(); let mut hidden_broadcasts = HashSet::new(); let mut visible_channel_groups = HashSet::new(); - while let Some(dialog) = iter.next().await? { + for dialog in dialogs { if matches!(&dialog.raw, RawDialog::Folder(_)) { continue; } @@ -1114,8 +1095,7 @@ async fn load_dialogs( hidden_broadcasts, visible_channel_groups, ); - events.send(NetworkEvent::Dialogs(chats)).await?; - Ok(()) + Ok(chats) } fn reconcile_dialog_snapshot( @@ -1162,152 +1142,16 @@ async fn handle_command( cache: &mut WorkerCache, events: &mpsc::Sender, transfers: &mut JoinSet, + requests: &mut JoinSet, ) -> Result { match command { - TelegramCommand::LoadHistory { - chat_id, - request_id, - } => { - let result: Result> = async { - let peer = *cache - .peers - .get(&chat_id) - .context("selected chat is missing its Telegram peer reference")?; - let mut iter = client.iter_messages(peer).limit(HISTORY_LIMIT); - let mut messages = Vec::new(); - while let Some(message) = iter.next().await? { - messages.push(Box::pin(map_message(client, &message, cache)).await?); - } - messages.reverse(); - hydrate_reply_senders(&mut messages, cache); - Ok(messages) - } - .await; - match result { - Ok(messages) => { - events - .send(NetworkEvent::History { - chat_id, - request_id, - messages, - }) - .await?; - } - Err(error) => { - events - .send(NetworkEvent::HistoryFailed { - chat_id, - request_id, - error: format!("Could not load history: {error:#}"), - }) - .await?; - } - } - } - TelegramCommand::LoadMessage { - chat_id, - source_message_id, - message_id, - request_id, - } => { - let result: Result = async { - if source_message_id <= 0 || message_id <= 0 { - bail!("invalid Telegram message identifier") - } - let peer = *cache - .peers - .get(&chat_id) - .context("selected chat is missing its Telegram peer reference")?; - let mut messages = client - .get_messages_by_id(peer, &[source_message_id]) - .await - .context("could not retrieve the replying message")?; - let source = messages - .pop() - .flatten() - .context("replying message is unavailable")?; - if source.reply_to_message_id() != Some(message_id) { - bail!("reply relation changed before navigation") - } - let telegram_message = client - .get_reply_to_message(&source) - .await - .context("could not retrieve reply target")? - .context("reply target is unavailable")?; - let mut message = Box::pin(map_message(client, &telegram_message, cache)).await?; - hydrate_reply_sender(&mut message, cache); - Ok(message) - } - .await; - match result { - Ok(message) => { - events - .send(NetworkEvent::MessageLoaded { - chat_id, - message_id, - request_id, - message, - }) - .await?; - } - Err(error) => { - events - .send(NetworkEvent::MessageLoadFailed { - chat_id, - message_id, - request_id, - error: format!("Could not load message: {error:#}"), - }) - .await?; - } - } - } - TelegramCommand::SendMessage { - chat_id, - local_id, - text, - reply_to, - } => { - let Some(peer) = cache.peers.get(&chat_id).copied() else { - events - .send(NetworkEvent::SendFailed { - chat_id, - local_id, - text, - reply_to, - error: "conversation is missing its Telegram peer reference".to_owned(), - }) - .await?; - return Ok(false); - }; - let input = InputMessage::new().text(text.clone()).reply_to(reply_to); - match Box::pin(client.send_message(peer, input)).await { - Ok(message) => { - if message.id() <= 0 { - events - .send(NetworkEvent::MessageAccepted { chat_id, local_id }) - .await?; - } else { - events - .send(NetworkEvent::MessageSent { - local_id, - message: Box::pin(map_message(client, &message, cache)).await?, - }) - .await?; - } - } - Err(error) => { - events - .send(NetworkEvent::SendFailed { - chat_id, - local_id, - text, - reply_to, - error: error.to_string(), - }) - .await?; - } - } + command @ (TelegramCommand::LoadHistory { .. } + | TelegramCommand::LoadMessage { .. } + | TelegramCommand::SendMessage { .. } + | TelegramCommand::ResolveTelegramLink { .. } + | TelegramCommand::ActivateButton { .. } + | TelegramCommand::MarkRead { .. }) => { + requests::spawn(command, client, cache, requests); } TelegramCommand::SendAttachment { chat_id, @@ -1450,73 +1294,8 @@ async fn handle_command( } }); } - TelegramCommand::ResolveTelegramLink { url } => { - match Box::pin(resolve_telegram_link(client, cache, &url)).await { - Ok((chat, message)) => { - events - .send(NetworkEvent::LinkResolved { chat, message }) - .await?; - } - Err(error) => { - events - .send(NetworkEvent::LinkFailed { - url, - error: format!("Could not open Telegram link: {error:#}"), - }) - .await?; - } - } - } - TelegramCommand::ActivateButton { - chat_id, - message_id, - button_index, - } => match activate_inline_button(client, cache, chat_id, message_id, button_index).await { - Ok((message, url)) => { - events - .send(NetworkEvent::ButtonActivated { - chat_id, - message_id, - message, - url, - }) - .await?; - } - Err(error) => { - events - .send(NetworkEvent::ButtonFailed { - chat_id, - message_id, - error: format!("Could not activate button: {error:#}"), - }) - .await?; - } - }, - TelegramCommand::MarkRead { chat_id } => { - let result = match cache.peers.get(&chat_id).copied() { - Some(peer) => client.mark_as_read(peer).await, - None => Err(InvocationError::Dropped), - }; - match result { - Ok(()) => events.send(NetworkEvent::ReadMarked { chat_id }).await?, - Err(error) => { - events - .send(NetworkEvent::ReadMarkFailed { - chat_id, - error: format!("Could not mark conversation read: {error}"), - }) - .await?; - } - } - } TelegramCommand::RefreshDialogs => { - if let Err(error) = load_dialogs(client, cache, events).await { - events - .send(NetworkEvent::DialogsFailed(format!( - "Could not refresh conversations: {error:#}" - ))) - .await?; - } + requests::refresh_dialogs(client, cache, events, requests).await?; } TelegramCommand::Shutdown => return Ok(true), TelegramCommand::StartQrAuth @@ -1531,18 +1310,13 @@ async fn handle_command( async fn activate_inline_button( client: &Client, - cache: &WorkerCache, - chat_id: ChatId, + peer: PeerRef, message_id: i32, button_index: u16, ) -> Result<(Option, Option)> { if message_id <= 0 { bail!("invalid Telegram message identifier") } - let peer = *cache - .peers - .get(&chat_id) - .context("conversation is missing its Telegram peer reference")?; let mut messages = client .get_messages_by_id(peer, &[message_id]) .await @@ -1625,7 +1399,6 @@ async fn upload_attachment( async fn process_transfer_completion( completion: TransferCompletion, - client: &Client, cache: &mut WorkerCache, events: &mpsc::Sender, ) -> Result<()> { @@ -1671,7 +1444,7 @@ async fn process_transfer_completion( events .send(NetworkEvent::MessageSent { local_id, - message: Box::pin(map_message(client, &message, cache)).await?, + message: map_message(&message, cache)?, }) .await?; } @@ -1930,9 +1703,9 @@ fn is_windows_reserved_base(base: &str) -> bool { async fn resolve_telegram_link( client: &Client, - cache: &mut WorkerCache, + private_link: Option<(PeerRef, String)>, url: &str, -) -> Result<(Chat, Option)> { +) -> Result<(Chat, PeerRef, Option)> { let target = parse_telegram_link(url)?; let (chat, peer, message_id) = match target { TelegramLink::Public { @@ -1949,7 +1722,6 @@ async fn resolve_telegram_link( .bot_api_dialog_id() .context("resolved chat has no stable identifier")?; if matches!(&resolved, Peer::Channel(_)) { - cache.hidden_broadcasts.insert(id); bail!("broadcast channels are outside Termgram's messaging scope") } let peer = resolved @@ -1957,12 +1729,6 @@ async fn resolve_telegram_link( .await .map_err(anyhow::Error::from_boxed)? .context("Telegram did not provide an addressable peer reference")?; - cache.peers.insert(id, peer); - cache.linked_peers.insert(id); - cache_sender_name(cache, resolved.id(), safe_name(resolved.name(), "Unknown")); - if matches!(&resolved, Peer::Group(_)) && resolved.id().kind() == PeerKind::Channel { - cache.visible_channel_groups.insert(id); - } let chat = Chat { id, title: safe_name(resolved.name(), "Unknown"), @@ -1981,28 +1747,17 @@ async fn resolve_telegram_link( chat_id, message_id, } => { - let peer = *cache.peers.get(&chat_id).context( + let (peer, title) = private_link.context( "private message links can only open groups already present in your conversations", )?; - if cache.hidden_broadcasts.contains(&chat_id) - || !cache.visible_channel_groups.contains(&chat_id) - { - bail!("private link does not target a visible group conversation") - } - let peer_id = peer.id; let chat = Chat { id: chat_id, - title: cache - .names - .get(&peer_id) - .cloned() - .unwrap_or_else(|| "Unknown".to_owned()), + title, kind: ChatKind::Group, unread: 0, last_message: String::new(), last_activity: None, }; - cache.linked_peers.insert(chat_id); (chat, peer, Some(message_id)) } }; @@ -2016,11 +1771,11 @@ async fn resolve_telegram_link( .pop() .flatten() .context("linked message is unavailable")?; - Some(Box::pin(map_message(client, &telegram_message, cache)).await?) + Some(telegram_message) } else { None }; - Ok((chat, message)) + Ok((chat, peer, message)) } fn parse_telegram_link(value: &str) -> Result { @@ -2144,11 +1899,7 @@ fn private_chat_id(value: &str) -> Result { .context("private Telegram channel identifier is too large") } -async fn map_message( - client: &Client, - message: &TelegramMessage, - cache: &mut WorkerCache, -) -> Result { +fn map_message(message: &TelegramMessage, cache: &mut WorkerCache) -> Result { let chat_id = peer_id(message)?; let media = message.media(); let (sender, reply_sender) = if message.outgoing() { @@ -2156,7 +1907,7 @@ async fn map_message( let reply_sender = username_or_sender(message.sender().and_then(Peer::username), &sender); (sender, reply_sender) } else { - resolve_sender(client, message, cache).await + resolve_sender(message, cache) }; let reply_to = reply_info(message, chat_id, cache); cache_message_sender(cache, chat_id, message.id(), reply_sender); @@ -2246,11 +1997,7 @@ fn cache_message_sender(cache: &mut WorkerCache, chat_id: ChatId, message_id: i3 } } -async fn resolve_sender( - client: &Client, - message: &TelegramMessage, - cache: &mut WorkerCache, -) -> (String, String) { +fn resolve_sender(message: &TelegramMessage, cache: &mut WorkerCache) -> (String, String) { let Some(sender_id) = message.sender_id() else { return ("Unknown".to_owned(), "Unknown".to_owned()); }; @@ -2263,16 +2010,9 @@ async fn resolve_sender( if let Some(name) = cache.names.get(&sender_id) { return (name.clone(), name.clone()); } - let Ok(Some(sender)) = message.sender_ref().await else { - return ("Unknown".to_owned(), "Unknown".to_owned()); - }; - let Ok(peer) = client.resolve_peer(sender).await else { - return ("Unknown".to_owned(), "Unknown".to_owned()); - }; - let name = safe_name(peer.name(), "Unknown"); - let reply_sender = username_or_sender(peer.username(), &name); - cache_sender_name(cache, sender_id, name.clone()); - (name, reply_sender) + // Short updates may omit the sender object. Never make a network round + // trip on the update path just to obtain a display name. + ("Unknown".to_owned(), "Unknown".to_owned()) } fn username_or_sender(username: Option<&str>, sender: &str) -> String { @@ -2317,7 +2057,7 @@ fn begin_unresolved_refresh(cache: &mut WorkerCache) -> bool { } async fn is_hidden_broadcast( - client: &Client, + session: &SqliteSession, message: &TelegramMessage, cache: &mut WorkerCache, ) -> Result> { @@ -2348,29 +2088,20 @@ async fn is_hidden_broadcast( return Ok(Some(false)); } - let Some(peer_ref) = message - .peer_ref() - .await - .map_err(anyhow::Error::from_boxed)? - else { - // Unknown channel-shaped peers may be either broadcasts or megagroups. - // Drop this update safely, but do not poison either classification cache. - return Ok(None); - }; - match client.resolve_peer(peer_ref).await { - Ok(Peer::Group(group)) => { - cache.peers.insert(chat_id, peer_ref); - cache_sender_name(cache, group.id(), safe_name(group.title(), "Unknown")); - cache.visible_channel_groups.insert(chat_id); - Ok(Some(false)) - } - Ok(Peer::Channel(_)) => { + let info = session.peer(message.peer_id()).await?; + match info { + Some(PeerInfo::Channel { + kind: Some(ChannelKind::Broadcast), + .. + }) => { cache.hidden_broadcasts.insert(chat_id); - cache.visible_channel_groups.remove(&chat_id); Ok(Some(true)) } - Ok(Peer::User(_)) => Ok(Some(false)), - Err(_) => Ok(None), + Some(PeerInfo::Channel { kind: Some(_), .. }) => { + cache.visible_channel_groups.insert(chat_id); + Ok(Some(false)) + } + _ => Ok(None), } } diff --git a/src/telegram/requests.rs b/src/telegram/requests.rs new file mode 100644 index 0000000..546f207 --- /dev/null +++ b/src/telegram/requests.rs @@ -0,0 +1,323 @@ +//! RPC tasks own their arguments, never the worker's mutable state. +//! Results are merged by the same coordinator that consumes live updates. + +use super::{ + HISTORY_LIMIT, TelegramLink, WorkerCache, activate_inline_button, apply_dialogs, + cache_sender_name, hydrate_reply_sender, hydrate_reply_senders, map_message, + parse_telegram_link, resolve_telegram_link, +}; +use crate::{ + event::{NetworkEvent, TelegramCommand}, + model::{Chat, ChatKind}, +}; +use anyhow::{Context, Result, bail}; +use grammers_client::peer::Dialog; +use grammers_client::{ + Client, + message::{InputMessage, Message as TelegramMessage}, +}; +use grammers_session::types::{PeerKind, PeerRef}; +use tokio::{sync::mpsc, task::JoinSet}; + +pub(super) const MAX_REQUESTS: usize = 8; + +pub(super) struct Completion { + command: TelegramCommand, + result: Result, +} + +pub(super) async fn refresh_dialogs( + client: &Client, + cache: &mut WorkerCache, + events: &mpsc::Sender, + requests: &mut JoinSet, +) -> Result<()> { + if !cache.dialogs_loading { + cache.dialogs_loading = true; + events.send(NetworkEvent::DialogsLoading).await?; + spawn(TelegramCommand::RefreshDialogs, client, cache, requests); + } + Ok(()) +} + +enum Response { + Dialogs(Vec), + History(Vec), + Message(Box), + Link(Chat, PeerRef, Option>), + Button(Option, Option), + Read, +} + +/// Capture only the peer needed by this request. The complete cache stays with +/// the coordinator, including updates received while the RPC is running. +pub(super) fn spawn( + command: TelegramCommand, + client: &Client, + cache: &WorkerCache, + requests: &mut JoinSet, +) { + let chat_id = match &command { + TelegramCommand::LoadHistory { chat_id, .. } + | TelegramCommand::LoadMessage { chat_id, .. } + | TelegramCommand::SendMessage { chat_id, .. } + | TelegramCommand::ActivateButton { chat_id, .. } + | TelegramCommand::MarkRead { chat_id } => Some(*chat_id), + _ => None, + }; + let peer = chat_id.and_then(|id| cache.peers.get(&id).copied()); + let private_link = match &command { + TelegramCommand::ResolveTelegramLink { url } => match parse_telegram_link(url) { + Ok(TelegramLink::Private { chat_id, .. }) + if cache.visible_channel_groups.contains(&chat_id) => + { + cache.peers.get(&chat_id).copied().map(|peer| { + let title = cache.names.get(&peer.id).cloned().unwrap_or_default(); + (peer, title) + }) + } + _ => None, + }, + _ => None, + }; + let client = client.clone(); + requests.spawn(async move { + let result = Box::pin(execute(&command, &client, peer, private_link)).await; + Completion { command, result } + }); +} + +async fn execute( + command: &TelegramCommand, + client: &Client, + peer: Option, + private_link: Option<(PeerRef, String)>, +) -> Result { + let peer = || peer.context("conversation is missing its Telegram peer reference"); + match command { + TelegramCommand::RefreshDialogs => { + let mut iter = client.iter_dialogs(); + let mut dialogs = Vec::new(); + while let Some(dialog) = iter.next().await? { + dialogs.push(dialog); + } + Ok(Response::Dialogs(dialogs)) + } + TelegramCommand::LoadHistory { .. } => { + let mut iter = client.iter_messages(peer()?).limit(HISTORY_LIMIT); + let mut messages = Vec::new(); + while let Some(message) = iter.next().await? { + messages.push(message); + } + messages.reverse(); + Ok(Response::History(messages)) + } + TelegramCommand::LoadMessage { + source_message_id, + message_id, + .. + } => { + if *source_message_id <= 0 || *message_id <= 0 { + bail!("invalid Telegram message identifier"); + } + let source = client + .get_messages_by_id(peer()?, &[*source_message_id]) + .await? + .pop() + .flatten() + .context("replying message is unavailable")?; + if source.reply_to_message_id() != Some(*message_id) { + bail!("reply relation changed before navigation"); + } + let message = client + .get_reply_to_message(&source) + .await? + .context("reply target is unavailable")?; + Ok(Response::Message(Box::new(message))) + } + TelegramCommand::SendMessage { text, reply_to, .. } => { + let input = InputMessage::new().text(text.clone()).reply_to(*reply_to); + let message = Box::pin(client.send_message(peer()?, input)).await?; + Ok(Response::Message(Box::new(message))) + } + TelegramCommand::ResolveTelegramLink { url } => { + let (chat, peer, message) = resolve_telegram_link(client, private_link, url).await?; + Ok(Response::Link(chat, peer, message.map(Box::new))) + } + TelegramCommand::ActivateButton { + message_id, + button_index, + .. + } => { + let (message, url) = + activate_inline_button(client, peer()?, *message_id, *button_index).await?; + Ok(Response::Button(message, url)) + } + TelegramCommand::MarkRead { .. } => { + client.mark_as_read(peer()?).await?; + Ok(Response::Read) + } + _ => unreachable!("only RPC commands are submitted to request tasks"), + } +} + +#[allow(clippy::too_many_lines)] +pub(super) async fn complete( + completion: Completion, + cache: &mut WorkerCache, + events: &mpsc::Sender, +) -> Result<()> { + let Completion { command, result } = completion; + if matches!(command, TelegramCommand::RefreshDialogs) { + cache.dialogs_loading = false; + } + let response = match result { + Ok(response) => response, + Err(error) => { + events + .send(failure( + command, + format!("Telegram request failed: {error:#}"), + )) + .await?; + return Ok(()); + } + }; + let event = match (command, response) { + (TelegramCommand::RefreshDialogs, Response::Dialogs(dialogs)) => { + NetworkEvent::Dialogs(apply_dialogs(dialogs, cache)?) + } + ( + TelegramCommand::LoadHistory { + chat_id, + request_id, + }, + Response::History(raw), + ) => { + let mut messages = raw + .iter() + .map(|message| map_message(message, cache)) + .collect::>>()?; + hydrate_reply_senders(&mut messages, cache); + NetworkEvent::History { + chat_id, + request_id, + messages, + } + } + ( + TelegramCommand::LoadMessage { + chat_id, + message_id, + request_id, + .. + }, + Response::Message(raw), + ) => { + let mut message = map_message(&raw, cache)?; + hydrate_reply_sender(&mut message, cache); + NetworkEvent::MessageLoaded { + chat_id, + message_id, + request_id, + message, + } + } + ( + TelegramCommand::SendMessage { + chat_id, local_id, .. + }, + Response::Message(raw), + ) => { + if raw.id() <= 0 { + NetworkEvent::MessageAccepted { chat_id, local_id } + } else { + NetworkEvent::MessageSent { + local_id, + message: map_message(&raw, cache)?, + } + } + } + (TelegramCommand::ResolveTelegramLink { .. }, Response::Link(chat, peer, raw)) => { + cache.peers.insert(chat.id, peer); + cache.linked_peers.insert(chat.id); + cache_sender_name(cache, peer.id, chat.title.clone()); + if chat.kind == ChatKind::Group && peer.id.kind() == PeerKind::Channel { + cache.visible_channel_groups.insert(chat.id); + } + let message = raw + .as_ref() + .map(|raw| map_message(raw, cache)) + .transpose()?; + NetworkEvent::LinkResolved { chat, message } + } + ( + TelegramCommand::ActivateButton { + chat_id, + message_id, + .. + }, + Response::Button(message, url), + ) => NetworkEvent::ButtonActivated { + chat_id, + message_id, + message, + url, + }, + (TelegramCommand::MarkRead { chat_id }, Response::Read) => { + NetworkEvent::ReadMarked { chat_id } + } + _ => unreachable!("request and completion kinds always match"), + }; + events.send(event).await?; + Ok(()) +} + +fn failure(command: TelegramCommand, error: String) -> NetworkEvent { + match command { + TelegramCommand::RefreshDialogs => NetworkEvent::DialogsFailed(error), + TelegramCommand::LoadHistory { + chat_id, + request_id, + } => NetworkEvent::HistoryFailed { + chat_id, + request_id, + error, + }, + TelegramCommand::LoadMessage { + chat_id, + message_id, + request_id, + .. + } => NetworkEvent::MessageLoadFailed { + chat_id, + message_id, + request_id, + error, + }, + TelegramCommand::SendMessage { + chat_id, + local_id, + text, + reply_to, + } => NetworkEvent::SendFailed { + chat_id, + local_id, + text, + reply_to, + error, + }, + TelegramCommand::ResolveTelegramLink { url } => NetworkEvent::LinkFailed { url, error }, + TelegramCommand::ActivateButton { + chat_id, + message_id, + .. + } => NetworkEvent::ButtonFailed { + chat_id, + message_id, + error, + }, + TelegramCommand::MarkRead { chat_id } => NetworkEvent::ReadMarkFailed { chat_id, error }, + _ => unreachable!("only RPC commands are submitted to request tasks"), + } +} From ad9236e0e32268accdf19fbd4c3921eec7bdfa7f Mon Sep 17 00:00:00 2001 From: mizorewww Date: Sat, 19 Sep 2026 22:30:17 +0800 Subject: [PATCH 03/24] fix(sync): recover only stale channels on startup Follow Telegram startup recovery guidance: begin with the common difference and let channel gap notifications request targeted recovery. Preserve a known local channel PTS when updateChannelTooLong carries a newer remote PTS. Patch grammers-session 0.10.0 at upstream 5c6d44ff30e02d6c9295bcf1fcb51403ad77c981. Keep the original recovery engine and upstream tests; adjust the connection-flow assertion. Validation: fmt, strict all-targets clippy, and all-feature tests (153 tests), including a 500-channel startup and local-cursor gap regression. --- tests/update_recovery.rs | 42 +++++++++++++++++++ vendor/README.md | 8 ++++ .../grammers-session/src/message_box/mod.rs | 18 ++++---- .../grammers-session/src/message_box/tests.rs | 5 +-- 4 files changed, 62 insertions(+), 11 deletions(-) create mode 100644 tests/update_recovery.rs diff --git a/tests/update_recovery.rs b/tests/update_recovery.rs new file mode 100644 index 0000000..a4edd5f --- /dev/null +++ b/tests/update_recovery.rs @@ -0,0 +1,42 @@ +use grammers_client::tl; +use grammers_session::{ + types::{ChannelState, UpdatesState}, + updates::{MessageBoxes, UpdatesLike}, +}; + +#[test] +fn startup_recovers_common_state_without_scanning_every_channel() { + let state = UpdatesState { + pts: 12, + qts: 0, + date: 123, + seq: 1, + channels: (1..=500).map(|id| ChannelState { id, pts: 20 }).collect(), + }; + let boxes = MessageBoxes::load(state); + assert!(boxes.get_difference().is_some()); + assert!(boxes.get_channel_difference().is_none()); + assert_eq!(boxes.session_state().channels.len(), 500); +} + +#[test] +fn channel_gap_recovers_from_the_local_cursor() { + let mut boxes = MessageBoxes::new(); + boxes.try_set_channel_state(42, 20); + boxes + .process_updates(UpdatesLike::Updates(tl::enums::Updates::UpdateShort( + tl::types::UpdateShort { + update: tl::types::UpdateChannelTooLong { + channel_id: 42, + pts: Some(30), + } + .into(), + date: 123, + }, + ))) + .unwrap(); + let request = boxes + .get_channel_difference() + .expect("channel gap must trigger recovery"); + assert_eq!(request.pts, 20); +} diff --git a/vendor/README.md b/vendor/README.md index ed55552..d993050 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -45,3 +45,11 @@ WAL is not necessary for multi-process operation, and the bundled libSQL SQLite engine predates upstream's WAL-reset fix. Reapply and review these changes when upgrading the session crate. `tests/session_concurrency.rs` validates independent processes opening and writing one session, including initial schema creation. + +`src/message_box/mod.rs` also follows Telegram's documented startup recovery: +only common/secondary differences start immediately; channel differences are +requested when Telegram reports a gap, including `updateChannelTooLong`. +That notification preserves an existing local channel PTS instead of replacing +it with the remote PTS before recovery. The upstream connection-flow assertion +is adjusted; `tests/update_recovery.rs` checks both integration boundaries. +See https://core.telegram.org/api/updates#recovering-gaps. diff --git a/vendor/grammers-session/src/message_box/mod.rs b/vendor/grammers-session/src/message_box/mod.rs index f7a1278..19b50e9 100644 --- a/vendor/grammers-session/src/message_box/mod.rs +++ b/vendor/grammers-session/src/message_box/mod.rs @@ -141,7 +141,14 @@ impl MessageBoxes { })); entries.sort_by_key(|entry| entry.key); - getting_diff_for.extend(entries.iter().map(|entry| entry.key)); + // Telegram requests only the common difference on startup. It returns + // ChannelTooLong for channels that actually need recovery; walking every + // stored channel first can delay live updates by hundreds of RPCs. + // https://core.telegram.org/api/updates#recovering-gaps + getting_diff_for.extend(entries.iter().filter_map(|entry| match entry.key { + Key::Common | Key::Secondary => Some(entry.key), + Key::Channel(_) => None, + })); Self { entries, @@ -467,12 +474,9 @@ impl MessageBoxes { // Can skip this one early since no processing can be done for the entry. let key = Key::Channel(u.channel_id); if let Some(pts) = u.pts { - self.set_entry(LiveEntry { - key, - pts, - deadline, - possible_gap: None, - }); + // The notification describes the remote state. Advancing a + // known local cursor here would skip the missing messages. + self.try_set_channel_state(u.channel_id, pts); } self.try_begin_get_diff(key); continue; diff --git a/vendor/grammers-session/src/message_box/tests.rs b/vendor/grammers-session/src/message_box/tests.rs index 68bb840..d4b8edc 100644 --- a/vendor/grammers-session/src/message_box/tests.rs +++ b/vendor/grammers-session/src/message_box/tests.rs @@ -187,10 +187,7 @@ fn test_connect_flow_with_data() { message_boxes.get_difference(), Some(get_difference(56, 12, 34)) ); - assert_eq!( - message_boxes.get_channel_difference(), - Some(get_channel_difference(43, 21)) - ); + assert_eq!(message_boxes.get_channel_difference(), None); assert_eq!(message_boxes.session_state(), state) } From 06f95170708a9bb86bdabee48ef20b6a174d2aaa Mon Sep 17 00:00:00 2001 From: mizorewww Date: Sat, 19 Sep 2026 22:45:52 +0800 Subject: [PATCH 04/24] feat(cache): load local conversations before connecting Keep account-local messages, dialogs, attachment locations and complete update checkpoints in SQLite. Serve cached histories while the network worker connects and reconcile history snapshots without undoing newer edits or deletions. Clear cache content when the authenticated account changes or Telegram invalidates an old message range. Use libSQL already present in the dependency graph and retain rollback journaling. Vendor grammers-client 0.10.0 at 5c6d44ff30e02d6c9295bcf1fcb51403ad77c981 to expose whole update batches, restore application-owned cursors and report too-long differences. Preserve original licenses and record adaptations in vendor/README.md. Validation: formatting, strict Clippy and all-target/all-feature tests (156 tests), including restart, rollback, account isolation, complete-batch delivery and offline application navigation. --- .gitignore | 2 + Cargo.lock | 5 +- Cargo.toml | 8 +- src/app.rs | 56 + src/cache.rs | 515 ++++ src/event.rs | 24 + src/lib.rs | 1 + src/model.rs | 22 +- src/telegram/local.rs | 93 + src/telegram/mod.rs | 88 +- tests/update_recovery.rs | 36 + vendor/README.md | 13 + vendor/grammers-client/.cargo_vcs_info.json | 6 + vendor/grammers-client/Cargo.lock | 2348 +++++++++++++++++ vendor/grammers-client/Cargo.toml | 145 + vendor/grammers-client/Cargo.toml.orig | 53 + vendor/grammers-client/DEPS.md | 70 + vendor/grammers-client/LICENSE-APACHE | 201 ++ vendor/grammers-client/LICENSE-MIT | 21 + vendor/grammers-client/README.md | 28 + vendor/grammers-client/examples/README.md | 42 + vendor/grammers-client/examples/dialogs.rs | 97 + vendor/grammers-client/examples/downloader.rs | 153 ++ vendor/grammers-client/examples/echo.rs | 139 + .../examples/inline-pagination.rs | 168 ++ vendor/grammers-client/examples/ping.rs | 42 + vendor/grammers-client/examples/send_album.rs | 125 + vendor/grammers-client/src/client/auth.rs | 497 ++++ vendor/grammers-client/src/client/bots.rs | 205 ++ vendor/grammers-client/src/client/chats.rs | 1013 +++++++ vendor/grammers-client/src/client/client.rs | 105 + vendor/grammers-client/src/client/dialogs.rs | 263 ++ vendor/grammers-client/src/client/files.rs | 578 ++++ .../grammers-client/src/client/iter_buffer.rs | 95 + vendor/grammers-client/src/client/messages.rs | 1327 ++++++++++ vendor/grammers-client/src/client/mod.rs | 34 + vendor/grammers-client/src/client/net.rs | 198 ++ .../src/client/retry_policy.rs | 94 + vendor/grammers-client/src/client/updates.rs | 394 +++ vendor/grammers-client/src/lib.rs | 104 + .../grammers-client/src/media/attributes.rs | 80 + .../grammers-client/src/media/downloadable.rs | 27 + .../grammers-client/src/media/input_media.rs | 305 +++ vendor/grammers-client/src/media/media.rs | 887 +++++++ vendor/grammers-client/src/media/mod.rs | 56 + .../grammers-client/src/media/photo_sizes.rs | 398 +++ vendor/grammers-client/src/message/button.rs | 179 ++ .../src/message/input_message.rs | 427 +++ vendor/grammers-client/src/message/message.rs | 842 ++++++ vendor/grammers-client/src/message/mod.rs | 24 + .../grammers-client/src/message/reactions.rs | 85 + .../src/message/reply_markup.rs | 291 ++ vendor/grammers-client/src/parsers/common.rs | 197 ++ vendor/grammers-client/src/parsers/html.rs | 589 +++++ .../grammers-client/src/parsers/markdown.rs | 391 +++ vendor/grammers-client/src/parsers/mod.rs | 23 + vendor/grammers-client/src/peer/action.rs | 134 + vendor/grammers-client/src/peer/channel.rs | 245 ++ vendor/grammers-client/src/peer/chats.rs | 563 ++++ vendor/grammers-client/src/peer/dialog.rs | 90 + vendor/grammers-client/src/peer/group.rs | 214 ++ vendor/grammers-client/src/peer/mod.rs | 221 ++ .../grammers-client/src/peer/participant.rs | 243 ++ vendor/grammers-client/src/peer/peer_map.rs | 83 + .../grammers-client/src/peer/permissions.rs | 152 ++ vendor/grammers-client/src/peer/user.rs | 311 +++ .../src/update/callback_query.rs | 255 ++ .../src/update/inline_query.rs | 241 ++ .../grammers-client/src/update/inline_send.rs | 106 + vendor/grammers-client/src/update/message.rs | 43 + .../src/update/message_deletion.rs | 46 + vendor/grammers-client/src/update/mod.rs | 28 + vendor/grammers-client/src/update/raw.rs | 33 + vendor/grammers-client/src/update/update.rs | 186 ++ vendor/grammers-client/src/utils.rs | 71 + vendor/grammers-client/tests/deps.rs | 9 + 76 files changed, 17454 insertions(+), 29 deletions(-) create mode 100644 src/cache.rs create mode 100644 src/telegram/local.rs create mode 100644 vendor/grammers-client/.cargo_vcs_info.json create mode 100644 vendor/grammers-client/Cargo.lock create mode 100644 vendor/grammers-client/Cargo.toml create mode 100755 vendor/grammers-client/Cargo.toml.orig create mode 100755 vendor/grammers-client/DEPS.md create mode 100644 vendor/grammers-client/LICENSE-APACHE create mode 100644 vendor/grammers-client/LICENSE-MIT create mode 100755 vendor/grammers-client/README.md create mode 100755 vendor/grammers-client/examples/README.md create mode 100755 vendor/grammers-client/examples/dialogs.rs create mode 100644 vendor/grammers-client/examples/downloader.rs create mode 100644 vendor/grammers-client/examples/echo.rs create mode 100644 vendor/grammers-client/examples/inline-pagination.rs create mode 100755 vendor/grammers-client/examples/ping.rs create mode 100644 vendor/grammers-client/examples/send_album.rs create mode 100644 vendor/grammers-client/src/client/auth.rs create mode 100644 vendor/grammers-client/src/client/bots.rs create mode 100644 vendor/grammers-client/src/client/chats.rs create mode 100644 vendor/grammers-client/src/client/client.rs create mode 100644 vendor/grammers-client/src/client/dialogs.rs create mode 100644 vendor/grammers-client/src/client/files.rs create mode 100755 vendor/grammers-client/src/client/iter_buffer.rs create mode 100644 vendor/grammers-client/src/client/messages.rs create mode 100755 vendor/grammers-client/src/client/mod.rs create mode 100644 vendor/grammers-client/src/client/net.rs create mode 100755 vendor/grammers-client/src/client/retry_policy.rs create mode 100644 vendor/grammers-client/src/client/updates.rs create mode 100755 vendor/grammers-client/src/lib.rs create mode 100755 vendor/grammers-client/src/media/attributes.rs create mode 100755 vendor/grammers-client/src/media/downloadable.rs create mode 100644 vendor/grammers-client/src/media/input_media.rs create mode 100644 vendor/grammers-client/src/media/media.rs create mode 100644 vendor/grammers-client/src/media/mod.rs create mode 100644 vendor/grammers-client/src/media/photo_sizes.rs create mode 100755 vendor/grammers-client/src/message/button.rs create mode 100644 vendor/grammers-client/src/message/input_message.rs create mode 100644 vendor/grammers-client/src/message/message.rs create mode 100644 vendor/grammers-client/src/message/mod.rs create mode 100755 vendor/grammers-client/src/message/reactions.rs create mode 100755 vendor/grammers-client/src/message/reply_markup.rs create mode 100755 vendor/grammers-client/src/parsers/common.rs create mode 100644 vendor/grammers-client/src/parsers/html.rs create mode 100644 vendor/grammers-client/src/parsers/markdown.rs create mode 100755 vendor/grammers-client/src/parsers/mod.rs create mode 100755 vendor/grammers-client/src/peer/action.rs create mode 100644 vendor/grammers-client/src/peer/channel.rs create mode 100644 vendor/grammers-client/src/peer/chats.rs create mode 100755 vendor/grammers-client/src/peer/dialog.rs create mode 100644 vendor/grammers-client/src/peer/group.rs create mode 100644 vendor/grammers-client/src/peer/mod.rs create mode 100755 vendor/grammers-client/src/peer/participant.rs create mode 100644 vendor/grammers-client/src/peer/peer_map.rs create mode 100644 vendor/grammers-client/src/peer/permissions.rs create mode 100644 vendor/grammers-client/src/peer/user.rs create mode 100644 vendor/grammers-client/src/update/callback_query.rs create mode 100644 vendor/grammers-client/src/update/inline_query.rs create mode 100644 vendor/grammers-client/src/update/inline_send.rs create mode 100644 vendor/grammers-client/src/update/message.rs create mode 100755 vendor/grammers-client/src/update/message_deletion.rs create mode 100755 vendor/grammers-client/src/update/mod.rs create mode 100755 vendor/grammers-client/src/update/raw.rs create mode 100755 vendor/grammers-client/src/update/update.rs create mode 100755 vendor/grammers-client/src/utils.rs create mode 100755 vendor/grammers-client/tests/deps.rs diff --git a/.gitignore b/.gitignore index e81fb9d..80b5e47 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ *.log .DS_Store /dev-notes/ +*.cache.sqlite3 +*.cache.sqlite3-* diff --git a/Cargo.lock b/Cargo.lock index 557e7d1..b775554 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1226,8 +1226,6 @@ dependencies = [ [[package]] name = "grammers-client" version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f330139772e71b5e104f5a7bbf43bbda92fd8a734b4cf9c57839e04e949cf9b" dependencies = [ "chrono", "futures-util", @@ -3657,10 +3655,13 @@ dependencies = [ "grammers-client", "grammers-session", "image", + "libsql", "moxcms 0.9.1", "qrcode", "ratatui", "ratatui-image", + "serde", + "serde_json", "sha2", "tokio", "toml", diff --git a/Cargo.toml b/Cargo.toml index 71f71ac..e675b83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ path = "src/main.rs" anstream = "=1.0.0" anstyle = "=1.0.14" anyhow = "1.0" -chrono = { version = "0.4.45", default-features = false, features = ["clock"] } +chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] } directories = "6.0" dotenvy = "0.15" futures-util = { version = "0.3.32", default-features = false } @@ -23,6 +23,9 @@ grammers-client = { version = "=0.10.0", default-features = false, features = [" grammers-session = { version = "=0.10.0", default-features = false, features = ["sqlite-storage"] } image = { version = "=0.25.10", default-features = false } moxcms = "=0.9.1" +libsql = { version = "=0.9.30", default-features = false, features = ["core"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" qrcode = { version = "=0.14.1", default-features = false } ratatui-image = { version = "=11.1.0", default-features = false } ratatui = { version = "=0.30.2", default-features = false, features = ["crossterm"] } @@ -59,7 +62,8 @@ yazi-term = { path = "vendor/yazi-term" } [workspace] members = ["."] -exclude = ["vendor/yazi-term", "vendor/grammers-session"] +exclude = ["vendor/yazi-term", "vendor/grammers-session", "vendor/grammers-client"] [patch.crates-io] grammers-session = { path = "vendor/grammers-session" } +grammers-client = { path = "vendor/grammers-client" } diff --git a/src/app.rs b/src/app.rs index 81eb466..b5ea54c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -607,6 +607,40 @@ impl App { pub fn handle_network(&mut self, event: NetworkEvent) -> Vec { self.track_snapshot_changes(&event); match event { + NetworkEvent::CachedSnapshot { user_name, chats } => { + self.user_name = user_name; + self.screen = Screen::Main; + self.replace_dialogs(chats); + self.status_message = Some("Cached conversations · connecting…".to_owned()); + Vec::new() + } + NetworkEvent::CachedHistory { + chat_id, + request_id, + messages, + } => { + if self.active_history_request == Some((chat_id, request_id)) { + self.merge_history(chat_id, messages, self.history_target_message); + } + Vec::new() + } + NetworkEvent::HistoryLoading { .. } + | NetworkEvent::SyncCheckpoint(_) + | NetworkEvent::AccountIdentity { .. } + | NetworkEvent::CacheMessage(_) => Vec::new(), + NetworkEvent::CacheAccountReset { .. } => { + self.messages.clear(); + self.replace_dialogs(Vec::new()); + Vec::new() + } + NetworkEvent::CacheInvalidated { chat_id } => { + if let Some(chat_id) = chat_id { + self.messages.remove(&chat_id); + } else { + self.messages.clear(); + } + self.request_dialog_refresh() + } NetworkEvent::Auth(prompt) => { if self.auth_restart_pending && !matches!(&prompt, AuthPrompt::Phone) { return Vec::new(); @@ -3649,6 +3683,28 @@ mod tests { }); } + #[test] + fn cached_conversations_are_usable_before_network_authentication() { + let mut app = App::new(); + app.handle_network(NetworkEvent::CachedSnapshot { + user_name: Some("Ada".to_owned()), + chats: vec![chat(1, "Cached")], + }); + assert_eq!(app.screen, Screen::Main); + assert_eq!(app.connection, crate::event::ConnectionStatus::Connecting); + app.handle_action(KeyAction::Enter); + app.handle_network(NetworkEvent::CachedHistory { + chat_id: 1, + request_id: 1, + messages: vec![message(1, 1, "available offline", false)], + }); + assert_eq!(app.active_messages()[0].text, "available offline"); + assert!( + app.loading_history, + "cached content remains visible during reconciliation" + ); + } + #[test] fn password_is_masked_and_submitted_without_trimming() { let mut app = App::new(); diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..77143c0 --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,515 @@ +//! Account-local, discardable message storage. Authentication stays in the +//! Telegram session; user preferences do not belong in this database. + +use std::{collections::BTreeMap, path::Path, time::Duration}; + +use anyhow::{Context, Result, bail}; +use libsql::{Connection, Database, TransactionBehavior, params}; +use serde::{Deserialize, Serialize}; + +use crate::{ + event::NetworkEvent, + model::{Chat, ChatId, Delivery, Message}, +}; + +const SCHEMA_VERSION: i64 = 1; +const MAX_MESSAGES: i64 = 100_000; +const MAX_CHAT_MESSAGES: i64 = 5_000; + +/// SDK-independent cursor. Only complete update batches may advance it. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct SyncCursor { + pub pts: i32, + pub qts: i32, + pub date: i32, + pub seq: i32, + pub channels: Vec<(i64, i32)>, +} + +pub struct Store { + _database: Database, + connection: Connection, + revision: i64, + dialog_revision: i64, + history_revisions: BTreeMap<(ChatId, u64), i64>, +} + +impl Store { + /// # Errors + /// Returns filesystem, migration or SQLite errors; never discards a cache + /// merely because it could not be read. + pub async fn open(path: &Path) -> Result { + prepare_file(path)?; + let database = libsql::Builder::new_local(path).build().await?; + let connection = database.connect()?; + connection.busy_timeout(Duration::from_secs(5))?; + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .await?; + let version = transaction + .query("PRAGMA user_version", ()) + .await? + .next() + .await? + .context("missing schema version")? + .get::(0)?; + if version > SCHEMA_VERSION { + bail!("message cache was created by a newer Termgram version"); + } + if version == 0 { + transaction.execute_batch( + "CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE chats (id INTEGER PRIMARY KEY, data TEXT NOT NULL, revision INTEGER NOT NULL); + CREATE TABLE messages (chat_id INTEGER NOT NULL, id INTEGER NOT NULL, + data TEXT, timestamp INTEGER NOT NULL, revision INTEGER NOT NULL, + PRIMARY KEY (chat_id, id)); + CREATE INDEX messages_recent ON messages(timestamp DESC); + CREATE TABLE global_deletions (id INTEGER PRIMARY KEY, revision INTEGER NOT NULL); + CREATE TABLE attachments (chat_id INTEGER NOT NULL, message_id INTEGER NOT NULL, + path TEXT NOT NULL, PRIMARY KEY(chat_id, message_id)); + PRAGMA user_version = 1;" + ).await?; + } + transaction.commit().await?; + let revision = metadata(&connection, "revision") + .await? + .and_then(|value| value.parse().ok()) + .unwrap_or(0); + Ok(Self { + _database: database, + connection, + revision, + dialog_revision: revision, + history_revisions: BTreeMap::new(), + }) + } + + /// # Errors + /// Returns database or decoding errors. + pub async fn snapshot(&self) -> Result<(Option, Vec)> { + let name = metadata(&self.connection, "user_name").await?; + let mut rows = self.connection.query("SELECT data FROM chats", ()).await?; + let mut chats: Vec = Vec::new(); + while let Some(row) = rows.next().await? { + chats.push(serde_json::from_str(&row.get::(0)?)?); + } + chats.sort_by_key(|chat| std::cmp::Reverse(chat.last_activity)); + Ok((name, chats)) + } + + /// # Errors + /// Returns database or decoding errors. + pub async fn cursor(&self) -> Result { + metadata(&self.connection, "cursor") + .await? + .map(|value| serde_json::from_str(&value)) + .transpose() + .map(Option::unwrap_or_default) + .map_err(Into::into) + } + + /// # Errors + /// Returns a database error. + pub async fn account_id(&self) -> Result> { + Ok(metadata(&self.connection, "account_id") + .await? + .and_then(|value| value.parse().ok())) + } + + /// Read a bounded page without any network dependency. + /// # Errors + /// Returns database or decoding errors. + pub async fn history( + &self, + chat_id: ChatId, + before: Option, + limit: usize, + ) -> Result> { + let limit = i64::try_from(limit.min(500)).unwrap_or(500); + let mut rows = self.connection.query( + "SELECT data FROM messages WHERE chat_id=?1 AND id(0)?)?); + } + messages.reverse(); + Ok(messages) + } + + /// Commit events and any covered cursor together. Replay after an interrupted + /// commit is idempotent, and snapshot writes cannot overwrite later updates. + /// # Errors + /// Returns database or encoding errors; the transaction is rolled back. + #[allow(clippy::too_many_lines)] + pub async fn apply(&mut self, events: &[NetworkEvent]) -> Result<()> { + if events.is_empty() { + return Ok(()); + } + let transaction = self + .connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .await?; + for event in events { + self.revision += 1; + let revision = self.revision; + match event { + NetworkEvent::CacheAccountReset { user_id } => { + transaction.execute_batch("DELETE FROM chats; DELETE FROM messages; DELETE FROM metadata; DELETE FROM attachments; DELETE FROM global_deletions;").await?; + set_metadata(&transaction, "account_id", &user_id.to_string()).await?; + } + NetworkEvent::AccountIdentity { user_id } => { + set_metadata(&transaction, "account_id", &user_id.to_string()).await?; + } + NetworkEvent::Ready { user_name } => { + set_metadata(&transaction, "user_name", user_name).await?; + } + NetworkEvent::SyncCheckpoint(cursor) => { + set_metadata(&transaction, "cursor", &serde_json::to_string(cursor)?).await?; + } + NetworkEvent::DialogsLoading => self.dialog_revision = revision, + NetworkEvent::HistoryLoading { + chat_id, + request_id, + } => { + self.history_revisions + .insert((*chat_id, *request_id), revision); + } + NetworkEvent::Dialogs(chats) => { + for chat in chats { + let mut chat = chat.clone(); + if let Some((current, updated)) = load_chat(&transaction, chat.id).await? + && updated > self.dialog_revision + { + chat.last_message = current.last_message; + chat.last_activity = current.last_activity; + chat.unread = current.unread; + } + write_chat(&transaction, &chat, revision).await?; + } + transaction + .execute( + "DELETE FROM chats WHERE revision<=?1", + [self.dialog_revision], + ) + .await?; + } + NetworkEvent::History { + chat_id, + request_id, + messages, + } => { + let Some(started) = self.history_revisions.remove(&(*chat_id, *request_id)) + else { + continue; + }; + for message in messages { + write_message(&transaction, message, revision, started).await?; + } + transaction + .execute( + "DELETE FROM messages WHERE chat_id=?1 AND id NOT IN + (SELECT id FROM messages WHERE chat_id=?1 ORDER BY id DESC LIMIT ?2)", + params![*chat_id, MAX_CHAT_MESSAGES], + ) + .await?; + } + NetworkEvent::HistoryFailed { + chat_id, + request_id, + .. + } => { + self.history_revisions.remove(&(*chat_id, *request_id)); + } + NetworkEvent::NewMessage(message) + | NetworkEvent::CacheMessage(message) + | NetworkEvent::MessageUpdated(message) + | NetworkEvent::MessageSent { message, .. } + | NetworkEvent::MessageLoaded { message, .. } => { + write_message(&transaction, message, revision, revision).await?; + if let Some((mut chat, _)) = load_chat(&transaction, message.chat_id).await? + && chat + .last_activity + .is_none_or(|date| message.timestamp >= date) + { + chat.last_message.clone_from(&message.text); + chat.last_activity = Some(message.timestamp); + write_chat(&transaction, &chat, revision).await?; + } + } + NetworkEvent::MessagesDeleted { + channel_id, + message_ids, + } => { + for id in message_ids { + if let Some(chat_id) = channel_id { + transaction.execute( + "INSERT INTO messages VALUES(?1,?2,NULL,unixepoch(),?3) ON CONFLICT(chat_id,id) + DO UPDATE SET data=NULL,revision=excluded.revision", + params![*chat_id, *id, revision], + ).await?; + } else { + transaction + .execute( + "INSERT OR REPLACE INTO global_deletions VALUES(?1,?2)", + params![*id, revision], + ) + .await?; + transaction.execute("UPDATE messages SET data=NULL,revision=?2 WHERE id=?1 AND chat_id>-1000000000000", params![*id, revision]).await?; + } + } + } + NetworkEvent::MessagesRead { chat_id, max_id } => { + let mut rows = transaction.query("SELECT data FROM messages WHERE chat_id=?1 AND id<=?2 AND data IS NOT NULL", params![*chat_id, *max_id]).await?; + let mut messages: Vec = Vec::new(); + while let Some(row) = rows.next().await? { + messages.push(serde_json::from_str(&row.get::(0)?)?); + } + drop(rows); + for mut message in messages { + if message.outgoing { + message.delivery = Delivery::Read; + write_message(&transaction, &message, revision, revision).await?; + } + } + } + NetworkEvent::ReadMarked { chat_id } => { + if let Some((mut chat, _)) = load_chat(&transaction, *chat_id).await? { + chat.unread = 0; + write_chat(&transaction, &chat, revision).await?; + } + } + NetworkEvent::AttachmentDownloaded { + chat_id, + message_id, + path, + } => { + transaction + .execute( + "INSERT OR REPLACE INTO attachments VALUES(?1,?2,?3)", + params![*chat_id, *message_id, path.to_string_lossy().as_ref()], + ) + .await?; + } + NetworkEvent::CacheInvalidated { chat_id } => { + self.history_revisions + .retain(|(id, _), _| chat_id.is_some_and(|chat_id| *id != chat_id)); + if let Some(chat_id) = chat_id { + transaction + .execute("DELETE FROM messages WHERE chat_id=?1", [*chat_id]) + .await?; + } else { + transaction.execute("DELETE FROM messages", ()).await?; + } + } + _ => {} + } + } + transaction.execute("DELETE FROM messages WHERE (chat_id,id) IN (SELECT chat_id,id FROM messages ORDER BY timestamp DESC LIMIT -1 OFFSET ?1)", [MAX_MESSAGES]).await?; + set_metadata(&transaction, "revision", &self.revision.to_string()).await?; + transaction.commit().await?; + Ok(()) + } +} + +async fn write_message( + connection: &Connection, + message: &Message, + revision: i64, + started: i64, +) -> Result<()> { + if message.id <= 0 { + return Ok(()); + } + connection.execute( + "INSERT INTO messages(chat_id,id,data,timestamp,revision) + SELECT ?1,?2,?3,?4,?5 WHERE NOT EXISTS + (SELECT 1 FROM global_deletions WHERE id=?2 AND ?1>-1000000000000) + ON CONFLICT(chat_id,id) DO UPDATE SET data=excluded.data,timestamp=excluded.timestamp,revision=excluded.revision + WHERE messages.data IS NOT NULL AND messages.revision<=?6", + params![message.chat_id, message.id, serde_json::to_string(message)?, message.timestamp.timestamp(), revision, started], + ).await?; + Ok(()) +} + +async fn load_chat(connection: &Connection, id: ChatId) -> Result> { + let row = connection + .query("SELECT data,revision FROM chats WHERE id=?1", [id]) + .await? + .next() + .await?; + row.map(|row| Ok((serde_json::from_str(&row.get::(0)?)?, row.get(1)?))) + .transpose() +} + +async fn write_chat(connection: &Connection, chat: &Chat, revision: i64) -> Result<()> { + connection + .execute( + "INSERT OR REPLACE INTO chats VALUES(?1,?2,?3)", + params![chat.id, serde_json::to_string(chat)?, revision], + ) + .await?; + Ok(()) +} + +async fn metadata(connection: &Connection, key: &str) -> Result> { + connection + .query("SELECT value FROM metadata WHERE key=?1", [key]) + .await? + .next() + .await? + .map(|row| row.get(0)) + .transpose() + .map_err(Into::into) +} + +async fn set_metadata(connection: &Connection, key: &str, value: &str) -> Result<()> { + connection + .execute( + "INSERT OR REPLACE INTO metadata VALUES(?1,?2)", + params![key, value], + ) + .await?; + Ok(()) +} + +fn prepare_file(path: &Path) -> Result<()> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if !metadata.is_file() || metadata.file_type().is_symlink() => { + bail!("message cache must be a regular file") + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options.open(path)?; + } + Err(error) => return Err(error.into()), + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::ChatKind; + use chrono::{TimeZone, Utc}; + + fn message(id: i32, text: &str) -> Message { + Message { + id, + chat_id: 42, + sender: "Ada".to_owned(), + reply_to: None, + text: text.to_owned(), + timestamp: Utc.timestamp_opt(100 + i64::from(id), 0).unwrap(), + outgoing: false, + delivery: Delivery::Read, + attachment: None, + links: Vec::new(), + buttons: Vec::new(), + } + } + + #[tokio::test] + async fn restart_keeps_live_changes_and_their_cursor_and_isolates_accounts() { + let directory = std::env::temp_dir().join(format!("termgram-cache-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let path = directory.join("one.sqlite3"); + let mut store = Store::open(&path).await.unwrap(); + let chat = Chat { + id: 42, + title: "Group".to_owned(), + kind: ChatKind::Group, + unread: 0, + last_message: String::new(), + last_activity: None, + }; + let cursor = SyncCursor { + pts: 12, + date: 123, + seq: 4, + ..SyncCursor::default() + }; + store + .apply(&[ + NetworkEvent::AccountIdentity { user_id: 7 }, + NetworkEvent::Ready { + user_name: "Ada".to_owned(), + }, + NetworkEvent::DialogsLoading, + NetworkEvent::Dialogs(vec![chat]), + NetworkEvent::HistoryLoading { + chat_id: 42, + request_id: 1, + }, + NetworkEvent::MessageUpdated(message(1, "edited")), + NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![2], + }, + NetworkEvent::NewMessage(message(3, "live")), + NetworkEvent::History { + chat_id: 42, + request_id: 1, + messages: vec![message(1, "old"), message(2, "deleted")], + }, + NetworkEvent::SyncCheckpoint(cursor.clone()), + ]) + .await + .unwrap(); + drop(store); + let mut store = Store::open(&path).await.unwrap(); + let messages = store.history(42, None, 80).await.unwrap(); + assert_eq!( + messages + .iter() + .map(|message| message.text.as_str()) + .collect::>(), + vec!["edited", "live"] + ); + assert_eq!(store.cursor().await.unwrap(), cursor); + assert_eq!(store.account_id().await.unwrap(), Some(7)); + assert_eq!(store.snapshot().await.unwrap().0.as_deref(), Some("Ada")); + let other = Store::open(&directory.join("two.sqlite3")).await.unwrap(); + assert!(other.history(42, None, 80).await.unwrap().is_empty()); + assert!(other.snapshot().await.unwrap().1.is_empty()); + drop(other); + + // A failed batch must roll back both its content and its checkpoint. + store.connection.execute_batch("CREATE TRIGGER reject_message BEFORE INSERT ON messages WHEN NEW.id=9 BEGIN SELECT RAISE(ABORT,'simulated write failure'); END;").await.unwrap(); + assert!( + store + .apply(&[ + NetworkEvent::SyncCheckpoint(SyncCursor { + pts: 99, + ..cursor.clone() + }), + NetworkEvent::NewMessage(message(9, "uncommitted")) + ]) + .await + .is_err() + ); + assert_eq!(store.cursor().await.unwrap(), cursor); + store + .apply(&[NetworkEvent::CacheAccountReset { user_id: 8 }]) + .await + .unwrap(); + assert!(store.history(42, None, 80).await.unwrap().is_empty()); + assert!(store.snapshot().await.unwrap().1.is_empty()); + assert_eq!(store.cursor().await.unwrap(), SyncCursor::default()); + drop(store); + std::fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/src/event.rs b/src/event.rs index 030b780..7a4df54 100644 --- a/src/event.rs +++ b/src/event.rs @@ -254,6 +254,30 @@ impl fmt::Debug for TelegramCommand { /// SDK-independent updates sent from the Telegram worker to the application. #[derive(Clone, Debug, Eq, PartialEq)] pub enum NetworkEvent { + CachedSnapshot { + user_name: Option, + chats: Vec, + }, + CachedHistory { + chat_id: ChatId, + request_id: u64, + messages: Vec, + }, + HistoryLoading { + chat_id: ChatId, + request_id: u64, + }, + SyncCheckpoint(crate::cache::SyncCursor), + CacheAccountReset { + user_id: i64, + }, + AccountIdentity { + user_id: i64, + }, + CacheMessage(Message), + CacheInvalidated { + chat_id: Option, + }, Auth(AuthPrompt), Ready { user_name: String, diff --git a/src/lib.rs b/src/lib.rs index 9f1a465..868155c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod app; +pub mod cache; pub mod config; pub mod event; pub mod input; diff --git a/src/model.rs b/src/model.rs index e73763d..a0f7c6b 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,9 +1,11 @@ +use serde::{Deserialize, Serialize}; + use chrono::{DateTime, Local, TimeZone, Utc}; use std::path::Path; pub type ChatId = i64; -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] pub struct Chat { pub id: ChatId, pub title: String, @@ -13,7 +15,7 @@ pub struct Chat { pub last_activity: Option>, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] pub enum ChatKind { Direct, Group, @@ -24,7 +26,7 @@ pub enum ChatKind { /// /// This deliberately contains metadata only. Telegram's file reference stays /// in the network layer and is refreshed on demand before a download. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] pub struct Attachment { pub kind: AttachmentKind, pub file_name: Option, @@ -34,7 +36,7 @@ pub struct Attachment { pub fallback_emoji: Option, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] pub enum AttachmentKind { Photo, File, @@ -91,7 +93,7 @@ impl Attachment { } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] pub struct Message { pub id: i32, pub chat_id: ChatId, @@ -114,20 +116,20 @@ pub struct Message { pub buttons: Vec, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] pub struct MessageLink { pub label: String, pub url: String, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] pub struct MessageButton { pub label: String, pub index: u16, pub kind: MessageButtonKind, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] pub enum MessageButtonKind { Url, Callback, @@ -143,7 +145,7 @@ impl MessageButtonKind { } /// Lightweight metadata used to render and navigate a reply. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] pub struct ReplyInfo { pub message_id: i32, /// Stable dialog identifier for the target. Telegram permits replies to a @@ -154,7 +156,7 @@ pub struct ReplyInfo { pub sender: Option, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] pub enum Delivery { Pending, Sent, diff --git a/src/telegram/local.rs b/src/telegram/local.rs new file mode 100644 index 0000000..fc392d7 --- /dev/null +++ b/src/telegram/local.rs @@ -0,0 +1,93 @@ +//! The local store remains available while Telegram connects or authenticates. + +use super::{ + COMMAND_QUEUE_CAPACITY, Config, EVENT_QUEUE_CAPACITY, NetworkEvent, Result, TelegramCommand, + VecDeque, mpsc, run, +}; +use crate::{ + cache::{Store, SyncCursor}, + model::Chat, +}; + +pub(super) struct Bootstrap { + pub cursor: SyncCursor, + pub chats: Vec, + pub account_id: Option, +} +use tokio::{ + task::JoinSet, + time::{Duration, MissedTickBehavior}, +}; + +pub(super) async fn serve( + config: Config, + mut commands: mpsc::Receiver, + events: mpsc::Sender, +) -> Result<()> { + config.prepare_session_dir()?; + let mut store = Store::open(&config.session_path.with_extension("cache.sqlite3")).await?; + let (user_name, chats) = store.snapshot().await?; + if !chats.is_empty() { + events + .send(NetworkEvent::CachedSnapshot { + user_name, + chats: chats.clone(), + }) + .await?; + } + let bootstrap = Bootstrap { + cursor: store.cursor().await?, + chats, + account_id: store.account_id().await?, + }; + let (network_tx, network_commands) = mpsc::channel(COMMAND_QUEUE_CAPACITY); + let (network_events, mut network_rx) = mpsc::channel(EVENT_QUEUE_CAPACITY); + let mut tasks = JoinSet::new(); + tasks.spawn(run(config, network_commands, network_events, bootstrap)); + let mut pending = VecDeque::new(); + let mut changes = Vec::new(); + let mut flush = tokio::time::interval(Duration::from_millis(100)); + flush.set_missed_tick_behavior(MissedTickBehavior::Skip); + + loop { + tokio::select! { + command = commands.recv(), if pending.len() < COMMAND_QUEUE_CAPACITY => { + let Some(command) = command else { break; }; + if let TelegramCommand::LoadHistory { chat_id, request_id } = &command { + store.apply(&changes).await?; + changes.clear(); + let messages = store.history(*chat_id, None, super::HISTORY_LIMIT).await?; + if !messages.is_empty() { + events.send(NetworkEvent::CachedHistory { chat_id: *chat_id, request_id: *request_id, messages }).await?; + } + } + pending.push_back(command); + } + permit = network_tx.reserve(), if !pending.is_empty() => { + if let Ok(permit) = permit { + permit.send(pending.pop_front().expect("pending command")); + } else { break; } + } + event = network_rx.recv() => { + let Some(event) = event else { + store.apply(&changes).await?; + return tasks.join_next().await.transpose()?.unwrap_or(Ok(())); + }; + let checkpoint = matches!(&event, NetworkEvent::SyncCheckpoint(_)); + changes.push(event.clone()); + if checkpoint || changes.len() >= 256 { + store.apply(&changes).await?; + changes.clear(); + } + events.send(event).await?; + } + _ = flush.tick(), if !changes.is_empty() => { + store.apply(&changes).await?; + changes.clear(); + } + } + } + store.apply(&changes).await?; + tasks.shutdown().await; + Ok(()) +} diff --git a/src/telegram/mod.rs b/src/telegram/mod.rs index 94c73cb..1593fde 100644 --- a/src/telegram/mod.rs +++ b/src/telegram/mod.rs @@ -1,3 +1,4 @@ +mod local; mod requests; use std::collections::{HashMap, HashSet, VecDeque}; @@ -167,7 +168,7 @@ pub fn spawn(config: Config) -> TelegramHandle { let (command_tx, command_rx) = mpsc::channel(COMMAND_QUEUE_CAPACITY); let (event_tx, event_rx) = mpsc::channel(EVENT_QUEUE_CAPACITY); let task = tokio::spawn(async move { - if let Err(error) = Box::pin(run(config, command_rx, event_tx.clone())).await { + if let Err(error) = Box::pin(local::serve(config, command_rx, event_tx.clone())).await { let _ = event_tx .send(NetworkEvent::Fatal(describe_worker_error(&error))) .await; @@ -194,6 +195,7 @@ async fn run( config: Config, mut commands: mpsc::Receiver, events: mpsc::Sender, + mut bootstrap: local::Bootstrap, ) -> Result<()> { config.prepare_session_dir()?; events @@ -251,10 +253,27 @@ async fn run( } } }; + let user_id = me.id().bare_id().context("Telegram account has no stable identifier")?; + if bootstrap.account_id.is_some_and(|previous| previous != user_id) { + bootstrap.cursor = crate::cache::SyncCursor::default(); + bootstrap.chats.clear(); + events.send(NetworkEvent::CacheAccountReset { user_id }).await?; + } + events.send(NetworkEvent::AccountIdentity { user_id }).await?; let user_name = safe_name(me.first_name(), "You"); events.send(NetworkEvent::Ready { user_name }).await.ok(); let mut cache = WorkerCache::default(); + for chat in bootstrap.chats { + if let Some(id) = PeerId::from_bot_api_dialog_id(chat.id) { + if let Some(peer) = session.peer_ref(id).await? { cache.peers.insert(chat.id, peer); } + cache.names.insert(id, chat.title); + if chat.kind == ChatKind::Group && id.kind() == PeerKind::Channel { + cache.visible_channel_groups.insert(chat.id); + } + } + } + let cursor = bootstrap.cursor; events .send(NetworkEvent::Status(ConnectionStatus::Online)) .await @@ -272,6 +291,10 @@ async fn run( ) .await .map_err(anyhow::Error::from_boxed)?; + updates.restore_state(UpdatesState { + pts: cursor.pts, qts: cursor.qts, date: cursor.date, seq: cursor.seq, + channels: cursor.channels.into_iter().map(|(id, pts)| grammers_session::types::ChannelState { id, pts }).collect(), + }); let mut recovering = false; let mut transfers = JoinSet::new(); let mut requests = JoinSet::new(); @@ -280,7 +303,7 @@ async fn run( 'worker: loop { // Grammers can change its message-box state before awaiting peer // storage. Keep this future alive when commands or RPCs complete. - let next = updates.next(); + let next = updates.next_batch(); tokio::pin!(next); let update = loop { tokio::select! { @@ -311,25 +334,28 @@ async fn run( if matches!(update, Err(InvocationError::Dropped)) { bail!("Telegram update stream closed"); } - let update_failed = update.is_err(); - if let Err(error) = Box::pin(process_update(update, &session, &client, &mut cache, - &events, &mut recovering, &mut requests)).await { - events.send(NetworkEvent::Error(format!( - "Could not process a Telegram update: {error:#}" - ))).await.ok(); - } - if update_failed { - tokio::time::sleep(Duration::from_millis(250)).await; + match update { + Ok((batch, state)) => { + for update in batch { + Box::pin(process_update(Ok(update), &session, &client, &mut cache, + &events, &mut recovering, &mut requests)).await?; + } + events.send(NetworkEvent::SyncCheckpoint(crate::cache::SyncCursor { + pts: state.pts, qts: state.qts, date: state.date, seq: state.seq, + channels: state.channels.into_iter().map(|channel| (channel.id, channel.pts)).collect(), + })).await?; + } + Err(error) => { + Box::pin(process_update(Err(error), &session, &client, &mut cache, + &events, &mut recovering, &mut requests)).await?; + tokio::time::sleep(Duration::from_millis(250)).await; + } } } requests.shutdown().await; transfers.shutdown().await; - updates - .sync_update_state() - .await - .map_err(anyhow::Error::from_boxed)?; Ok(()) }) .await; @@ -356,6 +382,9 @@ async fn process_update( if requests.len() < requests::MAX_REQUESTS && begin_unresolved_refresh(cache) { requests::refresh_dialogs(client, cache, events, requests).await?; } + events + .send(NetworkEvent::CacheMessage(map_message(&message, cache)?)) + .await?; return Ok(()); }; if hidden { @@ -386,6 +415,9 @@ async fn process_update( if requests.len() < requests::MAX_REQUESTS && begin_unresolved_refresh(cache) { requests::refresh_dialogs(client, cache, events, requests).await?; } + events + .send(NetworkEvent::CacheMessage(map_message(&message, cache)?)) + .await?; return Ok(()); }; if hidden { @@ -413,6 +445,20 @@ async fn process_update( Ok(Update::Raw(update)) => { restore_online_status(events, recovering).await; match &update.raw { + tl::enums::Update::PtsChanged => { + events + .send(NetworkEvent::CacheInvalidated { chat_id: None }) + .await?; + } + tl::enums::Update::ChannelTooLong(update) => { + let chat_id = + PeerId::channel_unchecked(update.channel_id).bot_api_dialog_id_unchecked(); + events + .send(NetworkEvent::CacheInvalidated { + chat_id: Some(chat_id), + }) + .await?; + } grammers_client::tl::enums::Update::ReadHistoryOutbox(read) => { if let Some(chat_id) = PeerId::from(read.peer.clone()).bot_api_dialog_id() { cache @@ -1151,6 +1197,18 @@ async fn handle_command( | TelegramCommand::ResolveTelegramLink { .. } | TelegramCommand::ActivateButton { .. } | TelegramCommand::MarkRead { .. }) => { + if let TelegramCommand::LoadHistory { + chat_id, + request_id, + } = &command + { + events + .send(NetworkEvent::HistoryLoading { + chat_id: *chat_id, + request_id: *request_id, + }) + .await?; + } requests::spawn(command, client, cache, requests); } TelegramCommand::SendAttachment { diff --git a/tests/update_recovery.rs b/tests/update_recovery.rs index a4edd5f..24a6b11 100644 --- a/tests/update_recovery.rs +++ b/tests/update_recovery.rs @@ -40,3 +40,39 @@ fn channel_gap_recovers_from_the_local_cursor() { .expect("channel gap must trigger recovery"); assert_eq!(request.pts, 20); } + +#[tokio::test] +async fn sdk_delivers_the_whole_batch_before_its_checkpoint() { + use grammers_client::{Client, SenderPool, client::UpdatesConfiguration}; + use grammers_session::storages::MemorySession; + use std::sync::Arc; + let pool = SenderPool::new(Arc::new(MemorySession::default()), 1); + let client = Client::new(pool.handle); + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + let mut stream = client + .stream_updates( + receiver, + UpdatesConfiguration { + catch_up: false, + update_queue_limit: None, + }, + ) + .await + .unwrap(); + sender + .send(UpdatesLike::Updates( + tl::types::Updates { + updates: vec![tl::enums::Update::PtsChanged, tl::enums::Update::PtsChanged], + users: Vec::new(), + chats: Vec::new(), + date: 123, + seq: 1, + } + .into(), + )) + .unwrap(); + let (updates, cursor) = stream.next_batch().await.unwrap(); + assert_eq!(updates.len(), 2); + assert_eq!(cursor.seq, 1); + assert_eq!(cursor.date, 123); +} diff --git a/vendor/README.md b/vendor/README.md index d993050..b93dcc3 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -53,3 +53,16 @@ That notification preserves an existing local channel PTS instead of replacing it with the remote PTS before recovery. The upstream connection-flow assertion is adjusted; `tests/update_recovery.rs` checks both integration boundaries. See https://core.telegram.org/api/updates#recovering-gaps. + +`grammers-client/` vendors crates.io 0.10.0 from the same Grammers revision +(`grammers-client/`). Original source, examples, tests and both licenses are +retained. Only `src/client/updates.rs` is adapted: + +- `next_batch` drains a complete update batch and returns its covered cursor; +- `restore_state` loads the application's durable cursor before polling; +- too-long differences emit a raw `PtsChanged` / `ChannelTooLong` invalidation + before replacement messages, allowing the local store to discard stale data. + +`src/telegram/local.rs` persists events before committing their cursor and reads +that cursor on restart. Telegram's session peer cache remains separate: dialog +iteration may advance its PTS without having cached any message content. diff --git a/vendor/grammers-client/.cargo_vcs_info.json b/vendor/grammers-client/.cargo_vcs_info.json new file mode 100644 index 0000000..87015b7 --- /dev/null +++ b/vendor/grammers-client/.cargo_vcs_info.json @@ -0,0 +1,6 @@ +{ + "git": { + "sha1": "5c6d44ff30e02d6c9295bcf1fcb51403ad77c981" + }, + "path_in_vcs": "grammers-client" +} \ No newline at end of file diff --git a/vendor/grammers-client/Cargo.lock b/vendor/grammers-client/Cargo.lock new file mode 100644 index 0000000..ec27e6a --- /dev/null +++ b/vendor/grammers-client/Cargo.lock @@ -0,0 +1,2348 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer", + "crypto-common", + "inout", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "ctutils", +] + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "glass_pumpkin" +version = "2.0.0-rc0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec260059e5d59a4899d78dff8bf59abf81f8f5b3b44e9d3ba25c9dade1390a36" +dependencies = [ + "getrandom 0.4.3", + "num-bigint", + "num-integer", + "num-traits", + "once_cell", + "rand_core", +] + +[[package]] +name = "grammers-client" +version = "0.10.0" +dependencies = [ + "chrono", + "futures-util", + "grammers-crypto", + "grammers-mtsender", + "grammers-session", + "grammers-tl-types", + "html5ever", + "log", + "md5", + "mime_guess", + "pin-project-lite", + "pulldown-cmark", + "simple_logger", + "tokio", + "toml", + "url", +] + +[[package]] +name = "grammers-crypto" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fe0fbd93bce6965d08248abdfec2d2544e11cb8d5c91dba3369c0a4295fd85" +dependencies = [ + "aes", + "ctr", + "getrandom 0.4.3", + "glass_pumpkin", + "hmac", + "num-bigint", + "num-traits", + "pbkdf2", + "sha1", + "sha2", +] + +[[package]] +name = "grammers-mtproto" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8d68ffa402c5f0707e22c29d44229eb01e968017dd0fb27e1adab953d49fc3" +dependencies = [ + "bytes", + "crc32fast", + "flate2", + "getrandom 0.4.3", + "grammers-crypto", + "grammers-tl-types", + "log", + "num-bigint", + "sha1", +] + +[[package]] +name = "grammers-mtsender" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f39b8556ddc94f7ec935135dcfbf84ca8628538e103779cdc701423b759b078e" +dependencies = [ + "bytes", + "grammers-crypto", + "grammers-mtproto", + "grammers-session", + "grammers-tl-types", + "hickory-resolver", + "locate-locale", + "log", + "os_info", + "tokio", + "tokio-socks", + "url", +] + +[[package]] +name = "grammers-session" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e4d5b9e434c6b9fc1e091fe82d1f201d8e19df5005d2c19e218b4977b4df506" +dependencies = [ + "grammers-tl-types", + "log", + "tokio", +] + +[[package]] +name = "grammers-tl-gen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a37dda68c9e775dfbffb85b99826b5ac8144e9da40be3327e77cbcd8618fc30" +dependencies = [ + "grammers-tl-parser", +] + +[[package]] +name = "grammers-tl-parser" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "646fc5eeb27461ffcc94fcf9fda3ffd9b402a20250447ae4a65c39c1a905e381" +dependencies = [ + "crc32fast", +] + +[[package]] +name = "grammers-tl-types" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67d450e2f2588af535fe85a17b9a6df8338794ee8ad6bcb1b97d61a14d472fc5" +dependencies = [ + "grammers-tl-gen", + "grammers-tl-parser", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "hickory-proto", + "idna", + "ipnet", + "jni", + "rand", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "prefix-trie", + "rand", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand", + "resolv-conf", + "smallvec", + "system-configuration", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +dependencies = [ + "serde", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f0862381daaec758576dcc22eb7bbf4d7efd67328553f3b45a412a51a3fb21" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "locate-locale" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2835eaaed39a92511442aff277d4dca3d7674ca058df3bc45170661c2ccb4619" +dependencies = [ + "winapi", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "md5" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "os_info" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b" +dependencies = [ + "android_system_properties", + "log", + "nix", + "objc2", + "objc2-foundation", + "objc2-ui-kit", + "windows-sys 0.61.2", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_logger" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7038d0e96661bf9ce647e1a6f6ef6d6f3663f66d9bf741abf14ba4876071c17" +dependencies = [ + "colored", + "log", + "windows-sys 0.61.2", +] + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.4", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de241cdc66a9d91bd84f097039eb140cdc6eec47e0cdbaf9d932a1dd6c35866" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e12fdf6649048f2e3de6d7d5ff3ced779cdedee0e0baffd7dff5cdfa3abc8a52" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e63d1795c565ac3462334c1e396fd46dbf481c40f51f5072c310717bc4fb309" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f9cdac23a5ce71f6bf9f8824898a501e511892791ea2a0c6b8568c68b9cb53" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web_atoms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/vendor/grammers-client/Cargo.toml b/vendor/grammers-client/Cargo.toml new file mode 100644 index 0000000..33d72c2 --- /dev/null +++ b/vendor/grammers-client/Cargo.toml @@ -0,0 +1,145 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +name = "grammers-client" +version = "0.10.0" +authors = ["Lonami Exo "] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = """ +A high level client to interact with Telegram's API. +""" +homepage = "https://codeberg.org/Lonami/grammers" +documentation = "https://docs.rs/grammers-client" +readme = "README.md" +keywords = [ + "mtproto", + "telegram", + "tl", +] +categories = ["api-bindings"] +license = "MIT OR Apache-2.0" +repository = "https://codeberg.org/Lonami/grammers" +resolver = "2" + +[features] +default = ["fs"] +fs = ["tokio/fs"] +html = ["html5ever"] +markdown = ["pulldown-cmark"] +parse_invite_link = ["url"] +proxy = ["grammers-mtsender/proxy"] + +[lib] +name = "grammers_client" +path = "src/lib.rs" + +[[example]] +name = "dialogs" +path = "examples/dialogs.rs" + +[[example]] +name = "downloader" +path = "examples/downloader.rs" + +[[example]] +name = "echo" +path = "examples/echo.rs" + +[[example]] +name = "inline-pagination" +path = "examples/inline-pagination.rs" + +[[example]] +name = "ping" +path = "examples/ping.rs" + +[[example]] +name = "send_album" +path = "examples/send_album.rs" + +[[test]] +name = "deps" +path = "tests/deps.rs" + +[dependencies.chrono] +version = "0.4.45" + +[dependencies.futures-util] +version = "0.3.32" +features = ["alloc"] +default-features = false + +[dependencies.grammers-crypto] +version = "0.10.0" + +[dependencies.grammers-mtsender] +version = "0.10.0" + +[dependencies.grammers-session] +version = "0.10.0" +default-features = false + +[dependencies.grammers-tl-types] +version = "0.10.0" +features = ["tl-mtproto"] + +[dependencies.html5ever] +version = "0.39.0" +optional = true + +[dependencies.log] +version = "0.4.33" + +[dependencies.md5] +version = "0.8.0" + +[dependencies.mime_guess] +version = "2.0.5" + +[dependencies.pin-project-lite] +version = "0.2" + +[dependencies.pulldown-cmark] +version = "0.13.4" +optional = true +default-features = false + +[dependencies.tokio] +version = "1.52.3" +features = [ + "rt", + "macros", +] +default-features = false + +[dependencies.url] +version = "2.5.8" +optional = true + +[dev-dependencies.simple_logger] +version = "5.2.0" +features = ["colors"] +default-features = false + +[dev-dependencies.tokio] +version = "1.52.3" +features = ["signal"] +default-features = false + +[dev-dependencies.toml] +version = "1.1.2" diff --git a/vendor/grammers-client/Cargo.toml.orig b/vendor/grammers-client/Cargo.toml.orig new file mode 100755 index 0000000..9c43261 --- /dev/null +++ b/vendor/grammers-client/Cargo.toml.orig @@ -0,0 +1,53 @@ +[package] +name = "grammers-client" +version = "0.10.0" +authors = ["Lonami Exo "] +license = "MIT OR Apache-2.0" +description = """ +A high level client to interact with Telegram's API. +""" +homepage = "https://codeberg.org/Lonami/grammers" +documentation = "https://docs.rs/grammers-client" +repository = "https://codeberg.org/Lonami/grammers" +keywords = ["mtproto", "telegram", "tl"] +categories = ["api-bindings"] +edition = "2024" + +[features] +markdown = ["pulldown-cmark"] +html = ["html5ever"] +proxy = ["grammers-mtsender/proxy"] +parse_invite_link = ["url"] +fs = ["tokio/fs"] +default = ["fs"] + +[dependencies] +chrono = "0.4.45" +futures-util = { version = "0.3.32", default-features = false, features = [ + "alloc" +] } +grammers-crypto = { path = "../grammers-crypto", version = "0.10.0" } +grammers-mtsender = { path = "../grammers-mtsender", version = "0.10.0" } +grammers-session = { path = "../grammers-session", version = "0.10.0", default-features = false } +grammers-tl-types = { path = "../grammers-tl-types", version = "0.10.0", features = [ + "tl-mtproto", +] } +html5ever = { version = "0.39.0", optional = true } +log = "0.4.33" +md5 = "0.8.0" +mime_guess = "2.0.5" +pin-project-lite = "0.2" +pulldown-cmark = { version = "0.13.4", default-features = false, optional = true } +tokio = { version = "1.52.3", default-features = false, features = [ + "rt", "macros" +] } +url = { version = "2.5.8", optional = true } + +[dev-dependencies] +tokio = { version = "1.52.3", default-features = false, features = [ + "signal", +] } +simple_logger = { version = "5.2.0", default-features = false, features = [ + "colors", +] } +toml = "1.1.2" diff --git a/vendor/grammers-client/DEPS.md b/vendor/grammers-client/DEPS.md new file mode 100755 index 0000000..18d9d0e --- /dev/null +++ b/vendor/grammers-client/DEPS.md @@ -0,0 +1,70 @@ +# Dependencies + +## grammers-crypto + +Used for supporting logging in to accounts with 2-factor authentication enabled. + +## grammers-mtsender + +Used to drive the network connection to Telegram. + +## grammers-session + +Used to load and store session data, such as authorization key or current user identifier. + +It also contains the logic needed to correctly process updates. + +## grammers-tl-types + +Used everywhere to invoke the "raw Telegram's API". It is the implementation of all the friendly +client methods. + +## pulldown-cmark + +Enables the user to use markdown text to send formatted messages. + +## html5ever + +Enables the user to use HTML text to send formatted messages. + +## tokio + +Used to coordinate the asynchronous methods of the client. + +## log + +Used to log the execution of the client to help debug issues. + +## md5 + +Needed when uploading files to Telegram. + +## mime_guess + +Used to guess the mime-type of uploaded files when sending media unless the user explicitly sets +the mime-type themselves. The mime-type is required by Telegram. + +## chrono + +Used for defining date types (for example, accessing the date of when a message was sent). + +## simple_logger + +Used by the examples to showcase how one may configure logging for more information. + +## toml + +Used to test that this file lists all dependencies from `Cargo.toml`. + +## pin-project-lite + +Used for return custom types that `impl Future` so that the requests can be further configured +without having to use `Box`. + +## futures-util + +Provides useful functions for working with futures/tasks. + +## url + +Used to parse certain URLs to offer features such as joining private chats via their invite link. diff --git a/vendor/grammers-client/LICENSE-APACHE b/vendor/grammers-client/LICENSE-APACHE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/vendor/grammers-client/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/grammers-client/LICENSE-MIT b/vendor/grammers-client/LICENSE-MIT new file mode 100644 index 0000000..5f4dc02 --- /dev/null +++ b/vendor/grammers-client/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Lonami + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/grammers-client/README.md b/vendor/grammers-client/README.md new file mode 100755 index 0000000..fcb4f9a --- /dev/null +++ b/vendor/grammers-client/README.md @@ -0,0 +1,28 @@ +# grammers-client + +This library is a higher-level interface to interact with Telegram's API. + +It contains the code necessary to create a client, connect to the API and +make Remote Procedure Calls (RPC) to it, such as signing in or sending a +message. + +The library is in development, but new releases are only cut rarely. +[Specifying the dependency from the git repository][dep-git] is recommended: + +```toml +grammers-client = { git = "https://codeberg.org/Lonami/grammers.git" } +``` + +Please note that traits across versions are not always compatible. +If you depend on other `grammers-` crates, be sure all of them use +a compatible version (e.g. all of them using `git`). + +Note that `grammers-tl-types` (needed to `client.invoke` "raw" functions) +is currently re-exported from within this crates, so it's easier to use the +re-export than to depend on the crate separatedly: + +```rust +use grammers_client::grammers_tl_types as tl; +``` + +[dep-git]: https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#specifying-dependencies-from-git-repositories diff --git a/vendor/grammers-client/examples/README.md b/vendor/grammers-client/examples/README.md new file mode 100755 index 0000000..6f8af3e --- /dev/null +++ b/vendor/grammers-client/examples/README.md @@ -0,0 +1,42 @@ +# grammers-client examples + +This folder contains various simple, self-contained examples that you can use to base your +projects on. If you would like to see any other example that could be useful for others, or +you think these could be improved, feel free to contribute. + +For the time being, the examples are licensed in the same way as the libraries. + +## [ping.rs] + +Pings Telegram by using raw API. This is the simplest possible example, and requires no valid +API ID or hash, since it's not logging in to any account. + +## [echo.rs] + +Simple echo-bot. It will respond to plain text messages with the same content. The updates will +be processed concurrently to showcase how one might use multiple handles the same time. In a +real-world scenario, you would probably want to use some form of task pool to spawn tasks within +limits. Or you might not need this at all if you want to process updates in order. + +## [dialogs.rs] + +Logs in to a user account and prints the title and ID of all the dialogs (chats they have joined +to and private conversations). It shows how to spawn a task to handle the network in the +background and using a single client handle to actually perform remote calls. If the client was +not running and thus not handling the network, no response would ever arrive to the handles! + +This separation between client, which deals with all network events, and client handles, enables +you to have as many handles as you need, for as many tasks as you need (and enabling things like +iterators which keep their own handle). It requires some boilerplate to setup, but it's a very +powerful approach. + +## [downloader.rs] + +Downloads all media from a peer, including files located in datacenters different than your own. +After logging in, the example iterates all messages of the supplied peer name, printing in the console +the ID and the text for each one; videos, images, stickers and contacts are saved in the `target` folder. + +[ping.rs]: ping.rs +[echo.rs]: echo.rs +[dialogs.rs]: dialogs.rs +[downloader.rs]: downloader.rs diff --git a/vendor/grammers-client/examples/dialogs.rs b/vendor/grammers-client/examples/dialogs.rs new file mode 100755 index 0000000..5c52ca9 --- /dev/null +++ b/vendor/grammers-client/examples/dialogs.rs @@ -0,0 +1,97 @@ +//! Example to print the ID and title of all the dialogs. +//! +//! The `TG_ID` and `TG_HASH` environment variables must be set (learn how to do it for +//! [Windows](https://ss64.com/nt/set.html) or [Linux](https://ss64.com/bash/export.html)) +//! to Telegram's API ID and API hash respectively. +//! +//! Then, run it as: +//! +//! ```sh +//! cargo run --example dialogs +//! ``` + +use std::env; +use std::io::{self, BufRead as _, Write as _}; +use std::sync::Arc; + +use grammers_client::{Client, SignInError}; +use grammers_mtsender::SenderPool; +use grammers_session::storages::SqliteSession; +use simple_logger::SimpleLogger; +use tokio::runtime; + +type Result = std::result::Result>; + +const SESSION_FILE: &str = "dialogs.session"; + +fn prompt(message: &str) -> Result { + let stdout = io::stdout(); + let mut stdout = stdout.lock(); + stdout.write_all(message.as_bytes())?; + stdout.flush()?; + + let stdin = io::stdin(); + let mut stdin = stdin.lock(); + + let mut line = String::new(); + stdin.read_line(&mut line)?; + Ok(line) +} + +async fn async_main() -> Result<()> { + SimpleLogger::new() + .with_level(log::LevelFilter::Debug) + .init() + .unwrap(); + + let api_id = env!("TG_ID").parse().expect("TG_ID invalid"); + + let session = Arc::new(SqliteSession::open(SESSION_FILE).await?); + + let SenderPool { runner, handle, .. } = SenderPool::new(Arc::clone(&session), api_id); + let client = Client::new(handle); + let _ = tokio::spawn(runner.run()); + + if !client.is_authorized().await? { + println!("Signing in..."); + let phone = prompt("Enter your phone number (international format): ")?; + let token = client.request_login_code(&phone, env!("TG_HASH")).await?; + let code = prompt("Enter the code you received: ")?; + let signed_in = client.sign_in(&token, &code).await; + match signed_in { + Err(SignInError::PasswordRequired(password_token)) => { + // Note: this `prompt` method will echo the password in the console. + // Real code might want to use a better way to handle this. + let hint = password_token.hint().unwrap_or("None"); + let prompt_message = format!("Enter the password (hint {}): ", &hint); + let password = prompt(prompt_message.as_str())?; + + client + .check_password(password_token, password.trim()) + .await?; + } + Ok(_) => (), + Err(e) => panic!("{}", e), + }; + println!("Signed in!"); + } + + let mut dialogs = client.iter_dialogs(); + + println!("Showing up to {} dialogs:", dialogs.total().await?); + while let Some(dialog) = dialogs.next().await? { + let peer = dialog.peer(); + println!("- {: >10} {}", peer.id(), peer.name().unwrap_or_default()); + } + + // `runner.run()`'s task will be dropped (and disconnect occur) once the runtime exits. + Ok(()) +} + +fn main() -> Result<()> { + runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async_main()) +} diff --git a/vendor/grammers-client/examples/downloader.rs b/vendor/grammers-client/examples/downloader.rs new file mode 100644 index 0000000..78536f4 --- /dev/null +++ b/vendor/grammers-client/examples/downloader.rs @@ -0,0 +1,153 @@ +//! Example to download all messages and media from a peer. +//! +//! The `TG_ID` and `TG_HASH` environment variables must be set (learn how to do it for +//! [Windows](https://ss64.com/nt/set.html) or [Linux](https://ss64.com/bash/export.html)) +//! to Telegram's API ID and API hash respectively. +//! +//! Then, run it as: +//! +//! ```sh +//! cargo run --example downloader -- PEER_NAME +//! ``` +//! +//! Messages will be printed to stdout, and media will be saved in the `target/` folder locally, named +//! message-[MSG_ID].[EXT] + +use std::io::{BufRead, Write}; +use std::path::Path; +use std::sync::Arc; +use std::{env, io}; + +use grammers_client::media::Media; +use grammers_client::{Client, SignInError}; +use grammers_mtsender::SenderPool; +use grammers_session::storages::SqliteSession; +use mime::Mime; +use mime_guess::mime; +use simple_logger::SimpleLogger; +use tokio::runtime; + +type Result = std::result::Result>; + +const SESSION_FILE: &str = "downloader.session"; + +async fn async_main() -> Result<()> { + SimpleLogger::new() + .with_level(log::LevelFilter::Info) + .init() + .unwrap(); + + let api_id = env!("TG_ID").parse().expect("TG_ID invalid"); + let peer_name = env::args().nth(1).expect("peer name missing"); + + let session = Arc::new(SqliteSession::open(SESSION_FILE).await?); + + let SenderPool { runner, handle, .. } = SenderPool::new(Arc::clone(&session), api_id); + let client = Client::new(handle); + let _ = tokio::spawn(runner.run()); + + if !client.is_authorized().await? { + println!("Signing in..."); + let phone = prompt("Enter your phone number (international format): ")?; + let token = client.request_login_code(&phone, env!("TG_HASH")).await?; + let code = prompt("Enter the code you received: ")?; + let signed_in = client.sign_in(&token, &code).await; + match signed_in { + Err(SignInError::PasswordRequired(password_token)) => { + // Note: this `prompt` method will echo the password in the console. + // Real code might want to use a better way to handle this. + let hint = password_token.hint().unwrap(); + let prompt_message = format!("Enter the password (hint {}): ", &hint); + let password = prompt(prompt_message.as_str())?; + + client + .check_password(password_token, password.trim()) + .await?; + } + Ok(_) => (), + Err(e) => panic!("{}", e), + }; + println!("Signed in!"); + } + + let maybe_peer = client + .resolve_username(peer_name.as_str()) + .await? + .ok_or("no peer with username")? + .to_ref() + .await?; + + let peer = maybe_peer.unwrap_or_else(|| panic!("Peer {peer_name} could not be found")); + + let mut messages = client.iter_messages(peer); + + println!( + "Peer {} has {} total messages.", + peer_name, + messages.total().await.unwrap() + ); + + let mut counter = 0; + + while let Some(msg) = messages.next().await? { + counter += 1; + println!("Message {}:{}", msg.id(), msg.text()); + if let Some(media) = msg.media() { + let dest = format!( + "target/message-{}{}", + &msg.id().to_string(), + get_file_extension(&media) + ); + client + .download_media(&media, &Path::new(dest.as_str())) + .await + .expect("Error downloading message"); + } + } + + println!("Downloaded {counter} messages"); + + // `runner.run()`'s task will be dropped (and disconnect occur) once the runtime exits. + Ok(()) +} + +fn main() -> Result<()> { + runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async_main()) +} + +fn get_file_extension(media: &Media) -> String { + match media { + Media::Photo(_) => ".jpg".to_string(), + Media::Sticker(sticker) => get_mime_extension(sticker.document.mime_type()), + Media::Document(document) => get_mime_extension(document.mime_type()), + Media::Contact(_) => ".vcf".to_string(), + _ => String::new(), + } +} + +fn get_mime_extension(mime_type: Option<&str>) -> String { + mime_type + .map(|m| { + let mime: Mime = m.parse().unwrap(); + format!(".{}", mime.subtype()) + }) + .unwrap_or_default() +} + +fn prompt(message: &str) -> Result { + let stdout = io::stdout(); + let mut stdout = stdout.lock(); + stdout.write_all(message.as_bytes())?; + stdout.flush()?; + + let stdin = io::stdin(); + let mut stdin = stdin.lock(); + + let mut line = String::new(); + stdin.read_line(&mut line)?; + Ok(line) +} diff --git a/vendor/grammers-client/examples/echo.rs b/vendor/grammers-client/examples/echo.rs new file mode 100644 index 0000000..29d0899 --- /dev/null +++ b/vendor/grammers-client/examples/echo.rs @@ -0,0 +1,139 @@ +//! Example to echo user text messages. Updates are handled concurrently. +//! +//! The `TG_ID` and `TG_HASH` environment variables must be set (learn how to do it for +//! [Windows](https://ss64.com/nt/set.html) or [Linux](https://ss64.com/bash/export.html)) +//! to Telegram's API ID and API hash respectively. +//! +//! Then, run it as: +//! +//! ```sh +//! cargo run --example echo -- BOT_TOKEN +//! ``` + +use std::sync::Arc; +use std::{env, time::Duration}; + +use grammers_client::Client; +use grammers_client::client::UpdatesConfiguration; +use grammers_client::update::Update; +use grammers_mtsender::SenderPool; +use grammers_session::storages::SqliteSession; +use simple_logger::SimpleLogger; +use tokio::task::JoinSet; +use tokio::{runtime, time::sleep}; + +type Result = std::result::Result<(), Box>; + +const SESSION_FILE: &str = "echo.session"; + +async fn handle_update(client: Client, update: Update) { + match update { + Update::NewMessage(message) if !message.outgoing() => { + let peer = message.peer().unwrap(); + println!( + "Responding to {}", + peer.name().unwrap_or(&format!("id {}", message.peer_id())) + ); + if message.text() == "slow" { + sleep(Duration::from_secs(5)).await; + } + if let Err(e) = client + .send_message(peer.to_ref().await.unwrap().unwrap(), message.text()) + .await + { + println!("Failed to respond! {e}"); + }; + } + _ => {} + } +} + +async fn async_main() -> Result { + SimpleLogger::new() + .with_level(log::LevelFilter::Debug) + .init() + .unwrap(); + + let api_id = env!("TG_ID").parse().expect("TG_ID invalid"); + let token = env::args().nth(1).expect("token missing"); + + let session = Arc::new(SqliteSession::open(SESSION_FILE).await?); + + let SenderPool { + runner, + updates, + handle, + } = SenderPool::new(Arc::clone(&session), api_id); + let client = Client::new(handle.clone()); + let pool_task = tokio::spawn(runner.run()); + + if !client.is_authorized().await? { + println!("Signing in..."); + client.bot_sign_in(&token, env!("TG_HASH")).await?; + println!("Signed in!"); + } + + println!("Waiting for messages..."); + + // This example spawns a task to handle each update. + // To guarantee that all handlers run to completion, they're stored in this set. + // You can use `task::spawn` if you don't care about dropping unfinished handlers midway. + let mut handler_tasks = JoinSet::new(); + let mut updates = client + .stream_updates( + updates, + UpdatesConfiguration { + catch_up: true, + ..Default::default() + }, + ) + .await?; + loop { + // Empty finished handlers (you could look at their return value here too.) + while let Some(_) = handler_tasks.try_join_next() {} + + // This code uses `select` on Ctrl+C to gracefully stop the client and have a chance to + // save the session. You could have fancier logic to save the session if you wanted to + // (or even save it on every update). Or you could also ignore Ctrl+C and just use + // `let update = client.next_update().await?`. + tokio::select! { + _ = tokio::signal::ctrl_c() => break, + update = updates.next() => { + let update = update?; + let handle = client.clone(); + handler_tasks.spawn(handle_update(handle, update)); + } + } + } + + println!("Saving session file..."); + updates.sync_update_state().await?; // you usually want this before closing the session + + // Pool's `run()` won't finish until all handles are dropped or quit is called. + // Here there are at least three handles alive: `handle`, `client` and `updates` + // which contains a `client`. Any ongoing `handle_update` handlers have one client too. + // In this case, it's easier to call `handle.quit()` to close them all. + // + // You don't need to explicitly close the connection, but this is a way to do it gracefully. + // This also gives a chance to the handlers to finish their work by handling the `Dropped` + // error from any pending method calls (RPC invocations). + // + // You can try this graceful shutdown by sending a message saying "slow" and then pressing Ctrl+C. + println!("Gracefully closing connection to notify all pending handlers..."); + handle.quit(); + let _ = pool_task.await; + + // Give a chance to all on-going handlers to finish. + println!("Waiting for any slow handlers to finish..."); + while let Some(_) = handler_tasks.join_next().await {} + + Ok(()) +} + +fn main() -> Result { + runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async_main()) +} diff --git a/vendor/grammers-client/examples/inline-pagination.rs b/vendor/grammers-client/examples/inline-pagination.rs new file mode 100644 index 0000000..d8bd827 --- /dev/null +++ b/vendor/grammers-client/examples/inline-pagination.rs @@ -0,0 +1,168 @@ +//! Example to showcase how to achieve pagination with inline buttons. +//! +//! The `TG_ID` and `TG_HASH` environment variables must be set (learn how to do it for +//! [Windows](https://ss64.com/nt/set.html) or [Linux](https://ss64.com/bash/export.html)) +//! to Telegram's API ID and API hash respectively. +//! +//! Then, run it as: +//! +//! ```sh +//! cargo run --example echo -- BOT_TOKEN +//! ``` +//! +//! In order to achieve pagination, the button data must contain enough information for the code +//! to determine which "offset" should it use when querying additional data. +//! +//! For this example, the button contains the last two values of the fibonacci sequence where it +//! last left off, separated by a comma. This way, the code can resume. +//! +//! If there is no comma, then there is a single number, which we use to know what was clicked. +//! +//! If it's the special string "done", then we know we've reached the end (there is a limit to +//! how much data a button's payload can contain, and to keep it simple, we're storing it inline +//! in decimal, so the numbers can't get too large). + +use std::env; +use std::sync::Arc; + +use grammers_client::Client; +use grammers_client::message::{Button, InputMessage, ReplyMarkup}; +use grammers_client::update::Update; +use grammers_mtsender::SenderPool; +use grammers_session::storages::SqliteSession; +use simple_logger::SimpleLogger; +use tokio::{runtime, task}; + +type Result = std::result::Result<(), Box>; + +const SESSION_FILE: &str = "inline-pagination.session"; + +const NUMBERS_PER_PAGE: usize = 4; + +// https://core.telegram.org/bots/api#inlinekeyboardbutton +const MAX_PAYLOAD_DATA_LEN: usize = 64; + +/// Generate the inline keyboard reply markup with a few more numbers from the sequence. +fn fib_markup(mut a: u128, mut b: u128) -> ReplyMarkup { + let mut rows = Vec::with_capacity(NUMBERS_PER_PAGE + 1); + for _ in 0..NUMBERS_PER_PAGE { + let text = a.to_string(); + rows.push(vec![Button::data(&text, text.as_bytes())]); + + let bb = b; + b += a; + a = bb; + } + + let next = format!("{a},{b}"); + if next.len() > MAX_PAYLOAD_DATA_LEN { + rows.push(vec![Button::data("I'm satisfied!!", b"done".to_vec())]); + } else { + rows.push(vec![ + Button::data("Restart!", b"0,1".to_vec()), + Button::data("More!", format!("{a},{b}").into_bytes()), + ]); + } + ReplyMarkup::from_buttons(&rows) +} + +async fn handle_update(_client: Client, update: Update) -> Result { + match update { + Update::NewMessage(message) if message.text() == "/start" => { + message + .respond( + InputMessage::new() + .text("Here's a fibonacci") + .reply_markup(fib_markup(0, 1)), + ) + .await?; + } + Update::CallbackQuery(query) => { + let data = std::str::from_utf8(query.data()).unwrap(); + println!("Got callback query for {data}"); + + // First check special-case. + if data == "done" { + query.answer().edit("Glad you liked it 👍").await?; + return Ok(()); + } + + // Otherwise get the stored number(s). + let mut parts = data.split(','); + let a = parts.next().unwrap().parse::().unwrap(); + if let Some(b) = parts.next() { + let os = (0..b.len()).map(|_| 'o').collect::(); + let b = b.parse::().unwrap(); + query + .answer() + .edit( + InputMessage::from(format!("S{os} much fibonacci 🔢")) + .reply_markup(fib_markup(a, b)), + ) + .await?; + } else if a % 2 == 0 { + query.answer().text("Even that's a number!").send().await?; + } else { + query.answer().alert("That's odd…").send().await?; + } + } + _ => {} + } + + Ok(()) +} + +async fn async_main() -> Result { + SimpleLogger::new() + .with_level(log::LevelFilter::Debug) + .init() + .unwrap(); + + let api_id = env!("TG_ID").parse().expect("TG_ID invalid"); + let token = env::args().nth(1).expect("token missing"); + + let session = Arc::new(SqliteSession::open(SESSION_FILE).await?); + + let SenderPool { + runner, + handle, + updates, + } = SenderPool::new(Arc::clone(&session), api_id); + let client = Client::new(handle); + let _ = tokio::spawn(runner.run()); + + if !client.is_authorized().await? { + println!("Signing in..."); + client.bot_sign_in(&token, env!("TG_HASH")).await?; + println!("Signed in!"); + } + + println!("Waiting for messages..."); + let mut updates = client.stream_updates(updates, Default::default()).await?; + loop { + tokio::select! { + _ = tokio::signal::ctrl_c() => break, + update = updates.next() => { + let update = update?; + let handle = client.clone(); + task::spawn(async move { + match handle_update(handle, update).await { + Ok(_) => {} + Err(e) => eprintln!("Error handling updates!: {e}"), + } + }); + } + } + } + + // `runner.run()`'s task will be dropped (and disconnect occur) once the runtime exits. + Ok(()) +} + +fn main() -> Result { + runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async_main()) +} diff --git a/vendor/grammers-client/examples/ping.rs b/vendor/grammers-client/examples/ping.rs new file mode 100755 index 0000000..4bc026a --- /dev/null +++ b/vendor/grammers-client/examples/ping.rs @@ -0,0 +1,42 @@ +//! This example sends a ping to Telegram through raw API, and that's it. +//! +//! ```sh +//! cargo run --example ping +//! ``` + +use std::sync::Arc; + +use grammers_client::Client; +use grammers_mtsender::SenderPool; +use grammers_session::storages::SqliteSession; +use grammers_tl_types as tl; +use tokio::runtime; + +type Result = std::result::Result<(), Box>; + +async fn async_main() -> Result { + let session = Arc::new(SqliteSession::open("ping.session").await?); + let SenderPool { runner, handle, .. } = SenderPool::new(Arc::clone(&session), 1); + let client = Client::new(handle); + let pool_task = tokio::spawn(runner.run()); + + println!("Sending ping..."); + dbg!(client.invoke(&tl::functions::Ping { ping_id: 0 }).await?); + println!("Ping sent successfully!"); + + // Pool's `run()` won't finish until all handles are dropped or quit is called. + // + // You don't need to explicitly close the connection, but this is a way to do it gracefully. + drop(client); + let _ = pool_task.await; + + Ok(()) +} + +fn main() -> Result { + runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async_main()) +} diff --git a/vendor/grammers-client/examples/send_album.rs b/vendor/grammers-client/examples/send_album.rs new file mode 100644 index 0000000..884e347 --- /dev/null +++ b/vendor/grammers-client/examples/send_album.rs @@ -0,0 +1,125 @@ +use std::env; +use std::io; +use std::io::{BufRead, Write}; +use std::sync::Arc; + +use grammers_client::Client; +use grammers_client::SignInError; +use grammers_client::media::InputMedia; +use grammers_mtsender::SenderPool; +use grammers_session::storages::SqliteSession; +use grammers_session::types::{PeerAuth, PeerId, PeerRef}; +use grammers_tl_types as tl; +use simple_logger::SimpleLogger; +use tokio::runtime; + +type Result = std::result::Result>; + +const SESSION_FILE: &str = "me.session"; + +async fn async_main() -> Result<()> { + SimpleLogger::new() + .with_level(log::LevelFilter::Info) + .init() + .unwrap(); + + let api_id: i32 = env!("TG_ID").parse().expect("TG_ID invalid"); + let api_hash: String = env!("TG_HASH").to_string(); + + let session = Arc::new(SqliteSession::open(SESSION_FILE).await?); + + let SenderPool { runner, handle, .. } = SenderPool::new(Arc::clone(&session), api_id); + let client = Client::new(handle); + let _ = tokio::spawn(runner.run()); + + if !client.is_authorized().await? { + println!("Signing in..."); + let phone = prompt("Enter your phone number (international format): ")?; + let token = client.request_login_code(&phone, &api_hash).await?; + let code = prompt("Enter the code you received: ")?; + let signed_in = client.sign_in(&token, &code).await; + match signed_in { + Err(SignInError::PasswordRequired(password_token)) => { + // Note: this `prompt` method will echo the password in the console. + // Real code might want to use a better way to handle this. + let hint = password_token.hint().unwrap_or(""); + let prompt_message = format!("Enter the password (hint {}): ", &hint); + let password = prompt(prompt_message.as_str())?; + + client + .check_password(password_token, password.trim()) + .await?; + } + Ok(_) => (), + Err(e) => panic!("{}", e), + }; + println!("Signed in!"); + } + + let peer = client + .resolve_username("telegram") + .await? + .ok_or("no peer with username")? + .to_ref() + .await? + .unwrap(); + + let messages = client.get_messages_by_id(peer, &[437, 438]).await?; + let medias = messages + .into_iter() + .map(|message| { + let media = message + .unwrap() + .media() + .unwrap() + .to_raw_input_media() + .unwrap(); + let (id, video_cover, video_timestamp) = match media { + tl::enums::InputMedia::Document(x) => (x.id, x.video_cover, x.video_timestamp), + _ => panic!("must be document"), + }; + let media = tl::types::InputMediaDocument { + spoiler: true, + id, + video_cover, + video_timestamp, + ttl_seconds: None, + query: None, + }; + InputMedia::new().media(media) + }) + .collect(); + + let peer = PeerRef { + id: PeerId::self_user(), + auth: PeerAuth::default(), + }; + + let m = client.send_album(peer, medias).await?; + println!("{:?}", m); + + // `runner.run()`'s task will be dropped (and disconnect occur) once the runtime exits. + Ok(()) +} + +fn main() -> Result<()> { + runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async_main()) +} + +fn prompt(message: &str) -> Result { + let stdout = io::stdout(); + let mut stdout = stdout.lock(); + stdout.write_all(message.as_bytes())?; + stdout.flush()?; + + let stdin = io::stdin(); + let mut stdin = stdin.lock(); + + let mut line = String::new(); + stdin.read_line(&mut line)?; + Ok(line) +} diff --git a/vendor/grammers-client/src/client/auth.rs b/vendor/grammers-client/src/client/auth.rs new file mode 100644 index 0000000..d4d10ee --- /dev/null +++ b/vendor/grammers-client/src/client/auth.rs @@ -0,0 +1,497 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::fmt; + +use grammers_crypto::two_factor_auth::{calculate_2fa, check_p_and_g}; +use grammers_mtsender::InvocationError; +use grammers_session::types::{PeerInfo, UpdateState, UpdatesState}; +use grammers_tl_types as tl; + +use super::Client; +use crate::peer::User; +use crate::utils; + +/// The error type which is returned when signing in fails. +#[derive(Debug)] +#[allow(clippy::large_enum_variant)] +pub enum SignInError { + /// Sign-up with an official client is required. + /// + /// Third-party applications, such as those developed with *grammers*, + /// cannot be used to register new accounts. For more details, see this + /// [comment regarding third-party app sign-ups](https://bugs.telegram.org/c/25410/1): + /// > \[…] if a user doesn’t have a Telegram account yet, + /// > they will need to create one first using an official mobile Telegram app. + SignUpRequired, + /// The account has 2FA enabled, and the password is required. + PasswordRequired(PasswordToken), + /// The code used to complete login was not valid. + InvalidCode, + /// The 2FA password used to complete login was not valid. + InvalidPassword(PasswordToken), + /// A generic invocation error occured. + Other(InvocationError), +} + +impl fmt::Display for SignInError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use SignInError::*; + match self { + SignUpRequired => write!(f, "sign in error: sign up with official client required"), + PasswordRequired(_password) => write!(f, "2fa password required"), + InvalidCode => write!(f, "sign in error: invalid code"), + InvalidPassword(_password) => write!(f, "invalid password"), + Other(e) => write!(f, "sign in error: {e}"), + } + } +} + +impl std::error::Error for SignInError {} + +/// Login token needed to continue the login process after sending the code. +pub struct LoginToken { + pub(crate) phone: String, + pub(crate) phone_code_hash: String, +} + +/// Password token needed to complete a 2FA login. +#[derive(Debug)] +pub struct PasswordToken { + pub(crate) password: tl::types::account::Password, +} + +impl PasswordToken { + pub fn new(password: tl::types::account::Password) -> Self { + PasswordToken { password } + } + + pub fn hint(&self) -> Option<&str> { + self.password.hint.as_deref() + } +} + +/// Method implementations related with the authentication of the user into the API. +/// +/// Most requests to the API require the user to have authorized their key, stored in the session, +/// before being able to use them. +impl Client { + /// Returns `true` if the current account is authorized. Otherwise, + /// logging in will be required before being able to invoke requests. + /// + /// This will likely be the first method you want to call on a connected [`Client`]. After you + /// determine if the account is authorized or not, you will likely want to use either + /// [`Client::bot_sign_in`] or [`Client::request_login_code`]. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// if client.is_authorized().await? { + /// println!("Client already authorized and ready to use!"); + /// } else { + /// println!("Client is not authorized, you will need to sign_in!"); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn is_authorized(&self) -> Result { + match self.invoke(&tl::functions::updates::GetState {}).await { + Ok(_) => Ok(true), + Err(InvocationError::Rpc(e)) if e.code == 401 => Ok(false), + Err(err) => Err(err), + } + } + + async fn complete_login( + &self, + auth: tl::types::auth::Authorization, + ) -> Result { + // In the extremely rare case where `Err` happens, there's not much we can do. + // `message_box` will try to correct its state as updates arrive. + let update_state = self.invoke(&tl::functions::updates::GetState {}).await.ok(); + + let user = User::from_raw(self, auth.user); + let auth = user.to_ref().await?.unwrap().auth; + + self.0 + .session + .cache_peer(&PeerInfo::User { + id: user.id().bare_id_unchecked(), + auth: Some(auth), + bot: Some(user.is_bot()), + is_self: Some(true), + }) + .await?; + if let Some(tl::enums::updates::State::State(state)) = update_state { + self.0 + .session + .set_update_state(UpdateState::All(UpdatesState { + pts: state.pts, + qts: state.qts, + date: state.date, + seq: state.seq, + channels: Vec::new(), + })) + .await?; + } + + Ok(user) + } + + /// Signs in to the bot account associated with this token. + /// + /// This is the method you need to call to use the client under a bot account. + /// + /// It is recommended to save the session on successful login. Some session storages will do this + /// automatically. If saving fails, it is recommended to [`Client::sign_out`]. If the session is never + /// saved post-login, then the authorization will be "lost" in the list of logged-in clients, since it + /// is unaccessible. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// // Note: these values are obviously fake. + /// // Obtain your own with the developer's phone at https://my.telegram.org. + /// const API_HASH: &str = "514727c32270b9eb8cc16daf17e21e57"; + /// // Obtain your own by talking to @BotFather via a Telegram app. + /// const TOKEN: &str = "776609994:AAFXAy5-PawQlnYywUlZ_b_GOXgarR3ah_yq"; + /// + /// let user = match client.bot_sign_in(TOKEN, API_HASH).await { + /// Ok(user) => user, + /// Err(err) => { + /// println!("Failed to sign in as a bot :(\n{}", err); + /// return Err(err.into()); + /// } + /// }; + /// + /// if let Some(first_name) = user.first_name() { + /// println!("Signed in as {}!", first_name); + /// } else { + /// println!("Signed in!"); + /// } + /// + /// # Ok(()) + /// # } + /// ``` + pub async fn bot_sign_in(&self, token: &str, api_hash: &str) -> Result { + let request = tl::functions::auth::ImportBotAuthorization { + flags: 0, + api_id: self.0.api_id, + api_hash: api_hash.to_string(), + bot_auth_token: token.to_string(), + }; + + let result = match self.invoke(&request).await { + Ok(x) => x, + Err(InvocationError::Rpc(err)) if err.code == 303 => { + let old_dc_id = self.0.session.home_dc_id()?; + let new_dc_id = err.value.unwrap() as i32; + // Disconnect from current DC to cull the now-unused connection. + // This also gives a chance for the new home DC to export its authorization + // if there's a need to connect back to the old DC after having logged in. + self.0.handle.disconnect_from_dc(old_dc_id); + self.0.session.set_home_dc_id(new_dc_id).await?; + self.invoke(&request).await? + } + Err(e) => return Err(e.into()), + }; + + match result { + tl::enums::auth::Authorization::Authorization(x) => { + self.complete_login(x).await.map_err(Into::into) + } + tl::enums::auth::Authorization::SignUpRequired(_) => { + panic!("API returned SignUpRequired even though we're logging in as a bot"); + } + } + } + + /// Requests the login code for the account associated to the given phone + /// number via another Telegram application or SMS. + /// + /// This is the method you need to call before being able to sign in to a user account. + /// After you obtain the code and it's inside your program (e.g. ask the user to enter it + /// via the console's standard input), you will need to [`Client::sign_in`] to complete the + /// process. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// // Note: these values are obviously fake. + /// // Obtain your own with the developer's phone at https://my.telegram.org. + /// const API_HASH: &str = "514727c32270b9eb8cc16daf17e21e57"; + /// // The phone used here does NOT need to be the same as the one used by the developer + /// // to obtain the API ID and hash. + /// const PHONE: &str = "+1 415 555 0132"; + /// + /// if !client.is_authorized().await? { + /// // We're not logged in, so request the login code. + /// client.request_login_code(PHONE, API_HASH).await?; + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn request_login_code( + &self, + phone: &str, + api_hash: &str, + ) -> Result { + let request = tl::functions::auth::SendCode { + phone_number: phone.to_string(), + api_id: self.0.api_id, + api_hash: api_hash.to_string(), + settings: tl::types::CodeSettings { + allow_flashcall: false, + current_number: false, + allow_app_hash: false, + allow_missed_call: false, + allow_firebase: false, + logout_tokens: None, + token: None, + app_sandbox: None, + unknown_number: false, + } + .into(), + }; + + use tl::enums::auth::SentCode as SC; + + let sent_code: tl::types::auth::SentCode = match self.invoke(&request).await { + Ok(x) => match x { + SC::Code(code) => code, + SC::Success(_) => panic!("should not have logged in yet"), + SC::PaymentRequired(_) => unimplemented!(), + }, + Err(InvocationError::Rpc(err)) if err.code == 303 => { + let old_dc_id = self.0.session.home_dc_id()?; + let new_dc_id = err.value.unwrap() as i32; + // Disconnect from current DC to cull the now-unused connection. + // This also gives a chance for the new home DC to export its authorization + // if there's a need to connect back to the old DC after having logged in. + self.0.handle.disconnect_from_dc(old_dc_id); + self.0.session.set_home_dc_id(new_dc_id).await?; + match self.invoke(&request).await? { + SC::Code(code) => code, + SC::Success(_) => panic!("should not have logged in yet"), + SC::PaymentRequired(_) => unimplemented!(), + } + } + Err(e) => return Err(e.into()), + }; + + Ok(LoginToken { + phone: phone.to_string(), + phone_code_hash: sent_code.phone_code_hash, + }) + } + + /// Signs in to the user account. + /// + /// You must call [`Client::request_login_code`] before using this method in order to obtain + /// necessary login token, and also have asked the user for the login code. + /// + /// It is recommended to save the session on successful login. Some session storages will do this + /// automatically. If saving fails, it is recommended to [`Client::sign_out`]. If the session is never + /// saved post-login, then the authorization will be "lost" in the list of logged-in clients, since it + /// is unaccessible. + /// + /// # Examples + /// + /// ``` + /// # use grammers_client::SignInError; + /// + /// async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// # const API_HASH: &str = ""; + /// # const PHONE: &str = ""; + /// fn ask_code_to_user() -> String { + /// unimplemented!() + /// } + /// + /// let token = client.request_login_code(PHONE, API_HASH).await?; + /// let code = ask_code_to_user(); + /// + /// let user = match client.sign_in(&token, &code).await { + /// Ok(user) => user, + /// Err(SignInError::PasswordRequired(_token)) => panic!("Please provide a password"), + /// Err(SignInError::SignUpRequired) => panic!("Sign up required"), + /// Err(err) => { + /// println!("Failed to sign in as a user :(\n{}", err); + /// return Err(err.into()); + /// } + /// }; + /// + /// if let Some(first_name) = user.first_name() { + /// println!("Signed in as {}!", first_name); + /// } else { + /// println!("Signed in!"); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn sign_in(&self, token: &LoginToken, code: &str) -> Result { + match self + .invoke(&tl::functions::auth::SignIn { + phone_number: token.phone.clone(), + phone_code_hash: token.phone_code_hash.clone(), + phone_code: Some(code.to_string()), + email_verification: None, + }) + .await + { + Ok(tl::enums::auth::Authorization::Authorization(x)) => { + self.complete_login(x).await.map_err(SignInError::Other) + } + Ok(tl::enums::auth::Authorization::SignUpRequired(_)) => { + Err(SignInError::SignUpRequired) + } + Err(err) if err.is("SESSION_PASSWORD_NEEDED") => { + let password_token = self.get_password_information().await; + match password_token { + Ok(token) => Err(SignInError::PasswordRequired(token)), + Err(e) => Err(SignInError::Other(e)), + } + } + Err(err) if err.is("PHONE_CODE_*") => Err(SignInError::InvalidCode), + Err(error) => Err(SignInError::Other(error)), + } + } + + /// Extract information needed for the two-factor authentication + /// It's called automatically when we get SESSION_PASSWORD_NEEDED error during sign in. + async fn get_password_information(&self) -> Result { + let request = tl::functions::account::GetPassword {}; + + let password: tl::types::account::Password = self.invoke(&request).await?.into(); + + Ok(PasswordToken::new(password)) + } + + /// Sign in using two-factor authentication (user password). + /// + /// [`PasswordToken`] can be obtained from [`SignInError::PasswordRequired`] error after the + /// [`Client::sign_in`] method fails. + /// + /// # Examples + /// + /// ``` + /// use grammers_client::SignInError; + /// + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// # const API_HASH: &str = ""; + /// # const PHONE: &str = ""; + /// fn get_user_password(hint: &str) -> Vec { + /// unimplemented!() + /// } + /// + /// # let token = client.request_login_code(PHONE, API_HASH).await?; + /// # let code = ""; + /// + /// // ... enter phone number, request login code ... + /// + /// let user = match client.sign_in(&token, &code).await { + /// Err(SignInError::PasswordRequired(password_token) ) => { + /// let mut password = get_user_password(password_token.hint().unwrap()); + /// + /// client + /// .check_password(password_token, password) + /// .await.unwrap() + /// } + /// Ok(user) => user, + /// Ok(_) => panic!("Sign in required"), + /// Err(err) => { + /// panic!("Failed to sign in as a user :(\n{err}"); + /// } + /// }; + /// # Ok(()) + /// # } + /// ``` + pub async fn check_password( + &self, + password_token: PasswordToken, + password: impl AsRef<[u8]>, + ) -> Result { + let mut password_info = password_token.password; + let current_algo = password_info.current_algo.clone().unwrap(); + let mut params = utils::extract_password_parameters(¤t_algo); + + // Telegram sent us incorrect parameters, trying to get them again + if !check_p_and_g(params.2, params.3) { + password_info = self + .get_password_information() + .await + .map_err(SignInError::Other)? + .password; + params = + utils::extract_password_parameters(password_info.current_algo.as_ref().unwrap()); + if !check_p_and_g(params.2, params.3) { + panic!("Failed to get correct password information from Telegram") + } + } + + let (salt1, salt2, p, g) = params; + + let g_b = password_info.srp_b.clone().unwrap(); + let a = password_info.secure_random.clone(); + + let (m1, g_a) = calculate_2fa(salt1, salt2, p, g, g_b, a, password); + + let check_password = tl::functions::auth::CheckPassword { + password: tl::enums::InputCheckPasswordSrp::Srp(tl::types::InputCheckPasswordSrp { + srp_id: password_info.srp_id.clone().unwrap(), + a: g_a.to_vec(), + m1: m1.to_vec(), + }), + }; + + match self.invoke(&check_password).await { + Ok(tl::enums::auth::Authorization::Authorization(x)) => { + self.complete_login(x).await.map_err(SignInError::Other) + } + Ok(tl::enums::auth::Authorization::SignUpRequired(_x)) => panic!("Unexpected result"), + Err(err) if err.is("PASSWORD_HASH_INVALID") => { + Err(SignInError::InvalidPassword(PasswordToken { + password: password_info, + })) + } + Err(error) => Err(SignInError::Other(error)), + } + } + + /// Signs out of the account authorized by this client's session. + /// + /// If the client was not logged in, this method returns false. + /// + /// The client is not disconnected after signing out. + /// + /// Note that after using this method you will have to sign in again. If all you want to do + /// is disconnect, simply [`drop`] the [`Client`] instance. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// if client.sign_out().await.is_ok() { + /// println!("Signed out successfully!"); + /// } else { + /// println!("No user was signed in, so nothing has changed..."); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn sign_out(&self) -> Result { + self.invoke(&tl::functions::auth::LogOut {}).await + } + + /// Signals all clients sharing the same sender pool to disconnect. + pub fn disconnect(&self) { + self.0.handle.quit(); + } +} diff --git a/vendor/grammers-client/src/client/bots.rs b/vendor/grammers-client/src/client/bots.rs new file mode 100644 index 0000000..2f9bdc0 --- /dev/null +++ b/vendor/grammers-client/src/client/bots.rs @@ -0,0 +1,205 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use grammers_mtsender::InvocationError; +use grammers_session::types::PeerRef; +use grammers_tl_types as tl; + +use super::messages::parse_mention_entities; +use super::{Client, IterBuffer}; +use crate::message::InputMessage; +use crate::utils::generate_random_id; + +const MAX_LIMIT: usize = 50; + +/// Item returned by [`Client::inline_query`]. +pub struct InlineResult { + client: Client, + query_id: i64, + pub raw: tl::enums::BotInlineResult, +} + +/// Iterator returned by [`Client::inline_query`]. +pub type InlineResultIter = IterBuffer; + +impl InlineResult { + /// Send this inline result to the specified peer. + // TODO return the produced message + pub async fn send>(&self, peer: C) -> Result<(), InvocationError> { + self.client + .invoke(&tl::functions::messages::SendInlineBotResult { + silent: false, + background: false, + clear_draft: false, + hide_via: false, + peer: peer.into().into(), + reply_to: None, + random_id: generate_random_id(), + query_id: self.query_id, + id: self.id().to_string(), + schedule_date: None, + send_as: None, + quick_reply_shortcut: None, + allow_paid_stars: None, + }) + .await + .map(drop) + } + + /// The ID for this result. + pub fn id(&self) -> &str { + use tl::enums::BotInlineResult::*; + + match &self.raw { + Result(r) => &r.id, + BotInlineMediaResult(r) => &r.id, + } + } + + /// The title for this result, if any. + pub fn title(&self) -> Option<&String> { + use tl::enums::BotInlineResult::*; + + match &self.raw { + Result(r) => r.title.as_ref(), + BotInlineMediaResult(r) => r.title.as_ref(), + } + } +} + +impl InlineResultIter { + fn new(client: &Client, bot: PeerRef, query: &str) -> Self { + Self::from_request( + client, + MAX_LIMIT, + tl::functions::messages::GetInlineBotResults { + bot: bot.into(), + peer: tl::enums::InputPeer::Empty, + geo_point: None, + query: query.to_string(), + offset: String::new(), + }, + ) + } + + /// Indicate the bot the peer where this inline query will be sent to. + /// + /// Some bots use this information to return different results depending on the type of the + /// peer, and some even "need" it to give useful results. + pub fn peer>(mut self, peer: C) -> Self { + self.request.peer = peer.into().into(); + self + } + + /// Return the next `InlineResult` from the internal buffer, filling the buffer previously if + /// it's empty. + /// + /// Returns `None` if the `limit` is reached or there are no results left. + pub async fn next(&mut self) -> Result, InvocationError> { + if let Some(result) = self.next_raw() { + return result; + } + + let tl::enums::messages::BotResults::Results(tl::types::messages::BotResults { + query_id, + next_offset, + results, + .. + }) = self.client.invoke(&self.request).await?; + + if let Some(offset) = next_offset { + self.request.offset = offset; + } else { + self.last_chunk = true; + } + + let client = self.client.clone(); + self.buffer + .extend(results.into_iter().map(|r| InlineResult { + client: client.clone(), + query_id, + raw: r, + })); + + Ok(self.pop_item()) + } +} + +/// Method implementations related to dealing with bots. +impl Client { + /// Perform an inline query to the specified bot. + /// + /// The query text may be empty. + /// + /// Executing the query will fail if the input peer does not actually represent a bot account + /// supporting inline mode. + /// + /// # Examples + /// + /// ``` + /// # async fn f(bot: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// // This is equivalent to writing `@bot inline query` in a Telegram app. + /// let mut inline_results = client.inline_query(bot, "inline query"); + /// + /// while let Some(result) = inline_results.next().await? { + /// println!("{}", result.title().unwrap()); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn inline_query>(&self, bot: C, query: &str) -> InlineResultIter { + InlineResultIter::new(self, bot.into(), query) + } + + /// Edits an inline message sent by a bot. + /// + /// Similar to [`Client::send_message`], advanced formatting can be achieved with the + /// options offered by [`InputMessage`]. + pub(crate) async fn edit_inline_message( + &self, + message_id: tl::enums::InputBotInlineMessageId, + message: InputMessage, + ) -> Result { + let entities = parse_mention_entities(self, message.entities).await; + if message.media.as_ref().is_some_and(|media| { + !matches!( + media, + tl::enums::InputMedia::PhotoExternal(_) + | tl::enums::InputMedia::DocumentExternal(_), + ) + }) { + let dc_id = message_id.dc_id(); + self.invoke_in_dc( + dc_id, + &tl::functions::messages::EditInlineBotMessage { + id: message_id, + message: Some(message.text), + media: message.media, + entities, + no_webpage: !message.link_preview, + reply_markup: message.reply_markup, + invert_media: message.invert_media, + rich_message: None, + }, + ) + .await + } else { + self.invoke(&tl::functions::messages::EditInlineBotMessage { + id: message_id, + message: Some(message.text), + media: message.media, + entities, + no_webpage: !message.link_preview, + reply_markup: message.reply_markup, + invert_media: message.invert_media, + rich_message: None, + }) + .await + } + } +} diff --git a/vendor/grammers-client/src/client/chats.rs b/vendor/grammers-client/src/client/chats.rs new file mode 100644 index 0000000..cfc0368 --- /dev/null +++ b/vendor/grammers-client/src/client/chats.rs @@ -0,0 +1,1013 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Methods related to users, groups and channels. + +use std::collections::HashMap; +use std::collections::VecDeque; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +use grammers_mtsender::{InvocationError, RpcError}; +use grammers_session::types::{PeerId, PeerKind, PeerRef}; +use grammers_tl_types as tl; + +use super::{Client, IterBuffer}; +use crate::media::Photo; +use crate::message::Message; +use crate::peer::ActionSender; +use crate::peer::AdminRightsBuilder; +use crate::peer::BannedRightsBuilder; +use crate::peer::Participant; +use crate::peer::Peer; +use crate::peer::PeerMap; +use crate::peer::User; +use crate::peer::chats::AdminRightsBuilderInner; +use crate::peer::chats::BannedRightsBuilderInner; + +const MAX_PARTICIPANT_LIMIT: usize = 200; +const MAX_PHOTO_LIMIT: usize = 100; +const KICK_BAN_DURATION: i32 = 60; // in seconds, in case the second request fails + +enum ParticipantIterInner { + Empty, + Chat { + client: Client, + chat_id: i64, + buffer: VecDeque, + total: Option, + }, + Channel(IterBuffer), +} + +/// Iterator returned by [`Client::iter_participants`]. +pub struct ParticipantIter(ParticipantIterInner); + +impl ParticipantIter { + fn new(client: &Client, peer: PeerRef) -> Self { + Self(if peer.id.kind() == PeerKind::Channel { + ParticipantIterInner::Channel(IterBuffer::from_request( + client, + MAX_PARTICIPANT_LIMIT, + tl::functions::channels::GetParticipants { + channel: peer.into(), + filter: tl::enums::ChannelParticipantsFilter::ChannelParticipantsRecent, + offset: 0, + limit: 0, + hash: 0, + }, + )) + } else if peer.id.kind() == PeerKind::Chat { + ParticipantIterInner::Chat { + client: client.clone(), + chat_id: peer.into(), + buffer: VecDeque::new(), + total: None, + } + } else { + ParticipantIterInner::Empty + }) + } + + /// Determines how many participants there are in total. + /// + /// This only performs a network call if `next` has not been called before. + pub async fn total(&mut self) -> Result { + match &self.0 { + ParticipantIterInner::Empty => Ok(0), + ParticipantIterInner::Chat { total, .. } => { + if let Some(total) = total { + Ok(*total) + } else { + self.fill_buffer().await + } + } + ParticipantIterInner::Channel(iter) => { + if let Some(total) = iter.total { + Ok(total) + } else { + self.fill_buffer().await + } + } + } + } + + /// Fills the buffer, and returns the total count. + async fn fill_buffer(&mut self) -> Result { + match &mut self.0 { + ParticipantIterInner::Empty => Ok(0), + ParticipantIterInner::Chat { + client, + chat_id, + buffer, + total, + } => { + assert!(buffer.is_empty()); + let tl::enums::messages::ChatFull::Full(full) = client + .invoke(&tl::functions::messages::GetFullChat { chat_id: *chat_id }) + .await?; + + let chat = match full.full_chat { + tl::enums::ChatFull::Full(chat) => chat, + tl::enums::ChatFull::ChannelFull(_) => panic!( + "API returned ChannelFull even though messages::GetFullChat was used" + ), + }; + + // Don't actually care for the chats, just the users. + let mut peers = client.build_peer_map(full.users, Vec::new()).await; + + let participants = match chat.participants { + tl::enums::ChatParticipants::Forbidden(c) => { + if let Some(p) = c.self_participant { + buffer.push_back(Participant::from_raw_chat(&mut peers, p)); + } + return Ok(buffer.len()); + } + tl::enums::ChatParticipants::Participants(c) => c.participants, + }; + + buffer.extend( + participants + .into_iter() + .map(|p| Participant::from_raw_chat(&mut peers, p)), + ); + + *total = Some(buffer.len()); + Ok(buffer.len()) + } + ParticipantIterInner::Channel(iter) => { + assert!(iter.buffer.is_empty()); + use tl::enums::channels::ChannelParticipants::*; + + iter.request.limit = iter.determine_limit(MAX_PARTICIPANT_LIMIT); + let (count, participants, _, users) = + match iter.client.invoke(&iter.request).await? { + Participants(p) => (p.count, p.participants, p.chats, p.users), + NotModified => { + panic!("API returned Dialogs::NotModified even though hash = 0") + } + }; + + // Telegram can return less participants than asked for but the count being higher + // (for example, count=4825, participants=199, users=200). The missing participant + // was an admin bot account, not sure why it's not included. + // + // In any case we pick whichever size is highest to avoid weird cases like this. + iter.last_chunk = + usize::max(participants.len(), users.len()) < iter.request.limit as usize; + iter.request.offset += participants.len() as i32; + + // Don't actually care for the chats, just the users. + let mut peers = iter.client.build_peer_map(users, Vec::new()).await; + + iter.buffer.extend( + participants + .into_iter() + .map(|p| Participant::from_raw_channel(&mut peers, p)), + ); + + iter.total = Some(count as usize); + Ok(count as usize) + } + } + } + + /// Return the next `Participant` from the internal buffer, filling the buffer previously if + /// it's empty. + /// + /// Returns `None` if the `limit` is reached or there are no participants left. + pub async fn next(&mut self) -> Result, InvocationError> { + // Need to split the `match` because `fill_buffer()` borrows mutably. + match &mut self.0 { + ParticipantIterInner::Empty => {} + ParticipantIterInner::Chat { buffer, .. } => { + if buffer.is_empty() { + self.fill_buffer().await?; + } + } + ParticipantIterInner::Channel(iter) => { + if let Some(result) = iter.next_raw() { + return result; + } + self.fill_buffer().await?; + } + } + + match &mut self.0 { + ParticipantIterInner::Empty => Ok(None), + ParticipantIterInner::Chat { buffer, .. } => { + let result = buffer.pop_front(); + if buffer.is_empty() { + self.0 = ParticipantIterInner::Empty; + } + Ok(result) + } + ParticipantIterInner::Channel(iter) => Ok(iter.pop_item()), + } + } + + /// apply a filter on fetched participants, note that this filter will apply only on large `Channel` and not small groups + pub fn filter(mut self, filter: tl::enums::ChannelParticipantsFilter) -> Self { + match self.0 { + ParticipantIterInner::Channel(ref mut c) => { + c.request.filter = filter; + self + } + _ => self, + } + } +} + +enum ProfilePhotoIterInner { + User(IterBuffer), + Chat(IterBuffer), +} + +/// Iterator returned by [`Client::iter_profile_photos`]. +pub struct ProfilePhotoIter(ProfilePhotoIterInner); + +impl ProfilePhotoIter { + fn new(client: &Client, peer: PeerRef) -> Self { + Self(if matches!(peer.id.kind(), PeerKind::User) { + ProfilePhotoIterInner::User(IterBuffer::from_request( + client, + MAX_PHOTO_LIMIT, + tl::functions::photos::GetUserPhotos { + user_id: peer.into(), + offset: 0, + max_id: 0, + limit: 0, + }, + )) + } else { + ProfilePhotoIterInner::Chat( + client + .search_messages(peer) + .filter(tl::enums::MessagesFilter::InputMessagesFilterChatPhotos), + ) + }) + } + + /// Determines how many profile photos there are in total. + /// + /// This only performs a network call if `next` has not been called before. + pub async fn total(&mut self) -> Result { + match &mut self.0 { + ProfilePhotoIterInner::User(iter) => { + if let Some(total) = iter.total { + Ok(total) + } else { + self.fill_buffer().await + } + } + ProfilePhotoIterInner::Chat(iter) => iter.total().await, + } + } + + /// Fills the buffer, and returns the total count. + async fn fill_buffer(&mut self) -> Result { + match &mut self.0 { + ProfilePhotoIterInner::User(iter) => { + use tl::enums::photos::Photos; + + iter.request.limit = iter.determine_limit(MAX_PHOTO_LIMIT); + let (total, photos) = match iter.client.invoke(&iter.request).await? { + Photos::Photos(p) => { + iter.last_chunk = true; + iter.total = Some(p.photos.len()); + (p.photos.len(), p.photos) + } + Photos::Slice(p) => { + iter.last_chunk = p.photos.len() < iter.request.limit as usize; + iter.total = Some(p.count as usize); + (p.count as usize, p.photos) + } + }; + + // Don't bother updating offsets if this is the last time stuff has to be fetched. + if !iter.last_chunk && !photos.is_empty() { + iter.request.offset += photos.len() as i32; + } + + iter.buffer.extend(photos.into_iter().map(Photo::from_raw)); + + Ok(total) + } + ProfilePhotoIterInner::Chat(_) => { + panic!("fill_buffer should not be called for Chat variant") + } + } + } + + /// Return the next photo from the internal buffer, filling the buffer previously if it's + /// empty. + /// + /// Returns `None` if the `limit` is reached or there are no photos left. + pub async fn next(&mut self) -> Result, InvocationError> { + // Need to split the `match` because `fill_buffer()` borrows mutably. + match &mut self.0 { + ProfilePhotoIterInner::User(iter) => { + if let Some(result) = iter.next_raw() { + return result; + } + self.fill_buffer().await?; + } + ProfilePhotoIterInner::Chat(iter) => { + while let Some(message) = iter.next().await? { + if let Some(tl::enums::MessageAction::ChatEditPhoto( + tl::types::MessageActionChatEditPhoto { photo }, + )) = message.action() + { + return Ok(Some(Photo::from_raw(photo.clone()))); + } else { + continue; + } + } + } + } + + match &mut self.0 { + ProfilePhotoIterInner::User(iter) => Ok(iter.pop_item()), + ProfilePhotoIterInner::Chat(_) => Ok(None), + } + } +} + +fn updates_to_chat(client: &Client, id: Option, updates: tl::enums::Updates) -> Option { + use tl::enums::Updates; + + let chats = match updates { + Updates::Combined(updates) => Some(updates.chats), + Updates::Updates(updates) => Some(updates.chats), + _ => None, + }; + + match chats { + Some(chats) => match id { + Some(id) => chats.into_iter().find(|chat| chat.id() == id), + None => chats.into_iter().next(), + }, + None => None, + } + .map(|chat| Peer::from_raw(client, chat)) +} + +/// Method implementations related to dealing with peers. +impl Client { + /// Resolves a username into the peer that owns it, if any. + /// + /// Note that this method is expensive to call, and can quickly cause long flood waits. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// if let Some(peer) = client.resolve_username("username").await? { + /// println!("Found peer!: {:?}", peer.name()); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn resolve_username(&self, username: &str) -> Result, InvocationError> { + let tl::types::contacts::ResolvedPeer { peer, users, chats } = match self + .invoke(&tl::functions::contacts::ResolveUsername { + username: username.into(), + referer: None, + }) + .await + { + Ok(tl::enums::contacts::ResolvedPeer::Peer(p)) => p, + Err(err) if err.is("USERNAME_NOT_OCCUPIED") => return Ok(None), + Err(err) => return Err(err), + }; + + let mut peers = self.build_peer_map(users, chats).await; + let peer_id = PeerId::from(peer); + + Ok(peers.take(peer_id)) + } + + /// Fetch full information about the currently logged-in user. + /// + /// Although this method is cheap to call, you might want to cache the results somewhere. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// println!("Displaying full user information of the logged-in user:"); + /// dbg!(client.get_me().await?); + /// # Ok(()) + /// # } + /// ``` + pub async fn get_me(&self) -> Result { + let mut res = self + .invoke(&tl::functions::users::GetUsers { + id: vec![tl::enums::InputUser::UserSelf], + }) + .await?; + + if res.len() != 1 { + panic!("fetching only one user should exactly return one user"); + } + + Ok(User::from_raw(self, res.pop().unwrap())) + } + + /// Iterate over the participants of a chat. + /// + /// The participants are returned in no particular order. + /// + /// When used to iterate the participants of "user", the iterator won't produce values. + /// + /// # Examples + /// + /// ``` + /// # async fn f(chat: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let mut participants = client.iter_participants(chat); + /// + /// while let Some(participant) = participants.next().await? { + /// println!( + /// "{} has role {:?}", + /// participant.user.first_name().unwrap_or(&participant.user.id().to_string()), + /// participant.role + /// ); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn iter_participants>(&self, chat: C) -> ParticipantIter { + ParticipantIter::new(self, chat.into()) + } + + /// Kicks the participant from the chat. + /// + /// This will fail if you do not have sufficient permissions to perform said operation, + /// or the target user is the logged-in account. Use [`Self::delete_dialog`] for the latter instead. + /// + /// The kicked user will be able to join after being kicked (they are not permanently banned). + /// + /// Kicking someone who was not in the chat prior to running this method will be able to join + /// after as well (effectively unbanning them). + /// + /// When used to kick users from "user" chat, nothing will be done. + /// + /// # Examples + /// + /// ``` + /// # async fn f(chat: grammers_session::types::PeerRef, user: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// match client.kick_participant(chat, user).await { + /// Ok(_) => println!("user is no more >:D"), + /// Err(_) => println!("Kick failed! Are you sure you're admin?"), + /// }; + /// # Ok(()) + /// # } + /// ``` + pub async fn kick_participant, U: Into>( + &self, + chat: C, + user: U, + ) -> Result<(), InvocationError> { + let chat = chat.into(); + let user = user.into(); + if chat.id.kind() == PeerKind::Channel { + self.set_banned_rights(chat, user) + .view_messages(false) + .duration(Duration::from_secs(KICK_BAN_DURATION as u64)) + .await?; + + self.set_banned_rights(chat, user).await + } else if chat.id.kind() == PeerKind::Chat { + self.invoke(&tl::functions::messages::DeleteChatUser { + chat_id: chat.into(), + user_id: user.into(), + revoke_history: false, + }) + .await + .map(drop) + } else { + Ok(()) + } + } + + /// Returns a builder to set the banned rights for a specific user. + /// + /// Nothing is done until it is awaited, at which point it might result in + /// error if you do not have sufficient permissions to ban the user in the input chat. + /// + /// By default, the user has all rights, and you need to revoke those you want to take away + /// from the user by setting the permissions to `false`. This means that not taking away any + /// permissions will effectively unban someone, granting them all default user permissions. + /// + /// By default, the ban is applied forever, but this can be changed to a shorter duration. + /// + /// The default group rights are respected, despite individual restrictions. + /// + /// # Example + /// + /// ``` + /// # async fn f(chat: grammers_session::types::PeerRef, user: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// // This user keeps spamming pepe stickers, take the sticker permission away from them + /// let res = client + /// .set_banned_rights(chat, user) + /// .send_stickers(false) + /// .await; + /// + /// match res { + /// Ok(_) => println!("No more sticker spam!"), + /// Err(_) => println!("Ban failed! Are you sure you're admin?"), + /// }; + /// # Ok(()) + /// # } + /// ``` + pub fn set_banned_rights, U: Into>( + &self, + channel: C, + user: U, + ) -> BannedRightsBuilder>> { + BannedRightsBuilder::new( + self.clone(), + channel.into(), + user.into(), + BannedRightsBuilderInner::invoke, + ) + } + + /// Returns a builder to set the administrator rights for a specific user. + /// + /// Nothing is done until it is awaited, at which point + /// it might result in error if you do not have sufficient permissions to grant those rights + /// to the other user. + /// + /// By default, no permissions are granted, and you need to specify those you want to grant by + /// setting the permissions to `true`. This means that not granting any permission will turn + /// the user into a normal user again, and they will no longer be an administrator. + /// + /// The change is applied forever and there is no way to set a specific duration. If the user + /// should only be an administrator for a set period of time, the administrator permissions + /// must be manually revoked at a later point in time. + /// + /// # Example + /// + /// ``` + /// # async fn f(chat: grammers_session::types::PeerRef, user: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// // Let the user pin messages and ban other people + /// let res = client.set_admin_rights(chat, user) + /// .load_current() + /// .await? + /// .pin_messages(true) + /// .ban_users(true) + /// .await?; + /// # Ok(()) + /// # } + /// ``` + pub fn set_admin_rights, U: Into>( + &self, + channel: C, + user: U, + ) -> AdminRightsBuilder>> { + AdminRightsBuilder::new( + self.clone(), + channel.into(), + user.into(), + AdminRightsBuilderInner::invoke, + ) + } + + /// Iterate over the history of profile photos for the given peer. + /// + /// Note that the current photo might not be present in the history, and to avoid doing more + /// work when it's generally not needed (the photo history tends to be complete but in some + /// cases it might not be), it's up to you to fetch this photo from the full channel. + /// + /// Note that you cannot use these photos to send them as messages directly. They must be + /// downloaded first, then uploaded, and finally sent. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let mut photos = client.iter_profile_photos(peer); + /// + /// while let Some(photo) = photos.next().await? { + /// println!("Did you know peer has a photo with ID {}?", photo.id()); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn iter_profile_photos>(&self, peer: C) -> ProfilePhotoIter { + ProfilePhotoIter::new(self, peer.into()) + } + + /// Convert a [`PeerRef`] back into a [`Peer`]. + /// + /// # Example + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let peer = client.resolve_peer(peer).await?; + /// + /// println!("Found peer: {}", peer.name().unwrap_or(&peer.id().to_string())); + /// # Ok(()) + /// # } + /// ``` + pub async fn resolve_peer>(&self, peer: C) -> Result { + let peer = peer.into(); + let mut peers = match peer.id.kind() { + PeerKind::User => { + let res = self + .invoke(&tl::functions::users::GetUsers { + id: vec![peer.into()], + }) + .await?; + if res.len() > 1 { + panic!("fetching only one user should exactly return one user"); + } + self.build_peer_map(res, Vec::new()).await + } + PeerKind::Chat => { + let res = match self + .invoke(&tl::functions::messages::GetChats { + id: vec![peer.into()], + }) + .await? + { + tl::enums::messages::Chats::Chats(chats) => chats.chats, + tl::enums::messages::Chats::Slice(chat_slice) => chat_slice.chats, + }; + if res.len() > 1 { + panic!("fetching only one chat should exactly return one chat"); + } + self.build_peer_map(Vec::new(), res).await + } + PeerKind::Channel => { + let res = match self + .invoke(&tl::functions::channels::GetChannels { + id: vec![peer.into()], + }) + .await? + { + tl::enums::messages::Chats::Chats(chats) => chats.chats, + tl::enums::messages::Chats::Slice(chat_slice) => chat_slice.chats, + }; + if res.len() > 1 { + panic!("fetching only one channel should exactly return one chat"); + } + self.build_peer_map(Vec::new(), res).await + } + }; + peers.take(peer.id).ok_or(InvocationError::Dropped) + } + + /// Get permissions of participant `user` from chat `chat`. + /// + /// # Example + /// + /// ``` + /// # async fn f(chat: grammers_session::types::PeerRef, user: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let permissions = client.get_permissions(chat, user).await?; + /// println!("The user {} an admin", if permissions.is_admin() { "is" } else { "is not" }); + /// # Ok(()) + /// # } + /// ``` + pub async fn get_permissions, U: Into>( + &self, + chat: C, + user: U, + ) -> Result { + let chat = chat.into(); + let user = user.into(); + + // Get by chat + if chat.id.kind() == PeerKind::Chat { + // Get user id + let user = user.into(); + let user_id = match user { + tl::enums::InputUser::User(user) => user.user_id, + tl::enums::InputUser::FromMessage(user) => user.user_id, + tl::enums::InputUser::UserSelf => { + let me = self.get_me().await?; + me.id().bare_id_unchecked() + } + tl::enums::InputUser::Empty => return Err(InvocationError::Dropped), + }; + + // Get chat and find user + let chat = self + .invoke(&tl::functions::messages::GetFullChat { + chat_id: chat.into(), + }) + .await?; + let tl::enums::messages::ChatFull::Full(chat) = chat; + if let tl::enums::ChatFull::Full(chat) = chat.full_chat { + if let tl::enums::ChatParticipants::Participants(participants) = chat.participants { + for participant in participants.participants { + if participant.user_id() == user_id { + return Ok(ParticipantPermissions(ParticipantPermissionsInner::Chat( + participant, + ))); + } + } + } + } + return Err(InvocationError::Rpc(RpcError { + code: 400, + name: "USER_NOT_PARTICIPANT".to_string(), + value: None, + caused_by: None, + })); + } + + // Get by channel + let participant = self + .invoke(&tl::functions::channels::GetParticipant { + channel: chat.into(), + participant: user.into(), + }) + .await?; + let tl::enums::channels::ChannelParticipant::Participant(participant) = participant; + Ok(ParticipantPermissions( + ParticipantPermissionsInner::Channel(participant.participant), + )) + } + + #[cfg(feature = "parse_invite_link")] + pub fn parse_invite_link(invite_link: &str) -> Option { + let url_parse_result = url::Url::parse(invite_link); + if url_parse_result.is_err() { + return None; + } + + let url_parse = url_parse_result.unwrap(); + let scheme = url_parse.scheme(); + let path = url_parse.path(); + if url_parse.host_str().is_none() || !vec!["https", "http"].contains(&scheme) { + return None; + } + let host = url_parse.host_str().unwrap(); + let hosts = [ + "t.me", + "telegram.me", + "telegram.dog", + "tg.dev", + "telegram.me", + "telesco.pe", + ]; + + if !hosts.contains(&host) { + return None; + } + let paths = path.split("/").skip(1).collect::>(); + + if paths.len() == 1 { + if paths[0].starts_with("+") { + return Some(paths[0].replace("+", "")); + } + return None; + } + + if paths.len() > 1 { + if paths[0].starts_with("joinchat") { + return Some(paths[1].to_string()); + } + if paths[0].starts_with("+") { + return Some(paths[0].replace("+", "")); + } + return None; + } + + None + } + + /// Accept an invite link to join the corresponding private chat. + /// + /// If the chat is public (has a public username), [`Client::join_chat`](Client::join_chat) should be used instead. + #[cfg(feature = "parse_invite_link")] + pub async fn accept_invite_link( + &self, + invite_link: &str, + ) -> Result, InvocationError> { + match Self::parse_invite_link(invite_link) { + Some(hash) => Ok( + if let tl::enums::messages::ChatInviteJoinResult::Ok(ok) = self + .invoke(&tl::functions::messages::ImportChatInvite { hash }) + .await? + { + updates_to_chat(self, None, ok.updates) + } else { + None + }, + ), + None => Err(InvocationError::Rpc(RpcError { + code: 400, + name: "INVITE_HASH_INVALID".to_string(), + value: None, + caused_by: None, + })), + } + } + + #[cfg_attr( + not(feature = "parse_invite_link"), + allow(rustdoc::broken_intra_doc_links) + )] + /// Join a public group or channel. + /// + /// A channel is public if it has a username. + /// To join private chats, [`Client::accept_invite_link`](Client::accept_invite_link) should be used instead. + pub async fn join_chat>( + &self, + chat: C, + ) -> Result, InvocationError> { + let chat: PeerRef = chat.into(); + let channel = chat.into(); + Ok( + if let tl::enums::messages::ChatInviteJoinResult::Ok(ok) = self + .invoke(&tl::functions::channels::JoinChannel { channel }) + .await? + { + updates_to_chat(self, chat.id.bare_id(), ok.updates) + } else { + None + }, + ) + } + + /// Send a message action (such as typing, uploading photo, or viewing an emoji interaction) + /// + /// # Examples + /// + /// **Do a one-shot pulse and let it fade away** + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_tl_types::enums::SendMessageAction; + /// + /// client + /// .action(peer) + /// .oneshot(SendMessageAction::SendMessageTypingAction) + /// .await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// **Repeat request until the future is done** + /// ``` + /// # use std::time::Duration; + /// + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_tl_types as tl; + /// + /// let heavy_task = async { + /// tokio::time::sleep(Duration::from_secs(10)).await; + /// + /// 42 + /// }; + /// + /// tokio::pin!(heavy_task); + /// + /// let (task_result, _) = client + /// .action(peer) + /// .repeat( + /// // most clients doesn't actually show progress of an action + /// || tl::types::SendMessageUploadDocumentAction { progress: 0 }, + /// heavy_task + /// ) + /// .await; + /// + /// // Note: repeat function does not cancel actions automatically, they will just fade away + /// + /// assert_eq!(task_result, 42); + /// # Ok(()) + /// # } + /// ``` + /// + /// **Cancel any actions** + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// client.action(peer).cancel().await?; + /// # Ok(()) + /// # } + /// ``` + pub fn action>(&self, peer: C) -> ActionSender { + ActionSender::new(self, peer) + } + + pub(crate) async fn build_peer_map( + &self, + users: Vec, + chats: Vec, + ) -> PeerMap { + let map = users + .into_iter() + .map(|user| Peer::from_user(self, user)) + .chain(chats.into_iter().map(|chat| Peer::from_raw(self, chat))) + .map(|peer| (peer.id(), peer)) + .collect::>(); + + if self.0.configuration.auto_cache_peers { + for peer in map.values() { + if peer.auth().is_some() { + if let Err(e) = self.0.session.cache_peer(&peer.into()).await { + log::warn!("cache_peer fail: {:?}", e) + } + } + } + } + + PeerMap { + map: Arc::new(map), + session: Arc::clone(&self.0.session), + } + } + + pub(crate) fn empty_peer_map(&self) -> PeerMap { + PeerMap { + map: Arc::new(HashMap::new()), + session: Arc::clone(&self.0.session), + } + } +} + +#[derive(Debug, Clone)] +enum ParticipantPermissionsInner { + Channel(tl::enums::ChannelParticipant), + Chat(tl::enums::ChatParticipant), +} + +/// Permissions returned by [`Client::get_permissions`]. +#[derive(Debug, Clone)] +pub struct ParticipantPermissions(ParticipantPermissionsInner); + +impl ParticipantPermissions { + /// Whether the user is the creator of the chat or not. + pub fn is_creator(&self) -> bool { + matches!( + self.0, + ParticipantPermissionsInner::Channel(tl::enums::ChannelParticipant::Creator(_)) + | ParticipantPermissionsInner::Chat(tl::enums::ChatParticipant::Creator(_)) + ) + } + + /// Whether the user is an administrator of the chat or not. The creator also counts as begin an administrator, since they have all permissions. + pub fn is_admin(&self) -> bool { + self.is_creator() + || matches!( + self.0, + ParticipantPermissionsInner::Channel(tl::enums::ChannelParticipant::Admin(_)) + | ParticipantPermissionsInner::Chat(tl::enums::ChatParticipant::Admin(_)) + ) + } + + /// Whether the user is banned in the chat. + pub fn is_banned(&self) -> bool { + matches!( + self.0, + ParticipantPermissionsInner::Channel(tl::enums::ChannelParticipant::Banned(_)) + ) + } + + /// Whether the user left the chat. + pub fn has_left(&self) -> bool { + matches!( + self.0, + ParticipantPermissionsInner::Channel(tl::enums::ChannelParticipant::Left(_)) + ) + } + + /// Whether the user is a normal user of the chat (not administrator, but not banned either, and has no restrictions applied). + pub fn has_default_permissions(&self) -> bool { + matches!( + self.0, + ParticipantPermissionsInner::Channel(tl::enums::ChannelParticipant::Participant(_)) + | ParticipantPermissionsInner::Channel( + tl::enums::ChannelParticipant::ParticipantSelf(_) + ) + | ParticipantPermissionsInner::Chat(tl::enums::ChatParticipant::Participant(_)) + ) + } + + /// Whether the administrator can add new administrators with the same or less permissions than them. + pub fn can_add_admins(&self) -> bool { + if !self.is_admin() { + return false; + } + match &self.0 { + ParticipantPermissionsInner::Channel(tl::enums::ChannelParticipant::Admin( + participant, + )) => { + let tl::enums::ChatAdminRights::Rights(rights) = &participant.admin_rights; + rights.add_admins + } + ParticipantPermissionsInner::Channel(tl::enums::ChannelParticipant::Creator(_)) => true, + ParticipantPermissionsInner::Chat(_) => self.is_creator(), + _ => false, + } + } +} diff --git a/vendor/grammers-client/src/client/client.rs b/vendor/grammers-client/src/client/client.rs new file mode 100644 index 0000000..b16614b --- /dev/null +++ b/vendor/grammers-client/src/client/client.rs @@ -0,0 +1,105 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::sync::Arc; + +use grammers_mtsender::SenderPoolHandle; +use grammers_session::ErasedSession; + +pub(crate) struct ClientInner { + pub(crate) session: Arc, + pub(crate) api_id: i32, + pub(crate) handle: SenderPoolHandle, + pub(crate) configuration: ClientConfiguration, + pub(crate) auth_copied_to_dcs: tokio::sync::Mutex>, +} + +/// Wrapper around [`SenderPool`] to facilitate interaction with Telegram's API. +/// +/// This structure is the "entry point" of the library, from which you can start using the rest. +/// +/// This structure owns all the necessary connections to Telegram, and has implementations for the +/// most basic methods, such as connecting, signing in, or processing network events. +/// +/// On drop, all state is synchronized to the session. The [`Session`] must be explicitly saved +/// to disk with its corresponding save method for persistence. +/// +/// [`SenderPool`]: grammers_mtsender::SenderPool +/// [`Session`]: grammers_session::Session +#[derive(Clone)] +pub struct Client(pub(crate) Arc); + +use std::time::Duration; + +/// Configuration that controls the [`Client`] behaviour when making requests. +pub struct ClientConfiguration { + /// The retry policy to use when encountering errors after invoking a request. + pub retry_policy: Box, + + /// Whether to call [`Session::cache_peer`] on all peer information that + /// the high-level methods receive as a response (e.g. [`Client::iter_dialogs`]). + /// + /// The cached peers are then usable by other methods such as [`Client::resolve_peer`] + /// for as long as the same persisted session is used. + pub auto_cache_peers: bool, +} + +/// Configuration that controls [`Client::stream_updates`]. +pub struct UpdatesConfiguration { + /// Should the client catch-up on updates sent to it while it was offline? + /// + /// By default, updates sent while the client was offline are ignored. + pub catch_up: bool, + + /// How many updates may be buffered by the client at any given time. + /// + /// Telegram passively sends updates to the client through the open connection, so they must + /// be buffered until the application has the capacity to consume them. + /// + /// Upon reaching this limit, updates will be dropped, and a warning log message will be + /// emitted (but not too often, to avoid spamming the log), in order to let the developer + /// know that they should either change how they handle updates or increase the limit. + /// + /// A limit of zero (`Some(0)`) indicates that updates should not be buffered. + /// They will be immediately dropped, and no warning will ever be emitted. + /// + /// A limit of `None` disables the upper bound for the buffer. This is not recommended, as it + /// could eventually lead to memory exhaustion. This option will also not emit any warnings. + /// + /// The default limit, which may change at any time, should be enough for user accounts, + /// although bot accounts may need to increase the limit depending on their capacity. + /// + /// When the limit is `Some`, a buffer to hold that many updates will be pre-allocated. + pub update_queue_limit: Option, +} + +impl Default for ClientConfiguration { + /// Returns an instance that with an [`AutoSleep::default`] retry policy, + /// where encountered peers are automatically passed to [`Session::cache_peer`]. + /// + /// [`AutoSleep::default`]: super::AutoSleep::default + fn default() -> Self { + Self { + retry_policy: Box::new(super::AutoSleep { + threshold: Duration::from_secs(60), + io_errors_as_flood_of: Some(Duration::from_secs(1)), + }), + auto_cache_peers: true, + } + } +} + +impl Default for UpdatesConfiguration { + /// Returns an instance that will not catch up, with a queue limit of 100 updates. + fn default() -> Self { + Self { + catch_up: false, + update_queue_limit: Some(100), + } + } +} diff --git a/vendor/grammers-client/src/client/dialogs.rs b/vendor/grammers-client/src/client/dialogs.rs new file mode 100644 index 0000000..dbc6df6 --- /dev/null +++ b/vendor/grammers-client/src/client/dialogs.rs @@ -0,0 +1,263 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use grammers_mtsender::InvocationError; +use grammers_session::types::{PeerKind, PeerRef, UpdateState}; +use grammers_tl_types as tl; + +use super::{Client, IterBuffer}; +use crate::peer::Dialog; + +const MAX_LIMIT: usize = 100; + +/// Iterator returned by [`Client::iter_dialogs`]. +pub type DialogIter = IterBuffer; + +impl DialogIter { + fn new(client: &Client) -> Self { + // TODO let users tweak all the options from the request + Self::from_request( + client, + MAX_LIMIT, + tl::functions::messages::GetDialogs { + exclude_pinned: false, + folder_id: None, + offset_date: 0, + offset_id: 0, + offset_peer: tl::enums::InputPeer::Empty, + limit: 0, + hash: 0, + }, + ) + } + + /// Determines how many dialogs there are in total. + /// + /// This only performs a network call if `next` has not been called before. + pub async fn total(&mut self) -> Result { + if let Some(total) = self.total { + return Ok(total); + } + + use tl::enums::messages::Dialogs; + + self.request.limit = 1; + let total = match self.client.invoke(&self.request).await? { + Dialogs::Dialogs(dialogs) => dialogs.dialogs.len(), + Dialogs::Slice(dialogs) => dialogs.count as usize, + Dialogs::NotModified(dialogs) => dialogs.count as usize, + }; + self.total = Some(total); + Ok(total) + } + + /// Return the next `Dialog` from the internal buffer, filling the buffer previously if it's + /// empty. + /// + /// Returns `None` if the `limit` is reached or there are no dialogs left. + pub async fn next(&mut self) -> Result, InvocationError> { + if let Some(result) = self.next_raw() { + return result; + } + + use tl::enums::messages::Dialogs; + + self.request.limit = self.determine_limit(MAX_LIMIT); + let (dialogs, mut messages, users, chats) = match self.client.invoke(&self.request).await? { + Dialogs::Dialogs(d) => { + self.last_chunk = true; + self.total = Some(d.dialogs.len()); + (d.dialogs, d.messages, d.users, d.chats) + } + Dialogs::Slice(d) => { + self.last_chunk = d.dialogs.len() < self.request.limit as usize; + self.total = Some(d.count as usize); + (d.dialogs, d.messages, d.users, d.chats) + } + Dialogs::NotModified(_) => { + panic!("API returned Dialogs::NotModified even though hash = 0") + } + }; + + let peers = self.client.build_peer_map(users, chats).await; + + for dialog in dialogs.iter() { + if let tl::enums::Dialog::Dialog(tl::types::Dialog { + peer: tl::enums::Peer::Channel(channel), + pts: Some(pts), + .. + }) = &dialog + { + self.client + .0 + .session + .set_update_state(UpdateState::Channel { + id: channel.channel_id, + pts: *pts, + }) + .await?; + } + } + + self.buffer.extend( + dialogs + .into_iter() + .map(|dialog| Dialog::new(&self.client, dialog, &mut messages, peers.handle())), + ); + + // Don't bother updating offsets if this is the last time stuff has to be fetched. + if !self.last_chunk && !self.buffer.is_empty() { + self.request.exclude_pinned = true; + if let Some(last_message) = self + .buffer + .iter() + .rev() + .find_map(|dialog| dialog.last_message.as_ref()) + { + self.request.offset_date = last_message.date_timestamp(); + self.request.offset_id = last_message.id(); + } + self.request.offset_peer = self.buffer[self.buffer.len() - 1].peer_ref().into(); + } + + Ok(self.pop_item()) + } +} + +/// Method implementations related to open conversations. +impl Client { + /// Returns a new iterator over the dialogs. + /// + /// While iterating, the update state for any broadcast channel or megagroup will be set if it was unknown before. + /// When the update state is set for these peers, the library can actively check to make sure it's not missing any + /// updates from them (as long as the queue limit for updates is larger than zero). + /// + /// Bot accounts will receive an error response when using this method, as they do not have dialogs. + /// The closest a bot account can get to listing the peers it has interacted with is by querying the + /// session cache, but that approach can be misleading, as it often contains both additional peers, + /// and peers that have since blocked the bot ("deleted the dialog" with it). + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// let mut dialogs = client.iter_dialogs(); + /// + /// while let Some(dialog) = dialogs.next().await? { + /// let peer = dialog.peer(); + /// println!("{} ({})", peer.name().unwrap_or_default(), peer.id()); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn iter_dialogs(&self) -> DialogIter { + DialogIter::new(self) + } + + /// Deletes a dialog, effectively removing it from your list of open conversations. + /// + /// The dialog is only deleted for yourself. + /// + /// Deleting a dialog effectively clears the message history and "kicks" you from it. + /// + /// For groups and channels, this is the same as leaving said chat. This method does **not** + /// delete the chat itself (the chat still exists and the other members will remain inside). + /// + /// Bot accounts can use this method to leave a channel or group, but attempting + /// to leave the dialog with a user will fail, as bots do not actually have dialogs. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// // Consider making a backup before, you will lose access to the messages in peer! + /// client.delete_dialog(peer).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn delete_dialog>(&self, peer: C) -> Result<(), InvocationError> { + let peer = peer.into(); + if peer.id.kind() == PeerKind::Channel { + self.invoke(&tl::functions::channels::LeaveChannel { + channel: peer.into(), + }) + .await + .map(drop) + } else if peer.id.kind() == PeerKind::Chat { + self.invoke(&tl::functions::messages::DeleteChatUser { + chat_id: peer.into(), + user_id: tl::enums::InputUser::UserSelf, + revoke_history: false, + }) + .await + .map(drop) + } else { + self.invoke(&tl::functions::messages::DeleteHistory { + just_clear: false, + revoke: false, + peer: peer.into(), + max_id: 0, + min_date: None, + max_date: None, + }) + .await + .map(drop) + } + } + + /// Mark a peer as read. + /// + /// If you want to get rid of all the mentions (for example, a voice note that you have not + /// listened to yet), you need to also use [`Client::clear_mentions`]. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// client.mark_as_read(peer).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn mark_as_read>(&self, peer: C) -> Result<(), InvocationError> { + let peer = peer.into(); + if peer.id.kind() == PeerKind::Channel { + self.invoke(&tl::functions::channels::ReadHistory { + channel: peer.into(), + max_id: 0, + }) + .await + .map(drop) + } else { + self.invoke(&tl::functions::messages::ReadHistory { + peer: peer.into(), + max_id: 0, + }) + .await + .map(drop) + } + } + + /// Clears all pending mentions from a peer, marking them as read. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// client.clear_mentions(peer).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn clear_mentions>(&self, peer: C) -> Result<(), InvocationError> { + self.invoke(&tl::functions::messages::ReadMentions { + peer: peer.into().into(), + top_msg_id: None, + }) + .await + .map(drop) + } +} diff --git a/vendor/grammers-client/src/client/files.rs b/vendor/grammers-client/src/client/files.rs new file mode 100644 index 0000000..f076b65 --- /dev/null +++ b/vendor/grammers-client/src/client/files.rs @@ -0,0 +1,578 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::mem; +use std::sync::Arc; +#[cfg(feature = "fs")] +use std::{io::SeekFrom, path::Path}; + +use futures_util::stream::{FuturesUnordered, StreamExt as _}; +use grammers_mtsender::InvocationError; +use grammers_tl_types as tl; +use tokio::io::{self, AsyncRead, AsyncReadExt}; +use tokio::sync::Mutex as AsyncMutex; +#[cfg(feature = "fs")] +use tokio::{ + fs, + io::{AsyncSeekExt, AsyncWriteExt}, + sync::mpsc::unbounded_channel, +}; + +use super::Client; +use crate::media::{Downloadable, Uploaded}; +use crate::utils::generate_random_id; + +pub const MIN_CHUNK_SIZE: i32 = 4 * 1024; +pub const MAX_CHUNK_SIZE: i32 = 512 * 1024; +const FILE_MIGRATE_ERROR: i32 = 303; +const BIG_FILE_SIZE: usize = 10 * 1024 * 1024; +const WORKER_COUNT: usize = 4; + +/// Iterator returned by [`Client::iter_download`]. +pub struct DownloadIter { + client: Client, + done: bool, + size: Option, + variant: DownloadIterVariant, +} + +enum DownloadIterVariant { + Request(tl::functions::upload::GetFile), + PreDownloaded(Vec), + PreFailed(io::Error), + Empty, +} + +impl DownloadIter { + /// Changes the chunk size, in bytes, used to make requests. Useful if you only need to get a + /// small part of a file. By default, `MAX_CHUNK_SIZE` is used. + /// + /// # Panics + /// + /// Panics if `size` is not divisible by `MIN_CHUNK_SIZE`, or if `size` is not in contained in + /// the range `MIN_CHUNK_SIZE..=MAX_CHUNK_SIZE`. + pub fn chunk_size(mut self, size: i32) -> Self { + assert!((MIN_CHUNK_SIZE..=MAX_CHUNK_SIZE).contains(&size) && size % MIN_CHUNK_SIZE == 0); + match &mut self.variant { + DownloadIterVariant::Request(request) => request.limit = size, + _ => {} + } + self + } + + /// Skips `n` chunks to start downloading a different offset from the file. If you want to + /// skip less data, modify the `chunk_size` before calling this method, and then reset it to + /// any value you want. + pub fn skip_chunks(mut self, n: i32) -> Self { + match &mut self.variant { + DownloadIterVariant::Request(request) => { + request.offset += request.limit as i64 * (n as i64) + } + _ => {} + } + self + } + + /// Fetch and return the next chunk. + pub async fn next(&mut self) -> Result>, InvocationError> { + if self.done { + return Ok(None); + } + + let variant = mem::replace(&mut self.variant, DownloadIterVariant::Empty); + + let mut request = match variant { + DownloadIterVariant::Request(r) => r, + DownloadIterVariant::PreDownloaded(data) => { + self.done = true; + return Ok(Some(data.clone())); + } + DownloadIterVariant::PreFailed(error) => { + return Err(InvocationError::Io(error)); + } + DownloadIterVariant::Empty => return Ok(None), + }; + + use tl::enums::upload::File; + + // TODO handle maybe FILEREF_UPGRADE_NEEDED + let mut dc = self.client.0.session.home_dc_id()?; + loop { + break match self.client.invoke_in_dc(dc, &request).await { + Ok(File::File(f)) => { + let reached_known_size = self + .size + .is_some_and(|size| request.offset as usize + f.bytes.len() >= size); + + if reached_known_size || f.bytes.len() < request.limit as usize { + self.done = true; + if f.bytes.is_empty() { + break Ok(None); + } + } + + request.offset += request.limit as i64; + self.variant = DownloadIterVariant::Request(request); + + Ok(Some(f.bytes)) + } + Ok(File::CdnRedirect(_)) => { + panic!("API returned File::CdnRedirect even though cdn_supported = false"); + } + Err(InvocationError::Rpc(err)) if &err.name == "AUTH_KEY_UNREGISTERED" => { + match self.client.copy_auth_to_dc(dc).await { + Ok(_) => continue, + Err(e) => Err(e), + } + } + Err(InvocationError::Rpc(err)) if err.code == FILE_MIGRATE_ERROR => { + dc = err.value.unwrap() as _; + continue; + } + Err(e) => Err(e), + }; + } + } +} + +/// Method implementations related to uploading or downloading files. +impl Client { + /// Returns a new iterator over the contents of a media document that will be downloaded. + /// + /// # Examples + /// + /// ``` + /// # async fn f(downloadable: grammers_client::media::Media, client: grammers_client::Client) -> Result<(), Box> { + /// let mut file_bytes = Vec::new(); + /// let mut download = client.iter_download(&downloadable); + /// + /// while let Some(chunk) = download.next().await? { + /// file_bytes.extend(chunk); + /// } + /// + /// // The file is now downloaded in-memory, inside `file_bytes`! + /// # Ok(()) + /// # } + /// ``` + pub fn iter_download(&self, downloadable: &D) -> DownloadIter { + if let Some(data) = downloadable.to_data() { + DownloadIter { + client: self.clone(), + done: false, + size: Some(data.len()), + variant: DownloadIterVariant::PreDownloaded(data), + } + } else if let Some(location) = downloadable.to_raw_input_location() { + DownloadIter { + client: self.clone(), + done: false, + size: downloadable.size(), + variant: DownloadIterVariant::Request(tl::functions::upload::GetFile { + precise: false, + cdn_supported: false, + location, + offset: 0, + limit: MAX_CHUNK_SIZE, + }), + } + } else { + DownloadIter { + client: self.clone(), + done: false, + size: None, + variant: DownloadIterVariant::PreFailed(io::Error::new( + io::ErrorKind::Other, + "media not downloadable", + )), + } + } + } + + /// Downloads a media file into the specified path. + /// + /// If the file already exists, it will be overwritten. + /// + /// This is a small wrapper around [`Client::iter_download`] for the common case of + /// wanting to save the file locally. + /// + /// # Examples + /// + /// ``` + /// # async fn f(downloadable: grammers_client::media::Media, client: grammers_client::Client) -> Result<(), Box> { + /// client.download_media(&downloadable, "/home/username/photos/holidays.jpg").await?; + /// # Ok(()) + /// # } + /// ``` + #[cfg(feature = "fs")] + pub async fn download_media>( + &self, + downloadable: &D, + path: P, + ) -> Result<(), InvocationError> { + // Concurrent downloader + if let Some((location, size)) = downloadable + .to_raw_input_location() + .zip(downloadable.size()) + { + if size > BIG_FILE_SIZE { + return self + .download_media_concurrent(location, size, path, WORKER_COUNT) + .await; + } + } + + let mut download = self.iter_download(downloadable); + Ok(Client::load(path, &mut download).await?) + } + + #[cfg(feature = "fs")] + async fn load>(path: P, download: &mut DownloadIter) -> Result<(), io::Error> { + let mut file = fs::File::create(path).await?; + while let Some(chunk) = download.next().await.map_err(io::Error::other)? { + file.write_all(&chunk).await?; + } + + Ok(()) + } + + /// Downloads a `Document` to specified path using multiple connections + #[cfg(feature = "fs")] + async fn download_media_concurrent>( + &self, + location: tl::enums::InputFileLocation, + size: usize, + path: P, + workers: usize, + ) -> Result<(), InvocationError> { + // Allocate + let mut file = fs::File::create(path).await.map_err(InvocationError::Io)?; + file.set_len(size as u64) + .await + .map_err(InvocationError::Io)?; + file.seek(SeekFrom::Start(0)) + .await + .map_err(InvocationError::Io)?; + + // Start workers + let (tx, mut rx) = unbounded_channel(); + let part_index = Arc::new(tokio::sync::Mutex::::new(0)); + let mut tasks = vec![]; + let home_dc_id = self.0.session.home_dc_id()?; + for _ in 0..workers { + let location = location.clone(); + let tx = tx.clone(); + let part_index = part_index.clone(); + let client = self.clone(); + let task = tokio::task::spawn(async move { + let mut retry_offset = None; + let mut dc = home_dc_id; + loop { + // Calculate file offset + let offset: i64 = { + if let Some(offset) = retry_offset { + retry_offset = None; + offset + } else { + let mut i = part_index.lock().await; + *i += 1; + MAX_CHUNK_SIZE as i64 * (*i - 1) + } + }; + if offset >= size as i64 { + break; + } + // Fetch from telegram + let request = &tl::functions::upload::GetFile { + precise: true, + cdn_supported: false, + location: location.clone(), + offset, + limit: MAX_CHUNK_SIZE, + }; + match client.invoke_in_dc(dc, request).await { + Ok(tl::enums::upload::File::File(file)) => { + tx.send((offset as u64, file.bytes)).unwrap(); + } + Ok(tl::enums::upload::File::CdnRedirect(_)) => { + panic!( + "API returned File::CdnRedirect even though cdn_supported = false" + ); + } + Err(InvocationError::Rpc(err)) if &err.name == "AUTH_KEY_UNREGISTERED" => { + match client.copy_auth_to_dc(dc).await { + Ok(_) => { + retry_offset = Some(offset); + continue; + } + Err(e) => return Err(e), + } + } + Err(InvocationError::Rpc(err)) => { + if err.code == FILE_MIGRATE_ERROR { + dc = err.value.unwrap() as _; + retry_offset = Some(offset); + continue; + } + return Err(InvocationError::Rpc(err)); + } + Err(e) => return Err(e), + } + } + Ok::<(), InvocationError>(()) + }); + tasks.push(task); + } + drop(tx); + + // File write loop + let mut pos = 0; + while let Some((offset, data)) = rx.recv().await { + if offset != pos { + file.seek(SeekFrom::Start(offset)).await?; + } + file.write_all(&data).await?; + pos = offset + data.len() as u64; + } + + // Check if all tasks finished succesfully + for task in tasks { + let res = task.await.map_err(io::Error::other)?; + res?; + } + Ok(()) + } + + /// Uploads an async stream to Telegram servers. + /// + /// The file is not sent to any peer, but can be used as media when sending messages for a + /// certain period of time (less than a day). You can use this uploaded file multiple times. + /// + /// Refer to [`InputMessage`] to learn more uses for `uploaded_file`. + /// + /// The stream size must be known beforehand. If this is not possible, you might need to + /// process the entire async stream to determine its size, and then use the size and the + /// downloaded buffer. + /// + /// The stream size may be less or equal to the actual length of the stream, but not more. + /// If it's less, you may continue to read from the stream after the method returns. + /// If it's more, the method will fail because it does not have enough data to read. + /// + /// Note that Telegram uses the file name in certain methods, for example, to make sure the + /// file is an image when trying to use send the file as photo media, so it is important that + /// the file name at least uses the right extension, even if the name is a dummy value. + /// If the input file name is empty, the non-empty dummy value "a" will be used instead. + /// Because it has no extension, you may not be able to use the file in certain methods. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client, some_vec: &[u8]) -> Result<(), Box> { + /// use grammers_client::message::InputMessage; + /// + /// // In-memory `Vec` buffers can be used as async streams + /// let size = some_vec.len(); + /// let mut stream = std::io::Cursor::new(some_vec); + /// let uploaded_file = client.upload_stream(&mut stream, size, "sleep.jpg".to_string()).await?; + /// + /// client.send_message(peer, InputMessage::new().text("Zzz...").photo(uploaded_file)).await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// [`InputMessage`]: crate::message::InputMessage + pub async fn upload_stream( + &self, + stream: &mut S, + size: usize, + name: String, + ) -> Result { + let file_id = generate_random_id(); + let name = if name.is_empty() { + "a".to_string() + } else { + name + }; + + let big_file = size > BIG_FILE_SIZE; + let parts = PartStream::new(stream, size); + let total_parts = parts.total_parts(); + + if big_file { + let parts = Arc::new(parts); + let mut tasks = FuturesUnordered::new(); + for _ in 0..WORKER_COUNT { + let handle = self.clone(); + let parts = Arc::clone(&parts); + let task = async move { + while let Some((part, bytes)) = parts.next_part().await? { + let ok = handle + .invoke(&tl::functions::upload::SaveBigFilePart { + file_id, + file_part: part, + file_total_parts: total_parts, + bytes, + }) + .await + .map_err(io::Error::other)?; + + if !ok { + return Err(io::Error::new( + io::ErrorKind::Other, + "server failed to store uploaded data", + )); + } + } + Ok(()) + }; + tasks.push(task); + } + + while let Some(res) = tasks.next().await { + res?; + } + + Ok(Uploaded::from_raw( + tl::types::InputFileBig { + id: file_id, + parts: total_parts, + name, + } + .into(), + )) + } else { + let mut md5 = md5::Context::new(); + while let Some((part, bytes)) = parts.next_part().await? { + md5.consume(&bytes); + let ok = self + .invoke(&tl::functions::upload::SaveFilePart { + file_id, + file_part: part, + bytes, + }) + .await + .map_err(io::Error::other)?; + + if !ok { + return Err(io::Error::new( + io::ErrorKind::Other, + "server failed to store uploaded data", + )); + } + } + Ok(Uploaded::from_raw( + tl::types::InputFile { + id: file_id, + parts: total_parts, + name, + md5_checksum: format!("{:x}", md5.finalize()), + } + .into(), + )) + } + } + + /// Uploads a local file to Telegram servers. + /// + /// The file is not sent to any peer, but can be used as media when sending messages for a + /// certain period of time (less than a day). You can use this uploaded file multiple times. + /// + /// Refer to [`InputMessage`] to learn more uses for `uploaded_file`. + /// + /// If you need more control over the uploaded data, such as performing only a partial upload + /// or with a different name, use [`Client::upload_stream`] instead. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_client::message::InputMessage; + /// + /// let uploaded_file = client.upload_file("/home/username/photos/holidays.jpg").await?; + /// + /// client.send_message(peer, InputMessage::new().text("Check this out!").photo(uploaded_file)).await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// [`InputMessage`]: crate::message::InputMessage + #[cfg(feature = "fs")] + pub async fn upload_file>(&self, path: P) -> Result { + let path = path.as_ref(); + + let mut file = fs::File::open(path).await?; + let size = file.seek(SeekFrom::End(0)).await? as usize; + file.seek(SeekFrom::Start(0)).await?; + + // File name will only be `None` for `..` path, and directories cannot be uploaded as + // files, so it's fine to unwrap. + let name = path.file_name().unwrap().to_string_lossy().to_string(); + + self.upload_stream(&mut file, size, name).await + } +} + +struct PartStreamInner<'a, S: AsyncRead + Unpin> { + stream: &'a mut S, + current_part: i32, +} + +struct PartStream<'a, S: AsyncRead + Unpin> { + inner: AsyncMutex>, + total_parts: i32, +} + +impl<'a, S: AsyncRead + Unpin> PartStream<'a, S> { + fn new(stream: &'a mut S, size: usize) -> Self { + let total_parts = ((size + MAX_CHUNK_SIZE as usize - 1) / MAX_CHUNK_SIZE as usize) as i32; + Self { + inner: AsyncMutex::new(PartStreamInner { + stream, + current_part: 0, + }), + total_parts, + } + } + + fn total_parts(&self) -> i32 { + self.total_parts + } + + async fn next_part(&self) -> Result)>, io::Error> { + let mut lock = self.inner.lock().await; + if lock.current_part >= self.total_parts { + return Ok(None); + } + let mut read = 0; + let mut buffer = vec![0; MAX_CHUNK_SIZE as usize]; + + while read != buffer.len() { + let n = lock.stream.read(&mut buffer[read..]).await?; + if n == 0 { + if lock.current_part == self.total_parts - 1 { + break; + } else { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "reached EOF before reaching the last file part", + )); + } + } + read += n; + } + + let bytes = if read == buffer.len() { + buffer + } else { + buffer[..read].to_vec() + }; + + let res = Ok(Some((lock.current_part, bytes))); + lock.current_part += 1; + res + } +} diff --git a/vendor/grammers-client/src/client/iter_buffer.rs b/vendor/grammers-client/src/client/iter_buffer.rs new file mode 100755 index 0000000..d729d71 --- /dev/null +++ b/vendor/grammers-client/src/client/iter_buffer.rs @@ -0,0 +1,95 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::collections::VecDeque; + +use grammers_mtsender::InvocationError; + +use super::Client; + +/// Common parts to most requests that are used for creating iterators. +/// +/// Client methods will either return a type alias or a wrapper around this. +pub struct IterBuffer { + pub(crate) client: Client, + pub(crate) limit: Option, + pub(crate) fetched: usize, + pub(crate) buffer: VecDeque, + pub(crate) last_chunk: bool, + pub(crate) total: Option, + pub(crate) request: R, +} + +impl IterBuffer { + /// Create a new `IterBuffer` instance from a handle, capacity and request. + pub(crate) fn from_request(client: &Client, capacity: usize, request: R) -> Self { + Self { + client: client.clone(), + limit: None, + fetched: 0, + buffer: VecDeque::with_capacity(capacity), + last_chunk: false, + total: None, + request, + } + } + + /// Change how many items will be returned from the iterator. + /// + /// Using `limit` instead of `take` on the iterator is useful because outgoing requests can + /// ask for less items from the server to only fetch what's needed. + pub fn limit(mut self, n: usize) -> Self { + self.limit = Some(n); + self + } + + /// Checks whether the limit has been reached and no more items should be fetched. + fn limit_reached(&self) -> bool { + if let Some(limit) = self.limit { + self.fetched >= limit + } else { + false + } + } + + /// Return the next result item from the buffer unless more data needs to be fetched. + /// + /// Data does not need to be fetched if the limit is reached or the buffer is empty and the + /// last chunk was reached. + pub(crate) fn next_raw(&mut self) -> Option, InvocationError>> { + if self.limit_reached() || (self.buffer.is_empty() && self.last_chunk) { + Some(Ok(None)) + } else { + self.pop_item().map(|item| Ok(Some(item))) + } + } + + /// Determines the new "limit" for the request, so that no unnecessary items are fetched from + /// the network. + pub(crate) fn determine_limit(&self, max: usize) -> i32 { + if let Some(limit) = self.limit { + if self.fetched < limit { + (limit - self.fetched).min(max) as i32 + } else { + 1 // 0 would cause Telegram to send a default amount and not actually 0 + } + } else { + max as i32 + } + } + + /// Pop a buffered item from the queue, and increment the amount of items fetched (returned). + pub(crate) fn pop_item(&mut self) -> Option { + if let Some(item) = self.buffer.pop_front() { + self.fetched += 1; + Some(item) + } else { + None + } + } +} diff --git a/vendor/grammers-client/src/client/messages.rs b/vendor/grammers-client/src/client/messages.rs new file mode 100644 index 0000000..2344a09 --- /dev/null +++ b/vendor/grammers-client/src/client/messages.rs @@ -0,0 +1,1327 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Methods related to sending messages. + +use std::collections::HashMap; + +use chrono::{DateTime, FixedOffset}; +use grammers_mtsender::InvocationError; +use grammers_session::types::{PeerAuth, PeerId, PeerKind, PeerRef}; +use grammers_tl_types::{self as tl, enums::InputPeer}; +use log::{Level, log_enabled, warn}; + +use super::{Client, IterBuffer}; +use crate::media::{InputMedia, Media}; +use crate::message::{InputMessage, InputReactions, Message}; +use crate::utils::{generate_random_id, generate_random_ids}; + +async fn map_random_ids_to_messages( + client: &Client, + fetched_in: PeerRef, + random_ids: &[i64], + updates: tl::enums::Updates, +) -> Result>, Box> { + match updates { + tl::enums::Updates::Updates(tl::types::Updates { + updates, + users, + chats, + date: _, + seq: _, + }) => { + let peers = client.build_peer_map(users, chats).await; + + let rnd_to_id = updates + .iter() + .filter_map(|update| match update { + tl::enums::Update::MessageId(u) => Some((u.random_id, u.id)), + _ => None, + }) + .collect::>(); + + // TODO ideally this would use the same UpdateIter mechanism to make sure we don't + // accidentally miss variants + let mut id_to_msg = updates + .into_iter() + .filter_map(|update| match update { + tl::enums::Update::NewMessage(tl::types::UpdateNewMessage { + message, .. + }) => Some(message), + tl::enums::Update::NewChannelMessage(tl::types::UpdateNewChannelMessage { + message, + .. + }) => Some(message), + tl::enums::Update::NewScheduledMessage( + tl::types::UpdateNewScheduledMessage { message, .. }, + ) => Some(message), + _ => None, + }) + .map(|message| { + Message::from_raw(client, message, Some(fetched_in.clone()), peers.handle()) + }) + .map(|message| (message.id(), message)) + .collect::>(); + + Ok(random_ids + .iter() + .map(|rnd| { + rnd_to_id + .get(rnd) + .and_then(|id| id_to_msg.remove(id)) + .or_else(|| { + if id_to_msg.len() == 1 { + // If there's no random_id to map from, in the common case a single message + // should've been produced regardless, so try to recover by returning that. + id_to_msg.drain().next().map(|(_, m)| m) + } else { + None + } + }) + }) + .collect()) + } + _ => panic!("API returned something other than Updates so messages can't be mapped"), + } +} + +pub(crate) async fn parse_mention_entities( + client: &Client, + mut entities: Vec, +) -> Option> { + if entities.is_empty() { + return None; + } + + if entities + .iter() + .any(|e| matches!(e, tl::enums::MessageEntity::MentionName(_))) + { + for entity in entities.iter_mut() { + if let tl::enums::MessageEntity::MentionName(mention_name) = entity { + *entity = tl::types::InputMessageEntityMentionName { + offset: mention_name.offset, + length: mention_name.length, + user_id: tl::enums::InputUser::User(tl::types::InputUser { + user_id: mention_name.user_id, + access_hash: client + .0 + .session + .peer(PeerId::user_unchecked(mention_name.user_id)) + .await + .unwrap_or_default() + .and_then(|peer| peer.auth()) + .unwrap_or_default() + .hash(), + }), + } + .into() + } + } + } + + Some(entities) +} + +const MAX_LIMIT: usize = 100; + +/// Build a reply-to a plain message by its ID, leaving all other fields unset. +fn input_reply_to(reply_to_msg_id: i32) -> tl::enums::InputReplyTo { + tl::types::InputReplyToMessage { + reply_to_msg_id, + top_msg_id: None, + reply_to_peer_id: None, + quote_text: None, + quote_entities: None, + quote_offset: None, + monoforum_peer_id: None, + todo_item_id: None, + poll_option: None, + } + .into() +} + +/// Flatten a `messages.Messages` response into its messages, users and chats. +/// +/// Panics on `NotModified`, which is only returned when a non-zero `hash` was sent. +fn unpack_messages( + res: tl::enums::messages::Messages, +) -> ( + Vec, + Vec, + Vec, +) { + use tl::enums::messages::Messages; + + match res { + Messages::Messages(m) => (m.messages, m.users, m.chats), + Messages::Slice(m) => (m.messages, m.users, m.chats), + Messages::ChannelMessages(m) => (m.messages, m.users, m.chats), + Messages::NotModified(_) => { + panic!("API returned Messages::NotModified even though GetMessages was used") + } + } +} + +impl> IterBuffer { + /// Fetches the total unless cached. + /// + /// The `request.limit` should be set to the right value before calling this method. + async fn get_total(&mut self) -> Result { + if let Some(total) = self.total { + return Ok(total); + } + + use tl::enums::messages::Messages; + + let total = match self.client.invoke(&self.request).await? { + Messages::Messages(messages) => messages.messages.len(), + Messages::Slice(messages) => messages.count as usize, + Messages::ChannelMessages(messages) => messages.count as usize, + Messages::NotModified(messages) => messages.count as usize, + }; + self.total = Some(total); + Ok(total) + } + + /// Performs the network call, fills the buffer, and returns the `offset_rate` if any. + /// + /// The `request.limit` should be set to the right value before calling this method. + async fn fill_buffer( + &mut self, + limit: i32, + reverse: bool, + peer: Option, + ) -> Result, InvocationError> { + use tl::enums::messages::Messages; + + let (mut messages, users, chats, rate) = match self.client.invoke(&self.request).await? { + Messages::Messages(m) => { + self.last_chunk = true; + self.total = Some(m.messages.len()); + (m.messages, m.users, m.chats, None) + } + Messages::Slice(m) => { + // Can't rely on `count(messages) < limit` as the stop condition. + // See https://t.me/tdlibchat/57257 for discussion. As an example: + // offset_id 132002, limit 4 => we get msg 131999 & 131998 + // offset_id 132002, limit 3 => we get msg 131999 + // offset_id 132002, limit 2 => we get msg 132000 + // offset_id 132002, limit 1 => we get msg 132001 + // + // When iterating newest-to-oldest, the highest fetched message ID is + // lower than or equal to the limit, there can't be more messages after + // (highest ID - limit), because the absolute lowest message ID is 1. + self.last_chunk = + m.messages.is_empty() || (!reverse && m.messages[0].id() <= limit); + + self.total = Some(m.count as usize); + + (m.messages, m.users, m.chats, m.next_rate) + } + Messages::ChannelMessages(m) => { + self.last_chunk = + m.messages.is_empty() || (!reverse && m.messages[0].id() <= limit); + self.total = Some(m.count as usize); + (m.messages, m.users, m.chats, None) + } + Messages::NotModified(_) => { + panic!("API returned Messages::NotModified even though hash = 0") + } + }; + + if reverse { + messages.reverse(); + } + + let peers = self.client.build_peer_map(users, chats).await; + + let client = self.client.clone(); + self.buffer.extend( + messages + .into_iter() + .map(|message| Message::from_raw(&client, message, peer, peers.handle())), + ); + + Ok(rate) + } +} + +/// Iterator returned by [`Client::iter_messages`]. +pub struct MessageIter { + inner: IterBuffer, + reverse: bool, +} + +impl MessageIter { + fn new(client: &Client, peer: PeerRef) -> Self { + let inner = IterBuffer::from_request( + client, + MAX_LIMIT, + tl::functions::messages::GetHistory { + peer: peer.into(), + offset_id: 0, + offset_date: 0, + add_offset: 0, + limit: 0, + max_id: 0, + min_id: 0, + hash: 0, + }, + ); + + Self { + inner, + reverse: false, + } + } + + /// Changes the starting message ID (exclusive). + pub fn offset_id(mut self, offset: i32) -> Self { + self.inner.request.offset_id = offset; + self + } + + /// Changes the starting message date. + pub fn offset_date(mut self, offset: i32) -> Self { + self.inner.request.offset_date = offset; + self + } + + /// Changes the order to oldest-to-newest. (default is newest-to-oldest) + pub fn reverse(mut self, reverse: bool) -> Self { + self.reverse = reverse; + self + } + + pub fn limit(mut self, limit: usize) -> Self { + self.inner = self.inner.limit(limit); + self + } + + /// Determines the total number of messages in the chat. + /// + /// This only performs a network call if `next` has not been called before. + pub async fn total(&mut self) -> Result { + self.inner.request.limit = 1; + self.inner.get_total().await + } + + /// Returns the next `Message` from the internal buffer, filling the buffer previously if it's + /// empty. + /// + /// Returns `None` if the `limit` is reached or there are no messages left. + pub async fn next(&mut self) -> Result, InvocationError> { + if let Some(result) = self.inner.next_raw() { + return result; + } + + self.inner.request.limit = self.inner.determine_limit(MAX_LIMIT); + let request = &mut self.inner.request; + if self.reverse { + request.add_offset = -request.limit; + request.min_id = request.offset_id; + if request.offset_id == 0 && request.offset_date == 0 { + // With no explicit offset, start from the oldest available page. + request.offset_id = 1; + request.add_offset = -request.limit + 1; // +1 to include the first message (ID=1) + } + } + self.inner + .fill_buffer( + self.inner.request.limit, + self.reverse, + Some(self.inner.request.peer.clone().into()), + ) + .await?; + + // Don't bother updating offsets if this is the last time stuff has to be fetched. + if !self.inner.last_chunk && !self.inner.buffer.is_empty() { + let last = &self.inner.buffer[self.inner.buffer.len() - 1]; + self.inner.request.offset_id = last.id(); + self.inner.request.offset_date = last.date_timestamp(); + } + + Ok(self.inner.pop_item()) + } +} + +/// Iterator returned by [`Client::search_messages`]. +pub type SearchIter = IterBuffer; + +impl SearchIter { + fn new(client: &Client, peer: PeerRef) -> Self { + // TODO let users tweak all the options from the request + Self::from_request( + client, + MAX_LIMIT, + tl::functions::messages::Search { + peer: peer.into(), + q: String::new(), + from_id: None, + saved_peer_id: None, + saved_reaction: None, + top_msg_id: None, + filter: tl::enums::MessagesFilter::InputMessagesFilterEmpty, + min_date: 0, + max_date: 0, + offset_id: 0, + add_offset: 0, + limit: 0, + max_id: 0, + min_id: 0, + hash: 0, + }, + ) + } + + /// Changes the message identifier upper bound. + pub fn offset_id(mut self, offset: i32) -> Self { + self.request.offset_id = offset; + self + } + + /// Changes the query of the search. Telegram servers perform a somewhat fuzzy search over + /// this query (so a word in singular may also return messages with the word in plural, for + /// example). + pub fn query(mut self, query: &str) -> Self { + self.request.q = query.to_string(); + self + } + + /// Restricts results to messages sent by the logged-in user + pub fn sent_by_self(mut self) -> Self { + self.request.from_id = Some(InputPeer::PeerSelf); + self + } + + /// Returns only messages with date bigger than date_time. + /// + /// ``` + /// use chrono::DateTime; + /// + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// // Search messages sent after Jan 1st, 2021 + /// let min_date = DateTime::parse_from_rfc3339("2021-01-01T00:00:00-00:00").unwrap(); + /// + /// let mut messages = client.search_messages(peer).min_date(&min_date); + /// + /// # Ok(()) + /// # } + /// ``` + pub fn min_date(mut self, date_time: &DateTime) -> Self { + self.request.min_date = date_time.timestamp() as i32; + self + } + + /// Returns only messages with date smaller than date_time + /// + /// ``` + /// use chrono::DateTime; + /// + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// // Search messages sent before Dec, 25th 2022 + /// let max_date = DateTime::parse_from_rfc3339("2022-12-25T00:00:00-00:00").unwrap(); + /// + /// let mut messages = client.search_messages(peer).max_date(&max_date); + /// + /// # Ok(()) + /// # } + /// ``` + pub fn max_date(mut self, date_time: &DateTime) -> Self { + self.request.max_date = date_time.timestamp() as i32; + self + } + + /// Changes the media filter. Only messages with this type of media will be fetched. + pub fn filter(mut self, filter: tl::enums::MessagesFilter) -> Self { + self.request.filter = filter; + self + } + + /// Determines how many messages there are in total. + /// + /// This only performs a network call if `next` has not been called before. + pub async fn total(&mut self) -> Result { + // Unlike most requests, a limit of 0 actually returns 0 and not a default amount + // (as of layer 120). + self.request.limit = 0; + self.get_total().await + } + + /// Return the next `Message` from the internal buffer, filling the buffer previously if it's + /// empty. + /// + /// Returns `None` if the `limit` is reached or there are no messages left. + pub async fn next(&mut self) -> Result, InvocationError> { + if let Some(result) = self.next_raw() { + return result; + } + + self.request.limit = self.determine_limit(MAX_LIMIT); + self.fill_buffer( + self.request.limit, + false, + Some(self.request.peer.clone().into()), + ) + .await?; + + // Don't bother updating offsets if this is the last time stuff has to be fetched. + if !self.last_chunk && !self.buffer.is_empty() { + let last = &self.buffer[self.buffer.len() - 1]; + self.request.offset_id = last.id(); + self.request.max_date = last.date_timestamp(); + } + + Ok(self.pop_item()) + } +} + +/// Iterator returned by [`Client::search_all_messages`]. +pub type GlobalSearchIter = IterBuffer; + +impl GlobalSearchIter { + fn new(client: &Client) -> Self { + // TODO let users tweak all the options from the request + Self::from_request( + client, + MAX_LIMIT, + tl::functions::messages::SearchGlobal { + folder_id: None, + q: String::new(), + filter: tl::enums::MessagesFilter::InputMessagesFilterEmpty, + min_date: 0, + max_date: 0, + offset_rate: 0, + offset_peer: tl::enums::InputPeer::Empty, + offset_id: 0, + limit: 0, + broadcasts_only: false, + groups_only: false, + users_only: false, + }, + ) + } + + /// Changes the message identifier upper bound. + pub fn offset_id(mut self, offset: i32) -> Self { + self.request.offset_id = offset; + self + } + + /// Changes the query of the search. Telegram servers perform a somewhat fuzzy search over + /// this query (so a word in singular may also return messages with the word in plural, for + /// example). + pub fn query(mut self, query: &str) -> Self { + self.request.q = query.to_string(); + self + } + + /// Changes the media filter. Only messages with this type of media will be fetched. + pub fn filter(mut self, filter: tl::enums::MessagesFilter) -> Self { + self.request.filter = filter; + self + } + + /// Determines how many messages there are in total. + /// + /// This only performs a network call if `next` has not been called before. + pub async fn total(&mut self) -> Result { + self.request.limit = 1; + self.get_total().await + } + + /// Return the next `Message` from the internal buffer, filling the buffer previously if it's + /// empty. + /// + /// Returns `None` if the `limit` is reached or there are no messages left. + pub async fn next(&mut self) -> Result, InvocationError> { + if let Some(result) = self.next_raw() { + return result; + } + + self.request.limit = self.determine_limit(MAX_LIMIT); + let offset_rate = self.fill_buffer(self.request.limit, false, None).await?; + + // Don't bother updating offsets if this is the last time stuff has to be fetched. + if !self.last_chunk && !self.buffer.is_empty() { + let last = &self.buffer[self.buffer.len() - 1]; + self.request.offset_rate = offset_rate.unwrap_or(0); + self.request.offset_peer = last + .peer_ref() + .await? + .map(|peer| peer.into()) + .unwrap_or(tl::enums::InputPeer::Empty); + self.request.offset_id = last.id(); + } + + Ok(self.pop_item()) + } +} + +/// Method implementations related to sending, modifying or getting messages. +impl Client { + /// Sends a message to the desired peer. + /// + /// This method can also be used to send media such as photos, videos, documents, polls, etc. + /// + /// If you want to send a local file as media, you will need to use + /// [`Client::upload_file`] first. + /// + /// Refer to [`InputMessage`] to learn more formatting options, such as using markdown or + /// adding buttons under your message (if you're logged in as a bot). + /// + /// See also: [`Message::respond`], [`Message::reply`]. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// client.send_message(peer, "Boring text message :-(").await?; + /// + /// use grammers_client::message::InputMessage; + /// + /// client.send_message(peer, InputMessage::new().text("Sneaky message").silent(true)).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn send_message, M: Into>( + &self, + peer: C, + message: M, + ) -> Result { + let peer = peer.into(); + let message = message.into(); + let random_id = generate_random_id(); + let entities = parse_mention_entities(self, message.entities.clone()).await; + let updates = if let Some(media) = message.media.clone() { + self.invoke(&tl::functions::messages::SendMedia { + silent: message.silent, + background: message.background, + clear_draft: message.clear_draft, + peer: peer.into(), + reply_to: message.reply_to.map(input_reply_to), + media, + message: message.text.clone(), + random_id, + reply_markup: message.reply_markup.clone(), + entities, + schedule_date: message.schedule_date, + schedule_repeat_period: None, + send_as: None, + noforwards: false, + update_stickersets_order: false, + invert_media: message.invert_media, + quick_reply_shortcut: None, + effect: None, + allow_paid_floodskip: false, + allow_paid_stars: None, + suggested_post: None, + }) + .await + } else { + self.invoke(&tl::functions::messages::SendMessage { + no_webpage: !message.link_preview, + silent: message.silent, + background: message.background, + clear_draft: message.clear_draft, + peer: peer.into(), + reply_to: message.reply_to.map(input_reply_to), + message: message.text.clone(), + random_id, + reply_markup: message.reply_markup.clone(), + entities, + schedule_date: message.schedule_date, + schedule_repeat_period: None, + send_as: None, + noforwards: false, + update_stickersets_order: false, + invert_media: message.invert_media, + quick_reply_shortcut: None, + effect: None, + allow_paid_floodskip: false, + allow_paid_stars: None, + suggested_post: None, + rich_message: None, + }) + .await + }?; + + Ok(match updates { + tl::enums::Updates::UpdateShortSentMessage(updates) => { + let peer = if peer.id.bare_id().is_none() { + // from_raw_short_updates needs the peer ID + self.0.session.peer_ref(peer.id).await?.unwrap() + } else { + peer + }; + + Message::from_raw_short_updates(self, updates, message, peer) + } + updates => { + let updates_debug = if log_enabled!(Level::Warn) { + Some(updates.clone()) + } else { + None + }; + + match map_random_ids_to_messages(self, peer, &[random_id], updates) + .await? + .pop() + .flatten() + { + Some(message) => message, + None => { + if let Some(updates) = updates_debug { + warn!( + "failed to find just-sent message in response updates; please report this:" + ); + warn!("{:#?}", updates); + } + Message::from_raw( + self, + tl::enums::Message::Empty(tl::types::MessageEmpty { + id: 0, + peer_id: Some(peer.id.into()), + }), + Some(peer), + self.empty_peer_map(), + ) + } + } + } + }) + } + + /// Sends a album to the desired peer. + /// + /// This method can also be used to send a bunch of media such as photos, videos, documents, polls, etc. + /// + /// If you want to send a local file as media, you will need to use + /// [`Client::upload_file`] first. + /// + /// Refer to [`InputMedia`] to learn more formatting options, such as using markdown. + /// + /// See also: [`Message::respond_album`], [`Message::reply_album`]. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_client::media::InputMedia; + /// + /// client.send_album(peer, vec![InputMedia::new().caption("A album").photo_url("https://example.com/cat.jpg")]).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn send_album>( + &self, + peer: C, + mut medias: Vec, + ) -> Result>, InvocationError> { + let peer = peer.into(); + let random_ids = generate_random_ids(medias.len()); + + // Upload external files + for media in medias.iter_mut() { + let raw_media = media.media.clone().unwrap(); + + if matches!( + raw_media, + tl::enums::InputMedia::UploadedPhoto(_) + | tl::enums::InputMedia::PhotoExternal(_) + | tl::enums::InputMedia::UploadedDocument(_) + | tl::enums::InputMedia::DocumentExternal(_) + ) { + let uploaded = self + .invoke(&tl::functions::messages::UploadMedia { + business_connection_id: None, + peer: peer.into(), + media: raw_media, + }) + .await?; + media.media = Some( + Media::from_raw(uploaded) + .unwrap() + .to_raw_input_media() + .unwrap(), + ); + } + } + + let first_media_reply = medias.first().unwrap().reply_to; + + let mut multi_media = Vec::with_capacity(medias.len()); + for (input_media, random_id) in medias.into_iter().zip(random_ids.iter()) { + let entities = parse_mention_entities(self, input_media.entities).await; + let raw_media = input_media.media.unwrap(); + multi_media.push(tl::enums::InputSingleMedia::Media( + tl::types::InputSingleMedia { + media: raw_media, + random_id: *random_id, + message: input_media.caption, + entities, + }, + )) + } + + let updates = self + .invoke(&tl::functions::messages::SendMultiMedia { + silent: false, + background: false, + clear_draft: false, + peer: peer.into(), + reply_to: first_media_reply.map(input_reply_to), + schedule_date: None, + multi_media, + send_as: None, + noforwards: false, + update_stickersets_order: false, + invert_media: false, + quick_reply_shortcut: None, + effect: None, + allow_paid_floodskip: false, + allow_paid_stars: None, + }) + .await?; + + Ok(map_random_ids_to_messages(self, peer, &random_ids, updates).await?) + } + + /// Edits an existing message. + /// + /// Similar to [`Client::send_message`], advanced formatting can be achieved with the + /// options offered by [`InputMessage`]. + /// + /// See also: [`Message::edit`]. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let old_message_id = 123; + /// client.edit_message(peer, old_message_id, "New text message").await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn edit_message, M: Into>( + &self, + peer: C, + message_id: i32, + new_message: M, + ) -> Result<(), InvocationError> { + let new_message = new_message.into(); + let entities = parse_mention_entities(self, new_message.entities).await; + self.invoke(&tl::functions::messages::EditMessage { + no_webpage: !new_message.link_preview, + invert_media: new_message.invert_media, + peer: peer.into().into(), + id: message_id, + message: Some(new_message.text), + media: new_message.media, + reply_markup: new_message.reply_markup, + entities, + schedule_date: new_message.schedule_date, + schedule_repeat_period: None, + quick_reply_shortcut_id: None, + rich_message: None, + }) + .await?; + + Ok(()) + } + + /// Deletes up to 100 messages in a peer. + /// + ///
+ /// + /// When deleting messages from small group peers or private conversations, this + /// method cannot validate that the provided message IDs actually belong to the input peer due + /// to the way Telegram's API works. Make sure to pass correct [`Message::id`]'s. + /// + ///
+ /// + /// The messages are deleted for both ends. + /// + /// The amount of deleted messages is returned (it might be less than the amount of input + /// message IDs if some of them were already missing). It is not possible to find out which + /// messages were actually deleted, but if the request succeeds, none of the specified message + /// IDs will appear in the message history from that point on. + /// + /// See also: [`Message::delete`]. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let message_ids = [123, 456, 789]; + /// + /// // Careful, these messages will be gone after the method succeeds! + /// client.delete_messages(peer, &message_ids).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn delete_messages>( + &self, + peer: C, + message_ids: &[i32], + ) -> Result { + let peer = peer.into(); + let tl::enums::messages::AffectedMessages::Messages(affected) = + if peer.id.kind() == PeerKind::Channel { + self.invoke(&tl::functions::channels::DeleteMessages { + channel: peer.into(), + id: message_ids.to_vec(), + }) + .await + } else { + self.invoke(&tl::functions::messages::DeleteMessages { + revoke: true, + id: message_ids.to_vec(), + }) + .await + }?; + + Ok(affected.pts_count as usize) + } + + /// Forwards up to 100 messages from `source` into `destination`. + /// + /// For consistency with other methods, the peer upon which this request acts comes first + /// (destination), and then the source peer. + /// + /// Returns the new forwarded messages in a list. Those messages that could not be forwarded + /// will be `None`. The length of the resulting list is the same as the length of the input + /// message IDs, and the indices from the list of IDs map to the indices in the result so + /// you can find which messages were forwarded and which message they became. + /// + /// See also: [`Message::forward_to`]. + /// + /// # Examples + /// + /// ``` + /// # async fn f(destination: grammers_session::types::PeerRef, source: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let message_ids = [123, 456, 789]; + /// + /// let messages = client.forward_messages(destination, &message_ids, source).await?; + /// let fwd_count = messages.into_iter().filter(Option::is_some).count(); + /// println!("Forwarded {} out of {} messages!", fwd_count, message_ids.len()); + /// # Ok(()) + /// # } + /// ``` + pub async fn forward_messages, S: Into>( + &self, + destination: C, + message_ids: &[i32], + source: S, + ) -> Result>, InvocationError> { + // TODO let user customize more options + let peer = destination.into(); + let request = tl::functions::messages::ForwardMessages { + silent: false, + background: false, + with_my_score: false, + drop_author: false, + drop_media_captions: false, + from_peer: source.into().into(), + id: message_ids.to_vec(), + random_id: generate_random_ids(message_ids.len()), + to_peer: peer.into(), + top_msg_id: None, + reply_to: None, + schedule_date: None, + schedule_repeat_period: None, + send_as: None, + noforwards: false, + quick_reply_shortcut: None, + allow_paid_floodskip: false, + effect: None, + video_timestamp: None, + allow_paid_stars: None, + suggested_post: None, + }; + let result = self.invoke(&request).await?; + Ok(map_random_ids_to_messages(self, peer.into(), &request.random_id, result).await?) + } + + /// Gets the [`Message`] to which the input message is replying to. + /// + /// See also: [`Message::get_reply`]. + /// + /// # Examples + /// + /// ``` + /// # async fn f(message: grammers_client::message::Message, client: grammers_client::Client) -> Result<(), Box> { + /// if let Some(reply) = client.get_reply_to_message(&message).await? { + /// println!("The reply said: {}", reply.text()); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn get_reply_to_message( + &self, + message: &Message, + ) -> Result, InvocationError> { + /// Helper method to fetch a single message by its input message. + async fn get_message( + client: &Client, + peer: PeerRef, + id: tl::enums::InputMessage, + ) -> Result<(tl::enums::messages::Messages, bool), InvocationError> { + if peer.id.kind() == PeerKind::Channel { + client + .invoke(&tl::functions::channels::GetMessages { + id: vec![id], + channel: peer.into(), + }) + .await + .map(|res| (res, false)) + } else { + client + .invoke(&tl::functions::messages::GetMessages { id: vec![id] }) + .await + .map(|res| (res, true)) + } + } + + // TODO shouldn't this method take in a message id anyway? + let peer_id = message.peer_id(); + let peer = match peer_id.kind() { + PeerKind::User | PeerKind::Chat => PeerRef { + id: peer_id, + auth: PeerAuth::default(), // unused, so no need to bother fetching it + }, + PeerKind::Channel => message.peer_ref().await?.ok_or(InvocationError::Dropped)?, + }; + let reply_to_message_id = match message.reply_to_message_id() { + Some(id) => id, + None => return Ok(None), + }; + + let input_id = + tl::enums::InputMessage::ReplyTo(tl::types::InputMessageReplyTo { id: message.id() }); + + let (res, filter_req) = match get_message(self, peer, input_id).await { + Ok(tup) => tup, + Err(_) => { + let input_id = tl::enums::InputMessage::Id(tl::types::InputMessageId { + id: reply_to_message_id, + }); + get_message(self, peer, input_id).await? + } + }; + + let (messages, users, chats) = unpack_messages(res); + + let peers = self.build_peer_map(users, chats).await; + Ok(messages + .into_iter() + .map(|m| Message::from_raw(self, m, Some(peer.into()), peers.handle())) + .next() + .filter(|m| !filter_req || m.peer_id() == message.peer_id())) + } + + /// Iterate over the message history of a peer, from most recent to oldest. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// // Note we're setting a reasonable limit, or we'd print out ALL the messages in peer! + /// let mut messages = client.iter_messages(peer).limit(100); + /// + /// while let Some(message) = messages.next().await? { + /// println!("{}", message.text()); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn iter_messages>(&self, peer: C) -> MessageIter { + MessageIter::new(self, peer.into()) + } + + /// Iterate over the messages that match certain search criteria. + /// + /// This allows you to search by text within a peer or filter by media among other things. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// // Let's print all the people who think grammers is cool. + /// let mut messages = client.search_messages(peer).query("grammers is cool"); + /// + /// while let Some(message) = messages.next().await? { + /// let sender = message.sender().unwrap(); + /// println!("{}", sender.name().unwrap_or(&sender.id().to_string())); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn search_messages>(&self, peer: C) -> SearchIter { + SearchIter::new(self, peer.into()) + } + + /// Iterate over the messages that match certain search criteria, without being restricted to + /// searching in a specific peer. The downside is that this global search supports less filters. + /// + /// This allows you to search by text within a peer or filter by media among other things. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// // Let's print all the peers were people think grammers is cool. + /// let mut messages = client.search_all_messages().query("grammers is cool"); + /// + /// while let Some(message) = messages.next().await? { + /// println!("{}", message.peer().unwrap().name().unwrap_or(&message.peer().unwrap().id().to_string())); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn search_all_messages(&self) -> GlobalSearchIter { + GlobalSearchIter::new(self) + } + + /// Get up to 100 messages using their ID. + /// + /// Returns the new retrieved messages in a list. Those messages that could not be retrieved + /// or do not belong to the input peer will be `None`. The length of the resulting list is the + /// same as the length of the input message IDs, and the indices from the list of IDs map to + /// the indices in the result so you can map them into the new list. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let message_ids = [123, 456, 789]; + /// + /// let messages = client.get_messages_by_id(peer, &message_ids).await?; + /// let count = messages.into_iter().filter(Option::is_some).count(); + /// println!("{} out of {} messages were deleted!", message_ids.len() - count, message_ids.len()); + /// # Ok(()) + /// # } + /// ``` + pub async fn get_messages_by_id>( + &self, + peer: C, + message_ids: &[i32], + ) -> Result>, InvocationError> { + let peer = peer.into(); + let id = message_ids + .iter() + .map(|&id| tl::enums::InputMessage::Id(tl::types::InputMessageId { id })) + .collect(); + + let result = if peer.id.kind() == PeerKind::Channel { + self.invoke(&tl::functions::channels::GetMessages { + channel: peer.into(), + id, + }) + .await + } else { + self.invoke(&tl::functions::messages::GetMessages { id }) + .await + }?; + + let (messages, users, chats) = unpack_messages(result); + + let peers = self.build_peer_map(users, chats).await; + let mut map = messages + .into_iter() + .map(|m| Message::from_raw(self, m, Some(peer.into()), peers.handle())) + .filter(|m| m.peer_id() == peer.id) + .map(|m| (m.id(), m)) + .collect::>(); + + Ok(message_ids.iter().map(|id| map.remove(id)).collect()) + } + + /// Get the latest pin from a peer. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// if let Some(message) = client.get_pinned_message(peer).await? { + /// println!("There is a message pinned: {}", message.text()); + /// } else { + /// println!("There are no messages pinned"); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn get_pinned_message>( + &self, + peer: C, + ) -> Result, InvocationError> { + let peer = peer.into(); + // TODO return types::Message and print its text in the example + let id = vec![tl::enums::InputMessage::Pinned]; + + let result = if peer.id.kind() == PeerKind::Channel { + self.invoke(&tl::functions::channels::GetMessages { + channel: peer.into(), + id, + }) + .await + } else { + self.invoke(&tl::functions::messages::GetMessages { id }) + .await + }?; + + let (messages, users, chats) = unpack_messages(result); + + let peers = self.build_peer_map(users, chats).await; + Ok(messages + .into_iter() + .map(|m| Message::from_raw(self, m, Some(peer.into()), peers.handle())) + .find(|m| m.peer_id() == peer.id)) + } + + /// Pin a message in the peer. This will not notify any users. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let message_id = 123; + /// client.pin_message(peer, message_id).await?; + /// # Ok(()) + /// # } + /// ``` + // TODO return produced Option + pub async fn pin_message>( + &self, + peer: C, + message_id: i32, + ) -> Result<(), InvocationError> { + self.update_pinned(peer.into(), message_id, true).await + } + + /// Unpin a message from the peer. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let message_id = 123; + /// client.unpin_message(peer, message_id).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn unpin_message>( + &self, + peer: C, + message_id: i32, + ) -> Result<(), InvocationError> { + self.update_pinned(peer.into(), message_id, false).await + } + + async fn update_pinned( + &self, + peer: PeerRef, + id: i32, + pin: bool, + ) -> Result<(), InvocationError> { + self.invoke(&tl::functions::messages::UpdatePinnedMessage { + silent: true, + unpin: !pin, + pm_oneside: false, + peer: peer.into(), + id, + }) + .await + .map(drop) + } + + /// Unpin all currently-pinned messages from the peer. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// client.unpin_all_messages(peer).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn unpin_all_messages>( + &self, + peer: C, + ) -> Result<(), InvocationError> { + self.invoke(&tl::functions::messages::UnpinAllMessages { + peer: peer.into().into(), + top_msg_id: None, + saved_peer_id: None, + }) + .await?; + Ok(()) + } + + /// Send reaction. + /// + /// # Examples + /// + /// Via emoticon + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let message_id = 123; + /// + /// client.send_reactions(peer, message_id, "👍").await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// Make animation big & Add to recent + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_client::message::InputReactions; + /// + /// let message_id = 123; + /// let reactions = InputReactions::emoticon("🤯").big().add_to_recent(); + /// + /// client.send_reactions(peer, message_id, reactions).await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// Remove reactions + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_client::message::InputReactions; + /// + /// let message_id = 123; + /// + /// client.send_reactions(peer, message_id, InputReactions::remove()).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn send_reactions, R: Into>( + &self, + peer: C, + message_id: i32, + reactions: R, + ) -> Result<(), InvocationError> { + let reactions = reactions.into(); + + self.invoke(&tl::functions::messages::SendReaction { + big: reactions.big, + add_to_recent: reactions.add_to_recent, + peer: peer.into().into(), + msg_id: message_id, + reaction: Some(reactions.reactions), + }) + .await?; + + Ok(()) + } +} diff --git a/vendor/grammers-client/src/client/mod.rs b/vendor/grammers-client/src/client/mod.rs new file mode 100755 index 0000000..0df6942 --- /dev/null +++ b/vendor/grammers-client/src/client/mod.rs @@ -0,0 +1,34 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Contains client-specific configuration and types. + +mod auth; +mod bots; +mod chats; +#[allow(clippy::module_inception)] +mod client; +mod dialogs; +mod files; +mod iter_buffer; +mod messages; +mod net; +mod retry_policy; +mod updates; + +pub use auth::{LoginToken, PasswordToken, SignInError}; +pub use bots::{InlineResult, InlineResultIter}; +pub use chats::{ParticipantIter, ParticipantPermissions, ProfilePhotoIter}; +pub(crate) use client::ClientInner; +pub use client::{Client, ClientConfiguration, UpdatesConfiguration}; +pub use dialogs::DialogIter; +pub use files::DownloadIter; +pub use iter_buffer::IterBuffer; +pub use messages::{GlobalSearchIter, MessageIter, SearchIter}; +pub use retry_policy::{AutoSleep, NoRetries, RetryContext, RetryPolicy}; +pub use updates::UpdateStream; diff --git a/vendor/grammers-client/src/client/net.rs b/vendor/grammers-client/src/client/net.rs new file mode 100644 index 0000000..745ff79 --- /dev/null +++ b/vendor/grammers-client/src/client/net.rs @@ -0,0 +1,198 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::num::NonZeroU32; +use std::ops::ControlFlow; +use std::sync::Arc; +use std::time::Duration; + +use grammers_mtsender::{InvocationError, SenderPoolFatHandle}; +use grammers_tl_types::{self as tl, Deserializable}; +use log::info; +use tokio::{sync::Mutex, time::sleep}; + +use super::{Client, ClientConfiguration, ClientInner, RetryContext}; + +/// Method implementations directly related with network connectivity. +impl Client { + /// Creates and returns a new client instance upon successful connection to Telegram. + /// + /// If the session in the configuration did not have an authorization key, a new one + /// will be created and the session will be saved with it. + /// + /// The connection will be initialized with the data from the input configuration. + /// + /// The [`grammers_mtsender::SenderPoolHandle`] does not keep a reference to the [`grammers_session::Session`] + /// or `api_id`, but the [`SenderPool`] itself does, so the latter is used as input to guarantee + /// that the values are correctly shared between the pool and the client handles. + /// + /// # Examples + /// + /// ``` + /// use std::sync::Arc; + /// use grammers_client::Client; + /// use grammers_session::storages::MemorySession; // avoid this storage outside tests! + /// use grammers_mtsender::SenderPool; + /// + /// // Note: these are example values and are not actually valid. + /// // Obtain your own with the developer's phone at https://my.telegram.org. + /// const API_ID: i32 = 932939; + /// + /// # async fn f() -> Result<(), Box> { + /// let session = Arc::new(MemorySession::default()); + /// let pool = SenderPool::new(Arc::clone(&session), API_ID); + /// let client = Client::new(pool.handle); + /// # Ok(()) + /// # } + /// ``` + pub fn new(sender_pool: SenderPoolFatHandle) -> Self { + Self::with_configuration(sender_pool, Default::default()) + } + + /// Like [`Self::new`] but with a custom [`ClientConfiguration`]. + pub fn with_configuration( + sender_pool: SenderPoolFatHandle, + configuration: ClientConfiguration, + ) -> Self { + // TODO Sender doesn't have a way to handle backpressure yet + Self(Arc::new(ClientInner { + session: sender_pool.session, + api_id: sender_pool.api_id, + handle: sender_pool.thin, + configuration, + auth_copied_to_dcs: Mutex::new(Vec::new()), + })) + } + + /// Invoke a raw API call. This directly sends the request to Telegram's servers. + /// + /// Using function definitions corresponding to a different layer is likely to cause the + /// responses to the request to not be understood. + /// + ///
+ /// + /// This method is **not** part of the stability guarantees of semantic + /// versioning. It **may** break during *minor* version changes (but not on patch version + /// changes). Use with care. + /// + ///
+ /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_tl_types as tl; + /// + /// dbg!(client.invoke(&tl::functions::Ping { ping_id: 0 }).await?); + /// # Ok(()) + /// # } + /// ``` + pub async fn invoke( + &self, + request: &R, + ) -> Result { + let dc_id = self.0.session.home_dc_id()?; + self.do_invoke_in_dc(dc_id, request.to_bytes()) + .await + .and_then(|body| R::Return::from_bytes(&body).map_err(|e| e.into())) + } + + /// Like [`Self::invoke`], but in the specified DC. + /// + ///
+ /// + /// This method is **not** part of the stability guarantees of semantic + /// versioning. It **may** break during *minor* version changes (but not on patch version + /// changes). Use with care. + /// + ///
+ pub async fn invoke_in_dc( + &self, + dc_id: i32, + request: &R, + ) -> Result { + self.do_invoke_in_dc(dc_id, request.to_bytes()) + .await + .and_then(|body| R::Return::from_bytes(&body).map_err(|e| e.into())) + } + + async fn do_invoke_in_dc( + &self, + dc_id: i32, + request_body: Vec, + ) -> Result, InvocationError> { + let mut retry_context = RetryContext { + fail_count: NonZeroU32::new(1).unwrap(), + slept_so_far: Duration::default(), + error: InvocationError::Dropped, + }; + + loop { + match self + .0 + .handle + .invoke_in_dc(dc_id, request_body.clone()) + .await + { + Ok(response) => break Ok(response), + Err(e) => { + retry_context.error = e; + match self + .0 + .configuration + .retry_policy + .should_retry(&retry_context) + { + ControlFlow::Continue(delay) => { + info!( + "sleeping on {} for {:?} before retrying", + retry_context.error, delay, + ); + sleep(delay).await; + retry_context.fail_count = retry_context.fail_count.saturating_add(1); + retry_context.slept_so_far += delay; + continue; + } + ControlFlow::Break(()) => break Err(retry_context.error), + } + } + } + } + } + + pub(crate) async fn copy_auth_to_dc(&self, target_dc_id: i32) -> Result<(), InvocationError> { + let mut auth_copied_to_dcs = self.0.auth_copied_to_dcs.lock().await; + if auth_copied_to_dcs.contains(&target_dc_id) { + return Ok(()); + } + + let home_dc_id = self.0.session.home_dc_id()?; + if target_dc_id == home_dc_id { + return Ok(()); + } + + let tl::enums::auth::ExportedAuthorization::Authorization(exported_auth) = self + .invoke(&tl::functions::auth::ExportAuthorization { + dc_id: target_dc_id, + }) + .await?; + + self.invoke_in_dc( + target_dc_id, + &tl::functions::auth::ImportAuthorization { + id: exported_auth.id, + bytes: exported_auth.bytes, + }, + ) + .await?; + + auth_copied_to_dcs.push(target_dc_id); + + Ok(()) + } +} diff --git a/vendor/grammers-client/src/client/retry_policy.rs b/vendor/grammers-client/src/client/retry_policy.rs new file mode 100755 index 0000000..2f5e5aa --- /dev/null +++ b/vendor/grammers-client/src/client/retry_policy.rs @@ -0,0 +1,94 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Retry policy trait and built-in policies for use in [`ClientConfiguration`]. +//! +//! [`ClientConfiguration`]: crate::ClientConfiguration + +use std::num::NonZeroU32; +use std::ops::ControlFlow; +use std::time::Duration; + +use grammers_mtsender::{InvocationError, RpcError}; + +/// This trait controls how the [`Client`] should behave when +/// an invoked request fails with an [`InvocationError`]. +/// +/// [`Client`]: crate::Client +pub trait RetryPolicy: Send + Sync { + /// Determines whether the failing request should retry. + /// + /// If it should Continue, a sleep duration before retrying is included.\ + /// If it should Break, the context error will be propagated to the caller. + fn should_retry(&self, ctx: &RetryContext) -> ControlFlow<(), Duration>; +} + +/// Context passed to [`RetryPolicy::should_retry`]. +pub struct RetryContext { + /// Amount of times the instance of this request has failed. + pub fail_count: NonZeroU32, + /// Sum of the durations for all previous continuations (not total time elapsed since first failure). + pub slept_so_far: Duration, + /// The most recent error caused by the instance of the request. + pub error: InvocationError, +} + +/// Retry policy that will never retry. +pub struct NoRetries; + +impl RetryPolicy for NoRetries { + fn should_retry(&self, _: &RetryContext) -> ControlFlow<(), Duration> { + ControlFlow::Break(()) + } +} + +/// Retry policy that will retry *once* on flood-wait and slow mode wait errors. +/// +/// The library will sleep only if the duration to sleep for is below or equal to the threshold. +pub struct AutoSleep { + /// The (inclusive) threshold below which the library should automatically sleep. + pub threshold: Duration, + + /// `Some` if I/O errors should be treated as a flood error that would last the specified duration. + /// This duration will ignore the `threshold` and always be slept on on the first I/O error. + pub io_errors_as_flood_of: Option, +} + +impl RetryPolicy for AutoSleep { + fn should_retry(&self, ctx: &RetryContext) -> ControlFlow<(), Duration> { + match ctx.error { + InvocationError::Rpc(RpcError { + code: 420, + value: Some(seconds), + .. + }) if ctx.fail_count.get() == 1 && seconds as u64 <= self.threshold.as_secs() => { + ControlFlow::Continue(Duration::from_secs(seconds as _)) + } + InvocationError::Io(_) if ctx.fail_count.get() == 1 => { + if let Some(duration) = self.io_errors_as_flood_of { + ControlFlow::Continue(duration) + } else { + ControlFlow::Break(()) + } + } + _ => ControlFlow::Break(()), + } + } +} + +impl Default for AutoSleep { + /// Returns an instance with a threshold of 60 seconds. + /// + /// I/O errors will be treated as if they were a 1-second flood. + fn default() -> Self { + Self { + threshold: Duration::from_secs(60), + io_errors_as_flood_of: Some(Duration::from_secs(1)), + } + } +} diff --git a/vendor/grammers-client/src/client/updates.rs b/vendor/grammers-client/src/client/updates.rs new file mode 100644 index 0000000..2b33e87 --- /dev/null +++ b/vendor/grammers-client/src/client/updates.rs @@ -0,0 +1,394 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Methods to deal with and offer access to updates. + +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +use grammers_mtsender::InvocationError; +use grammers_session::ErasedSession; +use grammers_session::types::{PeerId, PeerInfo, UpdateState, UpdatesState}; +use grammers_session::updates::{MessageBoxes, PrematureEndReason, State, UpdatesLike}; +use grammers_tl_types as tl; +use log::{trace, warn}; +use tokio::sync::mpsc; +use tokio::time::timeout_at; + +use super::{Client, UpdatesConfiguration}; +use crate::peer::PeerMap; +use crate::update::Update; + +/// How long to wait after warning the user that the updates limit was exceeded. +const UPDATE_LIMIT_EXCEEDED_LOG_COOLDOWN: Duration = Duration::from_secs(300); + +// See https://core.telegram.org/method/updates.getChannelDifference. +const BOT_CHANNEL_DIFF_LIMIT: i32 = 100000; +const USER_CHANNEL_DIFF_LIMIT: i32 = 100; + +async fn prepare_channel_difference( + mut request: tl::functions::updates::GetChannelDifference, + session: &ErasedSession, + message_box: &mut MessageBoxes, +) -> Result< + Option, + Box, +> { + let id = match &request.channel { + tl::enums::InputChannel::Channel(channel) => PeerId::channel_unchecked(channel.channel_id), + _ => unreachable!(), + }; + + if let Some(PeerInfo::Channel { + id, + auth: Some(auth), + .. + }) = session.peer(id).await? + { + request.channel = tl::enums::InputChannel::Channel(tl::types::InputChannel { + channel_id: id, + access_hash: auth.hash(), + }); + request.limit = if session + .peer(PeerId::self_user()) + .await? + .map(|user| match user { + PeerInfo::User { bot, .. } => bot.unwrap_or(false), + _ => false, + }) + .unwrap_or(false) + { + BOT_CHANNEL_DIFF_LIMIT + } else { + USER_CHANNEL_DIFF_LIMIT + }; + trace!("requesting {:?}", request); + Ok(Some(request)) + } else { + warn!( + "cannot getChannelDifference for {:?} as we're missing its hash", + id + ); + message_box.end_channel_difference(PrematureEndReason::Banned); + Ok(None) + } +} + +/// Iterator returned by [`Client::stream_updates`]. +pub struct UpdateStream { + client: Client, + message_box: MessageBoxes, + // When did we last warn the user that the update queue filled up? + // This is used to avoid spamming the log. + last_update_limit_warn: Option, + buffer: VecDeque<(tl::enums::Update, State, PeerMap)>, + updates: mpsc::UnboundedReceiver, + configuration: UpdatesConfiguration, + should_get_state: bool, +} + +impl UpdateStream { + /// Return one complete application batch and the cursor it covers. + /// Persist every update before committing this cursor. In particular, a + /// difference response can give several updates the same terminal PTS. + pub async fn next_batch(&mut self) -> Result<(Vec, UpdatesState), InvocationError> { + let first = self.next_raw().await?; + let raw = std::iter::once(first).chain(self.buffer.drain(..)); + let updates = raw.map(|(update, state, peers)| { + Update::from_raw(&self.client, update, state, peers) + }).collect(); + Ok((updates, self.message_box.session_state())) + } + + /// Restore the cursor owned by the application's durable message store. + /// Call before polling this stream. Session peer/dialog cursors may be ahead + /// of the messages persisted by an application and are not a checkpoint. + pub fn restore_state(&mut self, state: UpdatesState) { + assert!(self.buffer.is_empty(), "restore state before polling updates"); + self.message_box = MessageBoxes::load(state); + self.should_get_state = self.message_box.is_empty(); + } + + /// Pops an update from the queue, waiting for an update to arrive first if the queue is empty. + pub async fn next(&mut self) -> Result { + let (update, state, peers) = self.next_raw().await?; + Ok(Update::from_raw(&self.client, update, state, peers)) + } + + /// Pops an update from the queue, waiting for an update to arrive first if the queue is empty. + /// + /// Unlike [`Self::next`], the update is not wrapped at all, but it is still processed. + pub async fn next_raw( + &mut self, + ) -> Result<(tl::enums::Update, State, PeerMap), InvocationError> { + if self.should_get_state { + self.should_get_state = false; + match self + .client + .invoke(&tl::functions::updates::GetState {}) + .await + { + Ok(tl::enums::updates::State::State(state)) => { + self.client + .0 + .session + .set_update_state(UpdateState::All(UpdatesState { + pts: state.pts, + qts: state.qts, + date: state.date, + seq: state.seq, + channels: Vec::new(), + })) + .await?; + self.message_box.set_state(state); + } + Err(_err) => { + // The account may no longer actually be logged in, or it can rarely fail. + // `message_box` will try to correct its state as updates arrive. + } + } + } + + loop { + let (deadline, get_diff, get_channel_diff) = { + if let Some(update) = self.buffer.pop_front() { + return Ok(update); + } + ( + self.message_box.check_deadlines(), // first, as it might trigger differences + self.message_box.get_difference(), + match self.message_box.get_channel_difference() { + Some(gd) => { + prepare_channel_difference( + gd, + self.client.0.session.as_ref(), + &mut self.message_box, + ) + .await? + } + None => None, + }, + ) + }; + + if let Some(request) = get_diff { + let response = self.client.invoke(&request).await?; + let invalidated = matches!(&response, tl::enums::updates::Difference::TooLong(_)); + let (mut updates, users, chats) = self.message_box.apply_difference(response); + if invalidated { + let state = self.message_box.session_state(); + updates.insert(0, (tl::enums::Update::PtsChanged, State { + date: state.date, seq: state.seq, message_box: None, + })); + } + let peers = self.client.build_peer_map(users, chats).await; + self.extend_update_queue(updates, peers); + continue; + } + + if let Some(request) = get_channel_diff { + let maybe_response = self.client.invoke(&request).await; + + let response = match maybe_response { + Ok(r) => r, + Err(e) if e.is("PERSISTENT_TIMESTAMP_OUTDATED") => { + // According to Telegram's docs: + // "Channel internal replication issues, try again later (treat this like an RPC_CALL_FAIL)." + // We can treat this as "empty difference" and not update the local pts. + // Then this same call will be retried when another gap is detected or timeout expires. + // + // Another option would be to literally treat this like an RPC_CALL_FAIL and retry after a few + // seconds, but if Telegram is having issues it's probably best to wait for it to send another + // update (hinting it may be okay now) and retry then. + // + // This is a bit hacky because MessageBox doesn't really have a way to "not update" the pts. + // Instead we manually extract the previously-known pts and use that. + log::warn!( + "Getting difference for channel updates caused PersistentTimestampOutdated; ending getting difference prematurely until server issues are resolved" + ); + { + self.message_box + .end_channel_difference(PrematureEndReason::TemporaryServerIssues); + } + continue; + } + Err(e) if e.is("CHANNEL_PRIVATE") => { + log::info!( + "Account is now banned so we can no longer fetch updates with request: {:?}", + request + ); + { + self.message_box + .end_channel_difference(PrematureEndReason::Banned); + } + continue; + } + Err(InvocationError::Rpc(rpc_error)) if rpc_error.code == 500 => { + log::warn!("Telegram is having internal issues: {:#?}", rpc_error); + { + self.message_box + .end_channel_difference(PrematureEndReason::TemporaryServerIssues); + } + continue; + } + Err(e) => return Err(e), + }; + + let invalidated_channel = if matches!(&response, tl::enums::updates::ChannelDifference::TooLong(_)) { + match &request.channel { + tl::enums::InputChannel::Channel(channel) => Some(channel.channel_id), + _ => None, + } + } else { None }; + let (mut updates, users, chats) = self.message_box.apply_channel_difference(response); + if let Some(channel_id) = invalidated_channel { + let state = self.message_box.session_state(); + updates.insert(0, (tl::types::UpdateChannelTooLong { channel_id, pts: None }.into(), State { + date: state.date, seq: state.seq, message_box: None, + })); + } + + let peers = self.client.build_peer_map(users, chats).await; + self.extend_update_queue(updates, peers); + continue; + } + + match timeout_at(deadline.into(), self.updates.recv()).await { + Ok(Some(updates)) => self.process_socket_updates(updates).await?, + Ok(None) => break Err(InvocationError::Dropped), + Err(_) => {} + } + } + } + + pub(crate) async fn process_socket_updates( + &mut self, + updates: UpdatesLike, + ) -> Result<(), InvocationError> { + let mut result = Option::<(Vec<_>, Vec<_>, Vec<_>)>::None; + match self.message_box.process_updates(updates) { + Ok(tup) => { + if let Some(res) = result.as_mut() { + res.0.extend(tup.0); + res.1.extend(tup.1); + res.2.extend(tup.2); + } else { + result = Some(tup); + } + } + Err(_) => return Ok(()), + } + + if let Some((updates, users, chats)) = result { + let peers = self.client.build_peer_map(users, chats).await; + self.extend_update_queue(updates, peers); + } + + Ok(()) + } + + fn extend_update_queue( + &mut self, + mut updates: Vec<(tl::enums::Update, State)>, + peer_map: PeerMap, + ) { + if let Some(limit) = self.configuration.update_queue_limit { + if let Some(exceeds) = (self.buffer.len() + updates.len()).checked_sub(limit + 1) { + let exceeds = exceeds + 1; + let now = Instant::now(); + let notify = match self.last_update_limit_warn { + None => true, + Some(instant) => now - instant > UPDATE_LIMIT_EXCEEDED_LOG_COOLDOWN, + }; + + updates.truncate(updates.len() - exceeds); + if notify { + log::warn!( + "{} updates were dropped because the update_queue_limit was exceeded", + exceeds + ); + } + + self.last_update_limit_warn = Some(now); + } + } + + self.buffer + .extend(updates.into_iter().map(|(u, s)| (u, s, peer_map.handle()))); + } + + /// Synchronize the updates state to the session. + /// + /// This is **not** automatically done on drop. + pub async fn sync_update_state(&self) -> Result<(), Box> { + self.client + .0 + .session + .set_update_state(UpdateState::All(self.message_box.session_state())) + .await?; + Ok(()) + } +} + +impl Client { + /// Returns an asynchronous stream of processed updates. + /// + /// The updates are guaranteed to be in order, and any gaps will be resolved.\ + /// **Important** to note that for gaps to be resolved, the peers must have been + /// persisted in the session cache beforehand (i.e. be retrievable with [`Session::peer`]). + /// A good way to achieve this is to use [`Self::iter_dialogs`] at least once after login. + /// + /// The updates are wrapped in [`crate::update::Update`] to make them more convenient to use, + /// but their raw type is still accessible to bridge any missing functionality. + pub async fn stream_updates( + &self, + updates: mpsc::UnboundedReceiver, + configuration: UpdatesConfiguration, + ) -> Result> { + let message_box = if configuration.catch_up { + MessageBoxes::load(self.0.session.updates_state().await?) + } else { + // If the user doesn't want to bother with catching up on previous update, start with + // pristine state instead. + MessageBoxes::new() + }; + // Don't bother getting pristine update state if we're not logged in. + let should_get_state = + message_box.is_empty() && self.0.session.peer(PeerId::self_user()).await?.is_some(); + + Ok(UpdateStream { + client: self.clone(), + message_box, + last_update_limit_warn: None, + buffer: VecDeque::new(), + updates, + configuration, + should_get_state, + }) + } +} + +#[cfg(test)] +mod tests { + use core::future::Future; + + use super::*; + + fn get_update_stream() -> UpdateStream { + panic!() + } + + #[test] + fn ensure_next_update_future_impls_send() { + if false { + // We just want it to type-check, not actually run. + fn typeck(_: impl Future + Send) {} + typeck(get_update_stream().next()); + } + } +} diff --git a/vendor/grammers-client/src/lib.rs b/vendor/grammers-client/src/lib.rs new file mode 100755 index 0000000..d9a6bc6 --- /dev/null +++ b/vendor/grammers-client/src/lib.rs @@ -0,0 +1,104 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! # Brief on Telegram's API +//! +//! This library is a high-level implementation to access [Telegram's API], which essentially +//! lets you automate everything you can do with official Telegram clients and more from Rust, +//! or even control bot accounts, making it a viable alternative to using the [Telegram Bot API]. +//! +//! In order to create an application with the library for people to use, you will need to +//! [obtain a developer API ID] first. You can embed it as a constant in your binary and ship +//! that to users (anyone can login, including yourself and bots, with the developer API ID; +//! they do *not* need to provide their own API ID). +//! +//! Once that's ready, create a new [`Client`] instance with its [`Client::new`] +//! method and start making API calls. +//! +//! # Method cost +//! +//! When a method is said to be "expensive", this often means that calling it too much in a +//! certain period of time will result in the API returning "flood wait" errors, meaning that +//! the method cannot be called again for a certain amount of seconds (trying to do so will +//! continue to return the flood wait error for that duration). +//! +//! On the other hand, a "cheap" method can be called a lot and is unlikely to result in a flood +//! wait error. However, all methods can potentially cause a flood wait, so some care is still +//! required. +//! +//! There is nothing wrong with causing the API to return flood wait errors, but it is something +//! to be avoided. Because they are expected, the default [`ClientConfiguration`] will sleep on +//! small flood errors to prevent interruption of simple scripts. +//! +//! A flood wait error is different from a peer flood error. A peer flood error means the flood +//! limitation is applied account-wide, and its duration is undefined. This often means that the +//! account spammed, or a young account tried to contact too many peers. +//! +//! # Re-exports +//! +//! For convenience and to ease using compatible traits in lockstep, this +//! library re-exports several modules and types from its direct dependencies. +//! +//! ## grammers-mtsender as sender +//! +//! `grammers_client` is purely a friendlier abstraction on top of the [`SenderPool`], +//! which is responsible for abstracting the connection wholesale. The [`InvocationError`] +//! is also re-exported at the top level, as all methods that interact with the API +//! eventually need to invoke the request and may thus fail. You may also use the +//! [`Result`] alias, which has the `InvocationError` set as its error variant. +//! +//! If you need access to the rest of the crate (e.g. to configure the `SenderPool`), +//! it is re-exported under [`sender`]. +//! +//! ## grammers-session as session +//! +//! After login, the generated Authorization Key should be persisted and reused, +//! in order to avoid logging in again, as that is a very expensive method. +//! +//! Session storages are responsible for doing so, and you may even implement your +//! own by using the [`session::Session`] trait. +//! +//! ## grammers-tl-types as tl +//! +//! This crate is re-exported wholesale under the [`tl`] namespace (short for +//! [Type Language](https://core.telegram.org/mtproto/TL)). All Telegram types and functions +//! are automatically generated from their public schema, which is versioned by "layers". +//! For now and the foreseeable future, *grammers* will only offer one layer at a time. +//! +//! When the friendly API of `grammers_client` falls short, you can reach out for its +//! raw [`tl::types`] to access all the information Telegram returned on its responses. +//! Additionally, if the [`Client`] is missing a method to do what you need, you may +//! directly [`Client::invoke`] any of the [`tl::functions`] offered by Telegram's API. +//! +//! Using raw types is **discouraged**. Not only are they far clunkier to use, but they +//! are also **not part of the semantic versioning**. While patch versions won't change +//! the layer, minor versions will, which would traditionally be considered a breaking change. +//! +//! [Telegram's API]: https://core.telegram.org/#telegram-api +//! [Telegram Bot API]: https://core.telegram.org/bots/api +//! [obtain a developer API ID]: https://my.telegram.org/auth +//! [`ClientConfiguration`]: crate::client::ClientConfiguration + +#![deny(unsafe_code)] + +pub mod client; +pub mod media; +pub mod message; +#[cfg(any(feature = "markdown", feature = "html"))] +pub mod parsers; +pub mod peer; +pub mod update; +pub(crate) mod utils; + +pub use client::{Client, SignInError}; +pub use grammers_mtsender::{self as sender, InvocationError, SenderPool}; +pub use grammers_session as session; +pub use grammers_tl_types as tl; + +/// Alias for [`std::result::Result`] with the error set to [`InvocationError`]. +pub type Result = std::result::Result; diff --git a/vendor/grammers-client/src/media/attributes.rs b/vendor/grammers-client/src/media/attributes.rs new file mode 100755 index 0000000..c790eff --- /dev/null +++ b/vendor/grammers-client/src/media/attributes.rs @@ -0,0 +1,80 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::convert::TryInto; +use std::time::Duration; + +use grammers_tl_types as tl; + +/// Document media attributes. +#[derive(Clone)] +pub enum Attribute { + Audio { + duration: Duration, + title: Option, + performer: Option, + }, + Voice { + duration: Duration, + waveform: Option>, + }, + Video { + round_message: bool, + supports_streaming: bool, + duration: Duration, + w: i32, + h: i32, + }, + FileName(String), +} + +impl From for tl::enums::DocumentAttribute { + fn from(attr: Attribute) -> Self { + use Attribute::*; + match attr { + Audio { + duration, + title, + performer, + } => Self::Audio(tl::types::DocumentAttributeAudio { + voice: false, + duration: duration.as_secs().try_into().unwrap(), + title, + performer, + waveform: None, + }), + Voice { duration, waveform } => Self::Audio(tl::types::DocumentAttributeAudio { + voice: true, + duration: duration.as_secs().try_into().unwrap(), + title: None, + performer: None, + waveform, + }), + Video { + round_message, + supports_streaming, + duration, + w, + h, + } => Self::Video(tl::types::DocumentAttributeVideo { + round_message, + supports_streaming, + nosound: false, + duration: duration.as_secs_f64(), + w, + h, + preload_prefix_size: None, + video_start_ts: None, + video_codec: None, + }), + FileName(file_name) => { + Self::Filename(tl::types::DocumentAttributeFilename { file_name }) + } + } + } +} diff --git a/vendor/grammers-client/src/media/downloadable.rs b/vendor/grammers-client/src/media/downloadable.rs new file mode 100755 index 0000000..8ec49f4 --- /dev/null +++ b/vendor/grammers-client/src/media/downloadable.rs @@ -0,0 +1,27 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use grammers_tl_types as tl; + +/// Trait implemented by all types that may be downloaded. +pub trait Downloadable { + /// Converts this downloadable into the input type that may be used by raw requests to download it. + fn to_raw_input_location(&self) -> Option; + + /// Returns `Some` if the media has a tiny thumbnail embedded within. + /// + /// No network request would occur when attempting to download this downloadable. + fn to_data(&self) -> Option> { + None + } + + /// File size, in bytes. + fn size(&self) -> Option { + None + } +} diff --git a/vendor/grammers-client/src/media/input_media.rs b/vendor/grammers-client/src/media/input_media.rs new file mode 100644 index 0000000..227233d --- /dev/null +++ b/vendor/grammers-client/src/media/input_media.rs @@ -0,0 +1,305 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use grammers_tl_types as tl; + +use super::{Attribute, Media, Uploaded}; + +/// Media item to be sent as part of an album. +#[derive(Default)] +pub struct InputMedia { + pub(crate) entities: Vec, + pub(crate) reply_to: Option, + pub(crate) caption: String, + pub(crate) media: Option, + media_ttl: Option, + mime_type: Option, +} + +impl InputMedia { + /// Creates a new empty media message for input. + pub fn new() -> Self { + Self::default() + } + + /// Replaces the plaintext in the message. + /// + /// The caller must ensure that formatting entities remain valid for the given text. + /// If you need to update formatting entities, call method [`InputMedia::fmt_entities`]. + /// + ///
+ /// Note that this method does not modify formatting entities, which may break + /// formatting or cause out-of-bounds errors if entities do not match the given text. + ///
+ pub fn caption(mut self, s: T) -> Self + where + T: Into, + { + self.caption = s.into(); + self + } + + /// The formatting entities within the caption (such as bold, italics, etc.). + pub fn fmt_entities(mut self, entities: I) -> Self + where + I: IntoIterator, + { + self.entities = entities.into_iter().collect(); + self + } + + /// Builds a new media from the given markdown-formatted string as the + /// caption contents and entities. + /// + /// Note that Telegram only supports a very limited subset of entities: + /// bold, italic, underline, strikethrough, code blocks, pre blocks and inline links (inline + /// links with this format `tg://user?id=12345678` will be replaced with inline mentions when + /// possible). + #[cfg(feature = "markdown")] + pub fn markdown(mut self, s: T) -> Self + where + T: AsRef, + { + let (caption, entities) = crate::parsers::parse_markdown_message(s.as_ref()); + self.caption = caption; + self.entities = entities; + self + } + + /// Builds a new media from the given HTML-formatted string as the + /// caption contents and entities. + /// + /// Note that Telegram only supports a very limited subset of entities: + /// bold, italic, underline, strikethrough, code blocks, pre blocks and inline links (inline + /// links with this format `tg://user?id=12345678` will be replaced with inline mentions when + /// possible). + #[cfg(feature = "html")] + pub fn html(mut self, s: T) -> Self + where + T: AsRef, + { + let (caption, entities) = crate::parsers::parse_html_message(s.as_ref()); + self.caption = caption; + self.entities = entities; + self + } + + /// The album identifier to which this album should reply to, if any. + /// + /// Otherwise, this album will not be a reply to any other. + /// + /// Only the reply_to from the first media is used. + pub fn reply_to(mut self, reply_to: Option) -> Self { + self.reply_to = reply_to; + self + } + + /// Include the uploaded file as a photo in the album. + /// + /// The Telegram server will compress the image and convert it to JPEG format if necessary. + /// + /// The text will be the caption of the photo, which may be empty for no caption. + pub fn photo(mut self, file: Uploaded) -> Self { + self.media = Some( + (tl::types::InputMediaUploadedPhoto { + spoiler: false, + file: file.raw, + stickers: None, + ttl_seconds: self.media_ttl, + live_photo: false, + video: None, + }) + .into(), + ); + self + } + + /// Include an external photo in the album. + /// + /// The Telegram server will download and compress the image and convert it to JPEG format if + /// necessary. + /// + /// The text will be the caption of the photo, which may be empty for no caption. + pub fn photo_url(mut self, url: impl Into) -> Self { + self.media = Some( + (tl::types::InputMediaPhotoExternal { + spoiler: false, + url: url.into(), + ttl_seconds: self.media_ttl, + }) + .into(), + ); + self + } + + /// Include the uploaded file as a document in the album. + /// + /// You can use this to send videos, stickers, audios, or uncompressed photos. + /// + /// The text will be the caption of the document, which may be empty for no caption. + pub fn document(mut self, file: Uploaded) -> Self { + let mime_type = self.get_file_mime(&file); + self.media = Some(super::uploaded_document( + file, + mime_type, + false, + self.media_ttl, + )); + self + } + + /// Include a media in the message using the raw TL types. + /// + /// You can use this to send any media using the raw TL types that don't have + /// a specific method in this builder such as Dice, Polls, etc. + /// + /// This can also be used to send media with a file reference, see `InputMediaDocument` + /// and `InputMediaPhoto` in the `grammers-tl-types` crate. + /// + /// The text will be the caption of the media, which may be empty for no caption. + pub fn media>(mut self, media: M) -> Self { + self.media = Some(media.into()); + self + } + + /// Include the video file with thumb in the album. + /// + /// The text will be the caption of the document, which may be empty for no caption. + /// + /// # Examples + /// + /// ``` + /// async fn f(client: &mut grammers_client::Client) -> Result<(), Box> { + /// use grammers_client::media::InputMedia; + /// + /// let video = client.upload_file("video.mp4").await?; + /// let thumb = client.upload_file("thumb.png").await?; + /// let media = InputMedia::new().caption("").document(video).thumbnail(thumb); + /// Ok(()) + /// } + /// ``` + pub fn thumbnail(mut self, thumb: Uploaded) -> Self { + if let Some(tl::enums::InputMedia::UploadedDocument(document)) = &mut self.media { + document.thumb = Some(thumb.raw); + } + self + } + + /// Include an external file as a document in the album. + /// + /// You can use this to send videos, stickers, audios, or uncompressed photos. + /// + /// The Telegram server will be the one that downloads and includes the document as media. + /// + /// The text will be the caption of the document, which may be empty for no caption. + pub fn document_url(mut self, url: impl Into) -> Self { + self.media = Some( + (tl::types::InputMediaDocumentExternal { + spoiler: false, + url: url.into(), + ttl_seconds: self.media_ttl, + video_cover: None, + video_timestamp: None, + }) + .into(), + ); + self + } + + /// Add additional attributes to the media. + /// + /// This must be called *after* setting a file. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: &mut grammers_client::Client) -> Result<(), Box> { + /// # let audio = client.upload_file("audio.flac").await?; + /// # + /// use std::time::Duration; + /// use grammers_client::media::{Attribute, InputMedia}; + /// + /// let media = InputMedia::new().caption("").document(audio).attribute( + /// Attribute::Audio { + /// duration: Duration::new(123, 0), + /// title: Some("Hello".to_string()), + /// performer: Some("World".to_string()), + /// } + /// ); + /// # Ok(()) + /// # } + /// ``` + pub fn attribute(mut self, attr: Attribute) -> Self { + if let Some(tl::enums::InputMedia::UploadedDocument(document)) = &mut self.media { + document.attributes.push(attr.into()); + } + self + } + + /// Copy media from an existing message. + /// + /// You can use this to send media from another message without re-uploading it. + pub fn copy_media(mut self, media: &Media) -> Self { + self.media = media.to_raw_input_media(); + self + } + + /// Include the uploaded file as a document file in the album. + /// + /// You can use this to send any type of media as a simple document file. + /// + /// The text will be the caption of the file, which may be empty for no caption. + pub fn file(mut self, file: Uploaded) -> Self { + let mime_type = self.get_file_mime(&file); + self.media = Some(super::uploaded_document( + file, + mime_type, + true, + self.media_ttl, + )); + self + } + + /// Change the media's Time To Live (TTL). + /// + /// For example, this enables you to send a `photo` that can only be viewed for a certain + /// amount of seconds before it expires. + /// + /// Not all media supports this feature. + /// + /// This method should be called before setting any media, else it won't have any effect. + pub fn media_ttl(mut self, seconds: i32) -> Self { + self.media_ttl = if seconds < 0 { None } else { Some(seconds) }; + self + } + + /// Change the media's mime type. + /// + /// This method will override the mime type that would otherwise be automatically inferred + /// from the extension of the used file + /// + /// If no mime type is set and it cannot be inferred, the mime type will be + /// "application/octet-stream". + /// + /// This method should be called before setting any media, else it won't have any effect. + pub fn mime_type(mut self, mime_type: &str) -> Self { + self.mime_type = Some(mime_type.to_string()); + self + } + + /// Return the mime type string for the given file. + fn get_file_mime(&self, file: &Uploaded) -> String { + if let Some(mime) = self.mime_type.as_ref() { + mime.clone() + } else if let Some(mime) = mime_guess::from_path(file.name()).first() { + mime.essence_str().to_string() + } else { + "application/octet-stream".to_string() + } + } +} diff --git a/vendor/grammers-client/src/media/media.rs b/vendor/grammers-client/src/media/media.rs new file mode 100644 index 0000000..11ebf71 --- /dev/null +++ b/vendor/grammers-client/src/media/media.rs @@ -0,0 +1,887 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::fmt::Debug; + +use chrono::{DateTime, Utc}; +use grammers_tl_types as tl; + +use super::{Downloadable, PhotoSize}; + +/// Photo media contained within a message. +#[derive(Clone, Debug, PartialEq)] +pub struct Photo { + pub raw: tl::types::MessageMediaPhoto, +} + +/// File documents unlike the alternative of compressed photos. +/// +/// This includes uncompressed photos, videos, stickers, and files of any type. +#[derive(Clone, Debug, PartialEq)] +pub struct Document { + pub raw: tl::types::MessageMediaDocument, +} + +/// A sticker [`Document`]. +#[derive(Clone, Debug, PartialEq)] +pub struct Sticker { + pub document: Document, + pub raw_attrs: tl::types::DocumentAttributeSticker, + animated: bool, +} + +/// Data uploaded by the client to be used as either a [`Photo`] or [`Document`]. +#[derive(Clone, Debug, PartialEq)] +pub struct Uploaded { + pub raw: tl::enums::InputFile, +} + +/// Contact media containing a phone number. +#[derive(Clone, Debug, PartialEq)] +pub struct Contact { + pub raw: tl::types::MessageMediaContact, +} + +/// Poll or quiz which may be voted on while open. +#[derive(Clone, Debug, PartialEq)] +pub struct Poll { + pub raw: tl::types::Poll, + pub raw_results: tl::types::PollResults, +} + +/// Geo point with latitude and longitude coordinates on the globe. +#[derive(Clone, Debug, PartialEq)] +pub struct Geo { + pub raw: tl::types::GeoPoint, +} + +/// Animated built-in sticker with a set of possible outcomes. +/// +/// Originally used to roll dice (🎲), but other emoji such as darts (🎯), +/// basket-balls (🏀) and slot-machines (🎰) can also be used. +#[derive(Clone, Debug, PartialEq)] +pub struct Dice { + pub raw: tl::types::MessageMediaDice, +} + +/// A place with optional coordinates. +#[derive(Clone, Debug, PartialEq)] +pub struct Venue { + pub geo: Option, + pub raw_venue: tl::types::MessageMediaVenue, +} + +/// Like [`Geo`], but periodcally edits itself with updated information. +#[derive(Clone, Debug, PartialEq)] +pub struct GeoLive { + pub geo: Option, + pub raw_geolive: tl::types::MessageMediaGeoLive, +} + +/// Instant View web-page previews. +#[derive(Clone, Debug, PartialEq)] +pub struct WebPage { + pub raw: tl::types::MessageMediaWebPage, +} + +/// Profile picture of a group. +#[derive(Clone, Debug, PartialEq)] +pub struct ChatPhoto { + pub raw: tl::enums::InputFileLocation, +} + +/// Message media (e.g. photos, polls, videos). +#[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] +pub enum Media { + /// Compressed JPEG photo. + Photo(Photo), + /// File which may or not be rendered directly by clients. + Document(Document), + /// Document that has the attributes of a sticker. + Sticker(Sticker), + /// Contact information. + Contact(Contact), + /// Poll which can be voted on while open. + Poll(Poll), + /// Coordinates of a place. + Geo(Geo), + /// Dice-like built-in sticker media. + Dice(Dice), + /// Information about a place with optional coordinates. + Venue(Venue), + /// Self-updating coordinates of a place or person. + GeoLive(GeoLive), + /// Web-page instant view. + WebPage(WebPage), +} + +impl Photo { + pub fn from_raw(photo: tl::enums::Photo) -> Self { + Self { + raw: tl::types::MessageMediaPhoto { + spoiler: false, + photo: Some(photo), + ttl_seconds: None, + live_photo: false, + video: None, + }, + } + } + + pub fn from_raw_media(photo: tl::types::MessageMediaPhoto) -> Self { + Self { raw: photo } + } + + pub fn to_raw_input_media(&self) -> tl::types::InputMediaPhoto { + use tl::{ + enums::{InputPhoto as eInputPhoto, Photo}, + types::InputPhoto, + }; + + tl::types::InputMediaPhoto { + spoiler: false, + id: match self.raw.photo { + Some(Photo::Photo(ref photo)) => InputPhoto { + id: photo.id, + access_hash: photo.access_hash, + file_reference: photo.file_reference.clone(), + } + .into(), + _ => eInputPhoto::Empty, + }, + ttl_seconds: self.raw.ttl_seconds, + live_photo: false, + video: None, + } + } + + /// The globally unique identifier for this media, as seen by any account. + pub fn id(&self) -> i64 { + use tl::enums::Photo as P; + + match self.raw.photo.as_ref().unwrap() { + P::Empty(photo) => photo.id, + P::Photo(photo) => photo.id, + } + } + + /// The size of the photo, if not empty. + pub fn size(&self) -> Option { + self.thumbs() + .iter() + .max_by_key(|x| x.size()) + .map(|thumb| thumb.size()) + } + + /// Get photo thumbs. + /// + /// Since Telegram doesn't store the original photo, it can be presented in different sizes + /// and quality, a.k.a. thumbnails. Each photo preview has a specific type, indicating + /// the resolution and image transform that was applied server-side. Some low-resolution + /// thumbnails already contain all necessary information that can be shown to the user, but + /// for other types an additional request to the Telegram should be performed. + /// Check the description of [`PhotoSize``] to get an information about each particular thumbnail. + /// + /// + pub fn thumbs(&self) -> Vec { + use tl::enums::Photo as P; + + let photo = match self.raw.photo.as_ref() { + Some(photo) => photo, + None => return vec![], + }; + + match photo { + P::Empty(_) => vec![], + P::Photo(photo) => photo + .sizes + .iter() + .map(|x| PhotoSize::make_from(x, photo)) + .collect(), + } + } + + /// Returns true if the photo is a spoiler. + pub fn is_spoiler(&self) -> bool { + self.raw.spoiler + } + + /// Returns TTL seconds if the photo is self-destructive, None otherwise + pub fn ttl_seconds(&self) -> Option { + self.raw.ttl_seconds + } +} + +impl Downloadable for Photo { + fn to_raw_input_location(&self) -> Option { + use tl::enums::Photo as P; + + self.raw.photo.as_ref().and_then(|p| match p { + P::Empty(_) => None, + P::Photo(photo) => Some( + tl::types::InputPhotoFileLocation { + id: photo.id, + access_hash: photo.access_hash, + file_reference: photo.file_reference.clone(), + thumb_size: self + .thumbs() + .iter() + .max_by_key(|x| x.size()) + .map(|ps| ps.photo_type()) + .unwrap_or(String::from("w")), + } + .into(), + ), + }) + } + + fn size(&self) -> Option { + self.size() + } +} + +impl Document { + pub fn from_raw_media(document: tl::types::MessageMediaDocument) -> Self { + Self { raw: document } + } + + pub fn to_raw_input_media(&self) -> tl::types::InputMediaDocument { + use tl::{ + enums::{Document, InputDocument as eInputDocument}, + types::InputDocument, + }; + + tl::types::InputMediaDocument { + spoiler: false, + id: match self.raw.document { + Some(Document::Document(ref document)) => InputDocument { + id: document.id, + access_hash: document.access_hash, + file_reference: document.file_reference.clone(), + } + .into(), + _ => eInputDocument::Empty, + }, + ttl_seconds: self.raw.ttl_seconds, + query: None, + video_cover: None, + video_timestamp: None, + } + } + + /// The globally unique identifier for this media, as seen by any account. + pub fn id(&self) -> i64 { + use tl::enums::Document as D; + + match self.raw.document.as_ref().unwrap() { + D::Empty(document) => document.id, + D::Document(document) => document.id, + } + } + + /// Return the file's name. + /// + /// If the file was uploaded with no file name, the returned string will be empty. + /// + /// Note that the file name is not necessarily valid as seen by the OS, + /// and using it directly as the download location is subject to causing + /// path trasversal attacks. + pub fn name(&self) -> Option<&str> { + use tl::enums::Document as D; + + match self.raw.document.as_ref().unwrap() { + D::Empty(_) => None, + D::Document(document) => document.attributes.iter().find_map(|attr| match attr { + tl::enums::DocumentAttribute::Filename(attr) => Some(attr.file_name.as_ref()), + _ => None, + }), + } + } + + /// Get the file's MIME type, if any. + pub fn mime_type(&self) -> Option<&str> { + match self.raw.document.as_ref() { + Some(tl::enums::Document::Document(d)) => Some(d.mime_type.as_str()), + _ => None, + } + } + + /// The date on which the file was created, if any. + pub fn creation_date(&self) -> Option> { + match self.raw.document.as_ref() { + Some(tl::enums::Document::Document(d)) => { + Some(DateTime::::from_timestamp(d.date as i64, 0).expect("date out of range")) + } + _ => None, + } + } + + /// The size of the file. + pub fn size(&self) -> Option { + match self.raw.document.as_ref() { + Some(tl::enums::Document::Document(d)) => Some(d.size as usize), + _ => None, + } + } + + /// Get document thumbs. + /// See . + pub fn thumbs(&self) -> Vec { + use tl::enums::Document as D; + + let document = match self.raw.document.as_ref() { + Some(document) => document, + None => return vec![], + }; + + match document { + D::Empty(_) => vec![], + D::Document(document) => match &document.thumbs { + Some(thumbs) => thumbs + .iter() + .map(|x| PhotoSize::make_from_document(x, document)) + .collect(), + None => vec![], + }, + } + } + + /// Duration of video/audio, in seconds. + pub fn duration(&self) -> Option { + match self.raw.document.as_ref() { + Some(tl::enums::Document::Document(d)) => { + for attr in &d.attributes { + match attr { + tl::enums::DocumentAttribute::Video(v) => return Some(v.duration), + tl::enums::DocumentAttribute::Audio(a) => return Some(a.duration as _), + _ => {} + } + } + None + } + _ => None, + } + } + + /// Width & height of video/image, in pixels. + pub fn resolution(&self) -> Option<(i32, i32)> { + match self.raw.document.as_ref() { + Some(tl::enums::Document::Document(d)) => { + for attr in &d.attributes { + match attr { + tl::enums::DocumentAttribute::Video(v) => return Some((v.w, v.h)), + tl::enums::DocumentAttribute::ImageSize(i) => return Some((i.w, i.h)), + _ => {} + } + } + None + } + _ => None, + } + } + + /// Title of audio. + pub fn audio_title(&self) -> Option<&str> { + match self.raw.document.as_ref() { + Some(tl::enums::Document::Document(d)) => { + for attr in &d.attributes { + #[allow(clippy::single_match)] + match attr { + tl::enums::DocumentAttribute::Audio(a) => return a.title.as_deref(), + _ => {} + } + } + None + } + _ => None, + } + } + + /// Performer (artist) of audio. + pub fn performer(&self) -> Option<&str> { + match self.raw.document.as_ref() { + Some(tl::enums::Document::Document(d)) => { + for attr in &d.attributes { + #[allow(clippy::single_match)] + match attr { + tl::enums::DocumentAttribute::Audio(a) => return a.performer.as_deref(), + _ => {} + } + } + None + } + _ => None, + } + } + + /// Returns `true` if the document is an animated sticker. + pub fn is_animated(&self) -> bool { + match self.raw.document.as_ref() { + Some(tl::enums::Document::Document(d)) => { + for attr in &d.attributes { + #[allow(clippy::single_match)] + match attr { + tl::enums::DocumentAttribute::Animated => return true, + _ => {} + } + } + false + } + _ => false, + } + } + + /// Returns `true` if the document is a spoiler. + pub fn is_spoiler(&self) -> bool { + self.raw.spoiler + } +} + +impl Downloadable for Document { + fn to_raw_input_location(&self) -> Option { + use tl::enums::Document as D; + + self.raw.document.as_ref().and_then(|p| match p { + D::Empty(_) => None, + D::Document(document) => Some( + tl::types::InputDocumentFileLocation { + id: document.id, + access_hash: document.access_hash, + file_reference: document.file_reference.clone(), + thumb_size: String::new(), + } + .into(), + ), + }) + } + + fn size(&self) -> Option { + self.size() + } +} + +impl Sticker { + pub fn from_document(document: &Document) -> Option { + match document.raw.document { + Some(tl::enums::Document::Document(ref doc)) => { + let mut animated = false; + let mut sticker_attrs: Option = None; + for attr in &doc.attributes { + match attr { + tl::enums::DocumentAttribute::Sticker(s) => sticker_attrs = Some(s.clone()), + tl::enums::DocumentAttribute::Animated => animated = true, + _ => (), + } + } + Some(Self { + document: document.clone(), + raw_attrs: sticker_attrs?, + animated, + }) + } + _ => None, + } + } + + /// Get the emoji associated with the sticker. + pub fn emoji(&self) -> &str { + self.raw_attrs.alt.as_str() + } + + /// Is this sticker an animated sticker? + pub fn is_animated(&self) -> bool { + self.animated + } +} + +impl Contact { + pub fn from_raw_media(contact: tl::types::MessageMediaContact) -> Self { + Self { raw: contact } + } + + pub fn to_raw_input_media(&self) -> tl::types::InputMediaContact { + tl::types::InputMediaContact { + phone_number: self.raw.phone_number.clone(), + first_name: self.raw.first_name.clone(), + last_name: self.raw.last_name.clone(), + vcard: self.raw.vcard.clone(), + } + } + + /// The contact's phone number, in international format. This field will always be a non-empty + /// string of digits, although there's no guarantee that the number actually exists. + pub fn phone_number(&self) -> &str { + self.raw.phone_number.as_str() + } + + /// The contact's first name. Although official clients will always send a non-empty string, + /// it is possible for this field to be empty when sent via different means. + pub fn first_name(&self) -> &str { + self.raw.first_name.as_str() + } + + /// The contact's last name. May be empty if it's not set by sender. + pub fn last_name(&self) -> &str { + self.raw.last_name.as_str() + } + + /// Contact information in [vCard format]. Applications such as Telegram Desktop leave this + /// field empty. The vCard version used in this field could be any. The field may also contain + /// arbitrary text when sent by non-official clients. + /// + /// [vCard format]: https://en.wikipedia.org/wiki/VCard + pub fn vcard(&self) -> &str { + self.raw.vcard.as_str() + } +} + +impl Poll { + pub fn from_raw_media(poll: tl::types::MessageMediaPoll) -> Self { + Self { + raw: match poll.poll { + tl::enums::Poll::Poll(poll) => poll, + }, + raw_results: match poll.results { + tl::enums::PollResults::Results(results) => *results, + }, + } + } + + pub fn to_raw_input_media(&self) -> tl::types::InputMediaPoll { + tl::types::InputMediaPoll { + poll: grammers_tl_types::enums::Poll::Poll(self.raw.clone()), + correct_answers: None, + solution: None, + solution_entities: None, + attached_media: None, + solution_media: None, + } + } + + /// Returns the question asked by the poll. + pub fn question(&self) -> &grammers_tl_types::enums::TextWithEntities { + &self.raw.question + } + + /// Return `true` if the poll is a quiz with a set of correct answers. + pub fn is_quiz(&self) -> bool { + self.raw.quiz + } + + /// Returns `true` if the poll is closed. + pub fn closed(&self) -> bool { + self.raw.closed + } + + /// Returns an iterator over the poll answer options. + pub fn iter_answers(&self) -> impl Iterator { + self.raw.answers.iter() + } + + /// Total voters that took part in the vote. + /// + /// May be `None` if poll hasn't started. + pub fn total_voters(&self) -> Option { + self.raw_results.total_voters + } + + /// Returns details of the voters choices, + /// such as amount of voters per answer and whether the answer + /// is currently selected by the logged-in account. + pub fn iter_voters_summary( + &self, + ) -> Option> { + self.raw_results.results.as_ref().map(|results| { + results.iter().map(|result| match result { + tl::enums::PollAnswerVoters::Voters(voters) => voters, + }) + }) + } +} + +impl Geo { + pub fn from_raw_media(geo: tl::types::MessageMediaGeo) -> Option { + use tl::enums::GeoPoint as eGeoPoint; + + match &geo.geo { + eGeoPoint::Empty => None, + eGeoPoint::Point(point) => Some(Self { raw: point.clone() }), + } + } + + pub fn to_raw_input_media(&self) -> tl::types::InputMediaGeoPoint { + use tl::types::InputGeoPoint; + + tl::types::InputMediaGeoPoint { + geo_point: InputGeoPoint { + lat: self.raw.lat, + long: self.raw.long, + accuracy_radius: self.raw.accuracy_radius, + } + .into(), + } + } + + pub fn to_raw_input_geo_point(&self) -> tl::enums::InputGeoPoint { + use tl::{enums::InputGeoPoint as eInputGeoPoint, types::InputGeoPoint}; + + eInputGeoPoint::Point(InputGeoPoint { + lat: self.raw.lat, + long: self.raw.long, + accuracy_radius: self.raw.accuracy_radius, + }) + } + + /// Get the latitude of the location. + pub fn latitue(&self) -> f64 { + self.raw.lat + } + + /// Get the latitude of the location. + pub fn longitude(&self) -> f64 { + self.raw.long + } + + /// Get the accuracy of the geo location, in meters. + pub fn accuracy_radius(&self) -> Option { + self.raw.accuracy_radius + } +} + +impl Dice { + pub fn from_raw_media(dice: tl::types::MessageMediaDice) -> Self { + Self { raw: dice } + } + + pub fn to_raw_input_media(&self) -> tl::types::InputMediaDice { + tl::types::InputMediaDice { + emoticon: self.raw.emoticon.clone(), + } + } + + /// Get the emoji of the dice. + pub fn emoji(&self) -> &str { + &self.raw.emoticon + } + + /// Get the value of the dice. + pub fn value(&self) -> i32 { + self.raw.value + } +} + +impl Venue { + pub fn from_raw_media(venue: tl::types::MessageMediaVenue) -> Self { + use tl::types::MessageMediaGeo; + Self { + geo: Geo::from_raw_media(MessageMediaGeo { + geo: venue.geo.clone(), + }), + raw_venue: venue, + } + } + + pub fn to_raw_input_media(&self) -> tl::types::InputMediaVenue { + tl::types::InputMediaVenue { + geo_point: match self.geo { + Some(ref geo) => geo.to_raw_input_geo_point(), + None => tl::enums::InputGeoPoint::Empty, + }, + title: self.raw_venue.title.clone(), + address: self.raw_venue.address.clone(), + provider: self.raw_venue.provider.clone(), + venue_id: self.raw_venue.venue_id.clone(), + venue_type: self.raw_venue.venue_type.clone(), + } + } + + /// Returns the title of the venue. + pub fn title(&self) -> &str { + &self.raw_venue.title + } + + /// Returns the address of the venue. + pub fn address(&self) -> &str { + &self.raw_venue.address + } + + /// Returns the provider of the venue location. + pub fn provider(&self) -> &str { + &self.raw_venue.provider + } + + /// Returns the id of the venue. + pub fn venue_id(&self) -> &str { + &self.raw_venue.venue_id + } + + /// Returns the type of the venue. + pub fn venue_type(&self) -> &str { + &self.raw_venue.venue_type + } +} + +impl GeoLive { + pub fn from_raw_media(geolive: tl::types::MessageMediaGeoLive) -> Self { + use tl::types::MessageMediaGeo; + Self { + geo: Geo::from_raw_media(MessageMediaGeo { + geo: geolive.geo.clone(), + }), + raw_geolive: geolive, + } + } + + pub fn to_raw_input_media(&self) -> tl::types::InputMediaGeoLive { + tl::types::InputMediaGeoLive { + geo_point: match self.geo { + Some(ref geo) => geo.to_raw_input_geo_point(), + None => tl::enums::InputGeoPoint::Empty, + }, + heading: self.raw_geolive.heading, + period: Some(self.raw_geolive.period), + proximity_notification_radius: self.raw_geolive.proximity_notification_radius, + stopped: false, + } + } + + /// Returns the heading of the live location in degress (1-360). + pub fn heading(&self) -> Option { + self.raw_geolive.heading + } + + /// Returns the validity period of the live location. + pub fn period(&self) -> i32 { + self.raw_geolive.period + } + + /// Returns the radius of the proximity alert. + pub fn proximity_notification_radius(&self) -> Option { + self.raw_geolive.proximity_notification_radius + } +} + +impl WebPage { + pub fn from_raw_media(webpage: tl::types::MessageMediaWebPage) -> Self { + Self { raw: webpage } + } +} + +impl Uploaded { + pub fn from_raw(input_file: tl::enums::InputFile) -> Self { + Self { raw: input_file } + } + + pub(crate) fn name(&self) -> &str { + match &self.raw { + tl::enums::InputFile::File(f) => f.name.as_ref(), + tl::enums::InputFile::Big(f) => f.name.as_ref(), + tl::enums::InputFile::StoryDocument(_) => "", + } + } +} + +impl Media { + pub fn from_raw(media: tl::enums::MessageMedia) -> Option { + use tl::enums::MessageMedia as M; + + // TODO implement the rest + match media { + M::Empty => None, + M::Photo(photo) => Some(Self::Photo(Photo::from_raw_media(photo))), + M::Geo(geo) => Geo::from_raw_media(geo).map(Self::Geo), + M::Contact(contact) => Some(Self::Contact(Contact::from_raw_media(contact))), + M::Unsupported => None, + M::Document(document) => { + let document = Document::from_raw_media(document); + Some(if let Some(sticker) = Sticker::from_document(&document) { + Self::Sticker(sticker) + } else { + Self::Document(document) + }) + } + M::WebPage(webpage) => Some(Self::WebPage(WebPage::from_raw_media(webpage))), + M::Venue(venue) => Some(Self::Venue(Venue::from_raw_media(venue))), + M::Game(_) => None, + M::Invoice(_) => None, + M::GeoLive(geolive) => Some(Self::GeoLive(GeoLive::from_raw_media(geolive))), + M::Poll(poll) => Some(Self::Poll(Poll::from_raw_media(*poll))), + M::Dice(dice) => Some(Self::Dice(Dice::from_raw_media(dice))), + M::Story(_) => None, + M::Giveaway(_) => None, + M::GiveawayResults(_) => None, + M::PaidMedia(_) => None, + M::ToDo(_) => None, + M::VideoStream(_) => None, + } + } + + pub fn size(&self) -> Option { + match self { + Media::Photo(photo) => photo.size(), + Media::Document(document) => document.size(), + Media::Sticker(sticker) => sticker.document.size(), + Media::Contact(_) => None, + Media::Poll(_) => None, + Media::Geo(_) => None, + Media::Dice(_) => None, + Media::Venue(_) => None, + Media::GeoLive(_) => None, + Media::WebPage(_) => None, + } + } + + pub fn to_raw_input_media(&self) -> Option { + match self { + Media::Photo(photo) => Some(photo.to_raw_input_media().into()), + Media::Document(document) => Some(document.to_raw_input_media().into()), + Media::Sticker(sticker) => Some(sticker.document.to_raw_input_media().into()), + Media::Contact(contact) => Some(contact.to_raw_input_media().into()), + Media::Poll(poll) => Some(poll.to_raw_input_media().into()), + Media::Geo(geo) => Some(geo.to_raw_input_media().into()), + Media::Dice(dice) => Some(dice.to_raw_input_media().into()), + Media::Venue(venue) => Some(venue.to_raw_input_media().into()), + Media::GeoLive(geolive) => Some(geolive.to_raw_input_media().into()), + Media::WebPage(_) => None, + } + } +} + +impl Downloadable for Media { + fn to_raw_input_location(&self) -> Option { + match self { + Media::Photo(photo) => photo.to_raw_input_location(), + Media::Document(document) => document.to_raw_input_location(), + Media::Sticker(sticker) => sticker.document.to_raw_input_location(), + Media::Contact(_) => None, + Media::Poll(_) => None, + Media::Geo(_) => None, + Media::Dice(_) => None, + Media::Venue(_) => None, + Media::GeoLive(_) => None, + Media::WebPage(_) => None, + } + } + + fn size(&self) -> Option { + self.size() + } +} + +impl From for Media { + fn from(photo: Photo) -> Self { + Self::Photo(photo) + } +} + +impl Downloadable for ChatPhoto { + fn to_raw_input_location(&self) -> Option { + Some(self.raw.clone()) + } +} diff --git a/vendor/grammers-client/src/media/mod.rs b/vendor/grammers-client/src/media/mod.rs new file mode 100644 index 0000000..dfcb653 --- /dev/null +++ b/vendor/grammers-client/src/media/mod.rs @@ -0,0 +1,56 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Types relating to message media and downloadables. +//! +//! Properties containing raw types are public and will either be called "raw" or prefixed with "raw_".\ +//! Keep in mind that **these fields are not part of the semantic versioning guarantees**. + +mod attributes; +mod downloadable; +mod input_media; +mod media; +mod photo_sizes; + +pub use attributes::Attribute; +pub use downloadable::Downloadable; +pub use input_media::InputMedia; +pub use media::{ + ChatPhoto, Contact, Dice, Document, Geo, GeoLive, Media, Photo, Poll, Sticker, Uploaded, Venue, + WebPage, +}; +pub use photo_sizes::PhotoSize; + +use grammers_tl_types as tl; + +/// Build an `InputMediaUploadedDocument` from an uploaded file. +/// +/// Shared by the `document` (`force_file = false`) and `file` (`force_file = true`) +/// builder methods of `InputMedia` and `InputMessage`. +pub(crate) fn uploaded_document( + file: Uploaded, + mime_type: String, + force_file: bool, + ttl_seconds: Option, +) -> tl::enums::InputMedia { + let file_name = file.name().to_string(); + tl::types::InputMediaUploadedDocument { + nosound_video: false, + force_file, + spoiler: false, + file: file.raw, + thumb: None, + mime_type, + attributes: vec![(tl::types::DocumentAttributeFilename { file_name }).into()], + stickers: None, + ttl_seconds, + video_cover: None, + video_timestamp: None, + } + .into() +} diff --git a/vendor/grammers-client/src/media/photo_sizes.rs b/vendor/grammers-client/src/media/photo_sizes.rs new file mode 100644 index 0000000..73bb941 --- /dev/null +++ b/vendor/grammers-client/src/media/photo_sizes.rs @@ -0,0 +1,398 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use grammers_tl_types as tl; + +use super::Downloadable; + +/// Build the raw input file location for a photo or document thumbnail. +fn raw_input_location( + from_document: bool, + id: i64, + access_hash: i64, + file_reference: Vec, + thumb_size: String, +) -> tl::enums::InputFileLocation { + if !from_document { + tl::types::InputPhotoFileLocation { + id, + access_hash, + file_reference, + thumb_size, + } + .into() + } else { + tl::types::InputDocumentFileLocation { + id, + access_hash, + file_reference, + thumb_size, + } + .into() + } +} + +/// Thumbnail of a [`Photo`] or [`Document`]. +/// +/// [`Photo`]: super::Photo +/// [`Document`]: super::Document +#[derive(Clone, Debug, PartialEq)] +pub enum PhotoSize { + /// Size that is no longer available. + Empty(SizeEmpty), + /// Size that may be downloaded. + Size(Size), + /// Size attached directly to the message object. + Cached(CachedSize), + /// Stripped size attached directly to the message object. + Stripped(StrippedSize), + /// Multiple sizes in one. + Progressive(ProgressiveSize), + /// Vector thumbnail. + Path(PathSize), +} + +impl PhotoSize { + pub(crate) fn make_from(size: &tl::enums::PhotoSize, photo: &tl::types::Photo) -> Self { + match size { + tl::enums::PhotoSize::Empty(size) => PhotoSize::Empty(SizeEmpty { + photo_type: size.r#type.clone(), + }), + tl::enums::PhotoSize::Size(size) => PhotoSize::Size(Size { + photo_type: size.r#type.clone(), + width: size.w, + height: size.h, + size: size.size, + id: photo.id, + access_hash: photo.access_hash, + file_reference: photo.file_reference.clone(), + from_document: false, + }), + tl::enums::PhotoSize::PhotoCachedSize(size) => PhotoSize::Cached(CachedSize { + photo_type: size.r#type.clone(), + width: size.w, + height: size.h, + bytes: size.bytes.clone(), + }), + tl::enums::PhotoSize::PhotoStrippedSize(size) => PhotoSize::Stripped(StrippedSize { + photo_type: size.r#type.clone(), + bytes: size.bytes.clone(), + }), + tl::enums::PhotoSize::Progressive(size) => PhotoSize::Progressive(ProgressiveSize { + photo_type: size.r#type.clone(), + width: size.w, + height: size.h, + sizes: size.sizes.clone(), + id: photo.id, + access_hash: photo.access_hash, + file_reference: photo.file_reference.clone(), + from_document: false, + }), + tl::enums::PhotoSize::PhotoPathSize(size) => PhotoSize::Path(PathSize { + photo_type: size.r#type.clone(), + bytes: size.bytes.clone(), + }), + } + } + + pub(crate) fn make_from_document( + size: &tl::enums::PhotoSize, + document: &tl::types::Document, + ) -> Self { + match size { + tl::enums::PhotoSize::Empty(size) => PhotoSize::Empty(SizeEmpty { + photo_type: size.r#type.clone(), + }), + tl::enums::PhotoSize::Size(size) => PhotoSize::Size(Size { + photo_type: size.r#type.clone(), + width: size.w, + height: size.h, + size: size.size, + id: document.id, + access_hash: document.access_hash, + file_reference: document.file_reference.clone(), + from_document: true, + }), + tl::enums::PhotoSize::PhotoCachedSize(size) => PhotoSize::Cached(CachedSize { + photo_type: size.r#type.clone(), + width: size.w, + height: size.h, + bytes: size.bytes.clone(), + }), + tl::enums::PhotoSize::PhotoStrippedSize(size) => PhotoSize::Stripped(StrippedSize { + photo_type: size.r#type.clone(), + bytes: size.bytes.clone(), + }), + tl::enums::PhotoSize::Progressive(size) => PhotoSize::Progressive(ProgressiveSize { + photo_type: size.r#type.clone(), + width: size.w, + height: size.h, + sizes: size.sizes.clone(), + id: document.id, + access_hash: document.access_hash, + file_reference: document.file_reference.clone(), + from_document: true, + }), + tl::enums::PhotoSize::PhotoPathSize(size) => PhotoSize::Path(PathSize { + photo_type: size.r#type.clone(), + bytes: size.bytes.clone(), + }), + } + } + + /// Size of the photo thumb. + pub fn size(&self) -> usize { + match self { + PhotoSize::Empty(_) => 0, + PhotoSize::Size(size) => size.size as usize, + PhotoSize::Cached(size) => size.bytes.len(), + PhotoSize::Stripped(size) => { + let bytes = &size.bytes; + if bytes.len() < 3 || bytes[0] != 0x01 { + return 0; + } + size.bytes.len() + 622 + } + PhotoSize::Progressive(size) => { + size.sizes.iter().max().unwrap_or(&0).to_owned() as usize + } + PhotoSize::Path(size) => size.bytes.len(), + } + } + + /// Resolution and image transform applied server-side. + /// + /// + pub fn photo_type(&self) -> String { + match self { + PhotoSize::Empty(size) => size.photo_type.clone(), + PhotoSize::Size(size) => size.photo_type.clone(), + PhotoSize::Cached(size) => size.photo_type.clone(), + PhotoSize::Stripped(size) => size.photo_type.clone(), + PhotoSize::Progressive(size) => size.photo_type.clone(), + PhotoSize::Path(size) => size.photo_type.clone(), + } + } +} + +impl Downloadable for PhotoSize { + fn to_raw_input_location(&self) -> Option { + match self { + PhotoSize::Size(size) => size.to_raw_input_location(), + PhotoSize::Empty(_) => None, + PhotoSize::Cached(_) => None, + PhotoSize::Stripped(_) => None, + PhotoSize::Progressive(size) => size.to_raw_input_location(), + PhotoSize::Path(_) => None, + } + } + + fn to_data(&self) -> Option> { + match self { + PhotoSize::Empty(_) => None, + PhotoSize::Size(_) => None, + PhotoSize::Cached(size) => Some(size.data()), + PhotoSize::Stripped(size) => Some(size.data()), + PhotoSize::Progressive(_) => None, + PhotoSize::Path(size) => Some(size.data()), + } + } +} + +/// Empty thumbnail. Image with this thumbnail is unavailable. +#[derive(Clone, Debug, PartialEq)] +pub struct SizeEmpty { + photo_type: String, +} + +/// Image description. An additional request to Telegram should be perfomed to download the image +#[derive(Clone, Debug, PartialEq)] +pub struct Size { + photo_type: String, + pub width: i32, + pub height: i32, + pub size: i32, + + id: i64, + access_hash: i64, + file_reference: Vec, + + from_document: bool, +} + +impl Size { + pub fn to_raw_input_location(&self) -> Option { + Some(raw_input_location( + self.from_document, + self.id, + self.access_hash, + self.file_reference.clone(), + self.photo_type.clone(), + )) + } +} + +/// Description of an image and its content. +#[derive(Clone, Debug, PartialEq)] +pub struct CachedSize { + photo_type: String, + + pub width: i32, + pub height: i32, + pub bytes: Vec, +} + +impl CachedSize { + fn data(&self) -> Vec { + self.bytes.clone() + } +} + +/// A low-resolution compressed JPG payload +#[derive(Clone, Debug, PartialEq)] +pub struct StrippedSize { + photo_type: String, + + pub bytes: Vec, +} + +impl StrippedSize { + fn data(&self) -> Vec { + // Based on https://core.tlgr.org/api/files#stripped-thumbnails + let bytes = &self.bytes; + if bytes.len() < 3 || bytes[0] != 0x01 { + return vec![]; + } + + let header = vec![ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, + 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43, 0x00, 0x28, 0x1c, 0x1e, + 0x23, 0x1e, 0x19, 0x28, 0x23, 0x21, 0x23, 0x2d, 0x2b, 0x28, 0x30, 0x3c, 0x64, 0x41, + 0x3c, 0x37, 0x37, 0x3c, 0x7b, 0x58, 0x5d, 0x49, 0x64, 0x91, 0x80, 0x99, 0x96, 0x8f, + 0x80, 0x8c, 0x8a, 0xa0, 0xb4, 0xe6, 0xc3, 0xa0, 0xaa, 0xda, 0xad, 0x8a, 0x8c, 0xc8, + 0xff, 0xcb, 0xda, 0xee, 0xf5, 0xff, 0xff, 0xff, 0x9b, 0xc1, 0xff, 0xff, 0xff, 0xfa, + 0xff, 0xe6, 0xfd, 0xff, 0xf8, 0xff, 0xdb, 0x00, 0x43, 0x01, 0x2b, 0x2d, 0x2d, 0x3c, + 0x35, 0x3c, 0x76, 0x41, 0x41, 0x76, 0xf8, 0xa5, 0x8c, 0xa5, 0xf8, 0xf8, 0xf8, 0xf8, + 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, + 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, + 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, + 0xf8, 0xf8, 0xf8, 0xf8, 0xff, 0xc0, 0x00, 0x11, 0x08, 0x00, 0x00, 0x00, 0x00, 0x03, + 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xff, 0xc4, 0x00, 0x1f, 0x00, + 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, + 0xff, 0xc4, 0x00, 0xb5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, + 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7d, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, + 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, + 0x91, 0xa1, 0x08, 0x23, 0x42, 0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, 0x24, 0x33, 0x62, + 0x72, 0x82, 0x09, 0x0a, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x25, 0x26, 0x27, 0x28, 0x29, + 0x2a, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, + 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, + 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x83, 0x84, + 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, + 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, + 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, + 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, + 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, + 0xfa, 0xff, 0xc4, 0x00, 0x1f, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, + 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x11, 0x00, 0x02, 0x01, + 0x02, 0x04, 0x04, 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, + 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, + 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xa1, 0xb1, 0xc1, 0x09, 0x23, + 0x33, 0x52, 0xf0, 0x15, 0x62, 0x72, 0xd1, 0x0a, 0x16, 0x24, 0x34, 0xe1, 0x25, 0xf1, + 0x17, 0x18, 0x19, 0x1a, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x35, 0x36, 0x37, 0x38, 0x39, + 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, + 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, + 0x76, 0x77, 0x78, 0x79, 0x7a, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, + 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, + 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2, + 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, + 0xd8, 0xd9, 0xda, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf2, 0xf3, + 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xda, 0x00, 0x0c, 0x03, 0x01, 0x00, + 0x02, 0x11, 0x03, 0x11, 0x00, 0x3f, 0x00, + ]; + let mut footer = vec![0xff, 0xd9]; + let mut real = header; + real[164] = bytes[1]; + real[166] = bytes[2]; + + let mut bytes_clone = bytes.clone()[3..].to_vec(); + real.append(&mut bytes_clone); + real.append(&mut footer); + + real + } +} + +/// Progressively encoded photosize. An additional request to Telegram should be perfomed to download the image +#[derive(Clone, Debug, PartialEq)] +pub struct ProgressiveSize { + photo_type: String, + + pub width: i32, + pub height: i32, + pub sizes: Vec, + + id: i64, + access_hash: i64, + file_reference: Vec, + + from_document: bool, +} + +impl ProgressiveSize { + pub fn to_raw_input_location(&self) -> Option { + Some(raw_input_location( + self.from_document, + self.id, + self.access_hash, + self.file_reference.clone(), + self.photo_type.clone(), + )) + } +} + +/// Messages with animated stickers can have a compressed svg (< 300 bytes) to show the outline +/// of the sticker before fetching the actual lottie animation. +#[derive(Clone, Debug, PartialEq)] +pub struct PathSize { + photo_type: String, + + pub bytes: Vec, +} + +impl PathSize { + fn data(&self) -> Vec { + // Based on https://core.tlgr.org/api/files#vector-thumbnails + let lookup = "AACAAAAHAAALMAAAQASTAVAAAZaacaaaahaaalmaaaqastava.az0123456789-,"; + let mut path = String::from("M"); + for num in &self.bytes { + let num = *num; + if num >= 128 + 64 { + path.push(lookup.chars().nth((num - 128 - 64) as usize).unwrap()); + } else { + if num >= 128 { + path.push(','); + } else if num >= 64 { + path.push('-'); + } + path.push((num & 63) as char); + } + } + path.push('z'); + let res = format!( + r###" + + +"### + ); + + res.as_bytes().to_vec() + } +} diff --git a/vendor/grammers-client/src/message/button.rs b/vendor/grammers-client/src/message/button.rs new file mode 100755 index 0000000..aac9913 --- /dev/null +++ b/vendor/grammers-client/src/message/button.rs @@ -0,0 +1,179 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Custom keyboards and inline buttons used by messages. + +use grammers_tl_types as tl; + +/// Inline button to be used as the reply markup underneath a message. +pub struct Button { + pub raw: tl::enums::KeyboardButton, +} + +/// Key to be used as the reply markup of an alternate virtual keyboard. +pub struct Key { + pub raw: tl::enums::KeyboardButton, +} + +impl Button { + /// Creates a button that will trigger a [`crate::update::CallbackQuery`] when clicked. + /// + /// Although any combination of bytes can be used (including null, and not just UTF-8), [there is + /// a limit](https://core.telegram.org/bots/api#inlinekeyboardbutton) to how long the payload data + /// can be (see the description for the `callback_data` field for an up-to-date value). If you + /// need to store more data than that, consider storing the real data in some form of database, + /// and a reference to that data's row in the button's payload. + /// + /// Both the text and bytes data must be non-empty. + pub fn data, B: Into>>(text: T, bytes: B) -> Button { + Button { + raw: tl::types::KeyboardButtonCallback { + text: text.into(), + data: bytes.into(), + requires_password: false, + style: None, + } + .into(), + } + } + + /// Creates a button to force the user to switch to inline mode (perform inline queries). + /// + /// Pressing the button will insert the bot's username and the specified inline query in the input + /// field. + pub fn switch, Q: Into>(text: T, query: Q) -> Button { + Button { + raw: tl::types::KeyboardButtonSwitchInline { + text: text.into(), + query: query.into(), + same_peer: true, + style: None, + peer_types: None, + } + .into(), + } + } + /// Creates a button identical to [`Self::switch`], except the user will be prompted to select a + /// different peer. + /// + /// Pressing the button will prompt the user to select one of their peers, open that peer and + /// insert the bot's username and the specified inline query in the input field. + pub fn switch_elsewhere, Q: Into>(text: T, query: Q) -> Button { + Button { + raw: tl::types::KeyboardButtonSwitchInline { + text: text.into(), + query: query.into(), + same_peer: false, + style: None, + peer_types: None, + } + .into(), + } + } + + /// Creates a button that when clicked will ask the user if they want to open the specified URL. + /// + /// The URL will be visible to the user before it's opened unless it's trusted (such as Telegram's + /// domain). + pub fn url, U: Into>(text: T, url: U) -> Button { + Button { + raw: tl::types::KeyboardButtonUrl { + style: None, + text: text.into(), + url: url.into(), + } + .into(), + } + } + + /// Creates a button that when clicked will open the specified URL in an in-app browser. + pub fn webview, U: Into>(text: T, url: U) -> Button { + Button { + raw: tl::types::KeyboardButtonWebView { + style: None, + text: text.into(), + url: url.into(), + } + .into(), + } + } +} + +impl Key { + /// Creates a simple keyboard key. + /// + /// When pressed, the button's text will be sent as a normal message, as if the user had typed it. + pub fn text>(text: T) -> Key { + Key { + raw: tl::types::KeyboardButton { + style: None, + text: text.into(), + } + .into(), + } + } + + /// Creates a keyboard key to request the user's contact information (including the phone). + pub fn request_phone>(text: T) -> Key { + Key { + raw: tl::types::KeyboardButtonRequestPhone { + style: None, + text: text.into(), + } + .into(), + } + } + + /// Creates a keyboard key to request the user's current geo-location. + pub fn request_geo>(text: T) -> Key { + Key { + raw: tl::types::KeyboardButtonRequestGeoLocation { + style: None, + text: text.into(), + } + .into(), + } + } + + /// Creates a keyboard key that will direct the user to create and send a poll when pressed. + /// + /// This is only available in direct conversations with the user. + pub fn request_poll>(text: T) -> Key { + Key { + raw: tl::types::KeyboardButtonRequestPoll { + style: None, + text: text.into(), + quiz: None, + } + .into(), + } + } + + /// Creates a keyboard key identical to [`Self::request_poll`], except the poll requested must be a quiz. + /// + /// This is only available in direct conversations with the user. + pub fn request_quiz>(text: T) -> Key { + Key { + raw: tl::types::KeyboardButtonRequestPoll { + style: None, + text: text.into(), + quiz: Some(true), + } + .into(), + } + } +} + +/* +TODO implement other buttons +(with password) keyboardButtonCallback#35bbdb6b flags:# requires_password:flags.0?true text:string data:bytes = KeyboardButton; +keyboardButtonUrlAuth#10b78d29 flags:# text:string fwd_text:flags.0?string url:string button_id:int = KeyboardButton; +keyboardButtonGame#50f41ccf text:string = KeyboardButton; +keyboardButtonBuy#afd93fbb text:string = KeyboardButton; +inputKeyboardButtonUrlAuth#d02e7fd4 flags:# request_write_access:flags.0?true text:string fwd_text:flags.1?string url:string bot:InputUser = KeyboardButton; +*/ diff --git a/vendor/grammers-client/src/message/input_message.rs b/vendor/grammers-client/src/message/input_message.rs new file mode 100644 index 0000000..5ef23d4 --- /dev/null +++ b/vendor/grammers-client/src/message/input_message.rs @@ -0,0 +1,427 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use grammers_tl_types as tl; + +use super::reply_markup::ReplyMarkup; +use crate::media::{Attribute, Media, Uploaded}; + +// https://github.com/telegramdesktop/tdesktop/blob/e7fbcce9d9f0a8944eb2c34e74bd01b8776cb891/Telegram/SourceFiles/data/data_scheduled_messages.h#L52 +const SCHEDULE_ONCE_ONLINE: i32 = 0x7ffffffe; + +/// Construct and send rich text messages with various options. +#[derive(Clone, Default)] +pub struct InputMessage { + pub(crate) background: bool, + pub(crate) clear_draft: bool, + pub(crate) entities: Vec, + pub(crate) invert_media: bool, + pub(crate) link_preview: bool, + pub(crate) reply_markup: Option, + pub(crate) reply_to: Option, + pub(crate) schedule_date: Option, + pub(crate) silent: bool, + pub(crate) text: String, + pub(crate) media: Option, + media_ttl: Option, + mime_type: Option, +} + +impl InputMessage { + /// Creates a new empty message for input. + pub fn new() -> Self { + Self::default() + } + + /// Whether to "send this message as a background message". + /// + /// This description is taken from . + pub fn background(mut self, background: bool) -> Self { + self.background = background; + self + } + + /// Whether the draft in this peer, if any, should be cleared. + pub fn clear_draft(mut self, clear_draft: bool) -> Self { + self.clear_draft = clear_draft; + self + } + + /// Replaces the plaintext in the message. + /// + /// The caller must ensure that formatting entities remain valid for the given text. + /// If you need to update formatting entities, call method [`InputMessage::fmt_entities`]. + /// + ///
+ /// Note that this method does not modify formatting entities, which may break + /// formatting or cause out-of-bounds errors if entities do not match the given text. + ///
+ pub fn text(mut self, s: T) -> Self + where + T: Into, + { + self.text = s.into(); + self + } + + /// The formatting entities within the message (such as bold, italics, etc.). + pub fn fmt_entities(mut self, entities: I) -> Self + where + I: IntoIterator, + { + self.entities = entities.into_iter().collect(); + self + } + + /// Builds a new message from the given markdown-formatted string as the + /// message contents and entities. + /// + /// Note that Telegram only supports a very limited subset of entities: + /// bold, italic, underline, strikethrough, code blocks, pre blocks and inline links (inline + /// links with this format `tg://user?id=12345678` will be replaced with inline mentions when + /// possible). + #[cfg(feature = "markdown")] + pub fn markdown(mut self, s: T) -> Self + where + T: AsRef, + { + let (text, entities) = crate::parsers::parse_markdown_message(s.as_ref()); + self.text = text; + self.entities = entities; + self + } + + /// Builds a new message from the given HTML-formatted string as the + /// message contents and entities. + /// + /// Note that Telegram only supports a very limited subset of entities: + /// bold, italic, underline, strikethrough, code blocks, pre blocks and inline links (inline + /// links with this format `tg://user?id=12345678` will be replaced with inline mentions when + /// possible). + #[cfg(feature = "html")] + pub fn html(mut self, s: T) -> Self + where + T: AsRef, + { + let (text, entities) = crate::parsers::parse_html_message(s.as_ref()); + self.text = text; + self.entities = entities; + self + } + + /// Whether the media will be inverted. + /// + /// If inverted, photos, videos, and documents will appear at the bottom and link previews at the top of the message. + pub fn invert_media(mut self, invert_media: bool) -> Self { + self.invert_media = invert_media; + self + } + + /// Whether the link preview be shown for the message. + /// + /// This has no effect when sending media, which cannot contain a link preview. + pub fn link_preview(mut self, link_preview: bool) -> Self { + self.link_preview = link_preview; + self + } + + /// Defines the suggested reply markup for the message (such as adding inline buttons). + /// This will be displayed below the message. + /// + /// Only bot accounts can make use of the reply markup feature (a user attempting to send a + /// message with a reply markup will result in the markup being ignored by Telegram). + /// + /// The user is free to ignore the markup and continue sending usual text messages. + /// + /// See [`crate::message::ReplyMarkup`] for the different available markups along with how + /// they behave. + pub fn reply_markup(mut self, markup: ReplyMarkup) -> Self { + self.reply_markup = Some(markup.raw); + self + } + + /// The message identifier to which this message should reply to, if any. + /// + /// Otherwise, this message will not be a reply to any other. + pub fn reply_to(mut self, reply_to: Option) -> Self { + self.reply_to = reply_to; + self + } + + /// If set to a distant enough future time, the message won't be sent immediately, + /// and instead it will be scheduled to be automatically sent at a later time. + /// + /// This scheduling is done server-side, and may not be accurate to the second. + /// + /// Bot accounts cannot schedule messages. + pub fn schedule_date(mut self, schedule_date: Option) -> Self { + self.schedule_date = schedule_date.map(|t| { + t.duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i32) + .unwrap_or(0) + }); + self + } + + /// Schedule the message to be sent once the person comes online. + /// + /// This only works in private chats, and only if the person has their + /// last seen visible. + /// + /// Bot accounts cannot schedule messages. + pub fn schedule_once_online(mut self) -> Self { + self.schedule_date = Some(SCHEDULE_ONCE_ONLINE); + self + } + + /// Whether the message should notify people or not. + /// + /// Defaults to `false`, which means it will notify them. Set it to `true` + /// to alter this behaviour. + pub fn silent(mut self, silent: bool) -> Self { + self.silent = silent; + self + } + + /// Include the uploaded file as a photo in the message. + /// + /// The Telegram server will compress the image and convert it to JPEG format if necessary. + /// + /// The text will be the caption of the photo, which may be empty for no caption. + pub fn photo(mut self, file: Uploaded) -> Self { + self.media = Some( + (tl::types::InputMediaUploadedPhoto { + spoiler: false, + file: file.raw, + stickers: None, + ttl_seconds: self.media_ttl, + live_photo: false, + video: None, + }) + .into(), + ); + self + } + + /// Include an external photo in the message. + /// + /// The Telegram server will download and compress the image and convert it to JPEG format if + /// necessary. + /// + /// The text will be the caption of the photo, which may be empty for no caption. + pub fn photo_url(mut self, url: impl Into) -> Self { + self.media = Some( + (tl::types::InputMediaPhotoExternal { + spoiler: false, + url: url.into(), + ttl_seconds: self.media_ttl, + }) + .into(), + ); + self + } + + /// Include the uploaded file as a document in the message. + /// + /// You can use this to send videos, stickers, audios, or uncompressed photos. + /// + /// The text will be the caption of the document, which may be empty for no caption. + pub fn document(mut self, file: Uploaded) -> Self { + let mime_type = self.get_file_mime(&file); + self.media = Some(crate::media::uploaded_document( + file, + mime_type, + false, + self.media_ttl, + )); + self + } + + /// Include a media in the message using the raw TL types. + /// + /// You can use this to send any media using the raw TL types that don't have + /// a specific method in this builder such as Dice, Polls, etc. + /// + /// This can also be used to send media with a file reference, see `InputMediaDocument` + /// and `InputMediaPhoto` in the `grammers-tl-types` crate. + /// + /// The text will be the caption of the media, which may be empty for no caption. + pub fn media>(mut self, media: M) -> Self { + self.media = Some(media.into()); + self + } + + /// Include the video file with thumb in the message. + /// + /// The text will be the caption of the document, which may be empty for no caption. + /// + /// # Examples + /// + /// ``` + /// async fn f(client: &mut grammers_client::Client) -> Result<(), Box> { + /// use grammers_client::message::InputMessage; + /// + /// let video = client.upload_file("video.mp4").await?; + /// let thumb = client.upload_file("thumb.png").await?; + /// let message = InputMessage::new().text("").document(video).thumbnail(thumb); + /// Ok(()) + /// } + /// ``` + pub fn thumbnail(mut self, thumb: Uploaded) -> Self { + if let Some(tl::enums::InputMedia::UploadedDocument(document)) = &mut self.media { + document.thumb = Some(thumb.raw); + } + self + } + + /// Include an external file as a document in the message. + /// + /// You can use this to send videos, stickers, audios, or uncompressed photos. + /// + /// The Telegram server will be the one that downloads and includes the document as media. + /// + /// The text will be the caption of the document, which may be empty for no caption. + pub fn document_url(mut self, url: impl Into) -> Self { + self.media = Some( + (tl::types::InputMediaDocumentExternal { + spoiler: false, + url: url.into(), + ttl_seconds: self.media_ttl, + video_cover: None, + video_timestamp: None, + }) + .into(), + ); + self + } + + /// Add additional attributes to the message. + /// + /// This must be called *after* setting a file. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: &mut grammers_client::Client) -> Result<(), Box> { + /// # let audio = client.upload_file("audio.flac").await?; + /// # + /// use std::time::Duration; + /// use grammers_client::media::Attribute; + /// use grammers_client::message::InputMessage; + /// + /// let message = InputMessage::new().text("").document(audio).attribute( + /// Attribute::Audio { + /// duration: Duration::new(123, 0), + /// title: Some("Hello".to_string()), + /// performer: Some("World".to_string()), + /// } + /// ); + /// # Ok(()) + /// # } + /// ``` + pub fn attribute(mut self, attr: Attribute) -> Self { + if let Some(tl::enums::InputMedia::UploadedDocument(document)) = &mut self.media { + document.attributes.push(attr.into()); + } + self + } + + /// Copy media from an existing message. + /// + /// You can use this to send media from another message without re-uploading it. + pub fn copy_media(mut self, media: &Media) -> Self { + self.media = media.to_raw_input_media(); + self + } + + /// Include the uploaded file as a document file in the message. + /// + /// You can use this to send any type of media as a simple document file. + /// + /// The text will be the caption of the file, which may be empty for no caption. + pub fn file(mut self, file: Uploaded) -> Self { + let mime_type = self.get_file_mime(&file); + self.media = Some(crate::media::uploaded_document( + file, + mime_type, + true, + self.media_ttl, + )); + self + } + + /// Change the media's Time To Live (TTL). + /// + /// For example, this enables you to send a `photo` that can only be viewed for a certain + /// amount of seconds before it expires. + /// + /// Not all media supports this feature. + /// + /// This method should be called before setting any media, else it won't have any effect. + pub fn media_ttl(mut self, seconds: i32) -> Self { + self.media_ttl = if seconds < 0 { None } else { Some(seconds) }; + self + } + + /// Change the media's mime type. + /// + /// This method will override the mime type that would otherwise be automatically inferred + /// from the extension of the used file + /// + /// If no mime type is set and it cannot be inferred, the mime type will be + /// "application/octet-stream". + /// + /// This method should be called before setting any media, else it won't have any effect. + pub fn mime_type(mut self, mime_type: &str) -> Self { + self.mime_type = Some(mime_type.to_string()); + self + } + + /// Return the mime type string for the given file. + fn get_file_mime(&self, file: &Uploaded) -> String { + if let Some(mime) = self.mime_type.as_ref() { + mime.clone() + } else if let Some(mime) = mime_guess::from_path(file.name()).first() { + mime.essence_str().to_string() + } else { + "application/octet-stream".to_string() + } + } +} + +impl From for InputMessage +where + S: Into, +{ + fn from(text: S) -> Self { + Self::new().text(text) + } +} + +impl From for InputMessage { + fn from(message: super::Message) -> Self { + let (text, entities, media) = match message.raw { + tl::enums::Message::Empty(_) => (None, None, None), + tl::enums::Message::Message(message) => { + (Some(message.message), message.entities, message.media) + } + tl::enums::Message::Service(_) => (None, None, None), + }; + + Self { + text: text.unwrap_or_default(), + entities: entities.unwrap_or_default(), + media: media + .and_then(Media::from_raw) + .and_then(|m| m.to_raw_input_media()), + ..Default::default() + } + } +} diff --git a/vendor/grammers-client/src/message/message.rs b/vendor/grammers-client/src/message/message.rs new file mode 100644 index 0000000..4f27465 --- /dev/null +++ b/vendor/grammers-client/src/message/message.rs @@ -0,0 +1,842 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::fmt; +#[cfg(feature = "fs")] +use std::path::Path; + +use chrono::{DateTime, Utc}; +use grammers_mtsender::InvocationError; +use grammers_session::types::{PeerId, PeerKind, PeerRef}; +use grammers_tl_types as tl; + +use super::{InputMessage, InputReactions}; +use crate::Client; +use crate::media::{InputMedia, Media, Photo}; +#[cfg(any(feature = "markdown", feature = "html"))] +use crate::parsers; +use crate::peer::{Peer, PeerMap}; +use crate::utils; + +/// Represents a Telegram message, which includes text messages, messages with media, and service +/// messages. +/// +/// This message should be treated as a snapshot in time, that is, if the message is edited while +/// using this object, those changes won't alter this structure. +#[derive(Clone)] +pub struct Message { + pub raw: tl::enums::Message, + pub(crate) fetched_in: Option, + pub(crate) client: Client, + // When fetching messages or receiving updates, a set of peers will be present. A single + // server response contains a lot of peers, and some might be related to deep layers of + // a message action for instance. Keeping the entire set like this allows for cheaper clones + // and moves, and saves us from worrying about picking out all the peers we care about. + pub(crate) peers: PeerMap, +} + +impl Message { + pub fn from_raw( + client: &Client, + message: tl::enums::Message, + fetched_in: Option, + peers: PeerMap, + ) -> Self { + Self { + raw: message, + fetched_in, + client: client.clone(), + peers, + } + } + + pub fn from_raw_short_updates( + client: &Client, + updates: tl::types::UpdateShortSentMessage, + input: InputMessage, + peer: PeerRef, + ) -> Self { + Self { + raw: tl::enums::Message::Message(tl::types::Message { + out: updates.out, + mentioned: false, + media_unread: false, + silent: input.silent, + post: false, // TODO true if sent to broadcast channel + from_scheduled: false, + legacy: false, + edit_hide: false, + pinned: false, + noforwards: false, // TODO true if channel has noforwads? + video_processing_pending: false, + paid_suggested_post_stars: false, + invert_media: input.invert_media, + id: updates.id, + from_id: None, // TODO self + from_boosts_applied: None, + from_rank: None, + peer_id: peer.id.into(), + saved_peer_id: None, + fwd_from: None, + via_bot_id: None, + reply_to: input.reply_to.map(|reply_to_msg_id| { + tl::types::MessageReplyHeader { + reply_to_scheduled: false, + forum_topic: false, + quote: false, + reply_to_ephemeral: false, + reply_to_msg_id: Some(reply_to_msg_id), + reply_to_peer_id: None, + reply_from: None, + reply_media: None, + reply_to_top_id: None, + quote_text: None, + quote_entities: None, + quote_offset: None, + todo_item_id: None, + poll_option: None, + } + .into() + }), + date: updates.date, + message: input.text, + media: updates.media, + reply_markup: input.reply_markup, + entities: updates.entities, + views: None, + forwards: None, + replies: None, + edit_date: None, + post_author: None, + grouped_id: None, + restriction_reason: None, + ttl_period: updates.ttl_period, + reactions: None, + quick_reply_shortcut_id: None, + via_business_bot_id: None, + guestchat_via_from: None, + offline: false, + effect: None, + factcheck: None, + report_delivery_until_date: None, + paid_message_stars: None, + paid_suggested_post_ton: false, + suggested_post: None, + schedule_repeat_period: None, + summary_from_language: None, + rich_message: None, + }), + fetched_in: Some(peer), + client: client.clone(), + peers: client.empty_peer_map(), + } + } + + /// Whether the message is outgoing (i.e. you sent this message to some other peer) or + /// incoming (i.e. someone else sent it to you or the peer). + pub fn outgoing(&self) -> bool { + match &self.raw { + tl::enums::Message::Empty(_) => false, + tl::enums::Message::Message(message) => message.out, + tl::enums::Message::Service(message) => message.out, + } + } + + /// Whether you were mentioned in this message or not. + /// + /// This includes @username mentions, text mentions, and messages replying to one of your + /// previous messages (even if it contains no mention in the message text). + pub fn mentioned(&self) -> bool { + match &self.raw { + tl::enums::Message::Empty(_) => false, + tl::enums::Message::Message(message) => message.mentioned, + tl::enums::Message::Service(message) => message.mentioned, + } + } + + /// Whether you have read the media in this message or not. + /// + /// Most commonly, these are voice notes that you have not played yet. + pub fn media_unread(&self) -> bool { + match &self.raw { + tl::enums::Message::Empty(_) => false, + tl::enums::Message::Message(message) => message.media_unread, + tl::enums::Message::Service(message) => message.media_unread, + } + } + + /// Whether the message should notify people with sound or not. + pub fn silent(&self) -> bool { + match &self.raw { + tl::enums::Message::Empty(_) => false, + tl::enums::Message::Message(message) => message.silent, + tl::enums::Message::Service(message) => message.silent, + } + } + + /// Whether this message is a post in a broadcast channel or not. + pub fn post(&self) -> bool { + match &self.raw { + tl::enums::Message::Empty(_) => false, + tl::enums::Message::Message(message) => message.post, + tl::enums::Message::Service(message) => message.post, + } + } + + /// Whether this message was originated from a previously-scheduled message or not. + pub fn from_scheduled(&self) -> bool { + match &self.raw { + tl::enums::Message::Empty(_) => false, + tl::enums::Message::Message(message) => message.from_scheduled, + tl::enums::Message::Service(_) => false, + } + } + + // `legacy` is not exposed, though it can be if it proves to be useful + + /// Whether the edited mark of this message is edited should be hidden (e.g. in GUI clients) + /// or shown. + pub fn edit_hide(&self) -> bool { + match &self.raw { + tl::enums::Message::Empty(_) => false, + tl::enums::Message::Message(message) => message.edit_hide, + tl::enums::Message::Service(_) => false, + } + } + + /// Whether this message is currently pinned or not. + pub fn pinned(&self) -> bool { + match &self.raw { + tl::enums::Message::Empty(_) => false, + tl::enums::Message::Message(message) => message.pinned, + tl::enums::Message::Service(_) => false, + } + } + + /// The ID of this message. + /// + /// Message identifiers are counters that start at 1 and grow by 1 for each message produced. + /// + /// Every channel has its own unique message counter. This counter is the same for all users, + /// but unique to each channel. + /// + /// Every account has another unique message counter which is used for private conversations + /// and small group chats. This means different accounts will likely have different message + /// identifiers for the same message in a private conversation or small group chat. This also + /// implies that the message identifier alone is enough to uniquely identify the message, + /// without the need to know the chat ID. + /// + /// **You cannot use the message ID of User A when running as User B**, unless this message + /// belongs to a megagroup or broadcast channel. Beware of this when using methods like + /// [`Client::delete_messages`], which **cannot** validate the peer where the message + /// should be deleted for those cases. + pub fn id(&self) -> i32 { + self.raw.id() + } + + /// The [`Self::peer`]'s identifier. + pub fn peer_id(&self) -> PeerId { + utils::peer_from_message(&self.raw) + .map(PeerId::from) + .or_else(|| self.fetched_in.map(|peer| peer.id)) + .expect("empty messages from updates should contain peer_id") + } + + /// Cached reference to the [`Self::peer`], if it is in cache. + pub async fn peer_ref( + &self, + ) -> Result, Box> { + match self.fetched_in { + Some(peer) => Ok(Some(peer)), + None => self.peers.get_ref(self.peer_id()).await, + } + } + + /// The peer where this message was sent to. + /// + /// This might be the user you're talking to for private conversations, or the group or + /// channel where the message was sent. + pub fn peer(&self) -> Option<&Peer> { + self.peers.get(self.peer_id()) + } + + /// The [`Self::sender`]'s identifier, if there is a sender. + pub fn sender_id(&self) -> Option { + let from_id = match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.from_id.clone().map(PeerId::from), + tl::enums::Message::Service(message) => message.from_id.clone().map(PeerId::from), + }; + from_id.or_else(|| { + // Incoming messages in private conversations don't include `from_id` since + // layer 119, but the sender can only be the peer we're in. + let peer_id = self.peer_id(); + if matches!(peer_id.kind(), PeerKind::User) { + if self.outgoing() { + Some(PeerId::self_user()) + } else { + Some(peer_id) + } + } else { + None + } + }) + } + + /// Cached reference to the [`Self::sender`], if there is a sender and they are in cache. + pub async fn sender_ref( + &self, + ) -> Result, Box> { + match self.sender_id() { + None => Ok(None), + Some(x) => self.peers.get_ref(x).await, + } + } + + /// The sender of this message, if there is a sender **and** the sender is in cache. + pub fn sender(&self) -> Option<&Peer> { + self.sender_id().and_then(|id| self.peers.get(id)) + } + + /// If this message was forwarded from a previous message, return the header with information + /// about that forward. + pub fn forward_header(&self) -> Option { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.fwd_from.clone(), + tl::enums::Message::Service(_) => None, + } + } + + /// If this message was sent @via some inline bot, return the bot's user identifier. + pub fn via_bot_id(&self) -> Option { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.via_bot_id, + tl::enums::Message::Service(_) => None, + } + } + + /// If this message is replying to a previous message, return the header with information + /// about that reply. + pub fn reply_header(&self) -> Option { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.reply_to.clone(), + tl::enums::Message::Service(message) => message.reply_to.clone(), + } + } + + pub(crate) fn date_timestamp(&self) -> i32 { + match &self.raw { + tl::enums::Message::Empty(_) => 0, + tl::enums::Message::Message(message) => message.date, + tl::enums::Message::Service(message) => message.date, + } + } + + /// The date when this message was produced. + pub fn date(&self) -> DateTime { + utils::date(self.date_timestamp()) + } + + /// The message's text. + /// + /// For service or empty messages, this will be the empty strings. + /// + /// If the message has media, this text is the caption commonly displayed underneath it. + pub fn text(&self) -> &str { + match &self.raw { + tl::enums::Message::Empty(_) => "", + tl::enums::Message::Message(message) => &message.message, + tl::enums::Message::Service(_) => "", + } + } + + fn entities(&self) -> Option<&Vec> { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.entities.as_ref(), + tl::enums::Message::Service(_) => None, + } + } + + /// Like [`text`](Self::text), but with the [`fmt_entities`](Self::fmt_entities) + /// applied to produce a markdown string instead. + /// + /// Some formatting entities automatically added by Telegram, such as bot commands or + /// clickable emails, are ignored in the generated string, as those do not need to be + /// sent for Telegram to include them in the message. + /// + /// Formatting entities which cannot be represented in CommonMark without resorting to HTML, + /// such as underline, are also ignored. + #[cfg(feature = "markdown")] + pub fn markdown_text(&self) -> String { + if let Some(entities) = self.entities() { + parsers::generate_markdown_message(self.text(), entities) + } else { + self.text().to_owned() + } + } + + /// Like [`text`](Self::text), but with the [`fmt_entities`](Self::fmt_entities) + /// applied to produce a HTML string instead. + /// + /// Some formatting entities automatically added by Telegram, such as bot commands or + /// clickable emails, are ignored in the generated string, as those do not need to be + /// sent for Telegram to include them in the message. + #[cfg(feature = "html")] + pub fn html_text(&self) -> String { + if let Some(entities) = self.entities() { + parsers::generate_html_message(self.text(), entities) + } else { + self.text().to_owned() + } + } + + /// The media displayed by this message, if any. + /// + /// This not only includes photos or videos, but also contacts, polls, documents, locations + /// and many other types. + pub fn media(&self) -> Option { + let media = match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.media.clone(), + tl::enums::Message::Service(_) => None, + }; + media.and_then(Media::from_raw) + } + + /// If the message has a reply markup (which can happen for messages produced by bots), + /// returns said markup. + pub fn reply_markup(&self) -> Option { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.reply_markup.clone(), + tl::enums::Message::Service(_) => None, + } + } + + /// The formatting entities used to format this message, such as bold, italic, with their + /// offsets and lengths. + pub fn fmt_entities(&self) -> Option<&Vec> { + // TODO correct the offsets and lengths to match the byte offsets + self.entities() + } + + /// How many views does this message have, when applicable. + /// + /// The same user account can contribute to increment this counter indefinitedly, however + /// there is a server-side cooldown limitting how fast it can happen (several hours). + pub fn view_count(&self) -> Option { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.views, + tl::enums::Message::Service(_) => None, + } + } + + /// How many times has this message been forwarded, when applicable. + pub fn forward_count(&self) -> Option { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.forwards, + tl::enums::Message::Service(_) => None, + } + } + + /// How many replies does this message have, when applicable. + pub fn reply_count(&self) -> Option { + match &self.raw { + tl::enums::Message::Message(tl::types::Message { + replies: Some(tl::enums::MessageReplies::Replies(replies)), + .. + }) => Some(replies.replies), + _ => None, + } + } + + /// React to this message. + /// + /// # Examples + /// + /// ``` + /// # async fn f(message: grammers_client::message::Message, client: grammers_client::Client) -> Result<(), Box> { + /// message.react("👍").await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// Make animation big & Add to recent + /// + /// ``` + /// # async fn f(message: grammers_client::message::Message, client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_client::message::InputReactions; + /// + /// let reactions = InputReactions::emoticon("🤯").big().add_to_recent(); + /// + /// message.react(reactions).await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// Remove reactions + /// + /// ``` + /// # async fn f(message: grammers_client::message::Message, client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_client::message::InputReactions; + /// + /// message.react(InputReactions::remove()).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn react>( + &self, + reactions: R, + ) -> Result<(), InvocationError> { + self.client + .send_reactions( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + self.id(), + reactions, + ) + .await?; + Ok(()) + } + + /// How many reactions does this message have, when applicable. + pub fn reaction_count(&self) -> Option { + match &self.raw { + tl::enums::Message::Message(tl::types::Message { + reactions: Some(tl::enums::MessageReactions::Reactions(reactions)), + .. + }) => { + let count = reactions + .results + .iter() + .map(|reaction: &tl::enums::ReactionCount| { + let tl::enums::ReactionCount::Count(reaction) = reaction; + reaction.count + }) + .sum(); + Some(count) + } + _ => None, + } + } + + /// The date when this message was last edited. + pub fn edit_date(&self) -> Option> { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.edit_date.map(utils::date), + tl::enums::Message::Service(_) => None, + } + } + + /// If this message was sent to a channel, return the name used by the author to post it. + pub fn post_author(&self) -> Option<&str> { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.post_author.as_deref(), + tl::enums::Message::Service(_) => None, + } + } + + /// If this message belongs to a group of messages, return the unique identifier for that + /// group. + /// + /// This applies to albums of media, such as multiple photos grouped together. + /// + /// Note that there may be messages sent in between the messages forming a group. + pub fn grouped_id(&self) -> Option { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.grouped_id, + tl::enums::Message::Service(_) => None, + } + } + + /// A list of reasons on why this message is restricted. + /// + /// The message is not restricted if the return value is `None`. + pub fn restriction_reason(&self) -> Option<&Vec> { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.restriction_reason.as_ref(), + tl::enums::Message::Service(_) => None, + } + } + + /// If this message is a service message, return the service action that occured. + pub fn action(&self) -> Option<&tl::enums::MessageAction> { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(_) => None, + tl::enums::Message::Service(message) => Some(&message.action), + } + } + + /// If this message is replying to another message, return the replied message ID. + pub fn reply_to_message_id(&self) -> Option { + match &self.raw { + tl::enums::Message::Message(tl::types::Message { + reply_to: Some(tl::enums::MessageReplyHeader::Header(header)), + .. + }) => header.reply_to_msg_id, + _ => None, + } + } + + /// Fetch the message that this message is replying to, or `None` if this message is not a + /// reply to a previous message. + /// + /// Shorthand for `Client::get_reply_to_message`. + pub async fn get_reply(&self) -> Result, InvocationError> { + self.client + .clone() // TODO don't clone + .get_reply_to_message(self) + .await + } + + /// Respond to this message by sending a new message to the same peer, but without directly + /// replying to it. + /// + /// Shorthand for `Client::send_message`. + pub async fn respond>( + &self, + message: M, + ) -> Result { + self.client + .send_message( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + message, + ) + .await + } + + /// Respond to this message by sending a album in the same peer, but without directly + /// replying to it. + /// + /// Shorthand for `Client::send_album`. + pub async fn respond_album( + &self, + medias: Vec, + ) -> Result>, InvocationError> { + self.client + .send_album( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + medias, + ) + .await + } + + /// Directly reply to this message by sending a new message to the same peer that replies to + /// it. This methods overrides the `reply_to` on the `InputMessage` to point to `self`. + /// + /// Shorthand for `Client::send_message`. + pub async fn reply>(&self, message: M) -> Result { + let message = message.into(); + self.client + .send_message( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + message.reply_to(Some(self.id())), + ) + .await + } + + /// Directly reply to this message by sending a album to the same peer that replies to + /// it. This methods overrides the `reply_to` on the first `InputMedia` to point to `self`. + /// + /// Shorthand for `Client::send_album`. + pub async fn reply_album( + &self, + mut medias: Vec, + ) -> Result>, InvocationError> { + medias.first_mut().unwrap().reply_to = Some(self.id()); + self.client + .send_album( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + medias, + ) + .await + } + + /// Forward this message to another (or the same) peer. + /// + /// Shorthand for `Client::forward_messages`. If you need to forward multiple messages + /// at once, consider using that method instead. + pub async fn forward_to>(&self, chat: C) -> Result { + // TODO return `Message` + // When forwarding a single message, if it fails, Telegram should respond with RPC error. + // If it succeeds we will have the single forwarded message present which we can unwrap. + self.client + .forward_messages( + chat, + &[self.id()], + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + ) + .await + .map(|mut msgs| msgs.pop().unwrap().unwrap()) + } + + /// Edit this message to change its text or media. + /// + /// Shorthand for `Client::edit_message`. + pub async fn edit>(&self, new_message: M) -> Result<(), InvocationError> { + self.client + .edit_message( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + self.id(), + new_message, + ) + .await + } + + /// Delete this message for everyone. + /// + /// Shorthand for `Client::delete_messages`. If you need to delete multiple messages + /// at once, consider using that method instead. + pub async fn delete(&self) -> Result<(), InvocationError> { + self.client + .delete_messages( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + &[self.id()], + ) + .await + .map(drop) + } + + /// Mark this message and all messages above it as read. + /// + /// Unlike `Client::mark_as_read`, this method only will mark the conversation as read up to + /// this message, not the entire conversation. + pub async fn mark_as_read(&self) -> Result<(), InvocationError> { + let peer = self.peer_ref().await?.ok_or(InvocationError::Dropped)?; + if peer.id.kind() == PeerKind::Channel { + self.client + .invoke(&tl::functions::channels::ReadHistory { + channel: peer.into(), + max_id: self.id(), + }) + .await + .map(drop) + } else { + self.client + .invoke(&tl::functions::messages::ReadHistory { + peer: peer.into(), + max_id: self.id(), + }) + .await + .map(drop) + } + } + + /// Pin this message in the conversation. + /// + /// Shorthand for `Client::pin_message`. + pub async fn pin(&self) -> Result<(), InvocationError> { + self.client + .pin_message( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + self.id(), + ) + .await + } + + /// Unpin this message from the conversation. + /// + /// Shorthand for `Client::unpin_message`. + pub async fn unpin(&self) -> Result<(), InvocationError> { + self.client + .unpin_message( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + self.id(), + ) + .await + } + + /// Refetch this message, mutating all of its properties in-place. + /// + /// No changes will be made to the message if it fails to be fetched. + /// + /// Shorthand for `Client::get_messages_by_id`. + pub async fn refetch(&self) -> Result<(), InvocationError> { + // When fetching a single message, if it fails, Telegram should respond with RPC error. + // If it succeeds we will have the single message present which we can unwrap. + self.client + .get_messages_by_id( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + &[self.id()], + ) + .await? + .pop() + .unwrap() + .unwrap(); + todo!("actually mutate self after get_messages_by_id returns `Message`") + } + + /// Download the message media in this message if applicable. + /// + /// Returns `true` if there was media to download, or `false` otherwise. + /// + /// Shorthand for `Client::download_media`. + #[cfg(feature = "fs")] + pub async fn download_media>(&self, path: P) -> Result { + // TODO probably encode failed download in error + if let Some(media) = self.media() { + self.client.download_media(&media, path).await.map(|_| true) + } else { + Ok(false) + } + } + + /// Get photo attached to the message if any. + pub fn photo(&self) -> Option { + if let Media::Photo(photo) = self.media()? { + return Some(photo); + } + + None + } +} + +impl fmt::Debug for Message { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Message") + .field("id", &self.id()) + .field("outgoing", &self.outgoing()) + .field("date", &self.date()) + .field("text", &self.text()) + .field("peer", &self.peer()) + .field("sender", &self.sender()) + .field("reply_to_message_id", &self.reply_to_message_id()) + .field("via_bot_id", &self.via_bot_id()) + .field("media", &self.media()) + .field("mentioned", &self.mentioned()) + .field("media_unread", &self.media_unread()) + .field("silent", &self.silent()) + .field("post", &self.post()) + .field("from_scheduled", &self.from_scheduled()) + .field("edit_hide", &self.edit_hide()) + .field("pinned", &self.pinned()) + .field("forward_header", &self.forward_header()) + .field("reply_header", &self.reply_header()) + .field("reply_markup", &self.reply_markup()) + .field("fmt_entities", &self.fmt_entities()) + .field("view_count", &self.view_count()) + .field("forward_count", &self.forward_count()) + .field("reply_count", &self.reply_count()) + .field("edit_date", &self.edit_date()) + .field("post_author", &self.post_author()) + .field("grouped_id", &self.grouped_id()) + .field("restriction_reason", &self.restriction_reason()) + .field("action", &self.action()) + .finish() + } +} diff --git a/vendor/grammers-client/src/message/mod.rs b/vendor/grammers-client/src/message/mod.rs new file mode 100644 index 0000000..0f866a6 --- /dev/null +++ b/vendor/grammers-client/src/message/mod.rs @@ -0,0 +1,24 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Types relating to chat messages. +//! +//! Properties containing raw types are public and will either be called "raw" or prefixed with "raw_".\ +//! Keep in mind that **these fields are not part of the semantic versioning guarantees**. + +mod button; +mod input_message; +mod message; +mod reactions; +mod reply_markup; + +pub use button::{Button, Key}; +pub use input_message::InputMessage; +pub use message::Message; +pub use reactions::InputReactions; +pub use reply_markup::ReplyMarkup; diff --git a/vendor/grammers-client/src/message/reactions.rs b/vendor/grammers-client/src/message/reactions.rs new file mode 100755 index 0000000..3f1f4f5 --- /dev/null +++ b/vendor/grammers-client/src/message/reactions.rs @@ -0,0 +1,85 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use grammers_tl_types::{self as tl, enums::Reaction}; + +/// Parameter for reacting to messages with emoji. +#[derive(Clone, Debug, Default)] +pub struct InputReactions { + pub(crate) reactions: Vec, + pub(crate) add_to_recent: bool, + pub(crate) big: bool, +} + +impl InputReactions { + /// Make reaction animation big. + pub fn big(mut self) -> Self { + self.big = true; + self + } + + /// Add this reaction to the recent reactions list. + /// + /// [Read more about recent reactions](https://core.telegram.org/api/reactions#recent-reactions). + pub fn add_to_recent(mut self) -> Self { + self.add_to_recent = true; + self + } + + /// Create new InputReactions with one emoticon reaction + pub fn emoticon>(emoticon: S) -> Self { + Self { + reactions: vec![Reaction::Emoji(tl::types::ReactionEmoji { + emoticon: emoticon.into(), + })], + ..Self::default() + } + } + + /// Create new InputReactions with one custom emoji reaction + pub fn custom_emoji(document_id: i64) -> Self { + Self { + reactions: vec![Reaction::CustomEmoji(tl::types::ReactionCustomEmoji { + document_id, + })], + ..Self::default() + } + } + + /// Create an empty InputReactions which will remove reactions + pub fn remove() -> Self { + Self::default() + } +} + +impl From for InputReactions { + fn from(val: String) -> Self { + InputReactions::emoticon(val) + } +} + +impl From<&str> for InputReactions { + fn from(val: &str) -> Self { + InputReactions::emoticon(val) + } +} + +impl From> for InputReactions { + fn from(reactions: Vec) -> Self { + Self { + reactions, + ..Self::default() + } + } +} + +impl From for Vec { + fn from(val: InputReactions) -> Self { + val.reactions + } +} diff --git a/vendor/grammers-client/src/message/reply_markup.rs b/vendor/grammers-client/src/message/reply_markup.rs new file mode 100755 index 0000000..bf91912 --- /dev/null +++ b/vendor/grammers-client/src/message/reply_markup.rs @@ -0,0 +1,291 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Contains functions to build reply markups. +//! +//! These can only be used by bot accounts when sending +//! messages through [`InputMessage::reply_markup`]. +//! +//! Each function returns a concrete builder-like type that +//! may be further configured via their inherent methods. +//! +//! The trait is used to group all types as "something that +//! may be used as a reply markup". +//! +//! [`InputMessage::reply_markup`]: crate::message::InputMessage::reply_markup + +use grammers_tl_types as tl; + +use super::{Button, Key}; + +const EMPTY_KEYBOARD_MARKUP: tl::types::ReplyKeyboardMarkup = tl::types::ReplyKeyboardMarkup { + resize: false, + single_use: false, + selective: false, + persistent: false, + rows: Vec::new(), + placeholder: None, +}; + +/// Markup to be used as the intended way to reply to the message it is attached to. +pub struct ReplyMarkup { + pub raw: tl::enums::ReplyMarkup, +} + +impl ReplyMarkup { + /// Define inline buttons for a message. + /// + /// These will display right under the message. + /// + /// You cannot add images to the buttons, but you can use emoji (simply copy-paste them into your + /// code, or use the correct escape sequence, or using any other input methods you like). + /// + /// You will need to provide a matrix of [`Button`], that is, a vector that contains the + /// rows from top to bottom, where the rows consist of a vector of buttons from left to right. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: &mut grammers_client::Client, peer: grammers_session::types::PeerRef) -> Result<(), Box> { + /// use grammers_client::message::{InputMessage, ReplyMarkup, Button}; + /// + /// let artist = "Krewella"; + /// let markup = ReplyMarkup::from_buttons(&[ + /// vec![Button::data(format!("Song by {}", artist), b"play")], + /// vec![Button::data("Previous", b"prev"), Button::data("Next", b"next")], + /// ]); + /// client.send_message(peer, InputMessage::new().text("Select song").reply_markup(markup)).await?; + /// # Ok(()) + /// # } + /// ``` + pub fn from_buttons(buttons: &[Vec