diff --git a/crates/tele-core/src/lib.rs b/crates/tele-core/src/lib.rs index 164a093..4cad27e 100644 --- a/crates/tele-core/src/lib.rs +++ b/crates/tele-core/src/lib.rs @@ -5,6 +5,7 @@ mod utils; pub mod accounts; pub mod message_grouping; +pub mod session; pub mod tdlib; #[cfg(any(test, feature = "test-support"))] pub mod test_support; diff --git a/crates/tele-core/src/session.rs b/crates/tele-core/src/session.rs new file mode 100644 index 0000000..57c6b39 --- /dev/null +++ b/crates/tele-core/src/session.rs @@ -0,0 +1,933 @@ +use crate::tdlib::types::ForwardInfo; +use crate::tdlib::{ + AuthState, ChatInfo, FolderInfo, MediaInfo, MessageInfo, NetworkState, ProfileInfo, ReplyInfo, + TdClientTrait, +}; +use crate::types::{ChatId, MessageId, UserId}; +use std::collections::VecDeque; + +/// Platform-neutral Telegram session facade for native clients. +#[derive(Debug, Clone)] +pub struct CoreSession { + client: C, + events: VecDeque, +} + +impl CoreSession { + pub fn new(client: C) -> Self { + Self { client, events: VecDeque::new() } + } + + pub fn client(&self) -> &C { + &self.client + } + + pub fn client_mut(&mut self) -> &mut C { + &mut self.client + } + + pub fn into_client(self) -> C { + self.client + } + + pub fn enqueue_event(&mut self, event: CoreEvent) { + self.events.push_back(event); + } + + pub fn poll_events(&mut self) -> Vec { + self.events.drain(..).collect() + } +} + +impl CoreSession { + pub fn auth_state(&self) -> CoreAuthState { + CoreAuthState::from(self.client.auth_state()) + } + + pub fn network_state(&self) -> CoreNetworkState { + CoreNetworkState::from(&self.client.network_state()) + } + + pub fn emit_auth_state(&mut self) -> CoreAuthState { + let state = self.auth_state(); + self.enqueue_event(CoreEvent::AuthChanged(state.clone())); + state + } + + pub fn emit_network_state(&mut self) -> CoreNetworkState { + let state = self.network_state(); + self.enqueue_event(CoreEvent::NetworkChanged(state.clone())); + state + } + + pub async fn send_phone_number(&self, phone: String) -> Result<(), String> { + self.client.send_phone_number(phone).await + } + + pub async fn send_code(&self, code: String) -> Result<(), String> { + self.client.send_code(code).await + } + + pub async fn send_password(&self, password: String) -> Result<(), String> { + self.client.send_password(password).await + } + + pub async fn load_chats(&mut self, limit: i32) -> Result, String> { + self.client.load_chats(limit).await?; + let chats = self.chat_summaries(); + self.enqueue_event(CoreEvent::ChatListChanged(chats.clone())); + Ok(chats) + } + + pub async fn load_folder_chats( + &mut self, + folder_id: i32, + limit: i32, + ) -> Result, String> { + self.client.load_folder_chats(folder_id, limit).await?; + let chats = self.chat_summaries(); + self.enqueue_event(CoreEvent::ChatListChanged(chats.clone())); + Ok(chats) + } + + pub fn chat_summaries(&self) -> Vec { + self.client + .chats() + .iter() + .map(CoreChatSummary::from) + .collect() + } + + pub fn folders(&self) -> Vec { + self.client.folders().iter().map(CoreFolder::from).collect() + } + + pub async fn open_chat_history( + &mut self, + chat_id: ChatId, + limit: i32, + ) -> Result, String> { + self.client.set_current_chat_id(Some(chat_id)); + let messages = self.client.get_chat_history(chat_id, limit).await?; + let messages = messages.iter().map(CoreMessage::from).collect(); + Ok(messages) + } + + pub async fn send_text_message( + &mut self, + chat_id: ChatId, + text: String, + reply_to_message_id: Option, + reply: Option, + ) -> Result { + let message = self + .client + .send_message(chat_id, text, reply_to_message_id, reply) + .await?; + let message = CoreMessage::from(&message); + self.enqueue_event(CoreEvent::MessageAdded { chat_id, message: message.clone() }); + Ok(message) + } + + pub async fn edit_text_message( + &mut self, + chat_id: ChatId, + message_id: MessageId, + text: String, + ) -> Result { + let message = self.client.edit_message(chat_id, message_id, text).await?; + let message = CoreMessage::from(&message); + self.enqueue_event(CoreEvent::MessageUpdated { chat_id, message: message.clone() }); + Ok(message) + } + + pub async fn delete_messages( + &mut self, + chat_id: ChatId, + message_ids: Vec, + revoke: bool, + ) -> Result<(), String> { + self.client + .delete_messages(chat_id, message_ids.clone(), revoke) + .await?; + self.enqueue_event(CoreEvent::MessageDeleted { chat_id, message_ids }); + Ok(()) + } + + pub async fn forward_messages( + &mut self, + to_chat_id: ChatId, + from_chat_id: ChatId, + message_ids: Vec, + ) -> Result<(), String> { + self.client + .forward_messages(to_chat_id, from_chat_id, message_ids) + .await + } + + pub async fn toggle_reaction( + &mut self, + chat_id: ChatId, + message_id: MessageId, + reaction: String, + ) -> Result, String> { + self.client + .toggle_reaction(chat_id, message_id, reaction) + .await?; + + let reactions: Vec = self + .client + .get_chat_history(chat_id, i32::MAX) + .await? + .into_iter() + .find(|message| message.id() == message_id) + .map(|message| message.reactions().iter().map(CoreReaction::from).collect()) + .unwrap_or_default(); + + self.enqueue_event(CoreEvent::ReactionChanged { + chat_id, + message_id, + reactions: reactions.clone(), + }); + Ok(reactions) + } + + pub async fn download_photo(&self, file_id: i32) -> Result { + self.client + .download_file(file_id) + .await + .map(|path| CoreDownloadedFile { file_id, path }) + } + + pub async fn download_voice(&self, file_id: i32) -> Result { + self.client + .download_voice_note(file_id) + .await + .map(|path| CoreDownloadedFile { file_id, path }) + } + + pub async fn search_messages( + &self, + chat_id: ChatId, + query: &str, + ) -> Result, String> { + let messages = self.client.search_messages(chat_id, query).await?; + Ok(messages + .iter() + .map(|message| CoreSearchResult { chat_id, message: CoreMessage::from(message) }) + .collect()) + } + + pub async fn open_profile(&mut self, chat_id: ChatId) -> Result { + let profile = self + .client + .get_profile_info(chat_id) + .await + .map(|profile| CoreProfile::from(&profile))?; + self.enqueue_event(CoreEvent::ProfileLoaded(profile.clone())); + Ok(profile) + } + + pub async fn set_draft(&mut self, chat_id: ChatId, text: String) -> Result<(), String> { + self.client.set_draft_message(chat_id, text.clone()).await?; + self.enqueue_event(CoreEvent::DraftChanged(CoreDraft { chat_id, text })); + Ok(()) + } + + pub fn drain_client_events(&mut self) -> Vec { + let events: Vec<_> = self + .client + .drain_incoming_message_events() + .into_iter() + .map(|event| { + CoreEvent::IncomingNotificationCandidate(CoreNotificationCandidate { + chat: CoreChatSummary::from(&event.chat), + message: CoreMessage::from(&event.message), + sender_name: event.sender_name, + }) + }) + .collect(); + + self.events.extend(events.iter().cloned()); + events + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoreAccount { + pub id: String, + pub display_name: String, + pub is_active: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CoreAuthState { + WaitTdlibParameters, + WaitPhoneNumber, + WaitCode, + WaitPassword, + Ready, + Closed, + Error { message: String }, +} + +impl From<&AuthState> for CoreAuthState { + fn from(value: &AuthState) -> Self { + match value { + AuthState::WaitTdlibParameters => Self::WaitTdlibParameters, + AuthState::WaitPhoneNumber => Self::WaitPhoneNumber, + AuthState::WaitCode => Self::WaitCode, + AuthState::WaitPassword => Self::WaitPassword, + AuthState::Ready => Self::Ready, + AuthState::Closed => Self::Closed, + AuthState::Error(message) => Self::Error { message: message.clone() }, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoreChatSummary { + pub id: ChatId, + pub title: String, + pub username: Option, + pub last_message: String, + pub last_message_date: i32, + pub unread_count: i32, + pub unread_mention_count: i32, + pub is_pinned: bool, + pub order: i64, + pub last_read_outbox_message_id: MessageId, + pub folder_ids: Vec, + pub is_muted: bool, + pub draft: Option, +} + +impl From<&ChatInfo> for CoreChatSummary { + fn from(value: &ChatInfo) -> Self { + Self { + id: value.id, + title: value.title.clone(), + username: value.username.clone(), + last_message: value.last_message.clone(), + last_message_date: value.last_message_date, + unread_count: value.unread_count, + unread_mention_count: value.unread_mention_count, + is_pinned: value.is_pinned, + order: value.order, + last_read_outbox_message_id: value.last_read_outbox_message_id, + folder_ids: value.folder_ids.clone(), + is_muted: value.is_muted, + draft: value + .draft_text + .as_ref() + .map(|text| CoreDraft { chat_id: value.id, text: text.clone() }), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoreFolder { + pub id: i32, + pub name: String, +} + +impl From<&FolderInfo> for CoreFolder { + fn from(value: &FolderInfo) -> Self { + Self { id: value.id, name: value.name.clone() } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoreMessage { + pub id: MessageId, + pub sender_name: String, + pub date: i32, + pub edit_date: Option, + pub media_album_id: Option, + pub text: String, + pub media: Option, + pub is_outgoing: bool, + pub is_read: bool, + pub can_be_edited: bool, + pub can_be_deleted_only_for_self: bool, + pub can_be_deleted_for_all_users: bool, + pub reply: Option, + pub forward: Option, + pub reactions: Vec, +} + +impl From<&MessageInfo> for CoreMessage { + fn from(value: &MessageInfo) -> Self { + Self { + id: value.id(), + sender_name: value.sender_name().to_string(), + date: value.date(), + edit_date: value.is_edited().then_some(value.metadata.edit_date), + media_album_id: (value.media_album_id() != 0).then_some(value.media_album_id()), + text: value.text().to_string(), + media: value.content.media.as_ref().map(CoreMedia::from), + is_outgoing: value.is_outgoing(), + is_read: value.is_read(), + can_be_edited: value.can_be_edited(), + can_be_deleted_only_for_self: value.can_be_deleted_only_for_self(), + can_be_deleted_for_all_users: value.can_be_deleted_for_all_users(), + reply: value.reply_to().map(CoreReply::from), + forward: value.forward_from().map(CoreForward::from), + reactions: value.reactions().iter().map(CoreReaction::from).collect(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoreReply { + pub message_id: MessageId, + pub sender_name: String, + pub text: String, +} + +impl From<&ReplyInfo> for CoreReply { + fn from(value: &ReplyInfo) -> Self { + Self { + message_id: value.message_id, + sender_name: value.sender_name.clone(), + text: value.text.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoreForward { + pub sender_name: String, +} + +impl From<&ForwardInfo> for CoreForward { + fn from(value: &ForwardInfo) -> Self { + Self { sender_name: value.sender_name.clone() } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoreReaction { + pub emoji: String, + pub count: i32, + pub is_chosen: bool, +} + +impl From<&crate::tdlib::types::ReactionInfo> for CoreReaction { + fn from(value: &crate::tdlib::types::ReactionInfo) -> Self { + Self { + emoji: value.emoji.clone(), + count: value.count, + is_chosen: value.is_chosen, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CoreMedia { + Photo(CorePhoto), + Voice(CoreVoice), +} + +impl From<&MediaInfo> for CoreMedia { + fn from(value: &MediaInfo) -> Self { + match value { + MediaInfo::Photo(photo) => Self::Photo(CorePhoto { + file_id: photo.file_id, + width: photo.width, + height: photo.height, + download_state: CoreDownloadState::from(&photo.download_state), + }), + MediaInfo::Voice(voice) => Self::Voice(CoreVoice { + file_id: voice.file_id, + duration: voice.duration, + mime_type: voice.mime_type.clone(), + waveform: voice.waveform.clone(), + download_state: CoreDownloadState::from(&voice.download_state), + }), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CorePhoto { + pub file_id: i32, + pub width: i32, + pub height: i32, + pub download_state: CoreDownloadState, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoreVoice { + pub file_id: i32, + pub duration: i32, + pub mime_type: String, + pub waveform: String, + pub download_state: CoreDownloadState, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CoreDownloadState { + NotDownloaded, + Downloading, + Downloaded { path: String }, + Error { message: String }, +} + +impl From<&crate::tdlib::PhotoDownloadState> for CoreDownloadState { + fn from(value: &crate::tdlib::PhotoDownloadState) -> Self { + match value { + crate::tdlib::PhotoDownloadState::NotDownloaded => Self::NotDownloaded, + crate::tdlib::PhotoDownloadState::Downloading => Self::Downloading, + crate::tdlib::PhotoDownloadState::Downloaded(path) => { + Self::Downloaded { path: path.clone() } + } + crate::tdlib::PhotoDownloadState::Error(message) => { + Self::Error { message: message.clone() } + } + } + } +} + +impl From<&crate::tdlib::VoiceDownloadState> for CoreDownloadState { + fn from(value: &crate::tdlib::VoiceDownloadState) -> Self { + match value { + crate::tdlib::VoiceDownloadState::NotDownloaded => Self::NotDownloaded, + crate::tdlib::VoiceDownloadState::Downloading => Self::Downloading, + crate::tdlib::VoiceDownloadState::Downloaded(path) => { + Self::Downloaded { path: path.clone() } + } + crate::tdlib::VoiceDownloadState::Error(message) => { + Self::Error { message: message.clone() } + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoreProfile { + pub chat_id: ChatId, + pub title: String, + pub username: Option, + pub bio: Option, + pub phone_number: Option, + pub chat_type: String, + pub member_count: Option, + pub description: Option, + pub invite_link: Option, + pub is_group: bool, + pub online_status: Option, +} + +impl From<&ProfileInfo> for CoreProfile { + fn from(value: &ProfileInfo) -> Self { + Self { + chat_id: value.chat_id, + title: value.title.clone(), + username: value.username.clone(), + bio: value.bio.clone(), + phone_number: value.phone_number.clone(), + chat_type: value.chat_type.clone(), + member_count: value.member_count, + description: value.description.clone(), + invite_link: value.invite_link.clone(), + is_group: value.is_group, + online_status: value.online_status.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoreDraft { + pub chat_id: ChatId, + pub text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoreSearchResult { + pub chat_id: ChatId, + pub message: CoreMessage, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoreDownloadedFile { + pub file_id: i32, + pub path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CoreNetworkState { + WaitingForNetwork, + ConnectingToProxy, + Connecting, + Updating, + Ready, +} + +impl From<&NetworkState> for CoreNetworkState { + fn from(value: &NetworkState) -> Self { + match value { + NetworkState::WaitingForNetwork => Self::WaitingForNetwork, + NetworkState::ConnectingToProxy => Self::ConnectingToProxy, + NetworkState::Connecting => Self::Connecting, + NetworkState::Updating => Self::Updating, + NetworkState::Ready => Self::Ready, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CoreTypingState { + Idle, + Typing { + chat_id: ChatId, + user_id: UserId, + text: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoreNotificationCandidate { + pub chat: CoreChatSummary, + pub message: CoreMessage, + pub sender_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CoreEvent { + AuthChanged(CoreAuthState), + ChatListChanged(Vec), + FolderListChanged(Vec), + MessageAdded { + chat_id: ChatId, + message: CoreMessage, + }, + MessageUpdated { + chat_id: ChatId, + message: CoreMessage, + }, + MessageDeleted { + chat_id: ChatId, + message_ids: Vec, + }, + ReactionChanged { + chat_id: ChatId, + message_id: MessageId, + reactions: Vec, + }, + MediaDownloadProgress { + file_id: i32, + downloaded_size: i64, + total_size: i64, + }, + IncomingNotificationCandidate(CoreNotificationCandidate), + NetworkChanged(CoreNetworkState), + TypingChanged(CoreTypingState), + DraftChanged(CoreDraft), + ProfileLoaded(CoreProfile), +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tdlib::types::ReactionInfo; + use crate::tdlib::{ + AuthState, ChatInfo, FolderInfo, MessageBuilder, NetworkState, ProfileInfo, + }; + use crate::test_support::FakeTdClient; + use crate::types::{ChatId, MessageId}; + + fn sample_chat() -> ChatInfo { + ChatInfo { + id: ChatId::new(42), + title: "Team".to_string(), + username: Some("team_chat".to_string()), + last_message: "Latest".to_string(), + last_message_date: 1_700_000_000, + unread_count: 3, + unread_mention_count: 1, + is_pinned: true, + order: 99, + last_read_outbox_message_id: MessageId::new(7), + folder_ids: vec![0, 2], + is_muted: true, + draft_text: Some("Draft".to_string()), + } + } + + #[test] + fn auth_state_mapping_is_stable_for_swift() { + assert_eq!( + CoreAuthState::from(&AuthState::WaitPhoneNumber), + CoreAuthState::WaitPhoneNumber + ); + assert_eq!(CoreAuthState::from(&AuthState::WaitCode), CoreAuthState::WaitCode); + assert_eq!(CoreAuthState::from(&AuthState::WaitPassword), CoreAuthState::WaitPassword); + assert_eq!(CoreAuthState::from(&AuthState::Ready), CoreAuthState::Ready); + assert_eq!( + CoreAuthState::from(&AuthState::Error("bad code".to_string())), + CoreAuthState::Error { message: "bad code".to_string() } + ); + } + + #[test] + fn chat_summary_preserves_ios_relevant_state() { + let chat = CoreChatSummary::from(&sample_chat()); + + assert_eq!(chat.id, ChatId::new(42)); + assert_eq!(chat.title, "Team"); + assert_eq!(chat.username.as_deref(), Some("team_chat")); + assert_eq!(chat.last_message, "Latest"); + assert_eq!(chat.unread_count, 3); + assert_eq!(chat.unread_mention_count, 1); + assert!(chat.is_pinned); + assert!(chat.is_muted); + assert_eq!(chat.folder_ids, vec![0, 2]); + assert_eq!(chat.draft.as_ref().map(|draft| draft.text.as_str()), Some("Draft")); + } + + #[test] + fn message_mapping_preserves_reply_reactions_and_state() { + let message = MessageBuilder::new(MessageId::new(100)) + .sender_name("Alice") + .text("Hello") + .date(1_700_000_001) + .edit_date(1_700_000_002) + .reply_to(crate::tdlib::ReplyInfo { + message_id: MessageId::new(90), + sender_name: "Bob".to_string(), + text: "Original".to_string(), + }) + .reactions(vec![ReactionInfo { + emoji: "👍".to_string(), count: 2, is_chosen: true + }]) + .outgoing() + .read() + .build(); + + let mapped = CoreMessage::from(&message); + + assert_eq!(mapped.id, MessageId::new(100)); + assert_eq!(mapped.sender_name, "Alice"); + assert_eq!(mapped.text, "Hello"); + assert!(mapped.is_outgoing); + assert!(mapped.is_read); + assert_eq!(mapped.edit_date, Some(1_700_000_002)); + assert_eq!(mapped.reply.as_ref().map(|reply| reply.message_id), Some(MessageId::new(90))); + assert_eq!(mapped.reactions[0].emoji, "👍"); + assert!(mapped.reactions[0].is_chosen); + } + + #[test] + fn session_event_queue_drains_in_fifo_order() { + let mut session = CoreSession::new(()); + + session.enqueue_event(CoreEvent::AuthChanged(CoreAuthState::WaitCode)); + session.enqueue_event(CoreEvent::NetworkChanged(CoreNetworkState::Ready)); + + assert_eq!( + session.poll_events(), + vec![ + CoreEvent::AuthChanged(CoreAuthState::WaitCode), + CoreEvent::NetworkChanged(CoreNetworkState::Ready), + ] + ); + assert!(session.poll_events().is_empty()); + } + + #[test] + fn session_drains_incoming_message_events_as_notification_candidates() { + let chat = sample_chat(); + let client = FakeTdClient::new().with_chat(chat.clone()); + client.simulate_incoming_message(chat.id, "Ping".to_string(), "Alice"); + let mut session = CoreSession::new(client); + + let events = session.drain_client_events(); + + assert_eq!(events.len(), 1); + let CoreEvent::IncomingNotificationCandidate(candidate) = &events[0] else { + panic!("expected incoming notification candidate"); + }; + assert_eq!(candidate.chat.id, chat.id); + assert_eq!(candidate.message.text, "Ping"); + assert_eq!(candidate.sender_name, "Alice"); + assert_eq!(session.poll_events(), events); + } + + #[test] + fn events_cover_chat_message_profile_and_folder_shapes() { + let chat = CoreChatSummary::from(&sample_chat()); + let message = CoreMessage::from( + &MessageBuilder::new(MessageId::new(10)) + .sender_name("Alice") + .text("Hi") + .build(), + ); + let folder = CoreFolder::from(&FolderInfo { id: 2, name: "Work".to_string() }); + let profile = CoreProfile::from(&ProfileInfo { + chat_id: ChatId::new(42), + title: "Team".to_string(), + username: Some("team_chat".to_string()), + bio: None, + phone_number: None, + chat_type: "Group".to_string(), + member_count: Some(10), + description: Some("Project group".to_string()), + invite_link: None, + is_group: true, + online_status: None, + }); + + assert_eq!( + CoreEvent::ChatListChanged(vec![chat.clone()]), + CoreEvent::ChatListChanged(vec![chat]) + ); + assert_eq!( + CoreEvent::MessageAdded { chat_id: ChatId::new(42), message: message.clone() }, + CoreEvent::MessageAdded { chat_id: ChatId::new(42), message } + ); + assert_eq!(folder.name, "Work"); + assert_eq!(profile.member_count, Some(10)); + assert_eq!( + CoreNetworkState::from(&NetworkState::WaitingForNetwork), + CoreNetworkState::WaitingForNetwork + ); + } + + #[tokio::test] + async fn facade_methods_enqueue_state_profile_and_draft_events() { + let profile = ProfileInfo { + chat_id: ChatId::new(42), + title: "Team".to_string(), + username: Some("team_chat".to_string()), + bio: None, + phone_number: None, + chat_type: "Group".to_string(), + member_count: Some(10), + description: None, + invite_link: None, + is_group: true, + online_status: None, + }; + let client = FakeTdClient::new() + .with_auth_state(AuthState::WaitPassword) + .with_network_state(NetworkState::Connecting) + .with_profile(42, profile); + let mut session = CoreSession::new(client); + + session.emit_auth_state(); + session.emit_network_state(); + let loaded_profile = session.open_profile(ChatId::new(42)).await.unwrap(); + session + .set_draft(ChatId::new(42), "Later".to_string()) + .await + .unwrap(); + + assert_eq!(loaded_profile.title, "Team"); + assert_eq!( + session.poll_events(), + vec![ + CoreEvent::AuthChanged(CoreAuthState::WaitPassword), + CoreEvent::NetworkChanged(CoreNetworkState::Connecting), + CoreEvent::ProfileLoaded(CoreProfile { + chat_id: ChatId::new(42), + title: "Team".to_string(), + username: Some("team_chat".to_string()), + bio: None, + phone_number: None, + chat_type: "Group".to_string(), + member_count: Some(10), + description: None, + invite_link: None, + is_group: true, + online_status: None, + }), + CoreEvent::DraftChanged(CoreDraft { + chat_id: ChatId::new(42), + text: "Later".to_string(), + }), + ] + ); + } + + #[tokio::test] + async fn message_mutations_return_models_and_enqueue_events() { + let chat_id = ChatId::new(42); + let original = MessageBuilder::new(MessageId::new(10)) + .sender_name("Me") + .text("Before") + .outgoing() + .build(); + let client = FakeTdClient::new() + .with_chat(sample_chat()) + .with_message(chat_id.as_i64(), original); + let mut session = CoreSession::new(client); + + let sent = session + .send_text_message(chat_id, "Hello".to_string(), None, None) + .await + .unwrap(); + let edited = session + .edit_text_message(chat_id, MessageId::new(10), "After".to_string()) + .await + .unwrap(); + session + .delete_messages(chat_id, vec![MessageId::new(10)], true) + .await + .unwrap(); + + assert_eq!(sent.text, "Hello"); + assert_eq!(edited.text, "After"); + assert_eq!( + session.poll_events(), + vec![ + CoreEvent::MessageAdded { chat_id, message: sent }, + CoreEvent::MessageUpdated { chat_id, message: edited }, + CoreEvent::MessageDeleted { chat_id, message_ids: vec![MessageId::new(10)] }, + ] + ); + } + + #[tokio::test] + async fn facade_delegates_auth_forward_reactions_and_downloads() { + let chat_id = ChatId::new(42); + let other_chat_id = ChatId::new(100); + let message = MessageBuilder::new(MessageId::new(10)) + .sender_name("Alice") + .text("React here") + .build(); + let client = FakeTdClient::new() + .with_message(chat_id.as_i64(), message) + .with_downloaded_file(77, "/tmp/photo.jpg"); + let mut session = CoreSession::new(client); + + session + .send_phone_number("+10000000000".to_string()) + .await + .unwrap(); + session.send_code("12345".to_string()).await.unwrap(); + session.send_password("secret".to_string()).await.unwrap(); + session + .forward_messages(other_chat_id, chat_id, vec![MessageId::new(10)]) + .await + .unwrap(); + let reactions = session + .toggle_reaction(chat_id, MessageId::new(10), "👍".to_string()) + .await + .unwrap(); + let downloaded = session.download_photo(77).await.unwrap(); + + assert_eq!(downloaded.path, "/tmp/photo.jpg"); + assert_eq!(session.client().get_forwarded_messages().len(), 1); + assert_eq!( + reactions, + vec![CoreReaction { + emoji: "👍".to_string(), count: 1, is_chosen: true + }] + ); + assert_eq!( + session.poll_events(), + vec![CoreEvent::ReactionChanged { chat_id, message_id: MessageId::new(10), reactions }] + ); + } +} diff --git a/crates/tele-core/src/test_support/fake_tdclient/operations.rs b/crates/tele-core/src/test_support/fake_tdclient/operations.rs index aad491b..a0c417a 100644 --- a/crates/tele-core/src/test_support/fake_tdclient/operations.rs +++ b/crates/tele-core/src/test_support/fake_tdclient/operations.rs @@ -437,6 +437,24 @@ impl FakeTdClient { .or_default() .push(message.clone()); + if let Some(chat) = self + .chats + .lock() + .unwrap() + .iter() + .find(|chat| chat.id == chat_id) + .cloned() + { + self.incoming_message_events + .lock() + .unwrap() + .push(crate::tdlib::IncomingMessageEvent { + chat, + message: message.clone(), + sender_name: sender_name.to_string(), + }); + } + self.send_update(TdUpdate::NewMessage { chat_id, message: Box::new(message) }); } diff --git a/crates/tele-core/src/test_support/fake_tdclient/state.rs b/crates/tele-core/src/test_support/fake_tdclient/state.rs index 41dbf8a..9a873b9 100644 --- a/crates/tele-core/src/test_support/fake_tdclient/state.rs +++ b/crates/tele-core/src/test_support/fake_tdclient/state.rs @@ -1,5 +1,7 @@ use crate::tdlib::types::{FolderInfo, ReactionInfo}; -use crate::tdlib::{AuthState, ChatInfo, MessageInfo, NetworkState, ProfileInfo, ReplyInfo}; +use crate::tdlib::{ + AuthState, ChatInfo, IncomingMessageEvent, MessageInfo, NetworkState, ProfileInfo, ReplyInfo, +}; use crate::types::{ChatId, MessageId, UserId}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -73,6 +75,7 @@ pub struct FakeTdClient { pub viewed_messages: Arc>, pub chat_actions: Arc>>, pub pending_view_messages: Arc>, + pub incoming_message_events: Arc>>, pub update_tx: Arc>>>, pub downloaded_files: Arc>>, @@ -151,6 +154,7 @@ impl Clone for FakeTdClient { viewed_messages: Arc::clone(&self.viewed_messages), chat_actions: Arc::clone(&self.chat_actions), pending_view_messages: Arc::clone(&self.pending_view_messages), + incoming_message_events: Arc::clone(&self.incoming_message_events), downloaded_files: Arc::clone(&self.downloaded_files), update_tx: Arc::clone(&self.update_tx), simulate_delays: self.simulate_delays, @@ -192,6 +196,7 @@ impl FakeTdClient { viewed_messages: Arc::new(Mutex::new(vec![])), chat_actions: Arc::new(Mutex::new(vec![])), pending_view_messages: Arc::new(Mutex::new(vec![])), + incoming_message_events: Arc::new(Mutex::new(vec![])), downloaded_files: Arc::new(Mutex::new(HashMap::new())), update_tx: Arc::new(Mutex::new(None)), simulate_delays: false, diff --git a/crates/tele-core/src/test_support/fake_tdclient_impl.rs b/crates/tele-core/src/test_support/fake_tdclient_impl.rs index 665ab9f..93d7afa 100644 --- a/crates/tele-core/src/test_support/fake_tdclient_impl.rs +++ b/crates/tele-core/src/test_support/fake_tdclient_impl.rs @@ -353,6 +353,10 @@ impl UpdateClient for FakeTdClient { fn handle_update(&mut self, _update: Update) {} fn drain_incoming_message_events(&mut self) -> Vec { - Vec::new() + self.incoming_message_events + .lock() + .unwrap() + .drain(..) + .collect() } }