Add iOS-facing core session facade

This commit is contained in:
Mikhail Kilin
2026-05-20 00:56:42 +03:00
parent eefac431e5
commit 186f0edbb3
5 changed files with 963 additions and 2 deletions

View File

@@ -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;

View File

@@ -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<C> {
client: C,
events: VecDeque<CoreEvent>,
}
impl<C> CoreSession<C> {
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<CoreEvent> {
self.events.drain(..).collect()
}
}
impl<C: TdClientTrait> CoreSession<C> {
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<Vec<CoreChatSummary>, 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<Vec<CoreChatSummary>, 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<CoreChatSummary> {
self.client
.chats()
.iter()
.map(CoreChatSummary::from)
.collect()
}
pub fn folders(&self) -> Vec<CoreFolder> {
self.client.folders().iter().map(CoreFolder::from).collect()
}
pub async fn open_chat_history(
&mut self,
chat_id: ChatId,
limit: i32,
) -> Result<Vec<CoreMessage>, 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<MessageId>,
reply: Option<ReplyInfo>,
) -> Result<CoreMessage, String> {
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<CoreMessage, String> {
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<MessageId>,
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<MessageId>,
) -> 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<Vec<CoreReaction>, String> {
self.client
.toggle_reaction(chat_id, message_id, reaction)
.await?;
let reactions: Vec<CoreReaction> = 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<CoreDownloadedFile, String> {
self.client
.download_file(file_id)
.await
.map(|path| CoreDownloadedFile { file_id, path })
}
pub async fn download_voice(&self, file_id: i32) -> Result<CoreDownloadedFile, String> {
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<Vec<CoreSearchResult>, 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<CoreProfile, String> {
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<CoreEvent> {
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<String>,
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<i32>,
pub is_muted: bool,
pub draft: Option<CoreDraft>,
}
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<i32>,
pub media_album_id: Option<i64>,
pub text: String,
pub media: Option<CoreMedia>,
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<CoreReply>,
pub forward: Option<CoreForward>,
pub reactions: Vec<CoreReaction>,
}
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<String>,
pub bio: Option<String>,
pub phone_number: Option<String>,
pub chat_type: String,
pub member_count: Option<i32>,
pub description: Option<String>,
pub invite_link: Option<String>,
pub is_group: bool,
pub online_status: Option<String>,
}
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<CoreChatSummary>),
FolderListChanged(Vec<CoreFolder>),
MessageAdded {
chat_id: ChatId,
message: CoreMessage,
},
MessageUpdated {
chat_id: ChatId,
message: CoreMessage,
},
MessageDeleted {
chat_id: ChatId,
message_ids: Vec<MessageId>,
},
ReactionChanged {
chat_id: ChatId,
message_id: MessageId,
reactions: Vec<CoreReaction>,
},
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 }]
);
}
}

View File

@@ -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) });
}

View File

@@ -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<Mutex<ViewedMessages>>,
pub chat_actions: Arc<Mutex<Vec<(i64, String)>>>,
pub pending_view_messages: Arc<Mutex<PendingViewMessages>>,
pub incoming_message_events: Arc<Mutex<Vec<IncomingMessageEvent>>>,
pub update_tx: Arc<Mutex<Option<mpsc::UnboundedSender<TdUpdate>>>>,
pub downloaded_files: Arc<Mutex<HashMap<i32, String>>>,
@@ -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,

View File

@@ -353,6 +353,10 @@ impl UpdateClient for FakeTdClient {
fn handle_update(&mut self, _update: Update) {}
fn drain_incoming_message_events(&mut self) -> Vec<crate::tdlib::IncomingMessageEvent> {
Vec::new()
self.incoming_message_events
.lock()
.unwrap()
.drain(..)
.collect()
}
}