1use std::{
8 collections::{BTreeMap, HashMap, HashSet},
9 fmt,
10 sync::{
11 Arc,
12 atomic::{AtomicBool, Ordering},
13 },
14 time::{SystemTime, UNIX_EPOCH},
15};
16
17use futures_util::future::BoxFuture;
18use inline_sdk::{
19 ApiClient, ApiError, AuthMetadata, ClientIdentity, RealtimeError, RealtimeEvent,
20 RealtimeEventReceiver, RealtimeSession, RpcRequest, UploadFileBytesInput, UploadFileResult,
21 UploadFileType, UploadVideoMetadata, proto,
22};
23use serde_json::Value;
24use tokio::sync::Mutex;
25
26use crate::backend::retry_after_seconds_from_message;
27use crate::sync::{SyncHost, SyncManager};
28use crate::{
29 AccountStateSnapshot, AddChatParticipantRequest, AuthContactKind, AuthCredential,
30 AuthStartRequest, AuthStartResult, AuthToken, AuthVerifyRequest, AuthVerifyResult,
31 BackendError, BackendResult, ChatCreateParticipant, ChatParticipantRecord,
32 ChatParticipantsPage, ChatParticipantsRequest, ChatStateSnapshot, ClientBackend,
33 ClientErrorCategory, ClientEvent, ClientStatusSnapshot, ClientStore, ConnectRequest,
34 CreateDmRequest, CreateReplyThreadRequest, CreateThreadRequest, CreatedChat, DeleteChatRequest,
35 DeleteMessageRequest, DialogFollowMode, DialogNotificationMode, DialogRecord, DialogsPage,
36 DialogsRequest, EditMessageRequest, HistoryPage, HistoryRequest, InMemoryStore, InlineId,
37 MediaKind, MessageContent, MessageMutation, MessageRecord, NotificationMode, OperationOutcome,
38 PeerRef, RandomId, ReactRequest, ReadRequest, RealtimeConnectRequest, RealtimeConnector,
39 RemoveChatParticipantRequest, SendTextOutcome, SendTextRequest, SetMarkedUnreadRequest,
40 SpaceMemberRecord, SpaceMemberRole, SpaceRecord, StoreError, StoredReaction, StoredReadState,
41 StoredSession, StoredTransaction, SyncConfig, TransactionId, TransactionIdentity,
42 TransactionState, TypingRequest, UpdateChatInfoRequest, UpdateDialogNotificationsRequest,
43 UploadRequest, UserRecord, UserSettingsRecord, VERSION,
44};
45
46const DEFAULT_API_BASE_URL: &str = "https://api.inline.chat/v1";
47const DEFAULT_REALTIME_URL: &str = "wss://api.inline.chat/realtime";
48const SNAPSHOT_HISTORY_PAGE_LIMIT: i32 = 200;
49
50#[derive(Debug, thiserror::Error)]
52#[non_exhaustive]
53pub enum SdkBackendBuildError {
54 #[error("failed to build Inline API client: {0}")]
56 Api(#[from] ApiError),
57}
58
59#[derive(Clone)]
61pub struct SdkBackendBuilder {
62 api_base_url: String,
63 realtime_url: String,
64 identity: ClientIdentity,
65 store: Arc<dyn ClientStore>,
66 sync_config: SyncConfig,
67 realtime_handshake: bool,
68 realtime_connector: Option<Arc<dyn RealtimeConnector>>,
69}
70
71impl fmt::Debug for SdkBackendBuilder {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 f.debug_struct("SdkBackendBuilder")
74 .field("api_base_url", &self.api_base_url)
75 .field("realtime_url", &redacted_url_for_debug(&self.realtime_url))
76 .field("identity", &self.identity)
77 .field("store", &"<client-store>")
78 .field("sync_config", &self.sync_config)
79 .field("realtime_handshake", &self.realtime_handshake)
80 .field(
81 "realtime_connector",
82 &self
83 .realtime_connector
84 .as_ref()
85 .map(|_| "<realtime-connector>"),
86 )
87 .finish()
88 }
89}
90
91impl Default for SdkBackendBuilder {
92 fn default() -> Self {
93 Self {
94 api_base_url: DEFAULT_API_BASE_URL.to_owned(),
95 realtime_url: DEFAULT_REALTIME_URL.to_owned(),
96 identity: ClientIdentity::new("inline-client", VERSION),
97 store: Arc::new(InMemoryStore::new()),
98 sync_config: SyncConfig::default(),
99 realtime_handshake: false,
100 realtime_connector: None,
101 }
102 }
103}
104
105impl SdkBackendBuilder {
106 pub fn api_base_url(mut self, api_base_url: impl Into<String>) -> Self {
108 self.api_base_url = api_base_url.into();
109 self
110 }
111
112 pub fn realtime_url(mut self, realtime_url: impl Into<String>) -> Self {
114 self.realtime_url = realtime_url.into();
115 self
116 }
117
118 pub fn identity(mut self, identity: ClientIdentity) -> Self {
120 self.identity = identity;
121 self
122 }
123
124 pub fn store(mut self, store: impl ClientStore) -> Self {
126 self.store = Arc::new(store);
127 self
128 }
129
130 pub fn shared_store(mut self, store: Arc<dyn ClientStore>) -> Self {
132 self.store = store;
133 self
134 }
135
136 pub fn sync_config(mut self, config: SyncConfig) -> Self {
138 self.sync_config = config;
139 self
140 }
141
142 pub fn enable_realtime_handshake(mut self) -> Self {
144 self.realtime_handshake = true;
145 self.realtime_connector = None;
146 self
147 }
148
149 pub fn realtime_connector(mut self, connector: impl RealtimeConnector) -> Self {
151 self.realtime_handshake = true;
152 self.realtime_connector = Some(Arc::new(connector));
153 self
154 }
155
156 pub fn shared_realtime_connector(mut self, connector: Arc<dyn RealtimeConnector>) -> Self {
158 self.realtime_handshake = true;
159 self.realtime_connector = Some(connector);
160 self
161 }
162
163 pub fn without_realtime_handshake(mut self) -> Self {
165 self.realtime_handshake = false;
166 self.realtime_connector = None;
167 self
168 }
169
170 pub fn build(self) -> Result<SdkBackend, SdkBackendBuildError> {
172 let api = ApiClient::try_new_with_identity(self.api_base_url, self.identity.clone())?;
173 let sync = Arc::new(SyncManager::new(self.store.clone(), self.sync_config));
174 Ok(SdkBackend {
175 api,
176 realtime_url: self.realtime_url,
177 identity: self.identity,
178 store: self.store,
179 sync,
180 sync_required: Arc::new(AtomicBool::new(true)),
181 realtime_handshake: self.realtime_handshake,
182 realtime_connector: self.realtime_connector,
183 realtime: Arc::new(Mutex::new(None)),
184 realtime_events: Arc::new(Mutex::new(None)),
185 })
186 }
187}
188
189#[derive(Clone)]
191pub struct SdkBackend {
192 api: ApiClient,
193 realtime_url: String,
194 identity: ClientIdentity,
195 store: Arc<dyn ClientStore>,
196 sync: Arc<SyncManager>,
197 sync_required: Arc<AtomicBool>,
198 realtime_handshake: bool,
199 realtime_connector: Option<Arc<dyn RealtimeConnector>>,
200 realtime: Arc<Mutex<Option<RealtimeSession>>>,
201 realtime_events: Arc<Mutex<Option<RealtimeEventReceiver>>>,
202}
203
204impl fmt::Debug for SdkBackend {
205 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206 f.debug_struct("SdkBackend")
207 .field("api", &self.api)
208 .field("realtime_url", &redacted_url_for_debug(&self.realtime_url))
209 .field("identity", &self.identity)
210 .field("store", &"<client-store>")
211 .field("sync_required", &self.sync_required.load(Ordering::Relaxed))
212 .field("realtime_handshake", &self.realtime_handshake)
213 .field(
214 "realtime_connector",
215 &self
216 .realtime_connector
217 .as_ref()
218 .map(|_| "<realtime-connector>"),
219 )
220 .finish()
221 }
222}
223
224impl SdkBackend {
225 pub fn builder() -> SdkBackendBuilder {
227 SdkBackendBuilder::default()
228 }
229
230 pub fn api_client(&self) -> &ApiClient {
232 &self.api
233 }
234
235 pub fn realtime_url(&self) -> &str {
237 &self.realtime_url
238 }
239
240 pub fn identity(&self) -> &ClientIdentity {
242 &self.identity
243 }
244
245 pub fn store(&self) -> Arc<dyn ClientStore> {
247 self.store.clone()
248 }
249
250 pub fn realtime_handshake_enabled(&self) -> bool {
252 self.realtime_handshake
253 }
254
255 async fn require_session(&self) -> BackendResult<StoredSession> {
256 self.store
257 .load_session()
258 .await
259 .map_err(store_error_to_backend)?
260 .ok_or_else(|| {
261 BackendError::new(ClientErrorCategory::AuthRequired, "client is not connected")
262 })
263 }
264
265 async fn call_realtime<R>(
266 &self,
267 session: &StoredSession,
268 request: R,
269 ) -> BackendResult<R::Response>
270 where
271 R: RpcRequest,
272 {
273 let realtime = self.ensure_realtime(session).await?;
274 match realtime.call(request).await {
275 Ok(response) => Ok(response),
276 Err(error) => {
277 if realtime_error_closes_session(&error) {
278 self.clear_realtime().await;
279 }
280 Err(realtime_error_to_backend(error))
281 }
282 }
283 }
284
285 async fn ensure_realtime(&self, session: &StoredSession) -> BackendResult<RealtimeSession> {
286 let mut realtime = self.realtime.lock().await;
287 if let Some(existing) = realtime.as_ref()
288 && !existing.is_closed()
289 {
290 return Ok(existing.clone());
291 }
292
293 let connected = RealtimeSession::connect_with_identity(
294 &self.realtime_url,
295 session.auth.access_token().expose_secret(),
296 self.identity.clone(),
297 )
298 .await
299 .map_err(realtime_error_to_backend)?;
300 let receiver = connected.subscribe();
301 *self.realtime_events.lock().await = Some(receiver);
302 *realtime = Some(connected.clone());
303 Ok(connected)
304 }
305
306 async fn install_realtime(&self, realtime: RealtimeSession) {
307 let receiver = realtime.subscribe();
308 *self.realtime.lock().await = Some(realtime);
309 *self.realtime_events.lock().await = Some(receiver);
310 }
311
312 async fn clear_realtime(&self) {
313 *self.realtime.lock().await = None;
314 *self.realtime_events.lock().await = None;
315 self.sync_required.store(true, Ordering::Release);
316 }
317
318 async fn receive_realtime_event(
319 &self,
320 session: &StoredSession,
321 ) -> BackendResult<RealtimeEvent> {
322 self.ensure_realtime(session).await?;
323 let mut receiver = self.realtime_events.lock().await;
324 let Some(events) = receiver.as_mut() else {
325 return Err(BackendError::new(
326 ClientErrorCategory::Internal,
327 "realtime event receiver was not initialized",
328 ));
329 };
330 match events.recv().await {
331 Ok(event) => Ok(event),
332 Err(error) => {
333 self.sync_required.store(true, Ordering::Release);
334 drop(receiver);
335 if realtime_error_closes_session(&error) {
336 self.clear_realtime().await;
337 }
338 Err(realtime_error_to_backend(error))
339 }
340 }
341 }
342
343 async fn connect_with_auth(
344 &self,
345 request: ConnectRequest,
346 ) -> BackendResult<ClientStatusSnapshot> {
347 let existing_session = self
348 .store
349 .load_session()
350 .await
351 .map_err(store_error_to_backend)?;
352 let next_namespace = normalized_account_namespace(request.account_namespace.as_deref());
353 let account_changed = existing_session.as_ref().is_none_or(|session| {
354 normalized_account_namespace(session.account_namespace.as_deref()) != next_namespace
355 });
356 let connected = if self.realtime_handshake {
357 if let Some(connector) = &self.realtime_connector {
358 connector
359 .connect(RealtimeConnectRequest::new(
360 self.realtime_url.clone(),
361 request.auth.access_token().clone(),
362 self.identity.clone(),
363 ))
364 .await?;
365 None
366 } else {
367 Some(
368 RealtimeSession::connect_with_identity(
369 &self.realtime_url,
370 request.auth.access_token().expose_secret(),
371 self.identity.clone(),
372 )
373 .await
374 .map_err(realtime_error_to_backend)?,
375 )
376 }
377 } else {
378 None
379 };
380 self.clear_realtime().await;
381 if account_changed {
382 self.store
383 .clear_account_data()
384 .await
385 .map_err(store_error_to_backend)?;
386 }
387 self.store
388 .save_session(StoredSession {
389 auth: request.auth,
390 account_namespace: request.account_namespace,
391 })
392 .await
393 .map_err(store_error_to_backend)?;
394 if let Some(connected) = connected {
395 self.install_realtime(connected).await;
396 }
397 Ok(ClientStatusSnapshot::current(
398 crate::ClientStatus::Connected,
399 ))
400 }
401
402 fn auth_metadata(
403 &self,
404 kind: AuthContactKind,
405 contact: &str,
406 device_name: Option<&str>,
407 ) -> AuthMetadata {
408 let device_id = format!(
409 "inline-client-{:016x}",
410 stable_hash(&format!("{}:{contact}", kind.as_str()))
411 );
412 let metadata = AuthMetadata::new(device_id, self.identity.clone());
413 match device_name
414 .map(str::trim)
415 .filter(|device_name| !device_name.is_empty())
416 {
417 Some(device_name) => metadata.with_device_name(device_name),
418 None => metadata.with_device_name(self.identity.client_type()),
419 }
420 }
421
422 async fn resume_stored_session(&self) -> BackendResult<ClientStatusSnapshot> {
423 let Some(session) = self
424 .store
425 .load_session()
426 .await
427 .map_err(store_error_to_backend)?
428 else {
429 self.clear_realtime().await;
430 return Ok(ClientStatusSnapshot::current(
431 crate::ClientStatus::AuthRequired,
432 ));
433 };
434
435 self.clear_realtime().await;
436 if self.realtime_handshake {
437 if let Some(connector) = &self.realtime_connector {
438 connector
439 .connect(RealtimeConnectRequest::new(
440 self.realtime_url.clone(),
441 session.auth.access_token().clone(),
442 self.identity.clone(),
443 ))
444 .await?;
445 } else {
446 let connected = RealtimeSession::connect_with_identity(
447 &self.realtime_url,
448 session.auth.access_token().expose_secret(),
449 self.identity.clone(),
450 )
451 .await
452 .map_err(realtime_error_to_backend)?;
453 self.install_realtime(connected).await;
454 }
455 }
456 Ok(ClientStatusSnapshot::current(
457 crate::ClientStatus::Connected,
458 ))
459 }
460}
461
462impl ClientBackend for SdkBackend {
463 fn auth_start(
464 &self,
465 request: AuthStartRequest,
466 ) -> BoxFuture<'static, BackendResult<AuthStartResult>> {
467 let backend = self.clone();
468 Box::pin(async move {
469 let contact = request.contact.trim().to_owned();
470 if contact.is_empty() {
471 return Err(BackendError::new(
472 ClientErrorCategory::InvalidInput,
473 "auth contact must not be empty",
474 ));
475 }
476 let metadata =
477 backend.auth_metadata(request.kind, &contact, request.device_name.as_deref());
478 let result = match request.kind {
479 AuthContactKind::Email => backend
480 .api
481 .send_email_code(&contact, &metadata)
482 .await
483 .map_err(api_error_to_backend)?,
484 AuthContactKind::Phone => backend
485 .api
486 .send_sms_code(&contact, &metadata)
487 .await
488 .map_err(api_error_to_backend)?,
489 };
490 Ok(AuthStartResult {
491 existing_user: result.existing_user,
492 needs_invite_code: result.needs_invite_code,
493 challenge_token: result.challenge_token,
494 })
495 })
496 }
497
498 fn auth_verify(
499 &self,
500 request: AuthVerifyRequest,
501 ) -> BoxFuture<'static, BackendResult<AuthVerifyResult>> {
502 let backend = self.clone();
503 Box::pin(async move {
504 let contact = request.contact.trim().to_owned();
505 let code = request.code.trim().to_owned();
506 if contact.is_empty() {
507 return Err(BackendError::new(
508 ClientErrorCategory::InvalidInput,
509 "auth contact must not be empty",
510 ));
511 }
512 if code.is_empty() {
513 return Err(BackendError::new(
514 ClientErrorCategory::InvalidInput,
515 "verification code must not be empty",
516 ));
517 }
518
519 let metadata =
520 backend.auth_metadata(request.kind, &contact, request.device_name.as_deref());
521 let result = match request.kind {
522 AuthContactKind::Email => backend
523 .api
524 .verify_email_code(
525 &contact,
526 &code,
527 request.challenge_token.as_deref(),
528 &metadata,
529 )
530 .await
531 .map_err(api_error_to_backend)?,
532 AuthContactKind::Phone => backend
533 .api
534 .verify_sms_code(&contact, &code, &metadata)
535 .await
536 .map_err(api_error_to_backend)?,
537 };
538 let token = AuthToken::try_new(result.token).map_err(|error| {
539 BackendError::new(ClientErrorCategory::ProtocolMismatch, error.to_string())
540 })?;
541 let account_namespace = request
542 .account_namespace
543 .map(|namespace| namespace.trim().to_owned())
544 .filter(|namespace| !namespace.is_empty())
545 .unwrap_or_else(|| result.user_id.to_string());
546 let status = backend
547 .connect_with_auth(
548 ConnectRequest::new(AuthCredential::AccessToken { token })
549 .with_account_namespace(account_namespace.clone()),
550 )
551 .await?;
552 Ok(AuthVerifyResult {
553 user_id: InlineId::new(result.user_id),
554 account_namespace,
555 status,
556 })
557 })
558 }
559
560 fn resume_session(&self) -> BoxFuture<'static, BackendResult<ClientStatusSnapshot>> {
561 let backend = self.clone();
562 Box::pin(async move { backend.resume_stored_session().await })
563 }
564
565 fn connect(
566 &self,
567 request: ConnectRequest,
568 ) -> BoxFuture<'static, BackendResult<ClientStatusSnapshot>> {
569 let backend = self.clone();
570 Box::pin(async move { backend.connect_with_auth(request).await })
571 }
572
573 fn logout(&self) -> BoxFuture<'static, BackendResult<()>> {
574 let backend = self.clone();
575 Box::pin(async move {
576 let session = backend
577 .store
578 .load_session()
579 .await
580 .map_err(store_error_to_backend)?;
581 backend.clear_realtime().await;
582 if let Some(session) = session
583 && let Err(error) = backend
584 .api
585 .logout(session.auth.access_token().expose_secret())
586 .await
587 {
588 log::warn!("Inline remote session logout failed; clearing local session: {error}");
589 }
590 backend
591 .store
592 .clear_session()
593 .await
594 .map_err(store_error_to_backend)?;
595 backend
596 .store
597 .clear_account_data()
598 .await
599 .map_err(store_error_to_backend)
600 })
601 }
602
603 fn dialogs(&self, request: DialogsRequest) -> BoxFuture<'static, BackendResult<DialogsPage>> {
604 let backend = self.clone();
605 Box::pin(async move {
606 let session = backend.require_session().await?;
607 let result = backend
608 .call_realtime(&session, proto::GetChatsInput {})
609 .await?;
610 backend.apply_get_chats_snapshot(result).await?;
611 backend
612 .store
613 .dialogs(request)
614 .await
615 .map_err(store_error_to_backend)
616 })
617 }
618
619 fn cached_dialogs(
620 &self,
621 request: DialogsRequest,
622 ) -> BoxFuture<'static, BackendResult<DialogsPage>> {
623 let backend = self.clone();
624 Box::pin(async move {
625 backend
626 .store
627 .dialogs(request)
628 .await
629 .map_err(store_error_to_backend)
630 })
631 }
632
633 fn account_state(&self) -> BoxFuture<'static, BackendResult<AccountStateSnapshot>> {
634 let backend = self.clone();
635 Box::pin(async move {
636 let deleted_chat_ids = backend
637 .store
638 .deleted_chat_ids()
639 .await
640 .map_err(store_error_to_backend)?;
641 Ok(AccountStateSnapshot { deleted_chat_ids })
642 })
643 }
644
645 fn chat_state(
646 &self,
647 chat_id: InlineId,
648 ) -> BoxFuture<'static, BackendResult<ChatStateSnapshot>> {
649 let backend = self.clone();
650 Box::pin(async move {
651 let dialog = backend
652 .store
653 .dialog(chat_id)
654 .await
655 .map_err(store_error_to_backend)?;
656 let deleted = backend
657 .store
658 .deleted_chat_ids()
659 .await
660 .map_err(store_error_to_backend)?
661 .contains(&chat_id);
662 let deleted_message_ids = backend
663 .store
664 .deleted_message_ids(chat_id)
665 .await
666 .map_err(store_error_to_backend)?;
667 let reactions = backend
668 .store
669 .reactions_for_chat(chat_id)
670 .await
671 .map_err(store_error_to_backend)?;
672 let reaction_snapshot_message_ids = backend
673 .store
674 .reaction_snapshot_message_ids(chat_id)
675 .await
676 .map_err(store_error_to_backend)?;
677 let read_state = backend
678 .store
679 .read_state(chat_id)
680 .await
681 .map_err(store_error_to_backend)?;
682 let participants = backend
683 .store
684 .chat_participants(chat_id)
685 .await
686 .map_err(store_error_to_backend)?;
687 let participants_complete = backend
688 .store
689 .chat_participants_complete(chat_id)
690 .await
691 .map_err(store_error_to_backend)?;
692 Ok(ChatStateSnapshot {
693 chat_id,
694 dialog,
695 deleted,
696 deleted_message_ids,
697 reactions,
698 reaction_snapshot_message_ids,
699 read_state,
700 participants,
701 participants_complete,
702 })
703 })
704 }
705
706 fn history(&self, request: HistoryRequest) -> BoxFuture<'static, BackendResult<HistoryPage>> {
707 let backend = self.clone();
708 Box::pin(async move {
709 let session = backend.require_session().await?;
710 if request.before_message_id.is_some() && request.after_message_id.is_some() {
711 return Err(BackendError::new(
712 ClientErrorCategory::InvalidInput,
713 "history request cannot specify both before_message_id and after_message_id",
714 ));
715 }
716 let limit = request.limit.unwrap_or(50).max(1);
717 let fetch_limit = limit.saturating_add(1).min(i32::MAX as u32) as i32;
718 let result = backend
719 .call_realtime(&session, history_input_for_request(&request, fetch_limit))
720 .await?;
721 let mut records = Vec::with_capacity(result.messages.len());
722 for message in result.messages {
723 records.push(
724 backend
725 .record_proto_message(message, Some(request.chat_id), None)
726 .await?,
727 );
728 }
729 let (records, has_more) =
730 normalize_live_history_records(records, limit, request.after_message_id.is_some());
731 Ok(HistoryPage {
732 messages: records,
733 users: Vec::new(),
734 has_more,
735 next_cursor: None,
736 })
737 })
738 }
739
740 fn cached_history(
741 &self,
742 request: HistoryRequest,
743 ) -> BoxFuture<'static, BackendResult<HistoryPage>> {
744 let backend = self.clone();
745 Box::pin(async move {
746 backend
747 .store
748 .history(request)
749 .await
750 .map_err(store_error_to_backend)
751 })
752 }
753
754 fn chat_participants(
755 &self,
756 request: ChatParticipantsRequest,
757 ) -> BoxFuture<'static, BackendResult<ChatParticipantsPage>> {
758 let backend = self.clone();
759 Box::pin(async move {
760 let session = backend.require_session().await?;
761 let result = backend
762 .call_realtime(
763 &session,
764 proto::GetChatParticipantsInput {
765 chat_id: request.chat_id.get(),
766 },
767 )
768 .await?;
769 let page = chat_participants_page_from_proto(result);
770 backend
771 .store
772 .record_users(page.users.clone())
773 .await
774 .map_err(store_error_to_backend)?;
775 backend
776 .store
777 .record_chat_participants(request.chat_id, page.participants.clone())
778 .await
779 .map_err(store_error_to_backend)?;
780 Ok(page)
781 })
782 }
783
784 fn add_chat_participant(
785 &self,
786 request: AddChatParticipantRequest,
787 ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
788 let backend = self.clone();
789 Box::pin(async move {
790 validate_chat_and_user_ids(request.chat_id, request.user_id)?;
791 let session = backend.require_session().await?;
792 let result = backend
793 .call_realtime(
794 &session,
795 proto::AddChatParticipantInput {
796 chat_id: request.chat_id.get(),
797 user_id: request.user_id.get(),
798 },
799 )
800 .await?;
801 let participant = result.participant.ok_or_else(|| {
802 BackendError::new(
803 ClientErrorCategory::ProtocolMismatch,
804 "add chat participant result did not include the participant",
805 )
806 })?;
807 backend
808 .store
809 .record_chat_participant(
810 request.chat_id,
811 ChatParticipantRecord {
812 user_id: InlineId::new(participant.user_id),
813 date: Some(participant.date),
814 },
815 )
816 .await
817 .map_err(store_error_to_backend)?;
818 Ok(OperationOutcome::with_events(vec![
819 ClientEvent::ChatParticipantsChanged {
820 chat_id: request.chat_id,
821 },
822 ]))
823 })
824 }
825
826 fn remove_chat_participant(
827 &self,
828 request: RemoveChatParticipantRequest,
829 ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
830 let backend = self.clone();
831 Box::pin(async move {
832 validate_chat_and_user_ids(request.chat_id, request.user_id)?;
833 let session = backend.require_session().await?;
834 backend
835 .call_realtime(
836 &session,
837 proto::RemoveChatParticipantInput {
838 chat_id: request.chat_id.get(),
839 user_id: request.user_id.get(),
840 },
841 )
842 .await?;
843 backend
844 .store
845 .remove_chat_participant(request.chat_id, request.user_id)
846 .await
847 .map_err(store_error_to_backend)?;
848 Ok(OperationOutcome::with_events(vec![
849 ClientEvent::ChatParticipantsChanged {
850 chat_id: request.chat_id,
851 },
852 ]))
853 })
854 }
855
856 fn update_chat_info(
857 &self,
858 request: UpdateChatInfoRequest,
859 ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
860 let backend = self.clone();
861 Box::pin(async move {
862 if request.chat_id.get() <= 0 {
863 return Err(BackendError::new(
864 ClientErrorCategory::InvalidInput,
865 "chat_id must be positive",
866 ));
867 }
868 if request.title.is_none() && request.emoji.is_none() {
869 return Err(BackendError::new(
870 ClientErrorCategory::InvalidInput,
871 "at least one chat info field must be provided",
872 ));
873 }
874 let title = request.title.map(|title| title.trim().to_owned());
875 if title.as_deref().is_some_and(str::is_empty) {
876 return Err(BackendError::new(
877 ClientErrorCategory::InvalidInput,
878 "chat title must not be empty",
879 ));
880 }
881 let emoji = request.emoji.map(|emoji| emoji.trim().to_owned());
882 let session = backend.require_session().await?;
883 let result = backend
884 .call_realtime(
885 &session,
886 proto::UpdateChatInfoInput {
887 chat_id: request.chat_id.get(),
888 title,
889 emoji,
890 },
891 )
892 .await?;
893 let chat = result.chat.ok_or_else(|| {
894 BackendError::new(
895 ClientErrorCategory::ProtocolMismatch,
896 "update chat info result did not include the chat",
897 )
898 })?;
899 Ok(OperationOutcome::with_events(
900 backend.record_chat_update(chat, None, None).await?,
901 ))
902 })
903 }
904
905 fn delete_chat(
906 &self,
907 request: DeleteChatRequest,
908 ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
909 let backend = self.clone();
910 Box::pin(async move {
911 if request.chat_id.get() <= 0 {
912 return Err(BackendError::new(
913 ClientErrorCategory::InvalidInput,
914 "chat_id must be positive",
915 ));
916 }
917 let session = backend.require_session().await?;
918 backend
919 .call_realtime(
920 &session,
921 proto::DeleteChatInput {
922 peer_id: Some(input_peer_for_chat(request.chat_id)),
923 },
924 )
925 .await?;
926 backend
927 .store
928 .remove_dialog(request.chat_id)
929 .await
930 .map_err(store_error_to_backend)?;
931 Ok(OperationOutcome::with_events(vec![
932 ClientEvent::ChatDeleted {
933 chat_id: request.chat_id,
934 },
935 ]))
936 })
937 }
938
939 fn create_dm(
940 &self,
941 request: CreateDmRequest,
942 ) -> BoxFuture<'static, BackendResult<CreatedChat>> {
943 let backend = self.clone();
944 Box::pin(async move {
945 if request.user_id.get() <= 0 {
946 return Err(BackendError::new(
947 ClientErrorCategory::InvalidInput,
948 "user_id must be positive",
949 ));
950 }
951 let session = backend.require_session().await?;
952 let result = backend
953 .api
954 .create_private_chat(
955 session.auth.access_token().expose_secret(),
956 request.user_id.get(),
957 )
958 .await
959 .map_err(api_error_to_backend)?;
960 let (created, user) = created_chat_from_private_chat_result(
961 result.chat,
962 result.dialog,
963 result.user,
964 request.user_id,
965 )?;
966 backend
967 .store
968 .record_dialog(DialogRecord {
969 chat_id: created.chat_id,
970 peer_user_id: Some(request.user_id),
971 title: created.title.clone(),
972 last_message_id: None,
973 synced_through_message_id: None,
974 unread_count: Some(0),
975 ..DialogRecord::new(created.chat_id)
976 })
977 .await
978 .map_err(store_error_to_backend)?;
979 if let Some(user) = user {
980 backend
981 .store
982 .record_users(vec![user])
983 .await
984 .map_err(store_error_to_backend)?;
985 }
986 Ok(created)
987 })
988 }
989
990 fn create_thread(
991 &self,
992 request: CreateThreadRequest,
993 ) -> BoxFuture<'static, BackendResult<CreatedChat>> {
994 let backend = self.clone();
995 Box::pin(async move {
996 validate_create_participants(&request.participants)?;
997 let session = backend.require_session().await?;
998 let result = backend
999 .call_realtime(
1000 &session,
1001 proto::CreateChatInput {
1002 title: trimmed_option(request.title),
1003 space_id: request.space_id.map(InlineId::get),
1004 description: trimmed_option(request.description),
1005 emoji: trimmed_option(request.emoji),
1006 is_public: request.is_public,
1007 participants: request
1008 .participants
1009 .into_iter()
1010 .map(|participant| proto::InputChatParticipant {
1011 user_id: participant.user_id.get(),
1012 })
1013 .collect(),
1014 reserved_chat_id: None,
1015 },
1016 )
1017 .await?;
1018 let created = created_chat_from_proto(result.chat, result.dialog, None, None, None)?;
1019 backend
1020 .store
1021 .record_dialog(DialogRecord {
1022 chat_id: created.chat_id,
1023 peer_user_id: None,
1024 title: created.title.clone(),
1025 last_message_id: None,
1026 synced_through_message_id: None,
1027 unread_count: Some(0),
1028 ..DialogRecord::new(created.chat_id)
1029 })
1030 .await
1031 .map_err(store_error_to_backend)?;
1032 Ok(created)
1033 })
1034 }
1035
1036 fn create_reply_thread(
1037 &self,
1038 request: CreateReplyThreadRequest,
1039 ) -> BoxFuture<'static, BackendResult<CreatedChat>> {
1040 let backend = self.clone();
1041 Box::pin(async move {
1042 if request.parent_chat_id.get() <= 0 {
1043 return Err(BackendError::new(
1044 ClientErrorCategory::InvalidInput,
1045 "parent_chat_id must be positive",
1046 ));
1047 }
1048 validate_create_participants(&request.participants)?;
1049 let session = backend.require_session().await?;
1050 let result = backend
1051 .call_realtime(
1052 &session,
1053 proto::CreateSubthreadInput {
1054 parent_chat_id: request.parent_chat_id.get(),
1055 parent_message_id: request.parent_message_id.map(InlineId::get),
1056 title: trimmed_option(request.title),
1057 description: trimmed_option(request.description),
1058 emoji: trimmed_option(request.emoji),
1059 participants: request
1060 .participants
1061 .into_iter()
1062 .map(|participant| proto::InputChatParticipant {
1063 user_id: participant.user_id.get(),
1064 })
1065 .collect(),
1066 },
1067 )
1068 .await?;
1069 let created = created_chat_from_proto(
1070 result.chat,
1071 result.dialog,
1072 result.anchor_message,
1073 Some(request.parent_chat_id),
1074 request.parent_message_id,
1075 )?;
1076 backend
1077 .store
1078 .record_dialog(DialogRecord {
1079 chat_id: created.chat_id,
1080 peer_user_id: None,
1081 title: created.title.clone(),
1082 last_message_id: None,
1083 synced_through_message_id: None,
1084 unread_count: Some(0),
1085 ..DialogRecord::new(created.chat_id)
1086 })
1087 .await
1088 .map_err(store_error_to_backend)?;
1089 Ok(created)
1090 })
1091 }
1092
1093 fn send_text(
1094 &self,
1095 request: SendTextRequest,
1096 ) -> BoxFuture<'static, BackendResult<SendTextOutcome>> {
1097 let backend = self.clone();
1098 Box::pin(async move {
1099 if request.text.trim().is_empty() {
1100 return Err(BackendError::new(
1101 ClientErrorCategory::InvalidInput,
1102 "message text must not be empty",
1103 ));
1104 }
1105
1106 let session = backend.require_session().await?;
1107 let initial_chat_id = chat_id_for_peer(request.peer);
1108 let random_id = request
1109 .random_id
1110 .unwrap_or_else(|| random_id_for_request(&request));
1111 let transaction_id = transaction_id_for_send(&request, random_id);
1112 let new_identity = TransactionIdentity::new(
1113 transaction_id.clone(),
1114 request.external_id.clone(),
1115 random_id,
1116 );
1117 let existing = backend
1118 .store
1119 .transaction(transaction_id.clone())
1120 .await
1121 .map_err(store_error_to_backend)?;
1122 if let Some(existing) = existing.as_ref()
1123 && !stored_transaction_needs_retry(existing)
1124 {
1125 return Ok(outcome_from_stored_transaction(
1126 existing.clone(),
1127 initial_chat_id,
1128 ));
1129 }
1130 let identity = existing
1131 .map(|transaction| transaction.identity)
1132 .unwrap_or(new_identity);
1133
1134 if backend
1135 .store
1136 .transaction(transaction_id)
1137 .await
1138 .map_err(store_error_to_backend)?
1139 .is_none()
1140 {
1141 backend
1142 .store
1143 .record_transaction(
1144 StoredTransaction::new(identity.clone(), TransactionState::Queued)
1145 .with_chat_id(initial_chat_id),
1146 )
1147 .await
1148 .map_err(store_error_to_backend)?;
1149 }
1150
1151 backend
1152 .store
1153 .record_transaction(
1154 StoredTransaction::new(identity.clone(), TransactionState::Sent)
1155 .with_chat_id(initial_chat_id),
1156 )
1157 .await
1158 .map_err(store_error_to_backend)?;
1159
1160 let input = proto::SendMessageInput {
1161 peer_id: Some(input_peer_for_client_peer(request.peer)),
1162 message: Some(request.text.clone()),
1163 reply_to_msg_id: request.reply_to_message_id.map(InlineId::get),
1164 random_id: Some(random_id.get()),
1165 media: None,
1166 temporary_send_date: Some(now_seconds()),
1167 is_sticker: None,
1168 has_link: None,
1169 entities: None,
1170 parse_markdown: Some(false),
1171 send_mode: None,
1172 actions: None,
1173 };
1174 let send_result = match backend.call_realtime(&session, input).await {
1175 Ok(result) => result,
1176 Err(backend_error) => {
1177 backend
1178 .record_transaction_error(
1179 identity,
1180 initial_chat_id,
1181 TransactionState::Sent,
1182 backend_error.clone(),
1183 )
1184 .await?;
1185 return Err(backend_error);
1186 }
1187 };
1188
1189 let applied =
1190 apply_send_message_updates(&request, identity, initial_chat_id, send_result);
1191 if let Some(message) = &applied.message {
1192 backend
1193 .store
1194 .record_message(message.clone())
1195 .await
1196 .map_err(store_error_to_backend)?;
1197 }
1198 backend
1199 .store
1200 .record_transaction(applied.transaction.clone())
1201 .await
1202 .map_err(store_error_to_backend)?;
1203
1204 Ok(SendTextOutcome::with_state(
1205 MessageMutation {
1206 transaction: applied.transaction.identity,
1207 message_id: applied.message_id,
1208 state: None,
1209 failure: None,
1210 },
1211 applied.chat_id,
1212 applied.message_id,
1213 applied.message,
1214 applied.transaction.state,
1215 applied.transaction.failure,
1216 ))
1217 })
1218 }
1219
1220 fn send_media(
1221 &self,
1222 request: UploadRequest,
1223 bytes: Vec<u8>,
1224 ) -> BoxFuture<'static, BackendResult<SendTextOutcome>> {
1225 let backend = self.clone();
1226 Box::pin(async move {
1227 if bytes.is_empty() {
1228 return Err(BackendError::new(
1229 ClientErrorCategory::InvalidInput,
1230 "media bytes must not be empty",
1231 ));
1232 }
1233
1234 let session = backend.require_session().await?;
1235 let initial_chat_id = chat_id_for_peer(request.peer);
1236 let random_id = request
1237 .random_id
1238 .unwrap_or_else(|| random_id_for_upload_request(&request, bytes.len()));
1239 let transaction_id = transaction_id_for_upload(&request, random_id);
1240 let new_identity = TransactionIdentity::new(
1241 transaction_id.clone(),
1242 request.external_id.clone(),
1243 random_id,
1244 );
1245 let existing = backend
1246 .store
1247 .transaction(transaction_id.clone())
1248 .await
1249 .map_err(store_error_to_backend)?;
1250 if let Some(existing) = existing.as_ref()
1251 && !stored_transaction_needs_retry(existing)
1252 {
1253 return Ok(outcome_from_stored_transaction(
1254 existing.clone(),
1255 initial_chat_id,
1256 ));
1257 }
1258 let identity = existing
1259 .map(|transaction| transaction.identity)
1260 .unwrap_or(new_identity);
1261
1262 if backend
1263 .store
1264 .transaction(transaction_id)
1265 .await
1266 .map_err(store_error_to_backend)?
1267 .is_none()
1268 {
1269 backend
1270 .store
1271 .record_transaction(
1272 StoredTransaction::new(identity.clone(), TransactionState::Queued)
1273 .with_chat_id(initial_chat_id),
1274 )
1275 .await
1276 .map_err(store_error_to_backend)?;
1277 }
1278
1279 let upload_input = upload_input_for_request(&request, bytes);
1280 let upload = match backend
1281 .api
1282 .upload_file_bytes(session.auth.access_token().expose_secret(), upload_input)
1283 .await
1284 {
1285 Ok(upload) => upload,
1286 Err(error) => {
1287 let backend_error = api_error_to_backend(error);
1288 backend
1289 .record_transaction_error(
1290 identity,
1291 initial_chat_id,
1292 TransactionState::Queued,
1293 backend_error.clone(),
1294 )
1295 .await?;
1296 return Err(backend_error);
1297 }
1298 };
1299 let media = match input_media_from_upload(&upload) {
1300 Ok(media) => media,
1301 Err(error) => {
1302 backend
1303 .record_transaction_error(
1304 identity,
1305 initial_chat_id,
1306 TransactionState::Failed,
1307 error.clone(),
1308 )
1309 .await?;
1310 return Err(error);
1311 }
1312 };
1313
1314 backend
1315 .store
1316 .record_transaction(
1317 StoredTransaction::new(identity.clone(), TransactionState::Sent)
1318 .with_chat_id(initial_chat_id),
1319 )
1320 .await
1321 .map_err(store_error_to_backend)?;
1322
1323 let input = proto::SendMessageInput {
1324 peer_id: Some(input_peer_for_client_peer(request.peer)),
1325 message: request.caption.clone(),
1326 reply_to_msg_id: request.reply_to_message_id.map(InlineId::get),
1327 random_id: Some(random_id.get()),
1328 media: Some(media),
1329 temporary_send_date: Some(now_seconds()),
1330 is_sticker: None,
1331 has_link: None,
1332 entities: None,
1333 parse_markdown: Some(false),
1334 send_mode: None,
1335 actions: None,
1336 };
1337 let send_result = match backend.call_realtime(&session, input).await {
1338 Ok(result) => result,
1339 Err(backend_error) => {
1340 backend
1341 .record_transaction_error(
1342 identity,
1343 initial_chat_id,
1344 TransactionState::Sent,
1345 backend_error.clone(),
1346 )
1347 .await?;
1348 return Err(backend_error);
1349 }
1350 };
1351
1352 let applied =
1353 apply_upload_message_updates(&request, identity, initial_chat_id, send_result);
1354 if let Some(message) = &applied.message {
1355 backend
1356 .store
1357 .record_message(message.clone())
1358 .await
1359 .map_err(store_error_to_backend)?;
1360 }
1361 backend
1362 .store
1363 .record_transaction(applied.transaction.clone())
1364 .await
1365 .map_err(store_error_to_backend)?;
1366
1367 Ok(SendTextOutcome::with_state(
1368 MessageMutation {
1369 transaction: applied.transaction.identity,
1370 message_id: applied.message_id,
1371 state: None,
1372 failure: None,
1373 },
1374 applied.chat_id,
1375 applied.message_id,
1376 applied.message,
1377 applied.transaction.state,
1378 applied.transaction.failure,
1379 ))
1380 })
1381 }
1382
1383 fn edit_message(
1384 &self,
1385 request: EditMessageRequest,
1386 ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
1387 let backend = self.clone();
1388 Box::pin(async move {
1389 if request.text.trim().is_empty() {
1390 return Err(BackendError::new(
1391 ClientErrorCategory::InvalidInput,
1392 "message text must not be empty",
1393 ));
1394 }
1395 let session = backend.require_session().await?;
1396 let result = backend
1397 .call_realtime(
1398 &session,
1399 proto::EditMessageInput {
1400 message_id: request.message_id.get(),
1401 peer_id: Some(input_peer_for_chat(request.chat_id)),
1402 text: request.text,
1403 entities: None,
1404 parse_markdown: Some(false),
1405 actions: None,
1406 },
1407 )
1408 .await?;
1409 let events = backend
1410 .apply_updates(result.updates, Some(request.chat_id), None)
1411 .await?;
1412 Ok(OperationOutcome::with_events(events))
1413 })
1414 }
1415
1416 fn delete_message(
1417 &self,
1418 request: DeleteMessageRequest,
1419 ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
1420 let backend = self.clone();
1421 Box::pin(async move {
1422 let session = backend.require_session().await?;
1423 let result = backend
1424 .call_realtime(
1425 &session,
1426 proto::DeleteMessagesInput {
1427 message_ids: vec![request.message_id.get()],
1428 peer_id: Some(input_peer_for_chat(request.chat_id)),
1429 },
1430 )
1431 .await?;
1432 let mut events = backend
1433 .apply_updates(result.updates, Some(request.chat_id), None)
1434 .await?;
1435 if !events.iter().any(|event| {
1436 matches!(
1437 event,
1438 ClientEvent::MessageDeleted {
1439 chat_id,
1440 message_id
1441 } if *chat_id == request.chat_id && *message_id == request.message_id
1442 )
1443 }) {
1444 events.push(ClientEvent::MessageDeleted {
1445 chat_id: request.chat_id,
1446 message_id: request.message_id,
1447 });
1448 }
1449 Ok(OperationOutcome::with_events(events))
1450 })
1451 }
1452
1453 fn react(&self, request: ReactRequest) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
1454 let backend = self.clone();
1455 Box::pin(async move {
1456 let reaction = request.reaction.trim().to_owned();
1457 if reaction.is_empty() {
1458 return Err(BackendError::new(
1459 ClientErrorCategory::InvalidInput,
1460 "reaction must not be empty",
1461 ));
1462 }
1463 let session = backend.require_session().await?;
1464 let updates = if request.remove {
1465 backend
1466 .call_realtime(
1467 &session,
1468 proto::DeleteReactionInput {
1469 emoji: reaction.clone(),
1470 peer_id: Some(input_peer_for_chat(request.chat_id)),
1471 message_id: request.message_id.get(),
1472 },
1473 )
1474 .await?
1475 .updates
1476 } else {
1477 backend
1478 .call_realtime(
1479 &session,
1480 proto::AddReactionInput {
1481 emoji: reaction.clone(),
1482 message_id: request.message_id.get(),
1483 peer_id: Some(input_peer_for_chat(request.chat_id)),
1484 },
1485 )
1486 .await?
1487 .updates
1488 };
1489 let mut events = backend
1490 .apply_updates(updates, Some(request.chat_id), None)
1491 .await?;
1492 if !events.iter().any(|event| {
1493 matches!(
1494 event,
1495 ClientEvent::ReactionChanged {
1496 chat_id,
1497 message_id,
1498 ..
1499 } if *chat_id == request.chat_id && *message_id == request.message_id
1500 )
1501 }) {
1502 events.push(ClientEvent::ReactionChanged {
1503 chat_id: request.chat_id,
1504 message_id: request.message_id,
1505 user_id: InlineId::new(0),
1506 reaction,
1507 removed: request.remove,
1508 });
1509 }
1510 Ok(OperationOutcome::with_events(events))
1511 })
1512 }
1513
1514 fn read(&self, request: ReadRequest) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
1515 let backend = self.clone();
1516 Box::pin(async move {
1517 let session = backend.require_session().await?;
1518 let result = backend
1519 .call_realtime(
1520 &session,
1521 proto::ReadMessagesInput {
1522 peer_id: Some(input_peer_for_chat(request.chat_id)),
1523 max_id: request.max_message_id.map(InlineId::get),
1524 },
1525 )
1526 .await?;
1527 let mut events = backend
1528 .apply_updates(result.updates, Some(request.chat_id), None)
1529 .await?;
1530 if !events.iter().any(|event| {
1531 matches!(event, ClientEvent::ReadStateChanged { chat_id } if *chat_id == request.chat_id)
1532 }) {
1533 events.push(ClientEvent::ReadStateChanged {
1534 chat_id: request.chat_id,
1535 });
1536 }
1537 Ok(OperationOutcome::with_events(events))
1538 })
1539 }
1540
1541 fn set_marked_unread(
1542 &self,
1543 request: SetMarkedUnreadRequest,
1544 ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
1545 let backend = self.clone();
1546 Box::pin(async move {
1547 let session = backend.require_session().await?;
1548 let updates = if request.unread {
1549 backend
1550 .call_realtime(
1551 &session,
1552 proto::MarkAsUnreadInput {
1553 peer_id: Some(input_peer_for_chat(request.chat_id)),
1554 },
1555 )
1556 .await?
1557 .updates
1558 } else {
1559 backend
1560 .call_realtime(
1561 &session,
1562 proto::ReadMessagesInput {
1563 peer_id: Some(input_peer_for_chat(request.chat_id)),
1564 max_id: None,
1565 },
1566 )
1567 .await?
1568 .updates
1569 };
1570 let mut events = backend
1571 .apply_updates(updates, Some(request.chat_id), None)
1572 .await?;
1573 if !events.iter().any(|event| {
1574 matches!(event, ClientEvent::ReadStateChanged { chat_id } if *chat_id == request.chat_id)
1575 }) {
1576 events.push(ClientEvent::ReadStateChanged {
1577 chat_id: request.chat_id,
1578 });
1579 }
1580 Ok(OperationOutcome::with_events(events))
1581 })
1582 }
1583
1584 fn update_dialog_notifications(
1585 &self,
1586 request: UpdateDialogNotificationsRequest,
1587 ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
1588 let backend = self.clone();
1589 Box::pin(async move {
1590 let session = backend.require_session().await?;
1591 let result = backend
1592 .call_realtime(
1593 &session,
1594 proto::UpdateDialogNotificationSettingsInput {
1595 peer_id: Some(input_peer_for_chat(request.chat_id)),
1596 notification_settings: request.mode.map(|mode| {
1597 proto::DialogNotificationSettings {
1598 mode: Some(dialog_notification_mode_to_proto(mode) as i32),
1599 }
1600 }),
1601 },
1602 )
1603 .await?;
1604 let mut events = backend
1605 .apply_updates(result.updates, Some(request.chat_id), None)
1606 .await?;
1607 backend
1608 .mutate_dialog(request.chat_id, |dialog| {
1609 dialog.notification_mode = request.mode;
1610 })
1611 .await?;
1612 if !events.iter().any(|event| {
1613 matches!(event, ClientEvent::ChatUpserted { chat_id } if *chat_id == request.chat_id)
1614 }) {
1615 events.push(ClientEvent::ChatUpserted {
1616 chat_id: request.chat_id,
1617 });
1618 }
1619 Ok(OperationOutcome::with_events(events))
1620 })
1621 }
1622
1623 fn typing(
1624 &self,
1625 request: TypingRequest,
1626 ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
1627 let backend = self.clone();
1628 Box::pin(async move {
1629 let session = backend.require_session().await?;
1630 backend
1631 .call_realtime(
1632 &session,
1633 proto::SendComposeActionInput {
1634 peer_id: Some(input_peer_for_chat(request.chat_id)),
1635 action: request
1636 .is_typing
1637 .then_some(proto::update_compose_action::ComposeAction::Typing as i32),
1638 },
1639 )
1640 .await?;
1641 Ok(OperationOutcome::empty())
1642 })
1643 }
1644
1645 fn receive_events(&self) -> BoxFuture<'static, BackendResult<Vec<ClientEvent>>> {
1646 let backend = self.clone();
1647 Box::pin(async move {
1648 let session = backend.require_session().await?;
1649 if backend.sync_required.load(Ordering::Acquire) {
1650 let events = backend.sync.discover(&backend).await?;
1651 backend.sync_required.store(false, Ordering::Release);
1652 if !events.is_empty() {
1653 return Ok(events);
1654 }
1655 }
1656 loop {
1657 match backend.receive_realtime_event(&session).await? {
1658 RealtimeEvent::Updates(updates) => {
1659 let events = match backend.sync.process_realtime(&backend, updates).await {
1660 Ok(events) => events,
1661 Err(error) => {
1662 backend.sync_required.store(true, Ordering::Release);
1663 return Err(error);
1664 }
1665 };
1666 if !events.is_empty() {
1667 return Ok(events);
1668 }
1669 }
1670 RealtimeEvent::Ack { .. } | RealtimeEvent::Pong { .. } => {}
1671 _ => {}
1672 }
1673 }
1674 })
1675 }
1676}
1677
1678impl SyncHost for SdkBackend {
1679 fn get_updates_state(
1680 &self,
1681 date: i64,
1682 ) -> BoxFuture<'static, BackendResult<proto::GetUpdatesStateResult>> {
1683 let backend = self.clone();
1684 Box::pin(async move {
1685 let session = backend.require_session().await?;
1686 backend
1687 .call_realtime(&session, proto::GetUpdatesStateInput { date })
1688 .await
1689 })
1690 }
1691
1692 fn get_updates(
1693 &self,
1694 input: proto::GetUpdatesInput,
1695 ) -> BoxFuture<'static, BackendResult<proto::GetUpdatesResult>> {
1696 let backend = self.clone();
1697 Box::pin(async move {
1698 let session = backend.require_session().await?;
1699 backend.call_realtime(&session, input).await
1700 })
1701 }
1702
1703 fn apply_sync_batch(
1704 &self,
1705 updates: Vec<proto::Update>,
1706 sidecars: Option<proto::UpdateSidecars>,
1707 ) -> BoxFuture<'static, BackendResult<Vec<ClientEvent>>> {
1708 let backend = self.clone();
1709 Box::pin(async move {
1710 let mut events = if let Some(sidecars) = sidecars {
1711 backend.apply_sidecars(sidecars).await?
1712 } else {
1713 Vec::new()
1714 };
1715 events.extend(backend.apply_updates(updates, None, None).await?);
1716 Ok(events)
1717 })
1718 }
1719
1720 fn repair_bucket(
1721 &self,
1722 key: crate::SyncBucketKey,
1723 ) -> BoxFuture<'static, BackendResult<Vec<ClientEvent>>> {
1724 let backend = self.clone();
1725 Box::pin(async move {
1726 match key {
1727 crate::SyncBucketKey::User => backend.repair_user_bucket().await,
1728 crate::SyncBucketKey::Space { space_id } => {
1729 backend.repair_space_bucket(space_id).await
1730 }
1731 crate::SyncBucketKey::Chat { peer } => backend.repair_chat_bucket(peer).await,
1732 }
1733 })
1734 }
1735}
1736
1737impl SdkBackend {
1738 async fn repair_user_bucket(&self) -> BackendResult<Vec<ClientEvent>> {
1739 let session = self.require_session().await?;
1740 let chats = self
1741 .call_realtime(&session, proto::GetChatsInput {})
1742 .await?;
1743 let me = self.call_realtime(&session, proto::GetMeInput {}).await?;
1744 let settings = self
1745 .call_realtime(&session, proto::GetUserSettingsInput {})
1746 .await?;
1747 let chat_peers = chats
1748 .chats
1749 .iter()
1750 .map(sync_bucket_peer_for_chat)
1751 .collect::<HashSet<_>>();
1752 let space_ids = chats
1753 .spaces
1754 .iter()
1755 .map(|space| InlineId::new(space.id))
1756 .collect::<HashSet<_>>();
1757
1758 let user = me.user.ok_or_else(|| {
1759 BackendError::new(
1760 ClientErrorCategory::ProtocolMismatch,
1761 "user bucket snapshot did not include the current user",
1762 )
1763 })?;
1764 let settings = settings.user_settings.ok_or_else(|| {
1765 BackendError::new(
1766 ClientErrorCategory::ProtocolMismatch,
1767 "user bucket snapshot did not include user settings",
1768 )
1769 })?;
1770
1771 let mut events = self.apply_get_chats_snapshot(chats).await?;
1772 let user = user_record_from_proto(&user);
1773 self.store
1774 .record_users(vec![user.clone()])
1775 .await
1776 .map_err(store_error_to_backend)?;
1777 self.store
1778 .record_user_settings(user_settings_record_from_proto(&settings))
1779 .await
1780 .map_err(store_error_to_backend)?;
1781 events.push(ClientEvent::UserUpserted {
1782 user_id: user.user_id,
1783 });
1784 events.push(ClientEvent::UserSettingsChanged {});
1785 for space_id in space_ids {
1786 events.extend(self.repair_space_members(&session, space_id, true).await?);
1787 }
1788 for peer in chat_peers {
1789 events.extend(self.repair_chat_bucket(peer).await?);
1790 }
1791 Ok(events)
1792 }
1793
1794 async fn repair_space_bucket(&self, space_id: InlineId) -> BackendResult<Vec<ClientEvent>> {
1795 let session = self.require_session().await?;
1796 let chats = self
1797 .call_realtime(&session, proto::GetChatsInput {})
1798 .await?;
1799 let space_is_current = chats.spaces.iter().any(|space| space.id == space_id.get());
1800 let mut events = self.apply_get_chats_snapshot(chats).await?;
1801 events.extend(
1802 self.repair_space_members(&session, space_id, space_is_current)
1803 .await?,
1804 );
1805 Ok(events)
1806 }
1807
1808 async fn repair_space_members(
1809 &self,
1810 session: &StoredSession,
1811 space_id: InlineId,
1812 space_is_current: bool,
1813 ) -> BackendResult<Vec<ClientEvent>> {
1814 let members = if space_is_current {
1815 Some(
1816 self.call_realtime(
1817 session,
1818 proto::GetSpaceMembersInput {
1819 space_id: space_id.get(),
1820 },
1821 )
1822 .await?,
1823 )
1824 } else {
1825 None
1826 };
1827 let previous_members = self
1828 .store
1829 .space_members(space_id)
1830 .await
1831 .map_err(store_error_to_backend)?;
1832 let (current_members, users) = if let Some(result) = members {
1833 (
1834 result
1835 .members
1836 .iter()
1837 .map(space_member_record_from_proto)
1838 .collect::<Vec<_>>(),
1839 result
1840 .users
1841 .iter()
1842 .map(user_record_from_proto)
1843 .collect::<Vec<_>>(),
1844 )
1845 } else {
1846 (Vec::new(), Vec::new())
1847 };
1848 self.store
1849 .record_users(users.clone())
1850 .await
1851 .map_err(store_error_to_backend)?;
1852 self.store
1853 .record_space_members(space_id, current_members.clone())
1854 .await
1855 .map_err(store_error_to_backend)?;
1856
1857 let mut events = users
1858 .into_iter()
1859 .map(|user| ClientEvent::UserUpserted {
1860 user_id: user.user_id,
1861 })
1862 .collect::<Vec<_>>();
1863 let current_ids = current_members
1864 .iter()
1865 .map(|member| member.user_id)
1866 .collect::<HashSet<_>>();
1867 events.extend(
1868 current_members
1869 .into_iter()
1870 .map(|member| ClientEvent::SpaceMemberChanged {
1871 space_id,
1872 user_id: member.user_id,
1873 removed: false,
1874 }),
1875 );
1876 events.extend(
1877 previous_members
1878 .into_iter()
1879 .filter(|member| !current_ids.contains(&member.user_id))
1880 .map(|member| ClientEvent::SpaceMemberChanged {
1881 space_id,
1882 user_id: member.user_id,
1883 removed: true,
1884 }),
1885 );
1886 Ok(events)
1887 }
1888
1889 async fn repair_chat_bucket(
1890 &self,
1891 peer: crate::SyncBucketPeer,
1892 ) -> BackendResult<Vec<ClientEvent>> {
1893 let session = self.require_session().await?;
1894 let peer_id = input_peer_for_sync_bucket_peer(peer);
1895 let chat_result = match self
1896 .call_realtime(
1897 &session,
1898 proto::GetChatInput {
1899 peer_id: Some(peer_id.clone()),
1900 },
1901 )
1902 .await
1903 {
1904 Ok(result) => result,
1905 Err(error) if error.category == ClientErrorCategory::NotFound => {
1906 let Some(chat_id) = self.stored_chat_id_for_sync_peer(peer).await? else {
1907 return Ok(Vec::new());
1908 };
1909 self.store
1910 .remove_dialog(chat_id)
1911 .await
1912 .map_err(store_error_to_backend)?;
1913 return Ok(vec![ClientEvent::ChatDeleted { chat_id }]);
1914 }
1915 Err(error) => return Err(error),
1916 };
1917 let chat = chat_result.chat.ok_or_else(|| {
1918 BackendError::new(
1919 ClientErrorCategory::ProtocolMismatch,
1920 "chat bucket snapshot did not include the chat",
1921 )
1922 })?;
1923 let chat_id = InlineId::new(chat.id);
1924 let participants = self
1925 .call_realtime(
1926 &session,
1927 proto::GetChatParticipantsInput {
1928 chat_id: chat_id.get(),
1929 },
1930 )
1931 .await?;
1932 let history = self
1933 .fetch_complete_chat_history(&session, peer_id, chat_id)
1934 .await?;
1935 let previous = self
1936 .store
1937 .history(HistoryRequest {
1938 chat_id,
1939 limit: Some(u32::MAX),
1940 before_message_id: None,
1941 after_message_id: None,
1942 })
1943 .await
1944 .map_err(store_error_to_backend)?;
1945 let previous_ids = previous
1946 .messages
1947 .iter()
1948 .map(|message| message.message_id)
1949 .collect::<HashSet<_>>();
1950 let current_ids = history
1951 .iter()
1952 .map(|message| InlineId::new(message.id))
1953 .collect::<HashSet<_>>();
1954
1955 let dialog = chat_result.dialog;
1956 let read_state = dialog
1957 .as_ref()
1958 .and_then(|dialog| read_state_from_proto_dialog(chat_id, dialog));
1959 let pinned_message_ids = chat_result
1960 .pinned_message_ids
1961 .into_iter()
1962 .map(InlineId::new)
1963 .collect::<Vec<_>>();
1964 let mut events = self.record_chat_update(chat, dialog, None).await?;
1965 self.mutate_dialog(chat_id, |dialog| {
1966 dialog.pinned_message_ids = pinned_message_ids;
1967 })
1968 .await?;
1969
1970 let participant_page = chat_participants_page_from_proto(participants);
1971 self.store
1972 .record_users(participant_page.users.clone())
1973 .await
1974 .map_err(store_error_to_backend)?;
1975 self.store
1976 .record_chat_participants(chat_id, participant_page.participants)
1977 .await
1978 .map_err(store_error_to_backend)?;
1979 events.extend(
1980 participant_page
1981 .users
1982 .into_iter()
1983 .map(|user| ClientEvent::UserUpserted {
1984 user_id: user.user_id,
1985 }),
1986 );
1987 events.push(ClientEvent::ChatParticipantsChanged { chat_id });
1988
1989 self.store
1990 .clear_chat_messages(chat_id, None)
1991 .await
1992 .map_err(store_error_to_backend)?;
1993 for message in history {
1994 let record = self
1995 .record_proto_message(message, Some(chat_id), None)
1996 .await?;
1997 events.push(ClientEvent::MessageStored { message: record });
1998 }
1999 events.extend(
2000 previous_ids
2001 .difference(¤t_ids)
2002 .copied()
2003 .map(|message_id| ClientEvent::MessageDeleted {
2004 chat_id,
2005 message_id,
2006 }),
2007 );
2008 if let Some(read_state) = read_state {
2009 self.store
2010 .record_read_state(read_state)
2011 .await
2012 .map_err(store_error_to_backend)?;
2013 events.push(ClientEvent::ReadStateChanged { chat_id });
2014 }
2015 Ok(events)
2016 }
2017
2018 async fn fetch_complete_chat_history(
2019 &self,
2020 session: &StoredSession,
2021 peer_id: proto::InputPeer,
2022 chat_id: InlineId,
2023 ) -> BackendResult<Vec<proto::Message>> {
2024 let mut before_id = None;
2025 let mut messages = BTreeMap::new();
2026 for page in 0..100_000_u32 {
2027 let result = self
2028 .call_realtime(
2029 session,
2030 proto::GetChatHistoryInput {
2031 peer_id: Some(peer_id.clone()),
2032 offset_id: None,
2033 limit: Some(SNAPSHOT_HISTORY_PAGE_LIMIT),
2034 mode: Some(if before_id.is_some() {
2035 proto::GetChatHistoryMode::HistoryModeOlder as i32
2036 } else {
2037 proto::GetChatHistoryMode::HistoryModeLatest as i32
2038 }),
2039 anchor_id: None,
2040 before_id,
2041 after_id: None,
2042 before_limit: None,
2043 after_limit: None,
2044 include_anchor: None,
2045 },
2046 )
2047 .await?;
2048 let count = result.messages.len();
2049 if count > SNAPSHOT_HISTORY_PAGE_LIMIT as usize {
2050 return Err(BackendError::new(
2051 ClientErrorCategory::ProtocolMismatch,
2052 "chat history snapshot exceeded the requested page size",
2053 ));
2054 }
2055 if count == 0 {
2056 log::debug!(
2057 "completed Inline chat history snapshot chat_id={} pages={} messages={}",
2058 chat_id.get(),
2059 page + 1,
2060 messages.len()
2061 );
2062 return Ok(messages.into_values().collect());
2063 }
2064 let next_before = result
2065 .messages
2066 .iter()
2067 .map(|message| message.id)
2068 .min()
2069 .filter(|message_id| *message_id > 0)
2070 .ok_or_else(|| {
2071 BackendError::new(
2072 ClientErrorCategory::ProtocolMismatch,
2073 "chat history snapshot returned an invalid message id",
2074 )
2075 })?;
2076 if before_id.is_some_and(|previous| next_before >= previous) {
2077 return Err(BackendError::new(
2078 ClientErrorCategory::ProtocolMismatch,
2079 "chat history snapshot did not advance",
2080 ));
2081 }
2082 for message in result.messages {
2083 messages.insert(message.id, message);
2084 }
2085 if count < SNAPSHOT_HISTORY_PAGE_LIMIT as usize {
2086 log::debug!(
2087 "completed Inline chat history snapshot chat_id={} pages={} messages={}",
2088 chat_id.get(),
2089 page + 1,
2090 messages.len()
2091 );
2092 return Ok(messages.into_values().collect());
2093 }
2094 before_id = Some(next_before);
2095 }
2096 Err(BackendError::new(
2097 ClientErrorCategory::ProtocolMismatch,
2098 "chat history snapshot exceeded the page safety limit",
2099 ))
2100 }
2101
2102 async fn apply_get_chats_snapshot(
2103 &self,
2104 result: proto::GetChatsResult,
2105 ) -> BackendResult<Vec<ClientEvent>> {
2106 let dialogs = dialog_records_from_get_chats(&result);
2107 let live_ids = dialogs
2108 .iter()
2109 .map(|dialog| dialog.chat_id)
2110 .collect::<HashSet<_>>();
2111 let stored_ids = self.stored_dialog_ids().await?;
2112 let read_states = result
2113 .chats
2114 .iter()
2115 .filter_map(|chat| {
2116 dialog_for_chat(&result, chat)
2117 .and_then(|dialog| read_state_from_proto_dialog(InlineId::new(chat.id), dialog))
2118 })
2119 .collect::<Vec<_>>();
2120 let users = result
2121 .users
2122 .iter()
2123 .map(user_record_from_proto)
2124 .collect::<Vec<_>>();
2125 let spaces = result
2126 .spaces
2127 .iter()
2128 .map(space_record_from_proto)
2129 .collect::<Vec<_>>();
2130
2131 self.reconcile_live_dialogs(dialogs).await?;
2132 self.store
2133 .record_users(users.clone())
2134 .await
2135 .map_err(store_error_to_backend)?;
2136 for space in &spaces {
2137 self.store
2138 .record_space(space.clone())
2139 .await
2140 .map_err(store_error_to_backend)?;
2141 }
2142 for read_state in &read_states {
2143 self.store
2144 .record_read_state(*read_state)
2145 .await
2146 .map_err(store_error_to_backend)?;
2147 }
2148
2149 let mut events = stored_ids
2150 .difference(&live_ids)
2151 .copied()
2152 .map(|chat_id| ClientEvent::ChatDeleted { chat_id })
2153 .collect::<Vec<_>>();
2154 events.extend(
2155 live_ids
2156 .into_iter()
2157 .map(|chat_id| ClientEvent::ChatUpserted { chat_id }),
2158 );
2159 events.extend(users.into_iter().map(|user| ClientEvent::UserUpserted {
2160 user_id: user.user_id,
2161 }));
2162 events.extend(spaces.into_iter().map(|space| ClientEvent::SpaceUpserted {
2163 space_id: space.space_id,
2164 }));
2165 events.extend(
2166 read_states
2167 .into_iter()
2168 .map(|state| ClientEvent::ReadStateChanged {
2169 chat_id: state.chat_id,
2170 }),
2171 );
2172 for message in result.messages {
2173 let record = self.record_proto_message(message, None, None).await?;
2174 events.push(ClientEvent::MessageStored { message: record });
2175 }
2176 Ok(events)
2177 }
2178
2179 async fn stored_dialog_ids(&self) -> BackendResult<HashSet<InlineId>> {
2180 let mut cursor = None;
2181 let mut ids = HashSet::new();
2182 loop {
2183 let page = self
2184 .store
2185 .dialogs(DialogsRequest {
2186 limit: Some(1_000),
2187 cursor: cursor.clone(),
2188 })
2189 .await
2190 .map_err(store_error_to_backend)?;
2191 ids.extend(page.dialogs.into_iter().map(|dialog| dialog.chat_id));
2192 let Some(next) = page.next_cursor else {
2193 return Ok(ids);
2194 };
2195 if cursor.as_ref() == Some(&next) {
2196 return Err(BackendError::new(
2197 ClientErrorCategory::ProtocolMismatch,
2198 "stored dialog pagination did not advance",
2199 ));
2200 }
2201 cursor = Some(next);
2202 }
2203 }
2204
2205 async fn stored_chat_id_for_sync_peer(
2206 &self,
2207 peer: crate::SyncBucketPeer,
2208 ) -> BackendResult<Option<InlineId>> {
2209 match peer {
2210 crate::SyncBucketPeer::Chat { chat_id } => Ok(Some(chat_id)),
2211 crate::SyncBucketPeer::User { user_id } => {
2212 let dialogs = self
2213 .store
2214 .dialogs(DialogsRequest {
2215 limit: Some(u32::MAX),
2216 cursor: None,
2217 })
2218 .await
2219 .map_err(store_error_to_backend)?;
2220 Ok(dialogs
2221 .dialogs
2222 .into_iter()
2223 .find(|dialog| dialog.peer_user_id == Some(user_id))
2224 .map(|dialog| dialog.chat_id))
2225 }
2226 }
2227 }
2228
2229 async fn apply_sidecars(
2230 &self,
2231 sidecars: proto::UpdateSidecars,
2232 ) -> BackendResult<Vec<ClientEvent>> {
2233 let users = sidecars
2234 .users
2235 .iter()
2236 .map(user_record_from_proto)
2237 .collect::<Vec<_>>();
2238 self.store
2239 .record_users(users.clone())
2240 .await
2241 .map_err(store_error_to_backend)?;
2242
2243 let users_by_id = sidecars
2244 .users
2245 .iter()
2246 .map(|user| (user.id, user))
2247 .collect::<HashMap<_, _>>();
2248 for chat in &sidecars.chats {
2249 let dialog = sidecars.dialogs.iter().find(|dialog| {
2250 dialog.chat_id == Some(chat.id)
2251 || dialog
2252 .peer
2253 .as_ref()
2254 .zip(chat.peer_id.as_ref())
2255 .is_some_and(|(dialog_peer, chat_peer)| dialog_peer == chat_peer)
2256 });
2257 self.store
2258 .record_dialog(DialogRecord {
2259 chat_id: InlineId::new(chat.id),
2260 peer_user_id: dialog_peer_user_id(dialog, chat),
2261 title: Some(chat_title_from_proto(chat, &users_by_id)),
2262 emoji: chat_emoji_from_proto(chat),
2263 last_message_id: chat.last_msg_id.map(InlineId::new),
2264 synced_through_message_id: None,
2265 unread_count: dialog
2266 .and_then(|dialog| dialog.unread_count)
2267 .and_then(|count| u32::try_from(count).ok()),
2268 space_id: dialog
2269 .and_then(|dialog| dialog.space_id)
2270 .or(chat.space_id)
2271 .map(InlineId::new),
2272 is_public: chat.is_public,
2273 archived: dialog.and_then(|dialog| dialog.archived),
2274 pinned: dialog.and_then(|dialog| dialog.pinned),
2275 open: dialog.and_then(|dialog| dialog.open),
2276 chat_list_hidden: dialog.and_then(|dialog| dialog.chat_list_hidden),
2277 order: dialog.and_then(|dialog| dialog.order.clone()),
2278 pinned_order: dialog.and_then(|dialog| dialog.pinned_order.clone()),
2279 notification_mode: dialog.and_then(dialog_notification_mode_from_proto),
2280 follow_mode: dialog.and_then(dialog_follow_mode_from_proto),
2281 ..DialogRecord::new(InlineId::new(chat.id))
2282 })
2283 .await
2284 .map_err(store_error_to_backend)?;
2285 }
2286
2287 let mut events = users
2288 .into_iter()
2289 .map(|user| ClientEvent::UserUpserted {
2290 user_id: user.user_id,
2291 })
2292 .collect::<Vec<_>>();
2293 events.extend(
2294 sidecars
2295 .chats
2296 .into_iter()
2297 .map(|chat| ClientEvent::ChatUpserted {
2298 chat_id: InlineId::new(chat.id),
2299 }),
2300 );
2301 for space in sidecars.spaces {
2302 let record = space_record_from_proto(&space);
2303 self.store
2304 .record_space(record.clone())
2305 .await
2306 .map_err(store_error_to_backend)?;
2307 events.push(ClientEvent::SpaceUpserted {
2308 space_id: record.space_id,
2309 });
2310 }
2311 Ok(events)
2312 }
2313
2314 async fn record_chat_update(
2315 &self,
2316 chat: proto::Chat,
2317 dialog: Option<proto::Dialog>,
2318 user: Option<proto::User>,
2319 ) -> BackendResult<Vec<ClientEvent>> {
2320 let user_record = user.as_ref().map(user_record_from_proto);
2321 if let Some(user) = user_record.as_ref() {
2322 self.store
2323 .record_users(vec![user.clone()])
2324 .await
2325 .map_err(store_error_to_backend)?;
2326 }
2327 let existing = self
2328 .store
2329 .dialog(InlineId::new(chat.id))
2330 .await
2331 .map_err(store_error_to_backend)?;
2332 let users_by_id = user
2333 .as_ref()
2334 .map(|user| HashMap::from([(user.id, user)]))
2335 .unwrap_or_default();
2336 let record = DialogRecord {
2337 chat_id: InlineId::new(chat.id),
2338 peer_user_id: dialog_peer_user_id(dialog.as_ref(), &chat)
2339 .or_else(|| existing.as_ref().and_then(|dialog| dialog.peer_user_id)),
2340 title: Some(chat_title_from_proto(&chat, &users_by_id)),
2341 emoji: chat_emoji_from_proto(&chat),
2342 last_message_id: chat
2343 .last_msg_id
2344 .map(InlineId::new)
2345 .or_else(|| existing.as_ref().and_then(|dialog| dialog.last_message_id)),
2346 synced_through_message_id: existing
2347 .as_ref()
2348 .and_then(|dialog| dialog.synced_through_message_id),
2349 unread_count: dialog
2350 .as_ref()
2351 .and_then(|dialog| dialog.unread_count)
2352 .and_then(|count| u32::try_from(count).ok())
2353 .or_else(|| existing.as_ref().and_then(|dialog| dialog.unread_count)),
2354 space_id: dialog
2355 .as_ref()
2356 .and_then(|dialog| dialog.space_id)
2357 .or(chat.space_id)
2358 .map(InlineId::new)
2359 .or_else(|| existing.as_ref().and_then(|dialog| dialog.space_id)),
2360 is_public: chat
2361 .is_public
2362 .or_else(|| existing.as_ref().and_then(|dialog| dialog.is_public)),
2363 archived: dialog
2364 .as_ref()
2365 .and_then(|dialog| dialog.archived)
2366 .or_else(|| existing.as_ref().and_then(|dialog| dialog.archived)),
2367 pinned: dialog
2368 .as_ref()
2369 .and_then(|dialog| dialog.pinned)
2370 .or_else(|| existing.as_ref().and_then(|dialog| dialog.pinned)),
2371 open: dialog
2372 .as_ref()
2373 .and_then(|dialog| dialog.open)
2374 .or_else(|| existing.as_ref().and_then(|dialog| dialog.open)),
2375 chat_list_hidden: dialog
2376 .as_ref()
2377 .and_then(|dialog| dialog.chat_list_hidden)
2378 .or_else(|| existing.as_ref().and_then(|dialog| dialog.chat_list_hidden)),
2379 order: dialog
2380 .as_ref()
2381 .and_then(|dialog| dialog.order.clone())
2382 .or_else(|| existing.as_ref().and_then(|dialog| dialog.order.clone())),
2383 pinned_order: dialog
2384 .as_ref()
2385 .and_then(|dialog| dialog.pinned_order.clone())
2386 .or_else(|| {
2387 existing
2388 .as_ref()
2389 .and_then(|dialog| dialog.pinned_order.clone())
2390 }),
2391 notification_mode: dialog
2392 .as_ref()
2393 .and_then(dialog_notification_mode_from_proto)
2394 .or_else(|| {
2395 existing
2396 .as_ref()
2397 .and_then(|dialog| dialog.notification_mode)
2398 }),
2399 follow_mode: dialog
2400 .as_ref()
2401 .and_then(dialog_follow_mode_from_proto)
2402 .or_else(|| existing.as_ref().and_then(|dialog| dialog.follow_mode)),
2403 ..existing
2404 .clone()
2405 .unwrap_or_else(|| DialogRecord::new(InlineId::new(chat.id)))
2406 };
2407 self.store
2408 .record_dialog(record.clone())
2409 .await
2410 .map_err(store_error_to_backend)?;
2411
2412 let mut events = vec![ClientEvent::ChatUpserted {
2413 chat_id: record.chat_id,
2414 }];
2415 if let Some(user) = user_record {
2416 events.push(ClientEvent::UserUpserted {
2417 user_id: user.user_id,
2418 });
2419 }
2420 Ok(events)
2421 }
2422
2423 async fn resolve_chat_id(
2424 &self,
2425 peer: Option<&proto::Peer>,
2426 fallback: Option<InlineId>,
2427 ) -> BackendResult<Option<InlineId>> {
2428 let Some(peer) = peer else {
2429 return Ok(fallback);
2430 };
2431 match peer.r#type.as_ref() {
2432 Some(proto::peer::Type::Chat(chat)) => Ok(Some(InlineId::new(chat.chat_id))),
2433 Some(proto::peer::Type::User(user)) => {
2434 let dialogs = self
2435 .store
2436 .dialogs(DialogsRequest {
2437 limit: Some(u32::MAX),
2438 cursor: None,
2439 })
2440 .await
2441 .map_err(store_error_to_backend)?;
2442 Ok(dialogs
2443 .dialogs
2444 .into_iter()
2445 .find(|dialog| dialog.peer_user_id == Some(InlineId::new(user.user_id)))
2446 .map(|dialog| dialog.chat_id)
2447 .or(fallback))
2448 }
2449 None => Ok(fallback),
2450 }
2451 }
2452
2453 async fn mutate_dialog(
2454 &self,
2455 chat_id: InlineId,
2456 mutate: impl FnOnce(&mut DialogRecord),
2457 ) -> BackendResult<()> {
2458 let mut dialog = self
2459 .store
2460 .dialog(chat_id)
2461 .await
2462 .map_err(store_error_to_backend)?
2463 .unwrap_or_else(|| DialogRecord::new(chat_id));
2464 mutate(&mut dialog);
2465 self.store
2466 .record_dialog(dialog)
2467 .await
2468 .map_err(store_error_to_backend)
2469 }
2470
2471 async fn reconcile_live_dialogs(&self, dialogs: Vec<DialogRecord>) -> BackendResult<()> {
2472 let live_ids = dialogs
2473 .iter()
2474 .map(|dialog| dialog.chat_id)
2475 .collect::<HashSet<_>>();
2476 for dialog in dialogs {
2477 self.store
2478 .record_dialog(dialog)
2479 .await
2480 .map_err(store_error_to_backend)?;
2481 }
2482
2483 let mut cursor = None;
2484 let mut stored_ids = Vec::new();
2485 loop {
2486 let page = self
2487 .store
2488 .dialogs(DialogsRequest {
2489 limit: Some(1_000),
2490 cursor: cursor.clone(),
2491 })
2492 .await
2493 .map_err(store_error_to_backend)?;
2494 stored_ids.extend(page.dialogs.into_iter().map(|dialog| dialog.chat_id));
2495 let Some(next) = page.next_cursor else {
2496 break;
2497 };
2498 if cursor.as_ref() == Some(&next) {
2499 return Err(BackendError::new(
2500 ClientErrorCategory::ProtocolMismatch,
2501 "stored dialog pagination did not advance",
2502 ));
2503 }
2504 cursor = Some(next);
2505 }
2506 for chat_id in stored_ids {
2507 if !live_ids.contains(&chat_id) {
2508 self.store
2509 .remove_dialog(chat_id)
2510 .await
2511 .map_err(store_error_to_backend)?;
2512 }
2513 }
2514 Ok(())
2515 }
2516
2517 async fn record_proto_message(
2518 &self,
2519 message: proto::Message,
2520 fallback_chat_id: Option<InlineId>,
2521 transaction: Option<TransactionIdentity>,
2522 ) -> BackendResult<MessageRecord> {
2523 let reaction_snapshot = reaction_snapshot_from_proto_message(&message, fallback_chat_id);
2524 let record = message_record_from_proto_message(message, fallback_chat_id, transaction);
2525 self.store
2526 .record_message(record.clone())
2527 .await
2528 .map_err(store_error_to_backend)?;
2529 if let Some(reactions) = reaction_snapshot {
2530 self.store
2531 .replace_message_reactions(record.chat_id, record.message_id, reactions)
2532 .await
2533 .map_err(store_error_to_backend)?;
2534 }
2535 Ok(record)
2536 }
2537
2538 async fn record_transaction_error(
2539 &self,
2540 identity: TransactionIdentity,
2541 chat_id: InlineId,
2542 retry_state: TransactionState,
2543 error: BackendError,
2544 ) -> BackendResult<()> {
2545 let state = if retryable_transaction_category(error.category) {
2546 retry_state
2547 } else {
2548 TransactionState::Failed
2549 };
2550 self.store
2551 .record_transaction(
2552 StoredTransaction::new(identity, state)
2553 .with_chat_id(chat_id)
2554 .with_failure(error.into()),
2555 )
2556 .await
2557 .map_err(store_error_to_backend)
2558 }
2559
2560 async fn apply_updates(
2561 &self,
2562 updates: Vec<proto::Update>,
2563 fallback_chat_id: Option<InlineId>,
2564 transaction: Option<TransactionIdentity>,
2565 ) -> BackendResult<Vec<ClientEvent>> {
2566 let mut events = Vec::new();
2567 for update in updates {
2568 let seq = update.seq.unwrap_or_default();
2569 match update.update {
2570 Some(proto::update::Update::NewMessage(update)) => {
2571 if let Some(message) = update.message {
2572 let record = self
2573 .record_proto_message(message, fallback_chat_id, None)
2574 .await?;
2575 events.push(ClientEvent::MessageStored { message: record });
2576 }
2577 }
2578 Some(proto::update::Update::EditMessage(update)) => {
2579 if let Some(message) = update.message {
2580 let record = self
2581 .record_proto_message(message, fallback_chat_id, None)
2582 .await?;
2583 events.push(ClientEvent::MessageStored { message: record });
2584 }
2585 }
2586 Some(proto::update::Update::DeleteMessages(update)) => {
2587 let chat_id = self
2588 .resolve_chat_id(update.peer_id.as_ref(), fallback_chat_id)
2589 .await?
2590 .ok_or_else(|| {
2591 BackendError::new(
2592 ClientErrorCategory::ProtocolMismatch,
2593 "delete update did not resolve to a stored chat",
2594 )
2595 })?;
2596 for message_id in update.message_ids {
2597 let message_id = InlineId::new(message_id);
2598 self.store
2599 .record_message_deleted(chat_id, message_id)
2600 .await
2601 .map_err(store_error_to_backend)?;
2602 events.push(ClientEvent::MessageDeleted {
2603 chat_id,
2604 message_id,
2605 });
2606 }
2607 }
2608 Some(proto::update::Update::UpdateReaction(update)) => {
2609 if let Some(reaction) = update.reaction {
2610 let stored = StoredReaction {
2611 chat_id: InlineId::new(reaction.chat_id),
2612 message_id: InlineId::new(reaction.message_id),
2613 user_id: InlineId::new(reaction.user_id),
2614 reaction: reaction.emoji,
2615 };
2616 self.store
2617 .record_reaction(stored.clone())
2618 .await
2619 .map_err(store_error_to_backend)?;
2620 events.push(ClientEvent::ReactionChanged {
2621 chat_id: stored.chat_id,
2622 message_id: stored.message_id,
2623 user_id: stored.user_id,
2624 reaction: stored.reaction,
2625 removed: false,
2626 });
2627 }
2628 }
2629 Some(proto::update::Update::DeleteReaction(update)) => {
2630 let stored = StoredReaction {
2631 chat_id: InlineId::new(update.chat_id),
2632 message_id: InlineId::new(update.message_id),
2633 user_id: InlineId::new(update.user_id),
2634 reaction: update.emoji,
2635 };
2636 self.store
2637 .remove_reaction(stored.clone())
2638 .await
2639 .map_err(store_error_to_backend)?;
2640 events.push(ClientEvent::ReactionChanged {
2641 chat_id: stored.chat_id,
2642 message_id: stored.message_id,
2643 user_id: stored.user_id,
2644 reaction: stored.reaction,
2645 removed: true,
2646 });
2647 }
2648 Some(proto::update::Update::UpdateReadMaxId(update)) => {
2649 let chat_id = self
2650 .resolve_chat_id(update.peer_id.as_ref(), fallback_chat_id)
2651 .await?;
2652 if let Some(chat_id) = chat_id {
2653 self.store
2654 .record_read_state(StoredReadState {
2655 chat_id,
2656 read_max_id: Some(InlineId::new(update.read_max_id)),
2657 unread_count: u32::try_from(update.unread_count).ok(),
2658 marked_unread: false,
2659 })
2660 .await
2661 .map_err(store_error_to_backend)?;
2662 events.push(ClientEvent::ReadStateChanged { chat_id });
2663 }
2664 }
2665 Some(proto::update::Update::MarkAsUnread(update)) => {
2666 if let Some(chat_id) = self
2667 .resolve_chat_id(update.peer_id.as_ref(), fallback_chat_id)
2668 .await?
2669 {
2670 let current = self
2671 .store
2672 .read_state(chat_id)
2673 .await
2674 .map_err(store_error_to_backend)?;
2675 self.store
2676 .record_read_state(StoredReadState {
2677 chat_id,
2678 read_max_id: current.and_then(|state| state.read_max_id),
2679 unread_count: current.and_then(|state| state.unread_count),
2680 marked_unread: update.unread_mark,
2681 })
2682 .await
2683 .map_err(store_error_to_backend)?;
2684 events.push(ClientEvent::ReadStateChanged { chat_id });
2685 }
2686 }
2687 Some(proto::update::Update::UpdateComposeAction(update)) => {
2688 let chat_id = update
2689 .peer_id
2690 .as_ref()
2691 .and_then(chat_id_from_peer)
2692 .or(fallback_chat_id);
2693 if let Some(chat_id) = chat_id {
2694 events.push(ClientEvent::Typing {
2695 chat_id,
2696 user_id: InlineId::new(update.user_id),
2697 is_typing: update.action
2698 == proto::update_compose_action::ComposeAction::Typing as i32,
2699 });
2700 }
2701 }
2702 Some(proto::update::Update::UpdateMessageId(update)) => {
2703 if let (Some(chat_id), Some(transaction)) =
2704 (fallback_chat_id, transaction.as_ref())
2705 && update.random_id == transaction.random_id.get()
2706 {
2707 events.push(ClientEvent::MessageUpserted {
2708 chat_id,
2709 message_id: InlineId::new(update.message_id),
2710 });
2711 }
2712 }
2713 Some(proto::update::Update::NewChat(update)) => {
2714 if let Some(chat) = update.chat {
2715 events.extend(self.record_chat_update(chat, None, update.user).await?);
2716 }
2717 }
2718 Some(proto::update::Update::ChatOpen(update)) => {
2719 if let Some(chat) = update.chat {
2720 events.extend(
2721 self.record_chat_update(chat, update.dialog, update.user)
2722 .await?,
2723 );
2724 }
2725 }
2726 Some(proto::update::Update::ChatMoved(update)) => {
2727 if let Some(chat) = update.chat {
2728 events.extend(self.record_chat_update(chat, None, None).await?);
2729 }
2730 }
2731 Some(proto::update::Update::ChatInfo(update)) => {
2732 let chat_id = InlineId::new(update.chat_id);
2733 let mut dialog = self
2734 .store
2735 .dialog(chat_id)
2736 .await
2737 .map_err(store_error_to_backend)?
2738 .unwrap_or(DialogRecord {
2739 chat_id,
2740 peer_user_id: None,
2741 title: None,
2742 last_message_id: None,
2743 synced_through_message_id: None,
2744 unread_count: None,
2745 ..DialogRecord::new(chat_id)
2746 });
2747 if let Some(title) = update.title {
2748 dialog.title = trimmed_option(Some(title));
2749 }
2750 if let Some(emoji) = update.emoji {
2751 dialog.emoji = non_empty_option(Some(&emoji));
2752 }
2753 self.store
2754 .record_dialog(dialog)
2755 .await
2756 .map_err(store_error_to_backend)?;
2757 events.push(ClientEvent::ChatUpserted { chat_id });
2758 }
2759 Some(proto::update::Update::ChatVisibility(update)) => {
2760 let chat_id = InlineId::new(update.chat_id);
2761 self.mutate_dialog(chat_id, |dialog| {
2762 dialog.is_public = Some(update.is_public);
2763 })
2764 .await?;
2765 events.push(ClientEvent::ChatUpserted { chat_id });
2766 }
2767 Some(proto::update::Update::DialogArchived(update)) => {
2768 let chat_id = self
2769 .resolve_chat_id(update.peer_id.as_ref(), fallback_chat_id)
2770 .await?
2771 .ok_or_else(|| unresolved_update_peer("dialog archived"))?;
2772 self.mutate_dialog(chat_id, |dialog| {
2773 dialog.archived = Some(update.archived);
2774 })
2775 .await?;
2776 events.push(ClientEvent::ChatUpserted { chat_id });
2777 }
2778 Some(proto::update::Update::DialogNotificationSettings(update)) => {
2779 let chat_id = self
2780 .resolve_chat_id(update.peer_id.as_ref(), fallback_chat_id)
2781 .await?
2782 .ok_or_else(|| unresolved_update_peer("dialog notification settings"))?;
2783 let mode = update
2784 .notification_settings
2785 .as_ref()
2786 .and_then(dialog_notification_mode_from_settings);
2787 self.mutate_dialog(chat_id, |dialog| {
2788 dialog.notification_mode = mode;
2789 })
2790 .await?;
2791 events.push(ClientEvent::ChatUpserted { chat_id });
2792 }
2793 Some(proto::update::Update::DialogFollowMode(update)) => {
2794 let chat_id = self
2795 .resolve_chat_id(update.peer_id.as_ref(), fallback_chat_id)
2796 .await?
2797 .ok_or_else(|| unresolved_update_peer("dialog follow mode"))?;
2798 let mode = update.follow_mode.and_then(dialog_follow_mode_from_value);
2799 self.mutate_dialog(chat_id, |dialog| {
2800 dialog.follow_mode = mode;
2801 })
2802 .await?;
2803 events.push(ClientEvent::ChatUpserted { chat_id });
2804 }
2805 Some(proto::update::Update::PinnedMessages(update)) => {
2806 let chat_id = self
2807 .resolve_chat_id(update.peer_id.as_ref(), fallback_chat_id)
2808 .await?
2809 .ok_or_else(|| unresolved_update_peer("pinned messages"))?;
2810 let message_ids = update
2811 .message_ids
2812 .into_iter()
2813 .map(InlineId::new)
2814 .collect::<Vec<_>>();
2815 self.mutate_dialog(chat_id, |dialog| {
2816 dialog.pinned_message_ids = message_ids;
2817 })
2818 .await?;
2819 events.push(ClientEvent::ChatUpserted { chat_id });
2820 }
2821 Some(proto::update::Update::MessageAttachment(update)) => {
2822 let chat_id = (update.chat_id > 0)
2823 .then(|| InlineId::new(update.chat_id))
2824 .or(self
2825 .resolve_chat_id(update.peer_id.as_ref(), fallback_chat_id)
2826 .await?)
2827 .ok_or_else(|| unresolved_update_peer("message attachment"))?;
2828 events.push(ClientEvent::MessageUpserted {
2829 chat_id,
2830 message_id: InlineId::new(update.message_id),
2831 });
2832 }
2833 Some(proto::update::Update::ClearChatHistory(update)) => {
2834 let chat_ids = match update.target {
2835 Some(proto::update_clear_chat_history::Target::PeerId(peer)) => vec![
2836 self.resolve_chat_id(Some(&peer), fallback_chat_id)
2837 .await?
2838 .ok_or_else(|| unresolved_update_peer("clear history"))?,
2839 ],
2840 Some(proto::update_clear_chat_history::Target::SpaceId(space_id)) => self
2841 .store
2842 .chat_ids_in_space(InlineId::new(space_id))
2843 .await
2844 .map_err(store_error_to_backend)?,
2845 None => {
2846 return Err(BackendError::new(
2847 ClientErrorCategory::ProtocolMismatch,
2848 "clear history update had no target",
2849 ));
2850 }
2851 };
2852 for chat_id in chat_ids {
2853 self.store
2854 .clear_chat_messages(chat_id, update.before_date)
2855 .await
2856 .map_err(store_error_to_backend)?;
2857 events.push(ClientEvent::ChatHistoryCleared {
2858 chat_id,
2859 before_date: update.before_date,
2860 });
2861 }
2862 for deleted_chat_id in update.deleted_chat_ids {
2863 let chat_id = InlineId::new(deleted_chat_id);
2864 self.store
2865 .remove_dialog(chat_id)
2866 .await
2867 .map_err(store_error_to_backend)?;
2868 events.push(ClientEvent::ChatDeleted { chat_id });
2869 }
2870 for changed_chat_id in update
2871 .orphaned_chat_ids
2872 .into_iter()
2873 .chain(update.detached_chat_ids)
2874 {
2875 events.push(ClientEvent::ChatUpserted {
2876 chat_id: InlineId::new(changed_chat_id),
2877 });
2878 }
2879 }
2880 Some(proto::update::Update::DeleteChat(update)) => {
2881 if let Some(chat_id) = self
2882 .resolve_chat_id(update.peer_id.as_ref(), fallback_chat_id)
2883 .await?
2884 {
2885 self.store
2886 .remove_dialog(chat_id)
2887 .await
2888 .map_err(store_error_to_backend)?;
2889 events.push(ClientEvent::ChatDeleted { chat_id });
2890 }
2891 }
2892 Some(proto::update::Update::ParticipantAdd(update)) => {
2893 let chat_id = InlineId::new(update.chat_id);
2894 if let Some(participant) = update.participant {
2895 self.store
2896 .record_chat_participant(
2897 chat_id,
2898 ChatParticipantRecord {
2899 user_id: InlineId::new(participant.user_id),
2900 date: Some(participant.date),
2901 },
2902 )
2903 .await
2904 .map_err(store_error_to_backend)?;
2905 events.push(ClientEvent::ChatParticipantsChanged { chat_id });
2906 }
2907 }
2908 Some(proto::update::Update::ParticipantDelete(update)) => {
2909 let chat_id = InlineId::new(update.chat_id);
2910 self.store
2911 .remove_chat_participant(chat_id, InlineId::new(update.user_id))
2912 .await
2913 .map_err(store_error_to_backend)?;
2914 events.push(ClientEvent::ChatParticipantsChanged { chat_id });
2915 }
2916 Some(proto::update::Update::SpaceMemberAdd(update)) => {
2917 let member = update.member.ok_or_else(|| {
2918 BackendError::new(
2919 ClientErrorCategory::ProtocolMismatch,
2920 "space member add update had no member",
2921 )
2922 })?;
2923 if let Some(user) = update.user {
2924 let user = user_record_from_proto(&user);
2925 self.store
2926 .record_users(vec![user.clone()])
2927 .await
2928 .map_err(store_error_to_backend)?;
2929 events.push(ClientEvent::UserUpserted {
2930 user_id: user.user_id,
2931 });
2932 }
2933 let member = space_member_record_from_proto(&member);
2934 self.store
2935 .record_space_member(member.clone())
2936 .await
2937 .map_err(store_error_to_backend)?;
2938 events.push(ClientEvent::SpaceMemberChanged {
2939 space_id: member.space_id,
2940 user_id: member.user_id,
2941 removed: false,
2942 });
2943 }
2944 Some(proto::update::Update::SpaceMemberDelete(update)) => {
2945 let space_id = InlineId::new(update.space_id);
2946 let user_id = InlineId::new(update.user_id);
2947 self.store
2948 .remove_space_member(space_id, user_id)
2949 .await
2950 .map_err(store_error_to_backend)?;
2951 events.push(ClientEvent::SpaceMemberChanged {
2952 space_id,
2953 user_id,
2954 removed: true,
2955 });
2956 }
2957 Some(proto::update::Update::SpaceMemberUpdate(update)) => {
2958 let member = update.member.ok_or_else(|| {
2959 BackendError::new(
2960 ClientErrorCategory::ProtocolMismatch,
2961 "space member update had no member",
2962 )
2963 })?;
2964 let member = space_member_record_from_proto(&member);
2965 self.store
2966 .record_space_member(member.clone())
2967 .await
2968 .map_err(store_error_to_backend)?;
2969 events.push(ClientEvent::SpaceMemberChanged {
2970 space_id: member.space_id,
2971 user_id: member.user_id,
2972 removed: false,
2973 });
2974 }
2975 Some(proto::update::Update::JoinSpace(update)) => {
2976 let space = update.space.ok_or_else(|| {
2977 BackendError::new(
2978 ClientErrorCategory::ProtocolMismatch,
2979 "join space update had no space",
2980 )
2981 })?;
2982 let space = space_record_from_proto(&space);
2983 self.store
2984 .record_space(space.clone())
2985 .await
2986 .map_err(store_error_to_backend)?;
2987 events.push(ClientEvent::SpaceUpserted {
2988 space_id: space.space_id,
2989 });
2990 if let Some(member) = update.member {
2991 let member = space_member_record_from_proto(&member);
2992 self.store
2993 .record_space_member(member.clone())
2994 .await
2995 .map_err(store_error_to_backend)?;
2996 events.push(ClientEvent::SpaceMemberChanged {
2997 space_id: member.space_id,
2998 user_id: member.user_id,
2999 removed: false,
3000 });
3001 }
3002 }
3003 Some(proto::update::Update::UpdateUserSettings(update)) => {
3004 let settings = update.settings.ok_or_else(|| {
3005 BackendError::new(
3006 ClientErrorCategory::ProtocolMismatch,
3007 "user settings update had no settings",
3008 )
3009 })?;
3010 self.store
3011 .record_user_settings(user_settings_record_from_proto(&settings))
3012 .await
3013 .map_err(store_error_to_backend)?;
3014 events.push(ClientEvent::UserSettingsChanged {});
3015 }
3016 Some(proto::update::Update::MessageActionInvoked(update)) => {
3017 events.push(ClientEvent::MessageActionInvoked {
3018 interaction_id: InlineId::new(update.interaction_id),
3019 chat_id: InlineId::new(update.chat_id),
3020 message_id: InlineId::new(update.message_id),
3021 actor_user_id: InlineId::new(update.actor_user_id),
3022 action_id: update.action_id,
3023 data: update.data,
3024 });
3025 }
3026 Some(proto::update::Update::MessageActionAnswered(update)) => {
3027 let toast = update.ui.and_then(|ui| match ui.kind {
3028 Some(proto::message_action_response_ui::Kind::Toast(toast)) => {
3029 trimmed_option(Some(toast.text))
3030 }
3031 None => None,
3032 });
3033 events.push(ClientEvent::MessageActionAnswered {
3034 interaction_id: InlineId::new(update.interaction_id),
3035 toast,
3036 });
3037 }
3038 Some(proto::update::Update::UpdatedUser(update)) => {
3039 if let Some(user) = update.user {
3040 let record = user_record_from_proto(&user);
3041 self.store
3042 .record_users(vec![record.clone()])
3043 .await
3044 .map_err(store_error_to_backend)?;
3045 events.push(ClientEvent::UserUpserted {
3046 user_id: record.user_id,
3047 });
3048 }
3049 }
3050 Some(proto::update::Update::ChatSkipPts(_)) => {}
3051 Some(proto::update::Update::UpdateUserStatus(update)) => {
3052 let status = update.status.as_ref();
3053 let is_online = status.and_then(|status| {
3054 match proto::user_status::Status::try_from(status.online).ok()? {
3055 proto::user_status::Status::Online => Some(true),
3056 proto::user_status::Status::Offline => Some(false),
3057 proto::user_status::Status::Unknown => None,
3058 }
3059 });
3060 events.push(ClientEvent::UserStatusChanged {
3061 user_id: InlineId::new(update.user_id),
3062 is_online,
3063 last_online: status
3064 .and_then(|status| status.last_online.as_ref())
3065 .and_then(|last_online| last_online.date),
3066 });
3067 }
3068 Some(proto::update::Update::BotPresence(update)) => {
3069 let state = update.state.as_ref();
3070 let kind = state
3071 .and_then(|state| {
3072 proto::bot_presence_state::Kind::try_from(state.kind).ok()
3073 })
3074 .map(|kind| kind.as_str_name().to_owned())
3075 .unwrap_or_else(|| "KIND_UNSPECIFIED".to_owned());
3076 let chat_id = self
3077 .resolve_chat_id(update.peer_id.as_ref(), fallback_chat_id)
3078 .await?;
3079 events.push(ClientEvent::BotPresenceChanged {
3080 bot_user_id: InlineId::new(update.bot_user_id),
3081 chat_id,
3082 kind,
3083 comment: state.and_then(|state| trimmed_option(state.comment.clone())),
3084 avatar_changed: update.avatar_changed,
3085 });
3086 }
3087 Some(proto::update::Update::NewMessageNotification(update)) => {
3088 if let Some(message) = update.message {
3089 let message =
3090 message_record_from_proto_message(message, fallback_chat_id, None);
3091 let reason =
3092 proto::update_new_message_notification::Reason::try_from(update.reason)
3093 .map(|reason| reason.as_str_name().to_owned())
3094 .unwrap_or_else(|_| "REASON_UNSPECIFIED".to_owned());
3095 events.push(ClientEvent::NewMessageNotification { message, reason });
3096 }
3097 }
3098 Some(other) => {
3099 let kind = update_kind(&other);
3100 return Err(BackendError::new(
3101 ClientErrorCategory::Unsupported,
3102 format!("Inline update is not implemented: {kind}"),
3103 ));
3104 }
3105 None if seq > 0 => {
3106 return Err(BackendError::new(
3107 ClientErrorCategory::ProtocolMismatch,
3108 "sequenced Inline update had no payload",
3109 ));
3110 }
3111 None => {}
3112 }
3113 }
3114 Ok(events)
3115 }
3116}
3117
3118fn store_error_to_backend(error: StoreError) -> BackendError {
3119 BackendError::new(error.category, error.message)
3120}
3121
3122fn unresolved_update_peer(kind: &str) -> BackendError {
3123 BackendError::new(
3124 ClientErrorCategory::ProtocolMismatch,
3125 format!("{kind} update did not resolve to a stored chat"),
3126 )
3127}
3128
3129fn update_kind(update: &proto::update::Update) -> &'static str {
3130 use proto::update::Update;
3131 match update {
3132 Update::NewMessage(_) => "new_message",
3133 Update::EditMessage(_) => "edit_message",
3134 Update::UpdateMessageId(_) => "update_message_id",
3135 Update::DeleteMessages(_) => "delete_messages",
3136 Update::UpdateComposeAction(_) => "update_compose_action",
3137 Update::UpdateUserStatus(_) => "update_user_status",
3138 Update::MessageAttachment(_) => "message_attachment",
3139 Update::UpdateReaction(_) => "update_reaction",
3140 Update::DeleteReaction(_) => "delete_reaction",
3141 Update::ParticipantAdd(_) => "participant_add",
3142 Update::ParticipantDelete(_) => "participant_delete",
3143 Update::NewChat(_) => "new_chat",
3144 Update::DeleteChat(_) => "delete_chat",
3145 Update::SpaceMemberAdd(_) => "space_member_add",
3146 Update::SpaceMemberDelete(_) => "space_member_delete",
3147 Update::JoinSpace(_) => "join_space",
3148 Update::UpdateReadMaxId(_) => "update_read_max_id",
3149 Update::UpdateUserSettings(_) => "update_user_settings",
3150 Update::NewMessageNotification(_) => "new_message_notification",
3151 Update::MarkAsUnread(_) => "mark_as_unread",
3152 Update::ChatSkipPts(_) => "chat_skip_pts",
3153 Update::ChatHasNewUpdates(_) => "chat_has_new_updates",
3154 Update::SpaceHasNewUpdates(_) => "space_has_new_updates",
3155 Update::SpaceMemberUpdate(_) => "space_member_update",
3156 Update::ChatVisibility(_) => "chat_visibility",
3157 Update::DialogArchived(_) => "dialog_archived",
3158 Update::ChatInfo(_) => "chat_info",
3159 Update::PinnedMessages(_) => "pinned_messages",
3160 Update::ChatMoved(_) => "chat_moved",
3161 Update::DialogNotificationSettings(_) => "dialog_notification_settings",
3162 Update::ChatOpen(_) => "chat_open",
3163 Update::MessageActionInvoked(_) => "message_action_invoked",
3164 Update::MessageActionAnswered(_) => "message_action_answered",
3165 Update::ClearChatHistory(_) => "clear_chat_history",
3166 Update::BotPresence(_) => "bot_presence",
3167 Update::DialogFollowMode(_) => "dialog_follow_mode",
3168 Update::UpdatedUser(_) => "updated_user",
3169 }
3170}
3171
3172#[cfg(test)]
3173fn dialogs_page_from_get_chats(
3174 result: &proto::GetChatsResult,
3175 request: DialogsRequest,
3176) -> BackendResult<DialogsPage> {
3177 let start = request
3178 .cursor
3179 .as_deref()
3180 .map(str::trim)
3181 .filter(|cursor| !cursor.is_empty())
3182 .map(|cursor| {
3183 cursor.parse::<usize>().map_err(|_| {
3184 BackendError::new(
3185 ClientErrorCategory::InvalidInput,
3186 "invalid pagination cursor",
3187 )
3188 })
3189 })
3190 .transpose()?
3191 .unwrap_or(0);
3192 let limit = request.limit.unwrap_or(100).max(1) as usize;
3193
3194 let mut dialogs = dialog_records_from_get_chats(result);
3195 let total = dialogs.len();
3196 if start >= dialogs.len() {
3197 dialogs.clear();
3198 } else {
3199 dialogs = dialogs.into_iter().skip(start).take(limit).collect();
3200 }
3201 Ok(DialogsPage {
3202 dialogs,
3203 users: result.users.iter().map(user_record_from_proto).collect(),
3204 next_cursor: (start + limit < total).then(|| (start + limit).to_string()),
3205 })
3206}
3207
3208fn dialog_records_from_get_chats(result: &proto::GetChatsResult) -> Vec<DialogRecord> {
3209 let users_by_id = result
3210 .users
3211 .iter()
3212 .map(|user| (user.id, user))
3213 .collect::<HashMap<_, _>>();
3214 result
3215 .chats
3216 .iter()
3217 .map(|chat| {
3218 let dialog = dialog_for_chat(result, chat);
3219 DialogRecord {
3220 chat_id: InlineId::new(chat.id),
3221 peer_user_id: dialog_peer_user_id(dialog, chat),
3222 title: Some(chat_title_from_proto(chat, &users_by_id)),
3223 emoji: chat_emoji_from_proto(chat),
3224 last_message_id: chat.last_msg_id.map(InlineId::new),
3225 synced_through_message_id: None,
3226 unread_count: dialog
3227 .and_then(|dialog| dialog.unread_count)
3228 .and_then(|count| u32::try_from(count).ok()),
3229 space_id: dialog
3230 .and_then(|dialog| dialog.space_id)
3231 .or(chat.space_id)
3232 .map(InlineId::new),
3233 is_public: chat.is_public,
3234 archived: dialog.and_then(|dialog| dialog.archived),
3235 pinned: dialog.and_then(|dialog| dialog.pinned),
3236 open: dialog.and_then(|dialog| dialog.open),
3237 chat_list_hidden: dialog.and_then(|dialog| dialog.chat_list_hidden),
3238 order: dialog.and_then(|dialog| dialog.order.clone()),
3239 pinned_order: dialog.and_then(|dialog| dialog.pinned_order.clone()),
3240 notification_mode: dialog.and_then(dialog_notification_mode_from_proto),
3241 follow_mode: dialog.and_then(dialog_follow_mode_from_proto),
3242 ..DialogRecord::new(InlineId::new(chat.id))
3243 }
3244 })
3245 .collect()
3246}
3247
3248fn dialog_for_chat<'a>(
3249 result: &'a proto::GetChatsResult,
3250 chat: &proto::Chat,
3251) -> Option<&'a proto::Dialog> {
3252 result.dialogs.iter().find(|dialog| {
3253 dialog.chat_id == Some(chat.id)
3254 || dialog
3255 .peer
3256 .as_ref()
3257 .zip(chat.peer_id.as_ref())
3258 .is_some_and(|(dialog_peer, chat_peer)| dialog_peer == chat_peer)
3259 })
3260}
3261
3262fn read_state_from_proto_dialog(
3263 chat_id: InlineId,
3264 dialog: &proto::Dialog,
3265) -> Option<StoredReadState> {
3266 if dialog.read_max_id.is_none() && dialog.unread_count.is_none() && dialog.unread_mark.is_none()
3267 {
3268 return None;
3269 }
3270 Some(StoredReadState {
3271 chat_id,
3272 read_max_id: dialog.read_max_id.map(InlineId::new),
3273 unread_count: dialog
3274 .unread_count
3275 .and_then(|count| u32::try_from(count).ok()),
3276 marked_unread: dialog.unread_mark.unwrap_or(false),
3277 })
3278}
3279
3280fn dialog_peer_user_id(dialog: Option<&proto::Dialog>, chat: &proto::Chat) -> Option<InlineId> {
3281 dialog
3282 .and_then(|dialog| dialog.peer.as_ref())
3283 .or(chat.peer_id.as_ref())
3284 .and_then(user_id_from_peer)
3285 .map(InlineId::new)
3286}
3287
3288fn dialog_notification_mode_from_proto(dialog: &proto::Dialog) -> Option<DialogNotificationMode> {
3289 dialog
3290 .notification_settings
3291 .as_ref()
3292 .and_then(dialog_notification_mode_from_settings)
3293}
3294
3295fn dialog_notification_mode_from_settings(
3296 settings: &proto::DialogNotificationSettings,
3297) -> Option<DialogNotificationMode> {
3298 match proto::dialog_notification_settings::Mode::try_from(settings.mode?).ok()? {
3299 proto::dialog_notification_settings::Mode::All => Some(DialogNotificationMode::All),
3300 proto::dialog_notification_settings::Mode::Mentions => {
3301 Some(DialogNotificationMode::Mentions)
3302 }
3303 proto::dialog_notification_settings::Mode::None => Some(DialogNotificationMode::None),
3304 proto::dialog_notification_settings::Mode::Unspecified => None,
3305 }
3306}
3307
3308fn dialog_notification_mode_to_proto(
3309 mode: DialogNotificationMode,
3310) -> proto::dialog_notification_settings::Mode {
3311 match mode {
3312 DialogNotificationMode::All => proto::dialog_notification_settings::Mode::All,
3313 DialogNotificationMode::Mentions => proto::dialog_notification_settings::Mode::Mentions,
3314 DialogNotificationMode::None => proto::dialog_notification_settings::Mode::None,
3315 }
3316}
3317
3318fn dialog_follow_mode_from_proto(dialog: &proto::Dialog) -> Option<DialogFollowMode> {
3319 dialog.follow_mode.and_then(dialog_follow_mode_from_value)
3320}
3321
3322fn dialog_follow_mode_from_value(value: i32) -> Option<DialogFollowMode> {
3323 match proto::DialogFollowMode::try_from(value).ok()? {
3324 proto::DialogFollowMode::Following => Some(DialogFollowMode::Following),
3325 proto::DialogFollowMode::Unspecified => None,
3326 }
3327}
3328
3329fn chat_title_from_proto(chat: &proto::Chat, users_by_id: &HashMap<i64, &proto::User>) -> String {
3330 chat.peer_id
3331 .as_ref()
3332 .and_then(user_id_from_peer)
3333 .and_then(|user_id| users_by_id.get(&user_id))
3334 .and_then(|user| user_display_name_from_proto(user))
3335 .unwrap_or_else(|| {
3336 let title = chat.title.trim();
3337 if title.is_empty() {
3338 format!("Chat {}", chat.id)
3339 } else {
3340 title.to_owned()
3341 }
3342 })
3343}
3344
3345fn chat_emoji_from_proto(chat: &proto::Chat) -> Option<String> {
3346 non_empty_option(chat.emoji.as_deref())
3347}
3348
3349fn user_record_from_proto(user: &proto::User) -> UserRecord {
3350 UserRecord {
3351 user_id: InlineId::new(user.id),
3352 display_name: user_display_name_from_proto(user),
3353 username: non_empty_option(user.username.as_deref()),
3354 first_name: non_empty_option(user.first_name.as_deref()),
3355 last_name: non_empty_option(user.last_name.as_deref()),
3356 avatar_url: user
3357 .profile_photo
3358 .as_ref()
3359 .and_then(|photo| non_empty_option(photo.cdn_url.as_deref()))
3360 .or_else(|| {
3361 user.bot_avatar
3362 .as_ref()
3363 .and_then(|avatar| non_empty_option(avatar.cdn_url.as_deref()))
3364 }),
3365 is_bot: user.bot,
3366 }
3367}
3368
3369fn space_record_from_proto(space: &proto::Space) -> SpaceRecord {
3370 SpaceRecord {
3371 space_id: InlineId::new(space.id),
3372 name: space.name.clone(),
3373 creator: space.creator,
3374 date: space.date,
3375 is_public: space.is_public,
3376 }
3377}
3378
3379fn space_member_record_from_proto(member: &proto::Member) -> SpaceMemberRecord {
3380 SpaceMemberRecord {
3381 space_id: InlineId::new(member.space_id),
3382 user_id: InlineId::new(member.user_id),
3383 role: member
3384 .role
3385 .and_then(|role| match proto::member::Role::try_from(role).ok()? {
3386 proto::member::Role::Owner => Some(SpaceMemberRole::Owner),
3387 proto::member::Role::Admin => Some(SpaceMemberRole::Admin),
3388 proto::member::Role::Member => Some(SpaceMemberRole::Member),
3389 }),
3390 date: member.date,
3391 can_access_public_chats: member.can_access_public_chats,
3392 }
3393}
3394
3395fn user_settings_record_from_proto(settings: &proto::UserSettings) -> UserSettingsRecord {
3396 let notifications = settings.notification_settings.as_ref();
3397 UserSettingsRecord {
3398 notification_mode: notifications
3399 .and_then(|settings| settings.mode)
3400 .and_then(
3401 |mode| match proto::notification_settings::Mode::try_from(mode).ok()? {
3402 proto::notification_settings::Mode::All => Some(NotificationMode::All),
3403 proto::notification_settings::Mode::None => Some(NotificationMode::None),
3404 proto::notification_settings::Mode::Mentions => {
3405 Some(NotificationMode::Mentions)
3406 }
3407 proto::notification_settings::Mode::ImportantOnly => {
3408 Some(NotificationMode::ImportantOnly)
3409 }
3410 proto::notification_settings::Mode::OnlyMentions => {
3411 Some(NotificationMode::OnlyMentions)
3412 }
3413 proto::notification_settings::Mode::Unspecified => None,
3414 },
3415 ),
3416 silent: notifications.and_then(|settings| settings.silent),
3417 zen_mode_requires_mention: notifications
3418 .and_then(|settings| settings.zen_mode_requires_mention),
3419 zen_mode_uses_default_rules: notifications
3420 .and_then(|settings| settings.zen_mode_uses_default_rules),
3421 zen_mode_custom_rules: notifications
3422 .and_then(|settings| settings.zen_mode_custom_rules.clone()),
3423 disable_dm_notifications: notifications
3424 .and_then(|settings| settings.disable_dm_notifications),
3425 }
3426}
3427
3428fn chat_participants_page_from_proto(
3429 result: proto::GetChatParticipantsResult,
3430) -> ChatParticipantsPage {
3431 let mut seen = std::collections::HashSet::new();
3432 let mut participants = Vec::new();
3433 for participant in result.participants {
3434 if seen.insert(participant.user_id) {
3435 participants.push(ChatParticipantRecord {
3436 user_id: InlineId::new(participant.user_id),
3437 date: Some(participant.date),
3438 });
3439 }
3440 }
3441 participants.sort_by_key(|participant| participant.user_id.get());
3442 ChatParticipantsPage {
3443 participants,
3444 users: result.users.iter().map(user_record_from_proto).collect(),
3445 }
3446}
3447
3448fn validate_create_participants(participants: &[ChatCreateParticipant]) -> BackendResult<()> {
3449 if participants
3450 .iter()
3451 .any(|participant| participant.user_id.get() <= 0)
3452 {
3453 return Err(BackendError::new(
3454 ClientErrorCategory::InvalidInput,
3455 "participant user_id must be positive",
3456 ));
3457 }
3458 Ok(())
3459}
3460
3461fn validate_chat_and_user_ids(chat_id: InlineId, user_id: InlineId) -> BackendResult<()> {
3462 if chat_id.get() <= 0 || user_id.get() <= 0 {
3463 return Err(BackendError::new(
3464 ClientErrorCategory::InvalidInput,
3465 "chat_id and user_id must be positive",
3466 ));
3467 }
3468 Ok(())
3469}
3470
3471fn created_chat_from_private_chat_result(
3472 chat: Value,
3473 dialog: Value,
3474 user: Value,
3475 user_id: InlineId,
3476) -> BackendResult<(CreatedChat, Option<UserRecord>)> {
3477 let chat_id = api_i64_field(&chat, &["id", "chatId", "chat_id"])
3478 .or_else(|| api_i64_field(&dialog, &["chatId", "chat_id"]))
3479 .ok_or_else(|| {
3480 BackendError::new(
3481 ClientErrorCategory::ProtocolMismatch,
3482 "create_private_chat result did not include a chat id",
3483 )
3484 })?;
3485 let user = user_record_from_api_value(&user).or(Some(UserRecord {
3486 user_id,
3487 display_name: None,
3488 username: None,
3489 first_name: None,
3490 last_name: None,
3491 avatar_url: None,
3492 is_bot: None,
3493 }));
3494 let title = api_string_field(&chat, &["title"])
3495 .or_else(|| user.as_ref().and_then(user_display_name_from_record));
3496 Ok((
3497 CreatedChat {
3498 chat_id: InlineId::new(chat_id),
3499 title,
3500 parent_chat_id: None,
3501 parent_message_id: None,
3502 },
3503 user,
3504 ))
3505}
3506
3507fn created_chat_from_proto(
3508 chat: Option<proto::Chat>,
3509 dialog: Option<proto::Dialog>,
3510 anchor_message: Option<proto::Message>,
3511 parent_chat_id: Option<InlineId>,
3512 parent_message_id: Option<InlineId>,
3513) -> BackendResult<CreatedChat> {
3514 let chat_id = chat
3515 .as_ref()
3516 .map(|chat| chat.id)
3517 .or_else(|| dialog.as_ref().and_then(|dialog| dialog.chat_id))
3518 .ok_or_else(|| {
3519 BackendError::new(
3520 ClientErrorCategory::ProtocolMismatch,
3521 "create chat result did not include a chat id",
3522 )
3523 })?;
3524 let title = chat
3525 .as_ref()
3526 .and_then(|chat| trimmed_option(Some(chat.title.clone())));
3527 let parent_chat_id = parent_chat_id.or_else(|| {
3528 chat.as_ref()
3529 .and_then(|chat| chat.parent_chat_id)
3530 .map(InlineId::new)
3531 });
3532 let parent_message_id = parent_message_id
3533 .or_else(|| {
3534 chat.as_ref()
3535 .and_then(|chat| chat.parent_message_id)
3536 .map(InlineId::new)
3537 })
3538 .or_else(|| anchor_message.map(|message| InlineId::new(message.id)));
3539
3540 Ok(CreatedChat {
3541 chat_id: InlineId::new(chat_id),
3542 title,
3543 parent_chat_id,
3544 parent_message_id,
3545 })
3546}
3547
3548fn user_record_from_api_value(value: &Value) -> Option<UserRecord> {
3549 let user_id = api_i64_field(value, &["id", "userId", "user_id"])?;
3550 Some(UserRecord {
3551 user_id: InlineId::new(user_id),
3552 display_name: api_string_field(value, &["displayName", "display_name", "name"]),
3553 username: api_string_field(value, &["username"]),
3554 first_name: api_string_field(value, &["firstName", "first_name"]),
3555 last_name: api_string_field(value, &["lastName", "last_name"]),
3556 avatar_url: api_string_field(value, &["avatarUrl", "avatar_url"])
3557 .or_else(|| api_nested_string_field(value, "profilePhoto", &["cdnUrl", "cdn_url"]))
3558 .or_else(|| api_nested_string_field(value, "profile_photo", &["cdnUrl", "cdn_url"]))
3559 .or_else(|| api_nested_string_field(value, "botAvatar", &["cdnUrl", "cdn_url"]))
3560 .or_else(|| api_nested_string_field(value, "bot_avatar", &["cdnUrl", "cdn_url"])),
3561 is_bot: api_bool_field(value, &["isBot", "is_bot", "bot"]),
3562 })
3563}
3564
3565fn user_display_name_from_record(user: &UserRecord) -> Option<String> {
3566 user.display_name
3567 .clone()
3568 .or_else(|| {
3569 let first = user.first_name.as_deref().unwrap_or_default().trim();
3570 let last = user.last_name.as_deref().unwrap_or_default().trim();
3571 let full = [first, last]
3572 .into_iter()
3573 .filter(|part| !part.is_empty())
3574 .collect::<Vec<_>>()
3575 .join(" ");
3576 (!full.is_empty()).then_some(full)
3577 })
3578 .or_else(|| user.username.clone())
3579}
3580
3581fn api_i64_field(value: &Value, fields: &[&str]) -> Option<i64> {
3582 fields
3583 .iter()
3584 .find_map(|field| value.get(*field).and_then(value_as_i64))
3585}
3586
3587fn api_bool_field(value: &Value, fields: &[&str]) -> Option<bool> {
3588 fields
3589 .iter()
3590 .find_map(|field| value.get(*field).and_then(Value::as_bool))
3591}
3592
3593fn api_string_field(value: &Value, fields: &[&str]) -> Option<String> {
3594 fields
3595 .iter()
3596 .find_map(|field| value.get(*field).and_then(value_as_string))
3597}
3598
3599fn api_nested_string_field(value: &Value, field: &str, nested_fields: &[&str]) -> Option<String> {
3600 value
3601 .get(field)
3602 .and_then(|nested| api_string_field(nested, nested_fields))
3603}
3604
3605fn value_as_i64(value: &Value) -> Option<i64> {
3606 value
3607 .as_i64()
3608 .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok()))
3609 .or_else(|| value.as_str()?.trim().parse().ok())
3610}
3611
3612fn value_as_string(value: &Value) -> Option<String> {
3613 value
3614 .as_str()
3615 .map(str::trim)
3616 .filter(|value| !value.is_empty())
3617 .map(ToOwned::to_owned)
3618}
3619
3620fn trimmed_option(value: Option<String>) -> Option<String> {
3621 value
3622 .map(|value| value.trim().to_owned())
3623 .filter(|value| !value.is_empty())
3624}
3625
3626fn normalized_account_namespace(value: Option<&str>) -> Option<&str> {
3627 value.map(str::trim).filter(|value| !value.is_empty())
3628}
3629
3630fn user_display_name_from_proto(user: &proto::User) -> Option<String> {
3631 if let Some(bot_name) = user
3632 .bot_avatar
3633 .as_ref()
3634 .and_then(|avatar| non_empty_option(Some(&avatar.display_name)))
3635 {
3636 return Some(bot_name);
3637 }
3638 let first = user.first_name.as_deref().unwrap_or_default().trim();
3639 let last = user.last_name.as_deref().unwrap_or_default().trim();
3640 let full = [first, last]
3641 .into_iter()
3642 .filter(|part| !part.is_empty())
3643 .collect::<Vec<_>>()
3644 .join(" ");
3645 if !full.is_empty() {
3646 return Some(full);
3647 }
3648 non_empty_option(user.username.as_deref())
3649}
3650
3651fn user_id_from_peer(peer: &proto::Peer) -> Option<i64> {
3652 match &peer.r#type {
3653 Some(proto::peer::Type::User(user)) => Some(user.user_id),
3654 _ => None,
3655 }
3656}
3657
3658fn non_empty_option(value: Option<&str>) -> Option<String> {
3659 value
3660 .map(str::trim)
3661 .filter(|value| !value.is_empty())
3662 .map(ToOwned::to_owned)
3663}
3664
3665fn realtime_error_to_backend(error: RealtimeError) -> BackendError {
3666 match error {
3667 RealtimeError::InvalidUrl { message, .. } => {
3668 BackendError::new(ClientErrorCategory::InvalidInput, message)
3669 }
3670 RealtimeError::Timeout { .. } => {
3671 BackendError::new(ClientErrorCategory::Timeout, error.to_string())
3672 }
3673 RealtimeError::ConnectionError { .. } => {
3674 BackendError::new(ClientErrorCategory::AuthExpired, error.to_string())
3675 }
3676 RealtimeError::RpcError {
3677 code,
3678 error_name,
3679 friendly,
3680 ..
3681 } if code == 420 || code == 429 || is_rate_limit_error_name(&error_name) => {
3682 rate_limited_backend_error(friendly)
3683 }
3684 RealtimeError::RpcError {
3685 error_name,
3686 friendly,
3687 ..
3688 } if error_name == "UNAUTHENTICATED" => {
3689 BackendError::new(ClientErrorCategory::AuthExpired, friendly)
3690 }
3691 RealtimeError::RpcError { friendly, .. } => {
3692 BackendError::new(ClientErrorCategory::Internal, friendly)
3693 }
3694 RealtimeError::ConnectionClosed
3695 | RealtimeError::EventLagged { .. }
3696 | RealtimeError::WebSocket(_) => {
3697 BackendError::new(ClientErrorCategory::Network, error.to_string())
3698 }
3699 RealtimeError::InvalidHeaderValue { .. }
3700 | RealtimeError::Protocol(_)
3701 | RealtimeError::MissingResult
3702 | RealtimeError::UnexpectedResult { .. } => {
3703 BackendError::new(ClientErrorCategory::ProtocolMismatch, error.to_string())
3704 }
3705 _ => BackendError::new(ClientErrorCategory::Internal, error.to_string()),
3706 }
3707}
3708
3709fn realtime_error_closes_session(error: &RealtimeError) -> bool {
3710 matches!(
3711 error,
3712 RealtimeError::ConnectionClosed
3713 | RealtimeError::ConnectionError { .. }
3714 | RealtimeError::WebSocket(_)
3715 )
3716}
3717
3718fn api_error_to_backend(error: ApiError) -> BackendError {
3719 let rendered = error.to_string();
3720 match error {
3721 ApiError::InvalidBaseUrl { message, .. } | ApiError::InvalidInput { message } => {
3722 BackendError::new(ClientErrorCategory::InvalidInput, message)
3723 }
3724 ApiError::Status { status, .. } if status == 401 || status == 403 => {
3725 BackendError::new(ClientErrorCategory::AuthExpired, rendered)
3726 }
3727 ApiError::Status {
3728 status: 420 | 429, ..
3729 } => rate_limited_backend_error(rendered),
3730 ApiError::Api {
3731 status: Some(401) | Some(403),
3732 ..
3733 } => BackendError::new(ClientErrorCategory::AuthExpired, rendered),
3734 ApiError::Api {
3735 status: Some(420) | Some(429),
3736 ..
3737 } => rate_limited_backend_error(rendered),
3738 ApiError::Api { ref error, .. } if is_rate_limit_error_name(error) => {
3739 rate_limited_backend_error(rendered)
3740 }
3741 ApiError::Http(_) | ApiError::Status { .. } => {
3742 BackendError::new(ClientErrorCategory::Network, rendered)
3743 }
3744 ApiError::Json(_) => BackendError::new(ClientErrorCategory::ProtocolMismatch, rendered),
3745 ApiError::Io(_) | ApiError::Api { .. } => {
3746 BackendError::new(ClientErrorCategory::Internal, rendered)
3747 }
3748 _ => BackendError::new(ClientErrorCategory::Internal, rendered),
3749 }
3750}
3751
3752fn rate_limited_backend_error(message: String) -> BackendError {
3753 let retry_after_seconds = retry_after_seconds_from_message(&message);
3754 let error = BackendError::new(ClientErrorCategory::RateLimited, message);
3755 match retry_after_seconds {
3756 Some(seconds) => error.with_retry_after_seconds(seconds),
3757 None => error,
3758 }
3759}
3760
3761fn is_rate_limit_error_name(name: &str) -> bool {
3762 matches!(
3763 name.trim().to_ascii_uppercase().as_str(),
3764 "RATE_LIMIT" | "RATE_LIMITED" | "FLOOD_WAIT"
3765 )
3766}
3767
3768fn input_peer_for_client_peer(peer: PeerRef) -> proto::InputPeer {
3769 proto::InputPeer {
3770 r#type: Some(match peer {
3771 PeerRef::User { user_id } => proto::input_peer::Type::User(proto::InputPeerUser {
3772 user_id: user_id.get(),
3773 }),
3774 PeerRef::Chat { chat_id } => proto::input_peer::Type::Chat(proto::InputPeerChat {
3775 chat_id: chat_id.get(),
3776 }),
3777 PeerRef::Thread { thread_id } => proto::input_peer::Type::Chat(proto::InputPeerChat {
3778 chat_id: thread_id.get(),
3779 }),
3780 }),
3781 }
3782}
3783
3784fn input_peer_for_chat(chat_id: InlineId) -> proto::InputPeer {
3785 proto::InputPeer {
3786 r#type: Some(proto::input_peer::Type::Chat(proto::InputPeerChat {
3787 chat_id: chat_id.get(),
3788 })),
3789 }
3790}
3791
3792fn input_peer_for_sync_bucket_peer(peer: crate::SyncBucketPeer) -> proto::InputPeer {
3793 let r#type = match peer {
3794 crate::SyncBucketPeer::User { user_id } => {
3795 proto::input_peer::Type::User(proto::InputPeerUser {
3796 user_id: user_id.get(),
3797 })
3798 }
3799 crate::SyncBucketPeer::Chat { chat_id } => {
3800 proto::input_peer::Type::Chat(proto::InputPeerChat {
3801 chat_id: chat_id.get(),
3802 })
3803 }
3804 };
3805 proto::InputPeer {
3806 r#type: Some(r#type),
3807 }
3808}
3809
3810fn sync_bucket_peer_for_chat(chat: &proto::Chat) -> crate::SyncBucketPeer {
3811 match chat.peer_id.as_ref().and_then(|peer| peer.r#type.as_ref()) {
3812 Some(proto::peer::Type::User(user)) => crate::SyncBucketPeer::User {
3813 user_id: InlineId::new(user.user_id),
3814 },
3815 Some(proto::peer::Type::Chat(peer)) => crate::SyncBucketPeer::Chat {
3816 chat_id: InlineId::new(peer.chat_id),
3817 },
3818 None => crate::SyncBucketPeer::Chat {
3819 chat_id: InlineId::new(chat.id),
3820 },
3821 }
3822}
3823
3824fn history_input_for_request(
3825 request: &HistoryRequest,
3826 fetch_limit: i32,
3827) -> proto::GetChatHistoryInput {
3828 if let Some(after) = request.after_message_id {
3829 return proto::GetChatHistoryInput {
3830 peer_id: Some(input_peer_for_chat(request.chat_id)),
3831 offset_id: None,
3832 limit: Some(fetch_limit),
3833 mode: Some(proto::GetChatHistoryMode::HistoryModeNewer as i32),
3834 anchor_id: None,
3835 before_id: None,
3836 after_id: Some(after.get()),
3837 before_limit: None,
3838 after_limit: None,
3839 include_anchor: None,
3840 };
3841 }
3842
3843 proto::GetChatHistoryInput {
3844 peer_id: Some(input_peer_for_chat(request.chat_id)),
3845 offset_id: request.before_message_id.map(InlineId::get),
3846 limit: Some(fetch_limit),
3847 mode: None,
3848 anchor_id: None,
3849 before_id: None,
3850 after_id: None,
3851 before_limit: None,
3852 after_limit: None,
3853 include_anchor: None,
3854 }
3855}
3856
3857fn normalize_live_history_records(
3858 mut records: Vec<MessageRecord>,
3859 limit: u32,
3860 newer_page: bool,
3861) -> (Vec<MessageRecord>, bool) {
3862 records.sort_by_key(|message| (message.timestamp, message.message_id.get()));
3863 let limit = limit.max(1) as usize;
3864 let has_more = records.len() > limit;
3865 if has_more {
3866 if newer_page {
3867 records.truncate(limit);
3868 } else {
3869 records = records.split_off(records.len() - limit);
3870 }
3871 }
3872 (records, has_more)
3873}
3874
3875fn chat_id_from_peer(peer: &proto::Peer) -> Option<InlineId> {
3876 match &peer.r#type {
3877 Some(proto::peer::Type::Chat(chat)) => Some(InlineId::new(chat.chat_id)),
3878 Some(proto::peer::Type::User(user)) => Some(InlineId::new(user.user_id)),
3879 None => None,
3880 }
3881}
3882
3883fn chat_id_for_peer(peer: PeerRef) -> InlineId {
3884 match peer {
3885 PeerRef::User { user_id } => user_id,
3886 PeerRef::Chat { chat_id } => chat_id,
3887 PeerRef::Thread { thread_id } => thread_id,
3888 }
3889}
3890
3891fn transaction_id_for_send(request: &SendTextRequest, random_id: RandomId) -> TransactionId {
3892 let stable = request
3893 .external_id
3894 .as_ref()
3895 .map(|external| stable_hash(&format!("{}:{}", external.source(), external.id())))
3896 .unwrap_or_else(|| random_id.get() as u64);
3897 TransactionId::try_new(format!("sdk-send-{stable:016x}"))
3898 .expect("generated transaction ID should be valid")
3899}
3900
3901fn random_id_for_request(request: &SendTextRequest) -> RandomId {
3902 let seed = request
3903 .external_id
3904 .as_ref()
3905 .map(|external| format!("{}:{}:{}", external.source(), external.id(), request.text))
3906 .unwrap_or_else(|| format!("{}:{}", now_seconds(), request.text));
3907 RandomId::new((stable_hash(&seed) & 0x7fff_ffff_ffff_ffff) as i64)
3908}
3909
3910fn transaction_id_for_upload(request: &UploadRequest, random_id: RandomId) -> TransactionId {
3911 let stable = request
3912 .external_id
3913 .as_ref()
3914 .map(|external| stable_hash(&format!("{}:{}", external.source(), external.id())))
3915 .unwrap_or_else(|| random_id.get() as u64);
3916 TransactionId::try_new(format!("sdk-upload-{stable:016x}"))
3917 .expect("generated transaction ID should be valid")
3918}
3919
3920fn random_id_for_upload_request(request: &UploadRequest, size_bytes: usize) -> RandomId {
3921 let seed = request
3922 .external_id
3923 .as_ref()
3924 .map(|external| {
3925 format!(
3926 "{}:{}:{}",
3927 external.source(),
3928 external.id(),
3929 request.caption.as_deref().unwrap_or_default()
3930 )
3931 })
3932 .unwrap_or_else(|| {
3933 format!(
3934 "{}:{}:{}:{}",
3935 now_seconds(),
3936 request.file_name.as_deref().unwrap_or_default(),
3937 request.caption.as_deref().unwrap_or_default(),
3938 size_bytes
3939 )
3940 });
3941 RandomId::new((stable_hash(&seed) & 0x7fff_ffff_ffff_ffff) as i64)
3942}
3943
3944fn upload_input_for_request(request: &UploadRequest, bytes: Vec<u8>) -> UploadFileBytesInput {
3945 let file_name = request
3946 .file_name
3947 .as_deref()
3948 .map(str::trim)
3949 .filter(|value| !value.is_empty())
3950 .map(ToOwned::to_owned)
3951 .unwrap_or_else(|| default_upload_file_name(request));
3952 let file_type = upload_file_type_for_request(request);
3953 let mut input = UploadFileBytesInput::new(bytes, file_name, file_type);
3954 if let Some(mime_type) = request
3955 .mime_type
3956 .as_deref()
3957 .map(str::trim)
3958 .filter(|value| !value.is_empty())
3959 {
3960 input = input.with_mime_type(mime_type);
3961 }
3962 if file_type == UploadFileType::Video
3963 && let Some(metadata) = upload_video_metadata_for_request(request)
3964 {
3965 input = input.with_video_metadata(metadata);
3966 }
3967 input
3968}
3969
3970fn upload_file_type_for_request(request: &UploadRequest) -> UploadFileType {
3971 match request.kind {
3972 MediaKind::Photo => UploadFileType::Photo,
3973 MediaKind::Video if upload_video_metadata_for_request(request).is_some() => {
3974 UploadFileType::Video
3975 }
3976 MediaKind::Video | MediaKind::Document | MediaKind::Voice => UploadFileType::Document,
3977 }
3978}
3979
3980fn upload_video_metadata_for_request(request: &UploadRequest) -> Option<UploadVideoMetadata> {
3981 let width = i32::try_from(request.width?)
3982 .ok()
3983 .filter(|value| *value > 0)?;
3984 let height = i32::try_from(request.height?)
3985 .ok()
3986 .filter(|value| *value > 0)?;
3987 let duration = duration_ms_to_seconds_i32(request.duration_ms?)?;
3988 Some(UploadVideoMetadata::new(width, height, duration))
3989}
3990
3991fn duration_ms_to_seconds_i32(duration_ms: u64) -> Option<i32> {
3992 if duration_ms == 0 {
3993 return None;
3994 }
3995 let seconds = duration_ms.saturating_add(999) / 1_000;
3996 i32::try_from(seconds).ok().filter(|value| *value > 0)
3997}
3998
3999fn default_upload_file_name(request: &UploadRequest) -> String {
4000 let extension = match request.kind {
4001 MediaKind::Photo => extension_for_mime(request.mime_type.as_deref(), "jpg"),
4002 MediaKind::Video => extension_for_mime(request.mime_type.as_deref(), "mp4"),
4003 MediaKind::Voice => extension_for_mime(request.mime_type.as_deref(), "ogg"),
4004 MediaKind::Document => "bin",
4005 };
4006 format!("inline-upload.{}", extension.trim_start_matches('.'))
4007}
4008
4009fn extension_for_mime(mime_type: Option<&str>, fallback: &'static str) -> &'static str {
4010 match mime_type
4011 .unwrap_or_default()
4012 .trim()
4013 .to_ascii_lowercase()
4014 .as_str()
4015 {
4016 "image/png" => "png",
4017 "image/gif" => "gif",
4018 "image/webp" => "webp",
4019 "video/webm" => "webm",
4020 "audio/mpeg" => "mp3",
4021 "audio/mp4" | "audio/aac" => "m4a",
4022 _ => fallback,
4023 }
4024}
4025
4026fn input_media_from_upload(upload: &UploadFileResult) -> BackendResult<proto::InputMedia> {
4027 if let Some(photo_id) = upload.photo_id {
4028 return Ok(proto::InputMedia {
4029 media: Some(proto::input_media::Media::Photo(proto::InputMediaPhoto {
4030 photo_id,
4031 })),
4032 });
4033 }
4034 if let Some(video_id) = upload.video_id {
4035 return Ok(proto::InputMedia {
4036 media: Some(proto::input_media::Media::Video(proto::InputMediaVideo {
4037 video_id,
4038 })),
4039 });
4040 }
4041 if let Some(document_id) = upload.document_id {
4042 return Ok(proto::InputMedia {
4043 media: Some(proto::input_media::Media::Document(
4044 proto::InputMediaDocument { document_id },
4045 )),
4046 });
4047 }
4048 Err(BackendError::new(
4049 ClientErrorCategory::ProtocolMismatch,
4050 "uploadFile response did not include a media id",
4051 ))
4052}
4053
4054fn stable_hash(value: &str) -> u64 {
4055 let mut hash = 0xcbf2_9ce4_8422_2325u64;
4056 for byte in value.as_bytes() {
4057 hash ^= u64::from(*byte);
4058 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
4059 }
4060 hash
4061}
4062
4063#[derive(Debug)]
4064struct AppliedSendMessage {
4065 transaction: StoredTransaction,
4066 message: Option<MessageRecord>,
4067 chat_id: InlineId,
4068 message_id: Option<InlineId>,
4069}
4070
4071fn apply_send_message_updates(
4072 request: &SendTextRequest,
4073 identity: TransactionIdentity,
4074 fallback_chat_id: InlineId,
4075 result: proto::SendMessageResult,
4076) -> AppliedSendMessage {
4077 let mut final_message: Option<proto::Message> = None;
4078 let mut final_message_id: Option<InlineId> = None;
4079 for update in result.updates {
4080 match update.update {
4081 Some(proto::update::Update::NewMessage(update)) => {
4082 if let Some(message) = update.message {
4083 final_message_id = Some(InlineId::new(message.id));
4084 final_message = Some(message);
4085 }
4086 }
4087 Some(proto::update::Update::UpdateMessageId(update))
4088 if update.random_id == identity.random_id.get() =>
4089 {
4090 final_message_id = Some(InlineId::new(update.message_id));
4091 }
4092 _ => {}
4093 }
4094 }
4095
4096 let state = if final_message_id.is_some() {
4097 TransactionState::Completed
4098 } else {
4099 TransactionState::Acked
4100 };
4101 let mut identity = identity;
4102 if let Some(message_id) = final_message_id {
4103 identity = identity.with_final_message_id(message_id);
4104 }
4105 let message =
4106 final_message.map(|message| message_record_from_proto(message, request, identity.clone()));
4107 let chat_id = message
4108 .as_ref()
4109 .map(|message| message.chat_id)
4110 .unwrap_or(fallback_chat_id);
4111 let mut transaction = StoredTransaction::new(identity, state).with_chat_id(chat_id);
4112 if let Some(message_id) = final_message_id {
4113 transaction = transaction.with_message_id(message_id);
4114 }
4115
4116 AppliedSendMessage {
4117 transaction,
4118 message,
4119 chat_id,
4120 message_id: final_message_id,
4121 }
4122}
4123
4124fn apply_upload_message_updates(
4125 request: &UploadRequest,
4126 identity: TransactionIdentity,
4127 fallback_chat_id: InlineId,
4128 result: proto::SendMessageResult,
4129) -> AppliedSendMessage {
4130 let mut final_message: Option<proto::Message> = None;
4131 let mut final_message_id: Option<InlineId> = None;
4132 for update in result.updates {
4133 match update.update {
4134 Some(proto::update::Update::NewMessage(update)) => {
4135 if let Some(message) = update.message {
4136 final_message_id = Some(InlineId::new(message.id));
4137 final_message = Some(message);
4138 }
4139 }
4140 Some(proto::update::Update::UpdateMessageId(update))
4141 if update.random_id == identity.random_id.get() =>
4142 {
4143 final_message_id = Some(InlineId::new(update.message_id));
4144 }
4145 _ => {}
4146 }
4147 }
4148
4149 let state = if final_message_id.is_some() {
4150 TransactionState::Completed
4151 } else {
4152 TransactionState::Acked
4153 };
4154 let mut identity = identity;
4155 if let Some(message_id) = final_message_id {
4156 identity = identity.with_final_message_id(message_id);
4157 }
4158 let message = final_message.map(|message| {
4159 message_record_from_proto_message(
4160 message,
4161 Some(chat_id_for_peer(request.peer)),
4162 Some(identity.clone()),
4163 )
4164 });
4165 let chat_id = message
4166 .as_ref()
4167 .map(|message| message.chat_id)
4168 .unwrap_or(fallback_chat_id);
4169 let mut transaction = StoredTransaction::new(identity, state).with_chat_id(chat_id);
4170 if let Some(message_id) = final_message_id {
4171 transaction = transaction.with_message_id(message_id);
4172 }
4173
4174 AppliedSendMessage {
4175 transaction,
4176 message,
4177 chat_id,
4178 message_id: final_message_id,
4179 }
4180}
4181
4182fn message_record_from_proto(
4183 message: proto::Message,
4184 request: &SendTextRequest,
4185 transaction: TransactionIdentity,
4186) -> MessageRecord {
4187 message_record_from_proto_message(
4188 message,
4189 Some(chat_id_for_peer(request.peer)),
4190 Some(transaction),
4191 )
4192}
4193
4194fn message_record_from_proto_message(
4195 message: proto::Message,
4196 fallback_chat_id: Option<InlineId>,
4197 transaction: Option<TransactionIdentity>,
4198) -> MessageRecord {
4199 let chat_id = if message.chat_id != 0 {
4200 InlineId::new(message.chat_id)
4201 } else if let Some(peer) = &message.peer_id {
4202 chat_id_from_peer(peer).unwrap_or_else(|| fallback_chat_id.unwrap_or(InlineId::new(0)))
4203 } else {
4204 fallback_chat_id.unwrap_or(InlineId::new(0))
4205 };
4206 let content = content_from_proto_message(&message);
4207 MessageRecord {
4208 chat_id,
4209 message_id: InlineId::new(message.id),
4210 sender_id: InlineId::new(message.from_id),
4211 timestamp: message.date,
4212 is_outgoing: message.out,
4213 content,
4214 reply_to_message_id: message.reply_to_msg_id.map(InlineId::new),
4215 transaction,
4216 }
4217}
4218
4219fn reaction_snapshot_from_proto_message(
4220 message: &proto::Message,
4221 fallback_chat_id: Option<InlineId>,
4222) -> Option<Vec<StoredReaction>> {
4223 let snapshot = message.reactions.as_ref()?;
4224 let chat_id = if message.chat_id != 0 {
4225 InlineId::new(message.chat_id)
4226 } else if let Some(peer) = &message.peer_id {
4227 chat_id_from_peer(peer).unwrap_or_else(|| fallback_chat_id.unwrap_or(InlineId::new(0)))
4228 } else {
4229 fallback_chat_id.unwrap_or(InlineId::new(0))
4230 };
4231 Some(
4232 snapshot
4233 .reactions
4234 .iter()
4235 .filter(|reaction| !reaction.emoji.trim().is_empty())
4236 .map(|reaction| StoredReaction {
4237 chat_id: if reaction.chat_id != 0 {
4238 InlineId::new(reaction.chat_id)
4239 } else {
4240 chat_id
4241 },
4242 message_id: if reaction.message_id != 0 {
4243 InlineId::new(reaction.message_id)
4244 } else {
4245 InlineId::new(message.id)
4246 },
4247 user_id: InlineId::new(reaction.user_id),
4248 reaction: reaction.emoji.clone(),
4249 })
4250 .collect(),
4251 )
4252}
4253
4254fn content_from_proto_message(message: &proto::Message) -> MessageContent {
4255 match &message.media {
4256 None => match &message.message {
4257 Some(text) => MessageContent::Text { text: text.clone() },
4258 None => MessageContent::Unsupported {
4259 reason: "empty message content".to_owned(),
4260 },
4261 },
4262 Some(media) => media_content_from_proto(media, message.message.clone()),
4263 }
4264}
4265
4266fn media_content_from_proto(
4267 media: &proto::MessageMedia,
4268 caption: Option<String>,
4269) -> MessageContent {
4270 match &media.media {
4271 Some(proto::message_media::Media::Photo(photo)) => {
4272 if let Some(photo) = &photo.photo {
4273 let (url, size_bytes, width, height) = best_photo_size(photo);
4274 MessageContent::Media {
4275 kind: MediaKind::Photo,
4276 file_id: photo.id.to_string(),
4277 url,
4278 mime_type: photo_mime_type(photo),
4279 file_name: None,
4280 caption,
4281 size_bytes,
4282 width,
4283 height,
4284 duration_ms: None,
4285 }
4286 } else {
4287 empty_media_content(MediaKind::Photo, caption)
4288 }
4289 }
4290 Some(proto::message_media::Media::Video(video)) => {
4291 if let Some(video) = &video.video {
4292 MessageContent::Media {
4293 kind: MediaKind::Video,
4294 file_id: video.id.to_string(),
4295 url: video.cdn_url.clone(),
4296 mime_type: Some("video/mp4".to_owned()),
4297 file_name: None,
4298 caption,
4299 size_bytes: positive_u64(video.size),
4300 width: positive_u32(video.w),
4301 height: positive_u32(video.h),
4302 duration_ms: seconds_to_milliseconds(video.duration),
4303 }
4304 } else {
4305 empty_media_content(MediaKind::Video, caption)
4306 }
4307 }
4308 Some(proto::message_media::Media::Document(document)) => {
4309 if let Some(document) = &document.document {
4310 MessageContent::Media {
4311 kind: MediaKind::Document,
4312 file_id: document.id.to_string(),
4313 url: document.cdn_url.clone(),
4314 mime_type: non_empty_string(&document.mime_type),
4315 file_name: non_empty_string(&document.file_name),
4316 caption,
4317 size_bytes: positive_u64(document.size),
4318 width: None,
4319 height: None,
4320 duration_ms: None,
4321 }
4322 } else {
4323 empty_media_content(MediaKind::Document, caption)
4324 }
4325 }
4326 Some(proto::message_media::Media::Voice(voice)) => {
4327 if let Some(voice) = &voice.voice {
4328 MessageContent::Media {
4329 kind: MediaKind::Voice,
4330 file_id: voice.id.to_string(),
4331 url: voice.cdn_url.clone(),
4332 mime_type: non_empty_string(&voice.mime_type),
4333 file_name: None,
4334 caption,
4335 size_bytes: positive_u64(voice.size),
4336 width: None,
4337 height: None,
4338 duration_ms: seconds_to_milliseconds(voice.duration),
4339 }
4340 } else {
4341 empty_media_content(MediaKind::Voice, caption)
4342 }
4343 }
4344 Some(proto::message_media::Media::Nudge(_)) => MessageContent::Unsupported {
4345 reason: "nudge messages are not supported by inline-client yet".to_owned(),
4346 },
4347 None => MessageContent::Unsupported {
4348 reason: "empty media message".to_owned(),
4349 },
4350 }
4351}
4352
4353fn empty_media_content(kind: MediaKind, caption: Option<String>) -> MessageContent {
4354 MessageContent::Media {
4355 kind,
4356 file_id: String::new(),
4357 url: None,
4358 mime_type: None,
4359 file_name: None,
4360 caption,
4361 size_bytes: None,
4362 width: None,
4363 height: None,
4364 duration_ms: None,
4365 }
4366}
4367
4368fn best_photo_size(
4369 photo: &proto::Photo,
4370) -> (Option<String>, Option<u64>, Option<u32>, Option<u32>) {
4371 let mut best: Option<(&proto::PhotoSize, i64)> = None;
4372 for size in &photo.sizes {
4373 if size.cdn_url.is_none() {
4374 continue;
4375 }
4376 let area = i64::from(size.w) * i64::from(size.h);
4377 if best.is_none_or(|(_, best_area)| area > best_area) {
4378 best = Some((size, area));
4379 }
4380 }
4381 if let Some((size, _)) = best {
4382 return (
4383 size.cdn_url.clone(),
4384 positive_u64(size.size),
4385 positive_u32(size.w),
4386 positive_u32(size.h),
4387 );
4388 }
4389 (None, None, None, None)
4390}
4391
4392fn photo_mime_type(photo: &proto::Photo) -> Option<String> {
4393 match photo.format {
4394 1 => Some("image/jpeg".to_owned()),
4395 2 => Some("image/png".to_owned()),
4396 _ => None,
4397 }
4398}
4399
4400fn positive_u64(value: i32) -> Option<u64> {
4401 u64::try_from(value).ok().filter(|value| *value > 0)
4402}
4403
4404fn positive_u32(value: i32) -> Option<u32> {
4405 u32::try_from(value).ok().filter(|value| *value > 0)
4406}
4407
4408fn seconds_to_milliseconds(seconds: i32) -> Option<u64> {
4409 positive_u64(seconds).map(|seconds| seconds.saturating_mul(1_000))
4410}
4411
4412fn non_empty_string(value: &str) -> Option<String> {
4413 (!value.is_empty()).then(|| value.to_owned())
4414}
4415
4416fn outcome_from_stored_transaction(
4417 transaction: StoredTransaction,
4418 fallback_chat_id: InlineId,
4419) -> SendTextOutcome {
4420 let message_id = transaction
4421 .message_id
4422 .or(transaction.identity.final_message_id);
4423 SendTextOutcome::with_state(
4424 MessageMutation {
4425 transaction: transaction.identity.clone(),
4426 message_id,
4427 state: None,
4428 failure: None,
4429 },
4430 transaction.chat_id.unwrap_or(fallback_chat_id),
4431 message_id,
4432 None,
4433 transaction.state,
4434 transaction.failure,
4435 )
4436}
4437
4438fn stored_transaction_needs_retry(transaction: &StoredTransaction) -> bool {
4439 match transaction.state {
4440 TransactionState::Queued | TransactionState::Sent | TransactionState::Acked => true,
4441 TransactionState::Failed => transaction
4442 .failure
4443 .as_ref()
4444 .is_some_and(|failure| retryable_transaction_category(failure.category)),
4445 TransactionState::Completed | TransactionState::Cancelled => false,
4446 }
4447}
4448
4449fn retryable_transaction_category(category: ClientErrorCategory) -> bool {
4450 matches!(
4451 category,
4452 ClientErrorCategory::AuthRequired
4453 | ClientErrorCategory::AuthExpired
4454 | ClientErrorCategory::ReloginRequired
4455 | ClientErrorCategory::Network
4456 | ClientErrorCategory::Timeout
4457 | ClientErrorCategory::RateLimited
4458 | ClientErrorCategory::Internal
4459 )
4460}
4461
4462fn now_seconds() -> i64 {
4463 SystemTime::now()
4464 .duration_since(UNIX_EPOCH)
4465 .map(|duration| duration.as_secs() as i64)
4466 .unwrap_or_default()
4467}
4468
4469fn redacted_url_for_debug(url: &str) -> String {
4470 let without_fragment = url.split('#').next().unwrap_or(url);
4471 let without_query = without_fragment
4472 .split('?')
4473 .next()
4474 .unwrap_or(without_fragment);
4475 match without_query.split_once("://") {
4476 Some((scheme, rest)) => {
4477 let host_and_path = rest.rsplit_once('@').map_or(rest, |(_, tail)| tail);
4478 format!("{scheme}://{host_and_path}")
4479 }
4480 None => without_query.to_owned(),
4481 }
4482}
4483
4484#[cfg(test)]
4485mod tests {
4486 use futures_util::{SinkExt, StreamExt};
4487 use prost::Message;
4488 use tokio::{
4489 io::{AsyncReadExt, AsyncWriteExt},
4490 net::TcpListener,
4491 };
4492 use tokio_tungstenite::accept_async;
4493 use tokio_tungstenite::tungstenite::Message as WsMessage;
4494
4495 use crate::{
4496 AuthCredential, AuthToken, ClientBackend, ClientStatus, DialogRecord, DialogsRequest,
4497 ExternalId, FakeRealtimeConnector, HistoryRequest, InlineClient, InlineId, RandomId,
4498 SendTextRequest, StoredTransaction,
4499 };
4500
4501 use super::*;
4502
4503 fn connect_request() -> ConnectRequest {
4504 ConnectRequest::new(AuthCredential::AccessToken {
4505 token: AuthToken::try_new("secret-token").unwrap(),
4506 })
4507 .with_account_namespace("team")
4508 }
4509
4510 fn test_message_record(message_id: i64) -> MessageRecord {
4511 MessageRecord {
4512 chat_id: InlineId::new(7),
4513 message_id: InlineId::new(message_id),
4514 sender_id: InlineId::new(2),
4515 timestamp: message_id,
4516 is_outgoing: false,
4517 content: MessageContent::Text {
4518 text: format!("message {message_id}"),
4519 },
4520 reply_to_message_id: None,
4521 transaction: None,
4522 }
4523 }
4524
4525 #[test]
4526 fn proto_message_reaction_snapshot_preserves_complete_empty_and_nonempty_sets() {
4527 let mut message = proto::Message {
4528 id: 11,
4529 chat_id: 7,
4530 reactions: Some(proto::MessageReactions { reactions: vec![] }),
4531 ..Default::default()
4532 };
4533 assert_eq!(
4534 reaction_snapshot_from_proto_message(&message, None),
4535 Some(Vec::new())
4536 );
4537
4538 message.reactions = Some(proto::MessageReactions {
4539 reactions: vec![proto::Reaction {
4540 emoji: "👍".to_owned(),
4541 user_id: 2,
4542 message_id: 11,
4543 chat_id: 7,
4544 ..Default::default()
4545 }],
4546 });
4547 assert_eq!(
4548 reaction_snapshot_from_proto_message(&message, None),
4549 Some(vec![StoredReaction {
4550 chat_id: InlineId::new(7),
4551 message_id: InlineId::new(11),
4552 user_id: InlineId::new(2),
4553 reaction: "👍".to_owned(),
4554 }])
4555 );
4556 }
4557
4558 #[tokio::test]
4559 async fn live_dialog_reconciliation_tombstones_missing_chats() {
4560 let store = InMemoryStore::new();
4561 store
4562 .record_dialog(DialogRecord::new(InlineId::new(7)))
4563 .await
4564 .unwrap();
4565 store
4566 .record_dialog(DialogRecord::new(InlineId::new(8)))
4567 .await
4568 .unwrap();
4569 let removed_message = MessageRecord {
4570 chat_id: InlineId::new(8),
4571 ..test_message_record(10)
4572 };
4573 store.record_message(removed_message.clone()).await.unwrap();
4574 let backend = SdkBackend::builder().store(store.clone()).build().unwrap();
4575
4576 backend
4577 .reconcile_live_dialogs(vec![DialogRecord::new(InlineId::new(7))])
4578 .await
4579 .unwrap();
4580
4581 assert_eq!(
4582 store.deleted_chat_ids().await.unwrap(),
4583 vec![InlineId::new(8)]
4584 );
4585 assert_eq!(
4586 store.deleted_message_ids(InlineId::new(8)).await.unwrap(),
4587 vec![removed_message.message_id]
4588 );
4589 }
4590
4591 #[test]
4592 fn sdk_backend_debug_redacts_realtime_url_credentials() {
4593 let backend = SdkBackend::builder()
4594 .realtime_url("wss://user:secret@api.inline.chat/realtime?token=secret#frag")
4595 .build()
4596 .unwrap();
4597
4598 let rendered = format!("{backend:?}");
4599 assert!(rendered.contains("wss://api.inline.chat/realtime"));
4600 assert!(!rendered.contains("secret"));
4601 assert!(!rendered.contains("token="));
4602 }
4603
4604 #[test]
4605 fn sdk_backend_defaults_use_production_endpoints() {
4606 let backend = SdkBackend::builder().build().unwrap();
4607
4608 assert_eq!(
4609 backend.api_client().base_url(),
4610 "https://api.inline.chat/v1"
4611 );
4612 assert_eq!(backend.realtime_url(), "wss://api.inline.chat/realtime");
4613 }
4614
4615 #[tokio::test]
4616 async fn sdk_backend_connect_persists_session() {
4617 let store = InMemoryStore::new();
4618 let backend = SdkBackend::builder().store(store.clone()).build().unwrap();
4619
4620 let status = backend.connect(connect_request()).await.unwrap();
4621
4622 assert_eq!(status.status, crate::ClientStatus::Connected);
4623 let session = store.load_session().await.unwrap().unwrap();
4624 assert_eq!(session.account_namespace.as_deref(), Some("team"));
4625 let rendered = format!("{session:?}");
4626 assert!(!rendered.contains("secret-token"));
4627 }
4628
4629 #[tokio::test]
4630 async fn sdk_backend_connect_can_perform_realtime_handshake() {
4631 let store = InMemoryStore::new();
4632 let realtime = FakeRealtimeConnector::new();
4633 let backend = SdkBackend::builder()
4634 .store(store.clone())
4635 .realtime_connector(realtime.clone())
4636 .realtime_url("wss://api.inline.chat/realtime")
4637 .build()
4638 .unwrap();
4639
4640 assert!(backend.realtime_handshake_enabled());
4641 backend.connect(connect_request()).await.unwrap();
4642
4643 let attempts = realtime.attempts();
4644 assert_eq!(attempts.len(), 1);
4645 assert_eq!(attempts[0].realtime_url, "wss://api.inline.chat/realtime");
4646 assert!(store.load_session().await.unwrap().is_some());
4647 }
4648
4649 #[tokio::test]
4650 async fn sdk_backend_does_not_persist_session_when_realtime_handshake_fails() {
4651 let store = InMemoryStore::new();
4652 let backend = SdkBackend::builder()
4653 .store(store.clone())
4654 .realtime_connector(FakeRealtimeConnector::failing(BackendError::new(
4655 ClientErrorCategory::Network,
4656 "offline",
4657 )))
4658 .build()
4659 .unwrap();
4660
4661 let err = backend.connect(connect_request()).await.unwrap_err();
4662
4663 assert_eq!(err.category, ClientErrorCategory::Network);
4664 assert!(store.load_session().await.unwrap().is_none());
4665 }
4666
4667 #[tokio::test]
4668 async fn sdk_backend_resume_without_session_reports_auth_required() {
4669 let backend = SdkBackend::builder().build().unwrap();
4670
4671 let status = backend.resume_session().await.unwrap();
4672
4673 assert_eq!(status.status, crate::ClientStatus::AuthRequired);
4674 }
4675
4676 #[tokio::test]
4677 async fn sdk_backend_resume_uses_stored_session() {
4678 let store = InMemoryStore::new();
4679 store.save_session(connect_session()).await.unwrap();
4680 let realtime = FakeRealtimeConnector::new();
4681 let backend = SdkBackend::builder()
4682 .store(store)
4683 .realtime_connector(realtime.clone())
4684 .realtime_url("wss://api.inline.chat/realtime")
4685 .build()
4686 .unwrap();
4687
4688 let status = backend.resume_session().await.unwrap();
4689
4690 assert_eq!(status.status, crate::ClientStatus::Connected);
4691 let attempts = realtime.attempts();
4692 assert_eq!(attempts.len(), 1);
4693 assert_eq!(attempts[0].realtime_url, "wss://api.inline.chat/realtime");
4694 }
4695
4696 #[tokio::test]
4697 async fn live_dialog_failure_does_not_masquerade_as_cached_success() {
4698 let store = InMemoryStore::new();
4699 store.save_session(connect_session()).await.unwrap();
4700 store.upsert_dialog(DialogRecord {
4701 chat_id: InlineId::new(9),
4702 peer_user_id: Some(InlineId::new(10)),
4703 title: Some("general".to_owned()),
4704 last_message_id: None,
4705 synced_through_message_id: None,
4706 unread_count: Some(0),
4707 ..DialogRecord::new(InlineId::new(9))
4708 });
4709 let backend = SdkBackend::builder()
4710 .store(store)
4711 .realtime_url("not-a-websocket-url")
4712 .build()
4713 .unwrap();
4714
4715 backend.connect(connect_request()).await.unwrap();
4716 let error = backend
4717 .dialogs(DialogsRequest::default())
4718 .await
4719 .unwrap_err();
4720 assert_eq!(error.category, ClientErrorCategory::InvalidInput);
4721 let dialogs = backend
4722 .cached_dialogs(DialogsRequest::default())
4723 .await
4724 .unwrap();
4725
4726 assert_eq!(dialogs.dialogs.len(), 1);
4727 assert_eq!(dialogs.dialogs[0].chat_id, InlineId::new(9));
4728 }
4729
4730 #[tokio::test]
4731 async fn cached_dialogs_reads_durable_state_without_session_or_network() {
4732 let store = InMemoryStore::new();
4733 store
4734 .record_dialog(DialogRecord {
4735 title: Some("cached".to_owned()),
4736 ..DialogRecord::new(InlineId::new(9))
4737 })
4738 .await
4739 .unwrap();
4740 let backend = SdkBackend::builder()
4741 .store(store)
4742 .realtime_url("not-a-websocket-url")
4743 .build()
4744 .unwrap();
4745
4746 let dialogs = backend
4747 .cached_dialogs(DialogsRequest::default())
4748 .await
4749 .unwrap();
4750
4751 assert_eq!(dialogs.dialogs.len(), 1);
4752 assert_eq!(dialogs.dialogs[0].title.as_deref(), Some("cached"));
4753 }
4754
4755 #[tokio::test]
4756 async fn live_participant_failure_does_not_fall_back_to_cached_snapshot() {
4757 let store = InMemoryStore::new();
4758 store.save_session(connect_session()).await.unwrap();
4759 let backend = SdkBackend::builder()
4760 .store(store.clone())
4761 .realtime_url("not-a-websocket-url")
4762 .build()
4763 .unwrap();
4764 let request = ChatParticipantsRequest {
4765 chat_id: InlineId::new(7),
4766 };
4767
4768 assert!(backend.chat_participants(request.clone()).await.is_err());
4769
4770 store
4771 .record_chat_participants(
4772 InlineId::new(7),
4773 vec![ChatParticipantRecord {
4774 user_id: InlineId::new(42),
4775 date: Some(10),
4776 }],
4777 )
4778 .await
4779 .unwrap();
4780 assert!(backend.chat_participants(request).await.is_err());
4781 let cached = backend.chat_state(InlineId::new(7)).await.unwrap();
4782 assert_eq!(cached.participants.len(), 1);
4783 assert_eq!(cached.participants[0].user_id, InlineId::new(42));
4784 }
4785
4786 #[tokio::test]
4787 async fn live_history_failure_does_not_masquerade_as_cached_success() {
4788 let store = InMemoryStore::new();
4789 store.save_session(connect_session()).await.unwrap();
4790 store
4791 .record_message(MessageRecord {
4792 chat_id: InlineId::new(7),
4793 message_id: InlineId::new(11),
4794 sender_id: InlineId::new(42),
4795 timestamp: 100,
4796 is_outgoing: false,
4797 content: MessageContent::Text {
4798 text: "cached".to_owned(),
4799 },
4800 reply_to_message_id: None,
4801 transaction: None,
4802 })
4803 .await
4804 .unwrap();
4805 let backend = SdkBackend::builder()
4806 .store(store)
4807 .realtime_url("not-a-websocket-url")
4808 .build()
4809 .unwrap();
4810 let request = HistoryRequest {
4811 chat_id: InlineId::new(7),
4812 limit: Some(10),
4813 before_message_id: None,
4814 after_message_id: None,
4815 };
4816
4817 let error = backend.history(request.clone()).await.unwrap_err();
4818 assert_eq!(error.category, ClientErrorCategory::InvalidInput);
4819 let cached = backend.cached_history(request).await.unwrap();
4820 assert_eq!(cached.messages.len(), 1);
4821 assert_eq!(cached.messages[0].message_id, InlineId::new(11));
4822 }
4823
4824 #[tokio::test]
4825 async fn sdk_backend_clears_account_data_when_namespace_changes_or_logs_out() {
4826 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4827 let api_addr = listener.local_addr().unwrap();
4828 let logout_server = tokio::spawn(async move {
4829 let (mut stream, _) = listener.accept().await.unwrap();
4830 let mut request = vec![0; 4096];
4831 let read = stream.read(&mut request).await.unwrap();
4832 let request = String::from_utf8_lossy(&request[..read]);
4833 assert!(request.starts_with("POST /v1/logout HTTP/1.1"));
4834 assert!(request.contains("authorization: Bearer secret-token"));
4835 let body = r#"{"ok":true,"result":null}"#;
4836 stream
4837 .write_all(
4838 format!(
4839 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
4840 body.len()
4841 )
4842 .as_bytes(),
4843 )
4844 .await
4845 .unwrap();
4846 });
4847 let store = InMemoryStore::new();
4848 store
4849 .save_session(StoredSession {
4850 auth: AuthCredential::AccessToken {
4851 token: AuthToken::try_new("old-token").unwrap(),
4852 },
4853 account_namespace: Some("old-account".to_owned()),
4854 })
4855 .await
4856 .unwrap();
4857 store
4858 .record_dialog(DialogRecord::new(InlineId::new(9)))
4859 .await
4860 .unwrap();
4861 store
4862 .save_sync_bucket_state(
4863 crate::SyncBucketKey::User,
4864 crate::SyncBucketState { seq: 9, date: 9 },
4865 )
4866 .await
4867 .unwrap();
4868 let backend = SdkBackend::builder()
4869 .api_base_url(format!("http://{api_addr}/v1"))
4870 .store(store.clone())
4871 .build()
4872 .unwrap();
4873
4874 backend.connect(connect_request()).await.unwrap();
4875
4876 assert!(store.dialog(InlineId::new(9)).await.unwrap().is_none());
4877 assert_eq!(
4878 store
4879 .sync_bucket_state(crate::SyncBucketKey::User)
4880 .await
4881 .unwrap(),
4882 crate::SyncBucketState::default()
4883 );
4884 assert_eq!(
4885 store
4886 .load_session()
4887 .await
4888 .unwrap()
4889 .unwrap()
4890 .account_namespace
4891 .as_deref(),
4892 Some("team")
4893 );
4894
4895 store
4896 .record_dialog(DialogRecord::new(InlineId::new(10)))
4897 .await
4898 .unwrap();
4899 backend.logout().await.unwrap();
4900 logout_server.await.unwrap();
4901 assert!(store.load_session().await.unwrap().is_none());
4902 assert!(store.dialog(InlineId::new(10)).await.unwrap().is_none());
4903 }
4904
4905 #[tokio::test]
4906 async fn sdk_backend_reuses_realtime_connection_for_multiple_rpc_calls() {
4907 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4908 let addr = listener.local_addr().unwrap();
4909 let server = tokio::spawn(async move {
4910 let (stream, _) = listener.accept().await.unwrap();
4911 let mut ws = accept_async(stream).await.unwrap();
4912
4913 let init = read_test_client_message(&mut ws).await;
4914 assert!(matches!(
4915 init.body,
4916 Some(proto::client_message::Body::ConnectionInit(_))
4917 ));
4918 send_test_server_message(
4919 &mut ws,
4920 proto::ServerProtocolMessage {
4921 id: 1,
4922 body: Some(proto::server_protocol_message::Body::ConnectionOpen(
4923 proto::ConnectionOpen {},
4924 )),
4925 },
4926 )
4927 .await;
4928
4929 let get_chats = read_test_client_message(&mut ws).await;
4930 match &get_chats.body {
4931 Some(proto::client_message::Body::RpcCall(call)) => {
4932 assert_eq!(call.method, proto::Method::GetChats as i32);
4933 }
4934 other => panic!("expected getChats RPC, got {other:?}"),
4935 }
4936 send_test_server_message(
4937 &mut ws,
4938 proto::ServerProtocolMessage {
4939 id: 2,
4940 body: Some(proto::server_protocol_message::Body::RpcResult(
4941 proto::RpcResult {
4942 req_msg_id: get_chats.id,
4943 result: Some(proto::rpc_result::Result::GetChats(
4944 proto::GetChatsResult::default(),
4945 )),
4946 },
4947 )),
4948 },
4949 )
4950 .await;
4951
4952 let history = read_test_client_message(&mut ws).await;
4953 match &history.body {
4954 Some(proto::client_message::Body::RpcCall(call)) => {
4955 assert_eq!(call.method, proto::Method::GetChatHistory as i32);
4956 }
4957 other => panic!("expected getChatHistory RPC, got {other:?}"),
4958 }
4959 send_test_server_message(
4960 &mut ws,
4961 proto::ServerProtocolMessage {
4962 id: 3,
4963 body: Some(proto::server_protocol_message::Body::RpcResult(
4964 proto::RpcResult {
4965 req_msg_id: history.id,
4966 result: Some(proto::rpc_result::Result::GetChatHistory(
4967 proto::GetChatHistoryResult::default(),
4968 )),
4969 },
4970 )),
4971 },
4972 )
4973 .await;
4974 });
4975
4976 let store = InMemoryStore::new();
4977 store.save_session(connect_session()).await.unwrap();
4978 let backend = SdkBackend::builder()
4979 .store(store)
4980 .realtime_url(format!("ws://{addr}/realtime"))
4981 .without_realtime_handshake()
4982 .build()
4983 .unwrap();
4984
4985 let dialogs = backend.dialogs(DialogsRequest::default()).await.unwrap();
4986 assert!(dialogs.dialogs.is_empty());
4987 let history = tokio::time::timeout(
4988 std::time::Duration::from_secs(3),
4989 backend.history(HistoryRequest {
4990 chat_id: InlineId::new(7),
4991 limit: Some(10),
4992 before_message_id: None,
4993 after_message_id: None,
4994 }),
4995 )
4996 .await
4997 .expect("history should use the existing websocket")
4998 .unwrap();
4999 assert!(history.messages.is_empty());
5000 tokio::time::timeout(std::time::Duration::from_secs(3), server)
5001 .await
5002 .expect("server should observe both RPCs on one websocket")
5003 .unwrap();
5004 }
5005
5006 #[tokio::test]
5007 async fn inline_client_runner_allows_concurrent_rpcs_on_one_realtime_session() {
5008 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5009 let addr = listener.local_addr().unwrap();
5010 let server = tokio::spawn(async move {
5011 let (stream, _) = listener.accept().await.unwrap();
5012 let mut ws = accept_async(stream).await.unwrap();
5013 let _ = read_test_client_message(&mut ws).await;
5014 send_test_server_message(
5015 &mut ws,
5016 proto::ServerProtocolMessage {
5017 id: 1,
5018 body: Some(proto::server_protocol_message::Body::ConnectionOpen(
5019 proto::ConnectionOpen {},
5020 )),
5021 },
5022 )
5023 .await;
5024
5025 let first = read_test_client_message(&mut ws).await;
5026 let second = tokio::time::timeout(
5027 std::time::Duration::from_secs(1),
5028 read_test_client_message(&mut ws),
5029 )
5030 .await
5031 .expect("runner should issue the second RPC before the first completes");
5032 for (id, request) in [(2, first), (3, second)] {
5033 send_test_server_message(
5034 &mut ws,
5035 rpc_result_message(
5036 id,
5037 request.id,
5038 proto::rpc_result::Result::GetChats(proto::GetChatsResult::default()),
5039 ),
5040 )
5041 .await;
5042 }
5043 });
5044
5045 let store = InMemoryStore::new();
5046 store.save_session(connect_session()).await.unwrap();
5047 let backend = SdkBackend::builder()
5048 .store(store)
5049 .realtime_url(format!("ws://{addr}/realtime"))
5050 .without_realtime_handshake()
5051 .build()
5052 .unwrap();
5053 let client = InlineClient::builder()
5054 .backend(backend)
5055 .initial_status(ClientStatus::Connected)
5056 .build()
5057 .spawn();
5058
5059 let (first, second) = tokio::join!(
5060 client.dialogs(DialogsRequest::default()),
5061 client.dialogs(DialogsRequest::default())
5062 );
5063 assert!(first.unwrap().dialogs.is_empty());
5064 assert!(second.unwrap().dialogs.is_empty());
5065 server.await.unwrap();
5066 }
5067
5068 #[tokio::test]
5069 async fn inline_client_runner_bounds_in_flight_rpcs() {
5070 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5071 let addr = listener.local_addr().unwrap();
5072 let server = tokio::spawn(async move {
5073 let (stream, _) = listener.accept().await.unwrap();
5074 let mut ws = accept_async(stream).await.unwrap();
5075 let _ = read_test_client_message(&mut ws).await;
5076 send_test_server_message(
5077 &mut ws,
5078 proto::ServerProtocolMessage {
5079 id: 1,
5080 body: Some(proto::server_protocol_message::Body::ConnectionOpen(
5081 proto::ConnectionOpen {},
5082 )),
5083 },
5084 )
5085 .await;
5086
5087 let first = read_test_client_message(&mut ws).await;
5088 assert!(
5089 tokio::time::timeout(
5090 std::time::Duration::from_millis(50),
5091 read_test_client_message(&mut ws),
5092 )
5093 .await
5094 .is_err(),
5095 "runner issued a second RPC above its configured in-flight limit"
5096 );
5097 send_test_server_message(
5098 &mut ws,
5099 rpc_result_message(
5100 2,
5101 first.id,
5102 proto::rpc_result::Result::GetChats(proto::GetChatsResult::default()),
5103 ),
5104 )
5105 .await;
5106
5107 let second = read_test_client_message(&mut ws).await;
5108 send_test_server_message(
5109 &mut ws,
5110 rpc_result_message(
5111 3,
5112 second.id,
5113 proto::rpc_result::Result::GetChats(proto::GetChatsResult::default()),
5114 ),
5115 )
5116 .await;
5117 });
5118
5119 let store = InMemoryStore::new();
5120 store.save_session(connect_session()).await.unwrap();
5121 let backend = SdkBackend::builder()
5122 .store(store)
5123 .realtime_url(format!("ws://{addr}/realtime"))
5124 .without_realtime_handshake()
5125 .build()
5126 .unwrap();
5127 let client = InlineClient::builder()
5128 .backend(backend)
5129 .initial_status(ClientStatus::Connected)
5130 .max_concurrent_requests(1)
5131 .build()
5132 .spawn();
5133
5134 let (first, second) = tokio::join!(
5135 client.dialogs(DialogsRequest::default()),
5136 client.dialogs(DialogsRequest::default())
5137 );
5138 assert!(first.unwrap().dialogs.is_empty());
5139 assert!(second.unwrap().dialogs.is_empty());
5140 server.await.unwrap();
5141 }
5142
5143 #[tokio::test]
5144 async fn chat_management_rpcs_use_one_session_and_commit_durable_state() {
5145 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5146 let addr = listener.local_addr().unwrap();
5147 let server = tokio::spawn(async move {
5148 let (stream, _) = listener.accept().await.unwrap();
5149 let mut ws = accept_async(stream).await.unwrap();
5150
5151 let init = read_test_client_message(&mut ws).await;
5152 assert!(matches!(
5153 init.body,
5154 Some(proto::client_message::Body::ConnectionInit(_))
5155 ));
5156 send_test_server_message(
5157 &mut ws,
5158 proto::ServerProtocolMessage {
5159 id: 1,
5160 body: Some(proto::server_protocol_message::Body::ConnectionOpen(
5161 proto::ConnectionOpen {},
5162 )),
5163 },
5164 )
5165 .await;
5166
5167 let add = read_test_client_message(&mut ws).await;
5168 match &add.body {
5169 Some(proto::client_message::Body::RpcCall(call)) => match &call.input {
5170 Some(proto::rpc_call::Input::AddChatParticipant(input)) => {
5171 assert_eq!((input.chat_id, input.user_id), (7, 42));
5172 }
5173 other => panic!("expected add participant input, got {other:?}"),
5174 },
5175 other => panic!("expected add participant RPC, got {other:?}"),
5176 }
5177 send_test_server_message(
5178 &mut ws,
5179 rpc_result_message(
5180 2,
5181 add.id,
5182 proto::rpc_result::Result::AddChatParticipant(
5183 proto::AddChatParticipantResult {
5184 participant: Some(proto::ChatParticipant {
5185 user_id: 42,
5186 date: 123,
5187 }),
5188 },
5189 ),
5190 ),
5191 )
5192 .await;
5193
5194 let remove = read_test_client_message(&mut ws).await;
5195 match &remove.body {
5196 Some(proto::client_message::Body::RpcCall(call)) => match &call.input {
5197 Some(proto::rpc_call::Input::RemoveChatParticipant(input)) => {
5198 assert_eq!((input.chat_id, input.user_id), (7, 42));
5199 }
5200 other => panic!("expected remove participant input, got {other:?}"),
5201 },
5202 other => panic!("expected remove participant RPC, got {other:?}"),
5203 }
5204 send_test_server_message(
5205 &mut ws,
5206 rpc_result_message(
5207 3,
5208 remove.id,
5209 proto::rpc_result::Result::RemoveChatParticipant(
5210 proto::RemoveChatParticipantResult {},
5211 ),
5212 ),
5213 )
5214 .await;
5215
5216 let update = read_test_client_message(&mut ws).await;
5217 match &update.body {
5218 Some(proto::client_message::Body::RpcCall(call)) => match &call.input {
5219 Some(proto::rpc_call::Input::UpdateChatInfo(input)) => {
5220 assert_eq!(input.chat_id, 7);
5221 assert_eq!(input.title.as_deref(), Some("Renamed"));
5222 assert_eq!(input.emoji.as_deref(), Some("✨"));
5223 }
5224 other => panic!("expected update chat info input, got {other:?}"),
5225 },
5226 other => panic!("expected update chat info RPC, got {other:?}"),
5227 }
5228 send_test_server_message(
5229 &mut ws,
5230 rpc_result_message(
5231 4,
5232 update.id,
5233 proto::rpc_result::Result::UpdateChatInfo(proto::UpdateChatInfoResult {
5234 chat: Some(proto::Chat {
5235 id: 7,
5236 title: "Renamed".to_owned(),
5237 emoji: Some("✨".to_owned()),
5238 ..Default::default()
5239 }),
5240 }),
5241 ),
5242 )
5243 .await;
5244
5245 let mark = read_test_client_message(&mut ws).await;
5246 match &mark.body {
5247 Some(proto::client_message::Body::RpcCall(call)) => match &call.input {
5248 Some(proto::rpc_call::Input::MarkAsUnread(input)) => {
5249 assert_eq!(
5250 test_chat_id_from_input_peer(input.peer_id.as_ref()),
5251 Some(7)
5252 );
5253 }
5254 other => panic!("expected mark unread input, got {other:?}"),
5255 },
5256 other => panic!("expected mark unread RPC, got {other:?}"),
5257 }
5258 send_test_server_message(
5259 &mut ws,
5260 rpc_result_message(
5261 5,
5262 mark.id,
5263 proto::rpc_result::Result::MarkAsUnread(proto::MarkAsUnreadResult::default()),
5264 ),
5265 )
5266 .await;
5267
5268 let clear_mark = read_test_client_message(&mut ws).await;
5269 match &clear_mark.body {
5270 Some(proto::client_message::Body::RpcCall(call)) => match &call.input {
5271 Some(proto::rpc_call::Input::ReadMessages(input)) => {
5272 assert_eq!(
5273 test_chat_id_from_input_peer(input.peer_id.as_ref()),
5274 Some(7)
5275 );
5276 assert_eq!(input.max_id, None);
5277 }
5278 other => panic!("expected read messages input, got {other:?}"),
5279 },
5280 other => panic!("expected read messages RPC, got {other:?}"),
5281 }
5282 send_test_server_message(
5283 &mut ws,
5284 rpc_result_message(
5285 7,
5286 clear_mark.id,
5287 proto::rpc_result::Result::ReadMessages(proto::ReadMessagesResult::default()),
5288 ),
5289 )
5290 .await;
5291
5292 let notifications = read_test_client_message(&mut ws).await;
5293 match ¬ifications.body {
5294 Some(proto::client_message::Body::RpcCall(call)) => match &call.input {
5295 Some(proto::rpc_call::Input::UpdateDialogNotificationSettings(input)) => {
5296 assert_eq!(
5297 test_chat_id_from_input_peer(input.peer_id.as_ref()),
5298 Some(7)
5299 );
5300 assert_eq!(
5301 input
5302 .notification_settings
5303 .as_ref()
5304 .and_then(|settings| settings.mode),
5305 Some(proto::dialog_notification_settings::Mode::None as i32)
5306 );
5307 }
5308 other => panic!("expected notification settings input, got {other:?}"),
5309 },
5310 other => panic!("expected notification settings RPC, got {other:?}"),
5311 }
5312 send_test_server_message(
5313 &mut ws,
5314 rpc_result_message(
5315 7,
5316 notifications.id,
5317 proto::rpc_result::Result::UpdateDialogNotificationSettings(
5318 proto::UpdateDialogNotificationSettingsResult::default(),
5319 ),
5320 ),
5321 )
5322 .await;
5323
5324 let delete = read_test_client_message(&mut ws).await;
5325 match &delete.body {
5326 Some(proto::client_message::Body::RpcCall(call)) => match &call.input {
5327 Some(proto::rpc_call::Input::DeleteChat(input)) => {
5328 assert_eq!(
5329 test_chat_id_from_input_peer(input.peer_id.as_ref()),
5330 Some(7)
5331 );
5332 }
5333 other => panic!("expected delete chat input, got {other:?}"),
5334 },
5335 other => panic!("expected delete chat RPC, got {other:?}"),
5336 }
5337 send_test_server_message(
5338 &mut ws,
5339 rpc_result_message(
5340 8,
5341 delete.id,
5342 proto::rpc_result::Result::DeleteChat(proto::DeleteChatResult {}),
5343 ),
5344 )
5345 .await;
5346 });
5347
5348 let store = InMemoryStore::new();
5349 store.save_session(connect_session()).await.unwrap();
5350 store
5351 .record_dialog(DialogRecord::new(InlineId::new(7)))
5352 .await
5353 .unwrap();
5354 let backend = SdkBackend::builder()
5355 .store(store.clone())
5356 .realtime_url(format!("ws://{addr}/realtime"))
5357 .without_realtime_handshake()
5358 .build()
5359 .unwrap();
5360
5361 backend
5362 .add_chat_participant(AddChatParticipantRequest {
5363 chat_id: InlineId::new(7),
5364 user_id: InlineId::new(42),
5365 })
5366 .await
5367 .unwrap();
5368 assert_eq!(
5369 store
5370 .chat_participants(InlineId::new(7))
5371 .await
5372 .unwrap()
5373 .len(),
5374 1
5375 );
5376 backend
5377 .remove_chat_participant(RemoveChatParticipantRequest {
5378 chat_id: InlineId::new(7),
5379 user_id: InlineId::new(42),
5380 })
5381 .await
5382 .unwrap();
5383 assert!(
5384 store
5385 .chat_participants(InlineId::new(7))
5386 .await
5387 .unwrap()
5388 .is_empty()
5389 );
5390 backend
5391 .update_chat_info(UpdateChatInfoRequest {
5392 chat_id: InlineId::new(7),
5393 title: Some(" Renamed ".to_owned()),
5394 emoji: Some(" ✨ ".to_owned()),
5395 })
5396 .await
5397 .unwrap();
5398 assert_eq!(
5399 store
5400 .dialog(InlineId::new(7))
5401 .await
5402 .unwrap()
5403 .unwrap()
5404 .title
5405 .as_deref(),
5406 Some("Renamed")
5407 );
5408 assert_eq!(
5409 store
5410 .dialog(InlineId::new(7))
5411 .await
5412 .unwrap()
5413 .unwrap()
5414 .emoji
5415 .as_deref(),
5416 Some("✨")
5417 );
5418 backend
5419 .set_marked_unread(SetMarkedUnreadRequest {
5420 chat_id: InlineId::new(7),
5421 unread: true,
5422 })
5423 .await
5424 .unwrap();
5425 backend
5426 .set_marked_unread(SetMarkedUnreadRequest {
5427 chat_id: InlineId::new(7),
5428 unread: false,
5429 })
5430 .await
5431 .unwrap();
5432 backend
5433 .update_dialog_notifications(UpdateDialogNotificationsRequest {
5434 chat_id: InlineId::new(7),
5435 mode: Some(DialogNotificationMode::None),
5436 })
5437 .await
5438 .unwrap();
5439 assert_eq!(
5440 store
5441 .dialog(InlineId::new(7))
5442 .await
5443 .unwrap()
5444 .unwrap()
5445 .notification_mode,
5446 Some(DialogNotificationMode::None)
5447 );
5448 backend
5449 .delete_chat(DeleteChatRequest {
5450 chat_id: InlineId::new(7),
5451 })
5452 .await
5453 .unwrap();
5454 assert_eq!(
5455 store.deleted_chat_ids().await.unwrap(),
5456 vec![InlineId::new(7)]
5457 );
5458 server.await.unwrap();
5459 }
5460
5461 #[tokio::test]
5462 async fn sdk_backend_recovers_hint_only_updates_on_the_multiplexed_connection() {
5463 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5464 let addr = listener.local_addr().unwrap();
5465 let server = tokio::spawn(async move {
5466 let (stream, _) = listener.accept().await.unwrap();
5467 let mut ws = accept_async(stream).await.unwrap();
5468 let _ = read_test_client_message(&mut ws).await;
5469 send_test_server_message(
5470 &mut ws,
5471 proto::ServerProtocolMessage {
5472 id: 1,
5473 body: Some(proto::server_protocol_message::Body::ConnectionOpen(
5474 proto::ConnectionOpen {},
5475 )),
5476 },
5477 )
5478 .await;
5479
5480 let state = read_test_client_message(&mut ws).await;
5481 send_test_server_message(
5482 &mut ws,
5483 rpc_result_message(
5484 2,
5485 state.id,
5486 proto::rpc_result::Result::GetUpdatesState(proto::GetUpdatesStateResult {
5487 date: 100,
5488 updates_found: Some(true),
5489 }),
5490 ),
5491 )
5492 .await;
5493
5494 let user_bucket = read_test_client_message(&mut ws).await;
5495 send_test_server_message(
5496 &mut ws,
5497 rpc_result_message(
5498 3,
5499 user_bucket.id,
5500 proto::rpc_result::Result::GetUpdates(proto::GetUpdatesResult {
5501 updates: Vec::new(),
5502 seq: 1,
5503 date: 100,
5504 r#final: Some(true),
5505 result_type: proto::get_updates_result::ResultType::Empty as i32,
5506 sidecars: None,
5507 }),
5508 ),
5509 )
5510 .await;
5511
5512 let peer = proto::Peer {
5513 r#type: Some(proto::peer::Type::Chat(proto::PeerChat { chat_id: 7 })),
5514 };
5515 send_test_server_message(
5516 &mut ws,
5517 proto::ServerProtocolMessage {
5518 id: 10,
5519 body: Some(proto::server_protocol_message::Body::Message(
5520 proto::ServerMessage {
5521 payload: Some(proto::server_message::Payload::Update(
5522 proto::UpdatesPayload {
5523 updates: vec![proto::Update {
5524 seq: None,
5525 date: None,
5526 update: Some(proto::update::Update::ChatHasNewUpdates(
5527 proto::UpdateChatHasNewUpdates {
5528 chat_id: 7,
5529 update_seq: 2,
5530 peer_id: Some(peer.clone()),
5531 },
5532 )),
5533 }],
5534 },
5535 )),
5536 },
5537 )),
5538 },
5539 )
5540 .await;
5541 let ack = read_test_client_message(&mut ws).await;
5542 assert!(matches!(
5543 ack.body,
5544 Some(proto::client_message::Body::Ack(proto::Ack { msg_id: 10 }))
5545 ));
5546
5547 let chat_bucket = read_test_client_message(&mut ws).await;
5548 send_test_server_message(
5549 &mut ws,
5550 rpc_result_message(
5551 4,
5552 chat_bucket.id,
5553 proto::rpc_result::Result::GetUpdates(proto::GetUpdatesResult {
5554 updates: vec![proto::Update {
5555 seq: Some(2),
5556 date: Some(101),
5557 update: Some(proto::update::Update::NewMessage(
5558 proto::UpdateNewMessage {
5559 message: Some(proto::Message {
5560 id: 11,
5561 from_id: 2,
5562 peer_id: Some(peer),
5563 chat_id: 7,
5564 message: Some("recovered".to_owned()),
5565 date: 101,
5566 ..Default::default()
5567 }),
5568 },
5569 )),
5570 }],
5571 seq: 2,
5572 date: 101,
5573 r#final: Some(true),
5574 result_type: proto::get_updates_result::ResultType::Slice as i32,
5575 sidecars: None,
5576 }),
5577 ),
5578 )
5579 .await;
5580 });
5581
5582 let store = InMemoryStore::new();
5583 store.save_session(connect_session()).await.unwrap();
5584 store
5585 .save_sync_bucket_state(
5586 crate::SyncBucketKey::User,
5587 crate::SyncBucketState { seq: 1, date: 100 },
5588 )
5589 .await
5590 .unwrap();
5591 store
5592 .save_sync_bucket_state(
5593 crate::SyncBucketKey::Chat {
5594 peer: crate::SyncBucketPeer::Chat {
5595 chat_id: InlineId::new(7),
5596 },
5597 },
5598 crate::SyncBucketState { seq: 1, date: 100 },
5599 )
5600 .await
5601 .unwrap();
5602 let backend = SdkBackend::builder()
5603 .store(store.clone())
5604 .realtime_url(format!("ws://{addr}/realtime"))
5605 .without_realtime_handshake()
5606 .build()
5607 .unwrap();
5608
5609 let events =
5610 tokio::time::timeout(std::time::Duration::from_secs(3), backend.receive_events())
5611 .await
5612 .unwrap()
5613 .unwrap();
5614
5615 assert!(events.iter().any(|event| matches!(
5616 event,
5617 ClientEvent::MessageStored { message }
5618 if message.chat_id == InlineId::new(7)
5619 && message.message_id == InlineId::new(11)
5620 )));
5621 assert_eq!(
5622 store
5623 .sync_bucket_state(crate::SyncBucketKey::Chat {
5624 peer: crate::SyncBucketPeer::Chat {
5625 chat_id: InlineId::new(7),
5626 },
5627 })
5628 .await
5629 .unwrap(),
5630 crate::SyncBucketState { seq: 2, date: 101 }
5631 );
5632 server.await.unwrap();
5633 }
5634
5635 #[tokio::test]
5636 async fn sdk_backend_repairs_cold_chat_bucket_after_too_long() {
5637 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5638 let addr = listener.local_addr().unwrap();
5639 let server = tokio::spawn(async move {
5640 let (stream, _) = listener.accept().await.unwrap();
5641 let mut ws = accept_async(stream).await.unwrap();
5642 let _ = read_test_client_message(&mut ws).await;
5643 send_test_server_message(
5644 &mut ws,
5645 proto::ServerProtocolMessage {
5646 id: 1,
5647 body: Some(proto::server_protocol_message::Body::ConnectionOpen(
5648 proto::ConnectionOpen {},
5649 )),
5650 },
5651 )
5652 .await;
5653
5654 let state = read_test_client_message(&mut ws).await;
5655 send_test_server_message(
5656 &mut ws,
5657 rpc_result_message(
5658 2,
5659 state.id,
5660 proto::rpc_result::Result::GetUpdatesState(proto::GetUpdatesStateResult {
5661 date: 100,
5662 updates_found: Some(true),
5663 }),
5664 ),
5665 )
5666 .await;
5667 let user_bucket = read_test_client_message(&mut ws).await;
5668 send_test_server_message(
5669 &mut ws,
5670 rpc_result_message(
5671 3,
5672 user_bucket.id,
5673 proto::rpc_result::Result::GetUpdates(proto::GetUpdatesResult {
5674 updates: Vec::new(),
5675 seq: 1,
5676 date: 100,
5677 r#final: Some(true),
5678 result_type: proto::get_updates_result::ResultType::Empty as i32,
5679 sidecars: None,
5680 }),
5681 ),
5682 )
5683 .await;
5684
5685 let peer = proto::Peer {
5686 r#type: Some(proto::peer::Type::Chat(proto::PeerChat { chat_id: 7 })),
5687 };
5688 send_test_server_message(
5689 &mut ws,
5690 proto::ServerProtocolMessage {
5691 id: 10,
5692 body: Some(proto::server_protocol_message::Body::Message(
5693 proto::ServerMessage {
5694 payload: Some(proto::server_message::Payload::Update(
5695 proto::UpdatesPayload {
5696 updates: vec![proto::Update {
5697 seq: None,
5698 date: None,
5699 update: Some(proto::update::Update::ChatHasNewUpdates(
5700 proto::UpdateChatHasNewUpdates {
5701 chat_id: 7,
5702 update_seq: 500,
5703 peer_id: Some(peer.clone()),
5704 },
5705 )),
5706 }],
5707 },
5708 )),
5709 },
5710 )),
5711 },
5712 )
5713 .await;
5714 let _ack = read_test_client_message(&mut ws).await;
5715
5716 let bucket = read_test_client_message(&mut ws).await;
5717 send_test_server_message(
5718 &mut ws,
5719 rpc_result_message(
5720 4,
5721 bucket.id,
5722 proto::rpc_result::Result::GetUpdates(proto::GetUpdatesResult {
5723 updates: Vec::new(),
5724 seq: 400,
5725 date: 101,
5726 r#final: Some(false),
5727 result_type: proto::get_updates_result::ResultType::TooLong as i32,
5728 sidecars: None,
5729 }),
5730 ),
5731 )
5732 .await;
5733
5734 let get_chat = read_test_client_message(&mut ws).await;
5735 assert!(matches!(
5736 &get_chat.body,
5737 Some(proto::client_message::Body::RpcCall(proto::RpcCall {
5738 method,
5739 input: Some(proto::rpc_call::Input::GetChat(_)),
5740 })) if *method == proto::Method::GetChat as i32
5741 ));
5742 send_test_server_message(
5743 &mut ws,
5744 rpc_result_message(
5745 5,
5746 get_chat.id,
5747 proto::rpc_result::Result::GetChat(proto::GetChatResult {
5748 chat: Some(proto::Chat {
5749 id: 7,
5750 title: "Repaired".to_owned(),
5751 peer_id: Some(peer.clone()),
5752 ..Default::default()
5753 }),
5754 dialog: Some(proto::Dialog {
5755 chat_id: Some(7),
5756 peer: Some(peer.clone()),
5757 ..Default::default()
5758 }),
5759 pinned_message_ids: vec![11],
5760 anchor_message: None,
5761 }),
5762 ),
5763 )
5764 .await;
5765
5766 let participants = read_test_client_message(&mut ws).await;
5767 assert!(matches!(
5768 &participants.body,
5769 Some(proto::client_message::Body::RpcCall(proto::RpcCall {
5770 method,
5771 input: Some(proto::rpc_call::Input::GetChatParticipants(_)),
5772 })) if *method == proto::Method::GetChatParticipants as i32
5773 ));
5774 send_test_server_message(
5775 &mut ws,
5776 rpc_result_message(
5777 6,
5778 participants.id,
5779 proto::rpc_result::Result::GetChatParticipants(
5780 proto::GetChatParticipantsResult::default(),
5781 ),
5782 ),
5783 )
5784 .await;
5785
5786 let history = read_test_client_message(&mut ws).await;
5787 assert!(matches!(
5788 &history.body,
5789 Some(proto::client_message::Body::RpcCall(proto::RpcCall {
5790 method,
5791 input: Some(proto::rpc_call::Input::GetChatHistory(_)),
5792 })) if *method == proto::Method::GetChatHistory as i32
5793 ));
5794 send_test_server_message(
5795 &mut ws,
5796 rpc_result_message(
5797 6,
5798 history.id,
5799 proto::rpc_result::Result::GetChatHistory(proto::GetChatHistoryResult {
5800 messages: vec![proto::Message {
5801 id: 11,
5802 chat_id: 7,
5803 peer_id: Some(peer),
5804 message: Some("snapshot".to_owned()),
5805 date: 101,
5806 ..Default::default()
5807 }],
5808 }),
5809 ),
5810 )
5811 .await;
5812 });
5813
5814 let store = InMemoryStore::new();
5815 store.save_session(connect_session()).await.unwrap();
5816 store
5817 .save_sync_bucket_state(
5818 crate::SyncBucketKey::User,
5819 crate::SyncBucketState { seq: 1, date: 100 },
5820 )
5821 .await
5822 .unwrap();
5823 let backend = SdkBackend::builder()
5824 .store(store.clone())
5825 .realtime_url(format!("ws://{addr}/realtime"))
5826 .without_realtime_handshake()
5827 .build()
5828 .unwrap();
5829
5830 let events =
5831 tokio::time::timeout(std::time::Duration::from_secs(3), backend.receive_events())
5832 .await
5833 .unwrap()
5834 .unwrap();
5835
5836 assert!(events.iter().any(|event| matches!(
5837 event,
5838 ClientEvent::MessageStored { message } if message.message_id == InlineId::new(11)
5839 )));
5840 let dialog = store.dialog(InlineId::new(7)).await.unwrap().unwrap();
5841 assert_eq!(dialog.title.as_deref(), Some("Repaired"));
5842 assert_eq!(dialog.pinned_message_ids, vec![InlineId::new(11)]);
5843 assert_eq!(
5844 store
5845 .sync_bucket_state(crate::SyncBucketKey::Chat {
5846 peer: crate::SyncBucketPeer::Chat {
5847 chat_id: InlineId::new(7),
5848 },
5849 })
5850 .await
5851 .unwrap(),
5852 crate::SyncBucketState {
5853 seq: 500,
5854 date: 101
5855 }
5856 );
5857 server.await.unwrap();
5858 }
5859
5860 #[tokio::test]
5861 async fn sdk_backend_user_snapshot_rebuilds_authoritative_account_state() {
5862 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5863 let addr = listener.local_addr().unwrap();
5864 let server = tokio::spawn(async move {
5865 let (stream, _) = listener.accept().await.unwrap();
5866 let mut ws = accept_async(stream).await.unwrap();
5867 let init = read_test_client_message(&mut ws).await;
5868 assert!(matches!(
5869 init.body,
5870 Some(proto::client_message::Body::ConnectionInit(_))
5871 ));
5872 send_test_server_message(
5873 &mut ws,
5874 proto::ServerProtocolMessage {
5875 id: 1,
5876 body: Some(proto::server_protocol_message::Body::ConnectionOpen(
5877 proto::ConnectionOpen {},
5878 )),
5879 },
5880 )
5881 .await;
5882
5883 let chats = read_test_client_message(&mut ws).await;
5884 send_test_server_message(
5885 &mut ws,
5886 rpc_result_message(
5887 2,
5888 chats.id,
5889 proto::rpc_result::Result::GetChats(proto::GetChatsResult::default()),
5890 ),
5891 )
5892 .await;
5893 let me = read_test_client_message(&mut ws).await;
5894 send_test_server_message(
5895 &mut ws,
5896 rpc_result_message(
5897 3,
5898 me.id,
5899 proto::rpc_result::Result::GetMe(proto::GetMeResult {
5900 user: Some(proto::User {
5901 id: 42,
5902 first_name: Some("Ada".to_owned()),
5903 ..Default::default()
5904 }),
5905 }),
5906 ),
5907 )
5908 .await;
5909 let settings = read_test_client_message(&mut ws).await;
5910 send_test_server_message(
5911 &mut ws,
5912 rpc_result_message(
5913 4,
5914 settings.id,
5915 proto::rpc_result::Result::GetUserSettings(proto::GetUserSettingsResult {
5916 user_settings: Some(proto::UserSettings::default()),
5917 }),
5918 ),
5919 )
5920 .await;
5921 });
5922
5923 let store = InMemoryStore::new();
5924 store.save_session(connect_session()).await.unwrap();
5925 store
5926 .record_dialog(DialogRecord::new(InlineId::new(7)))
5927 .await
5928 .unwrap();
5929 let backend = SdkBackend::builder()
5930 .store(store.clone())
5931 .realtime_url(format!("ws://{addr}/realtime"))
5932 .without_realtime_handshake()
5933 .build()
5934 .unwrap();
5935
5936 let events = backend.repair_user_bucket().await.unwrap();
5937
5938 assert!(events.iter().any(|event| matches!(
5939 event,
5940 ClientEvent::ChatDeleted { chat_id } if *chat_id == InlineId::new(7)
5941 )));
5942 assert!(events.iter().any(|event| matches!(
5943 event,
5944 ClientEvent::UserUpserted { user_id } if *user_id == InlineId::new(42)
5945 )));
5946 assert!(
5947 events
5948 .iter()
5949 .any(|event| matches!(event, ClientEvent::UserSettingsChanged {}))
5950 );
5951 assert_eq!(
5952 store.deleted_chat_ids().await.unwrap(),
5953 vec![InlineId::new(7)]
5954 );
5955 assert!(store.user_settings().await.unwrap().is_some());
5956 server.await.unwrap();
5957 }
5958
5959 #[test]
5960 fn dialogs_page_from_get_chats_uses_user_records() {
5961 let result = proto::GetChatsResult {
5962 dialogs: vec![proto::Dialog {
5963 peer: Some(proto::Peer {
5964 r#type: Some(proto::peer::Type::User(proto::PeerUser { user_id: 42 })),
5965 }),
5966 chat_id: Some(7),
5967 unread_count: Some(3),
5968 ..Default::default()
5969 }],
5970 chats: vec![proto::Chat {
5971 id: 7,
5972 title: "Direct chat fallback".to_owned(),
5973 last_msg_id: Some(99),
5974 emoji: Some("*".to_owned()),
5975 peer_id: Some(proto::Peer {
5976 r#type: Some(proto::peer::Type::User(proto::PeerUser { user_id: 42 })),
5977 }),
5978 ..Default::default()
5979 }],
5980 users: vec![proto::User {
5981 id: 42,
5982 first_name: Some("Ada".to_owned()),
5983 last_name: Some("Lovelace".to_owned()),
5984 username: Some("ada".to_owned()),
5985 profile_photo: Some(proto::UserProfilePhoto {
5986 cdn_url: Some("https://cdn.inline.test/ada.jpg".to_owned()),
5987 ..Default::default()
5988 }),
5989 bot: Some(false),
5990 ..Default::default()
5991 }],
5992 ..Default::default()
5993 };
5994
5995 let page = dialogs_page_from_get_chats(
5996 &result,
5997 DialogsRequest {
5998 limit: Some(10),
5999 cursor: None,
6000 },
6001 )
6002 .unwrap();
6003
6004 assert_eq!(page.dialogs.len(), 1);
6005 assert_eq!(page.dialogs[0].chat_id, InlineId::new(7));
6006 assert_eq!(page.dialogs[0].title.as_deref(), Some("Ada Lovelace"));
6007 assert_eq!(page.dialogs[0].emoji.as_deref(), Some("*"));
6008 assert_eq!(page.dialogs[0].peer_user_id, Some(InlineId::new(42)));
6009 assert_eq!(page.dialogs[0].last_message_id, Some(InlineId::new(99)));
6010 assert_eq!(page.dialogs[0].unread_count, Some(3));
6011 assert_eq!(page.users.len(), 1);
6012 assert_eq!(page.users[0].user_id, InlineId::new(42));
6013 assert_eq!(page.users[0].display_name.as_deref(), Some("Ada Lovelace"));
6014 assert_eq!(
6015 page.users[0].avatar_url.as_deref(),
6016 Some("https://cdn.inline.test/ada.jpg")
6017 );
6018 assert_eq!(page.users[0].is_bot, Some(false));
6019 }
6020
6021 #[test]
6022 fn chat_participants_page_uses_direct_participants() {
6023 let page = chat_participants_page_from_proto(proto::GetChatParticipantsResult {
6024 participants: vec![proto::ChatParticipant {
6025 user_id: 10,
6026 date: 100,
6027 }],
6028 users: vec![proto::User {
6029 id: 10,
6030 first_name: Some("Ada".to_owned()),
6031 ..Default::default()
6032 }],
6033 });
6034
6035 assert_eq!(page.participants.len(), 1);
6036 assert_eq!(page.participants[0].user_id, InlineId::new(10));
6037 assert_eq!(page.participants[0].date, Some(100));
6038 assert_eq!(page.users.len(), 1);
6039 }
6040
6041 #[tokio::test]
6042 async fn sdk_backend_requires_session_for_history() {
6043 let backend = SdkBackend::builder().build().unwrap();
6044
6045 let err = backend
6046 .history(HistoryRequest {
6047 chat_id: InlineId::new(1),
6048 limit: Some(10),
6049 before_message_id: None,
6050 after_message_id: None,
6051 })
6052 .await
6053 .expect_err("history should require connect");
6054
6055 assert_eq!(err.category, ClientErrorCategory::AuthRequired);
6056 }
6057
6058 #[tokio::test]
6059 async fn sdk_backend_send_text_requires_session() {
6060 let backend = SdkBackend::builder().build().unwrap();
6061
6062 let err = backend
6063 .send_text(SendTextRequest::new(
6064 crate::PeerRef::Chat {
6065 chat_id: InlineId::new(1),
6066 },
6067 "hello",
6068 ))
6069 .await
6070 .expect_err("send_text should require connect");
6071
6072 assert_eq!(err.category, ClientErrorCategory::AuthRequired);
6073 }
6074
6075 #[tokio::test]
6076 async fn sdk_backend_send_text_rejects_empty_text_before_network() {
6077 let backend = SdkBackend::builder().build().unwrap();
6078
6079 let err = backend
6080 .send_text(SendTextRequest::new(
6081 crate::PeerRef::Chat {
6082 chat_id: InlineId::new(1),
6083 },
6084 " ",
6085 ))
6086 .await
6087 .expect_err("empty message should fail before auth or network");
6088
6089 assert_eq!(err.category, ClientErrorCategory::InvalidInput);
6090 }
6091
6092 #[tokio::test]
6093 async fn sdk_backend_send_text_returns_existing_transaction_without_network() {
6094 let store = InMemoryStore::new();
6095 let request = SendTextRequest {
6096 peer: crate::PeerRef::Chat {
6097 chat_id: InlineId::new(7),
6098 },
6099 text: "hello".to_owned(),
6100 external_id: Some(ExternalId::try_new("host-event", "event-1").unwrap()),
6101 random_id: Some(RandomId::new(99)),
6102 reply_to_message_id: None,
6103 };
6104 let transaction_id = transaction_id_for_send(&request, request.random_id.unwrap());
6105 let identity = TransactionIdentity::new(
6106 transaction_id.clone(),
6107 request.external_id.clone(),
6108 request.random_id.unwrap(),
6109 )
6110 .with_final_message_id(InlineId::new(11));
6111 store.save_session(connect_session()).await.unwrap();
6112 store
6113 .record_transaction(
6114 StoredTransaction::new(identity, TransactionState::Completed)
6115 .with_chat_id(InlineId::new(7))
6116 .with_message_id(InlineId::new(11)),
6117 )
6118 .await
6119 .unwrap();
6120 let backend = SdkBackend::builder().store(store).build().unwrap();
6121
6122 let outcome = backend.send_text(request).await.unwrap();
6123
6124 assert_eq!(outcome.message_id, Some(InlineId::new(11)));
6125 assert_eq!(outcome.state, TransactionState::Completed);
6126 assert_eq!(outcome.mutation.transaction.transaction_id, transaction_id);
6127 }
6128
6129 #[tokio::test]
6130 async fn sdk_backend_retries_uncertain_stored_send_with_same_random_id() {
6131 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6132 let addr = listener.local_addr().unwrap();
6133 let server = tokio::spawn(async move {
6134 let (stream, _) = listener.accept().await.unwrap();
6135 let mut ws = accept_async(stream).await.unwrap();
6136 let init = read_test_client_message(&mut ws).await;
6137 assert!(matches!(
6138 init.body,
6139 Some(proto::client_message::Body::ConnectionInit(_))
6140 ));
6141 send_test_server_message(
6142 &mut ws,
6143 proto::ServerProtocolMessage {
6144 id: 1,
6145 body: Some(proto::server_protocol_message::Body::ConnectionOpen(
6146 proto::ConnectionOpen {},
6147 )),
6148 },
6149 )
6150 .await;
6151
6152 let send = read_test_client_message(&mut ws).await;
6153 let input = match &send.body {
6154 Some(proto::client_message::Body::RpcCall(proto::RpcCall {
6155 method,
6156 input: Some(proto::rpc_call::Input::SendMessage(input)),
6157 })) if *method == proto::Method::SendMessage as i32 => input,
6158 other => panic!("expected sendMessage retry, got {other:?}"),
6159 };
6160 assert_eq!(input.random_id, Some(99));
6161 send_test_server_message(
6162 &mut ws,
6163 rpc_result_message(
6164 2,
6165 send.id,
6166 proto::rpc_result::Result::SendMessage(proto::SendMessageResult {
6167 updates: vec![proto::Update {
6168 seq: None,
6169 date: None,
6170 update: Some(proto::update::Update::UpdateMessageId(
6171 proto::UpdateMessageId {
6172 message_id: 11,
6173 random_id: 99,
6174 },
6175 )),
6176 }],
6177 }),
6178 ),
6179 )
6180 .await;
6181 });
6182
6183 let store = InMemoryStore::new();
6184 store.save_session(connect_session()).await.unwrap();
6185 let request = SendTextRequest {
6186 peer: crate::PeerRef::Chat {
6187 chat_id: InlineId::new(7),
6188 },
6189 text: "hello".to_owned(),
6190 external_id: Some(ExternalId::try_new("host-event", "event-1").unwrap()),
6191 random_id: Some(RandomId::new(99)),
6192 reply_to_message_id: None,
6193 };
6194 let transaction_id = transaction_id_for_send(&request, RandomId::new(99));
6195 let identity = TransactionIdentity::new(
6196 transaction_id.clone(),
6197 request.external_id.clone(),
6198 RandomId::new(99),
6199 );
6200 store
6201 .record_transaction(
6202 StoredTransaction::new(identity, TransactionState::Sent)
6203 .with_chat_id(InlineId::new(7))
6204 .with_failure(crate::ClientFailure::new(
6205 ClientErrorCategory::Timeout,
6206 "send result timed out",
6207 )),
6208 )
6209 .await
6210 .unwrap();
6211 let backend = SdkBackend::builder()
6212 .store(store.clone())
6213 .realtime_url(format!("ws://{addr}/realtime"))
6214 .without_realtime_handshake()
6215 .build()
6216 .unwrap();
6217
6218 let outcome = backend.send_text(request).await.unwrap();
6219
6220 assert_eq!(outcome.state, TransactionState::Completed);
6221 assert_eq!(outcome.message_id, Some(InlineId::new(11)));
6222 assert_eq!(outcome.mutation.transaction.transaction_id, transaction_id);
6223 assert_eq!(
6224 store
6225 .transaction(transaction_id)
6226 .await
6227 .unwrap()
6228 .unwrap()
6229 .state,
6230 TransactionState::Completed
6231 );
6232 server.await.unwrap();
6233 }
6234
6235 #[test]
6236 fn media_content_from_proto_preserves_document_descriptor() {
6237 let content = media_content_from_proto(
6238 &proto::MessageMedia {
6239 media: Some(proto::message_media::Media::Document(
6240 proto::MessageDocument {
6241 document: Some(proto::Document {
6242 id: 55,
6243 file_name: "report.pdf".to_owned(),
6244 mime_type: "application/pdf".to_owned(),
6245 size: 12_345,
6246 cdn_url: Some("https://cdn.inline.test/report.pdf".to_owned()),
6247 ..Default::default()
6248 }),
6249 },
6250 )),
6251 },
6252 Some("quarterly report".to_owned()),
6253 );
6254
6255 match content {
6256 MessageContent::Media {
6257 kind,
6258 file_id,
6259 url,
6260 mime_type,
6261 file_name,
6262 caption,
6263 size_bytes,
6264 width,
6265 height,
6266 duration_ms,
6267 } => {
6268 assert_eq!(kind, MediaKind::Document);
6269 assert_eq!(file_id, "55");
6270 assert_eq!(url.as_deref(), Some("https://cdn.inline.test/report.pdf"));
6271 assert_eq!(mime_type.as_deref(), Some("application/pdf"));
6272 assert_eq!(file_name.as_deref(), Some("report.pdf"));
6273 assert_eq!(caption.as_deref(), Some("quarterly report"));
6274 assert_eq!(size_bytes, Some(12_345));
6275 assert_eq!(width, None);
6276 assert_eq!(height, None);
6277 assert_eq!(duration_ms, None);
6278 }
6279 other => panic!("expected media content, got {other:?}"),
6280 }
6281 }
6282
6283 #[test]
6284 fn media_content_from_proto_picks_largest_photo_descriptor() {
6285 let content = media_content_from_proto(
6286 &proto::MessageMedia {
6287 media: Some(proto::message_media::Media::Photo(proto::MessagePhoto {
6288 photo: Some(proto::Photo {
6289 id: 9,
6290 format: 2,
6291 sizes: vec![
6292 proto::PhotoSize {
6293 w: 20,
6294 h: 20,
6295 size: 100,
6296 cdn_url: Some("https://cdn.inline.test/small.png".to_owned()),
6297 ..Default::default()
6298 },
6299 proto::PhotoSize {
6300 w: 200,
6301 h: 200,
6302 size: 500,
6303 cdn_url: None,
6304 ..Default::default()
6305 },
6306 proto::PhotoSize {
6307 w: 50,
6308 h: 50,
6309 size: 200,
6310 cdn_url: Some("https://cdn.inline.test/large.png".to_owned()),
6311 ..Default::default()
6312 },
6313 ],
6314 ..Default::default()
6315 }),
6316 })),
6317 },
6318 None,
6319 );
6320
6321 match content {
6322 MessageContent::Media {
6323 kind,
6324 file_id,
6325 url,
6326 mime_type,
6327 size_bytes,
6328 width,
6329 height,
6330 ..
6331 } => {
6332 assert_eq!(kind, MediaKind::Photo);
6333 assert_eq!(file_id, "9");
6334 assert_eq!(url.as_deref(), Some("https://cdn.inline.test/large.png"));
6335 assert_eq!(mime_type.as_deref(), Some("image/png"));
6336 assert_eq!(size_bytes, Some(200));
6337 assert_eq!(width, Some(50));
6338 assert_eq!(height, Some(50));
6339 }
6340 other => panic!("expected media content, got {other:?}"),
6341 }
6342 }
6343
6344 #[test]
6345 fn upload_input_for_video_without_complete_metadata_falls_back_to_document() {
6346 let request = UploadRequest {
6347 peer: crate::PeerRef::Chat {
6348 chat_id: InlineId::new(7),
6349 },
6350 kind: MediaKind::Video,
6351 file_name: Some("clip.mp4".to_owned()),
6352 mime_type: Some("video/mp4".to_owned()),
6353 size_bytes: Some(4),
6354 caption: None,
6355 width: Some(640),
6356 height: None,
6357 duration_ms: Some(1_500),
6358 external_id: None,
6359 random_id: None,
6360 reply_to_message_id: None,
6361 };
6362
6363 let input = upload_input_for_request(&request, vec![1, 2, 3, 4]);
6364
6365 assert_eq!(input.file_type, UploadFileType::Document);
6366 assert_eq!(input.file_name, "clip.mp4");
6367 assert_eq!(input.mime_type.as_deref(), Some("video/mp4"));
6368 assert_eq!(input.video_metadata, None);
6369 }
6370
6371 #[test]
6372 fn upload_input_for_video_with_complete_metadata_uses_video() {
6373 let request = UploadRequest {
6374 peer: crate::PeerRef::Chat {
6375 chat_id: InlineId::new(7),
6376 },
6377 kind: MediaKind::Video,
6378 file_name: Some("clip.mp4".to_owned()),
6379 mime_type: Some("video/mp4".to_owned()),
6380 size_bytes: Some(4),
6381 caption: None,
6382 width: Some(640),
6383 height: Some(480),
6384 duration_ms: Some(1_500),
6385 external_id: None,
6386 random_id: None,
6387 reply_to_message_id: None,
6388 };
6389
6390 let input = upload_input_for_request(&request, vec![1, 2, 3, 4]);
6391
6392 assert_eq!(input.file_type, UploadFileType::Video);
6393 assert_eq!(
6394 input.video_metadata,
6395 Some(UploadVideoMetadata::new(640, 480, 2))
6396 );
6397 }
6398
6399 #[test]
6400 fn history_input_for_after_message_id_uses_newer_mode() {
6401 let input = history_input_for_request(
6402 &HistoryRequest {
6403 chat_id: InlineId::new(7),
6404 limit: Some(20),
6405 before_message_id: None,
6406 after_message_id: Some(InlineId::new(10)),
6407 },
6408 21,
6409 );
6410
6411 assert_eq!(input.after_id, Some(10));
6412 assert_eq!(
6413 input.mode,
6414 Some(proto::GetChatHistoryMode::HistoryModeNewer as i32)
6415 );
6416 assert_eq!(input.offset_id, None);
6417 assert_eq!(input.limit, Some(21));
6418 }
6419
6420 #[test]
6421 fn history_input_for_before_message_id_uses_offset_id() {
6422 let input = history_input_for_request(
6423 &HistoryRequest {
6424 chat_id: InlineId::new(7),
6425 limit: Some(20),
6426 before_message_id: Some(InlineId::new(30)),
6427 after_message_id: None,
6428 },
6429 21,
6430 );
6431
6432 assert_eq!(input.offset_id, Some(30));
6433 assert_eq!(input.mode, None);
6434 assert_eq!(input.after_id, None);
6435 assert_eq!(input.limit, Some(21));
6436 }
6437
6438 #[test]
6439 fn normalize_live_history_latest_keeps_newest_messages() {
6440 let records = (1..=4).rev().map(test_message_record).collect::<Vec<_>>();
6441
6442 let (records, has_more) = normalize_live_history_records(records, 3, false);
6443
6444 assert!(has_more);
6445 assert_eq!(
6446 records
6447 .iter()
6448 .map(|message| message.message_id)
6449 .collect::<Vec<_>>(),
6450 vec![InlineId::new(2), InlineId::new(3), InlineId::new(4)]
6451 );
6452 }
6453
6454 #[test]
6455 fn normalize_live_history_newer_keeps_oldest_checkpoint_window() {
6456 let records = vec![
6457 test_message_record(8),
6458 test_message_record(7),
6459 test_message_record(6),
6460 test_message_record(5),
6461 ];
6462
6463 let (records, has_more) = normalize_live_history_records(records, 3, true);
6464
6465 assert!(has_more);
6466 assert_eq!(
6467 records
6468 .iter()
6469 .map(|message| message.message_id)
6470 .collect::<Vec<_>>(),
6471 vec![InlineId::new(5), InlineId::new(6), InlineId::new(7)]
6472 );
6473 }
6474
6475 #[test]
6476 fn normalize_live_history_older_keeps_newest_messages_below_cursor() {
6477 let records = vec![
6478 test_message_record(3),
6479 test_message_record(2),
6480 test_message_record(1),
6481 ];
6482
6483 let (records, has_more) = normalize_live_history_records(records, 2, false);
6484
6485 assert!(has_more);
6486 assert_eq!(
6487 records
6488 .iter()
6489 .map(|message| message.message_id)
6490 .collect::<Vec<_>>(),
6491 vec![InlineId::new(2), InlineId::new(3)]
6492 );
6493 }
6494
6495 #[test]
6496 fn apply_send_message_updates_extracts_final_message() {
6497 let request = SendTextRequest {
6498 peer: crate::PeerRef::Chat {
6499 chat_id: InlineId::new(7),
6500 },
6501 text: "hello".to_owned(),
6502 external_id: Some(ExternalId::try_new("host-event", "event-1").unwrap()),
6503 random_id: Some(RandomId::new(99)),
6504 reply_to_message_id: None,
6505 };
6506 let identity = TransactionIdentity::new(
6507 TransactionId::try_new("txn").unwrap(),
6508 request.external_id.clone(),
6509 request.random_id.unwrap(),
6510 );
6511 let result = proto::SendMessageResult {
6512 updates: vec![proto::Update {
6513 seq: Some(1),
6514 date: Some(1),
6515 update: Some(proto::update::Update::NewMessage(proto::UpdateNewMessage {
6516 message: Some(proto::Message {
6517 id: 11,
6518 from_id: 2,
6519 peer_id: None,
6520 chat_id: 7,
6521 message: Some("hello".to_owned()),
6522 out: true,
6523 date: 123,
6524 mentioned: None,
6525 reply_to_msg_id: None,
6526 media: None,
6527 edit_date: None,
6528 grouped_id: None,
6529 attachments: None,
6530 reactions: None,
6531 is_sticker: None,
6532 has_link: None,
6533 entities: None,
6534 send_mode: None,
6535 fwd_from: None,
6536 replies: None,
6537 actions: None,
6538 rev: None,
6539 }),
6540 })),
6541 }],
6542 };
6543
6544 let applied = apply_send_message_updates(&request, identity, InlineId::new(7), result);
6545
6546 assert_eq!(applied.message_id, Some(InlineId::new(11)));
6547 assert_eq!(applied.transaction.state, TransactionState::Completed);
6548 assert_eq!(applied.message.unwrap().message_id, InlineId::new(11));
6549 }
6550
6551 #[tokio::test]
6552 async fn apply_updates_persists_lossless_message_state_before_emitting() {
6553 let store = InMemoryStore::new();
6554 let backend = SdkBackend::builder().store(store.clone()).build().unwrap();
6555 let peer = proto::Peer {
6556 r#type: Some(proto::peer::Type::Chat(proto::PeerChat { chat_id: 7 })),
6557 };
6558 let updates = vec![
6559 proto::Update {
6560 seq: Some(1),
6561 date: Some(10),
6562 update: Some(proto::update::Update::NewMessage(proto::UpdateNewMessage {
6563 message: Some(proto::Message {
6564 id: 11,
6565 from_id: 2,
6566 peer_id: Some(peer.clone()),
6567 chat_id: 7,
6568 message: Some("hello".to_owned()),
6569 date: 10,
6570 ..Default::default()
6571 }),
6572 })),
6573 },
6574 proto::Update {
6575 seq: Some(2),
6576 date: Some(11),
6577 update: Some(proto::update::Update::UpdateReaction(
6578 proto::UpdateReaction {
6579 reaction: Some(proto::Reaction {
6580 emoji: "👍".to_owned(),
6581 user_id: 2,
6582 message_id: 11,
6583 chat_id: 7,
6584 date: 11,
6585 }),
6586 },
6587 )),
6588 },
6589 proto::Update {
6590 seq: Some(3),
6591 date: Some(12),
6592 update: Some(proto::update::Update::UpdateReadMaxId(
6593 proto::UpdateReadMaxId {
6594 peer_id: Some(peer.clone()),
6595 read_max_id: 11,
6596 unread_count: 1,
6597 },
6598 )),
6599 },
6600 proto::Update {
6601 seq: Some(4),
6602 date: Some(13),
6603 update: Some(proto::update::Update::ParticipantAdd(
6604 proto::UpdateChatParticipantAdd {
6605 chat_id: 7,
6606 participant: Some(proto::ChatParticipant {
6607 user_id: 2,
6608 date: 13,
6609 }),
6610 },
6611 )),
6612 },
6613 proto::Update {
6614 seq: Some(5),
6615 date: Some(14),
6616 update: Some(proto::update::Update::DeleteReaction(
6617 proto::UpdateDeleteReaction {
6618 emoji: "👍".to_owned(),
6619 chat_id: 7,
6620 message_id: 11,
6621 user_id: 2,
6622 },
6623 )),
6624 },
6625 proto::Update {
6626 seq: Some(6),
6627 date: Some(15),
6628 update: Some(proto::update::Update::DeleteMessages(
6629 proto::UpdateDeleteMessages {
6630 message_ids: vec![11],
6631 peer_id: Some(peer),
6632 },
6633 )),
6634 },
6635 ];
6636
6637 let events = backend.apply_updates(updates, None, None).await.unwrap();
6638
6639 assert!(events.iter().any(|event| matches!(
6640 event,
6641 ClientEvent::MessageDeleted { chat_id, message_id }
6642 if *chat_id == InlineId::new(7) && *message_id == InlineId::new(11)
6643 )));
6644 assert!(
6645 store
6646 .message_deleted(InlineId::new(7), InlineId::new(11))
6647 .await
6648 .unwrap()
6649 );
6650 assert!(
6651 store
6652 .reactions(InlineId::new(7), InlineId::new(11))
6653 .await
6654 .unwrap()
6655 .is_empty()
6656 );
6657 assert_eq!(
6658 store.read_state(InlineId::new(7)).await.unwrap(),
6659 Some(StoredReadState {
6660 chat_id: InlineId::new(7),
6661 read_max_id: Some(InlineId::new(11)),
6662 unread_count: Some(1),
6663 marked_unread: false,
6664 })
6665 );
6666 assert_eq!(
6667 store.chat_participants(InlineId::new(7)).await.unwrap(),
6668 vec![ChatParticipantRecord {
6669 user_id: InlineId::new(2),
6670 date: Some(13),
6671 }]
6672 );
6673 }
6674
6675 #[tokio::test]
6676 async fn apply_updates_persists_dialog_metadata_and_attachment_invalidation() {
6677 let store = InMemoryStore::new();
6678 let backend = SdkBackend::builder().store(store.clone()).build().unwrap();
6679 let peer = proto::Peer {
6680 r#type: Some(proto::peer::Type::Chat(proto::PeerChat { chat_id: 7 })),
6681 };
6682 store
6683 .record_dialog(DialogRecord::new(InlineId::new(7)))
6684 .await
6685 .unwrap();
6686 let updates = vec![
6687 proto::Update {
6688 update: Some(proto::update::Update::ChatInfo(proto::UpdateChatInfo {
6689 chat_id: 7,
6690 title: Some("General".to_owned()),
6691 emoji: None,
6692 untitled: None,
6693 })),
6694 ..Default::default()
6695 },
6696 proto::Update {
6697 update: Some(proto::update::Update::ChatInfo(proto::UpdateChatInfo {
6698 chat_id: 7,
6699 title: None,
6700 emoji: Some("✨".to_owned()),
6701 untitled: None,
6702 })),
6703 ..Default::default()
6704 },
6705 proto::Update {
6706 update: Some(proto::update::Update::ChatVisibility(
6707 proto::UpdateChatVisibility {
6708 chat_id: 7,
6709 is_public: true,
6710 },
6711 )),
6712 ..Default::default()
6713 },
6714 proto::Update {
6715 update: Some(proto::update::Update::DialogArchived(
6716 proto::UpdateDialogArchived {
6717 peer_id: Some(peer.clone()),
6718 archived: true,
6719 },
6720 )),
6721 ..Default::default()
6722 },
6723 proto::Update {
6724 update: Some(proto::update::Update::DialogNotificationSettings(
6725 proto::UpdateDialogNotificationSettings {
6726 peer_id: Some(peer.clone()),
6727 notification_settings: Some(proto::DialogNotificationSettings {
6728 mode: Some(proto::dialog_notification_settings::Mode::Mentions as i32),
6729 }),
6730 },
6731 )),
6732 ..Default::default()
6733 },
6734 proto::Update {
6735 update: Some(proto::update::Update::DialogFollowMode(
6736 proto::UpdateDialogFollowMode {
6737 peer_id: Some(peer.clone()),
6738 follow_mode: Some(proto::DialogFollowMode::Following as i32),
6739 },
6740 )),
6741 ..Default::default()
6742 },
6743 proto::Update {
6744 update: Some(proto::update::Update::PinnedMessages(
6745 proto::UpdatePinnedMessages {
6746 peer_id: Some(peer.clone()),
6747 message_ids: vec![11, 10],
6748 },
6749 )),
6750 ..Default::default()
6751 },
6752 proto::Update {
6753 update: Some(proto::update::Update::MessageAttachment(
6754 proto::UpdateMessageAttachment {
6755 attachment: None,
6756 message_id: 11,
6757 peer_id: Some(peer),
6758 chat_id: 7,
6759 },
6760 )),
6761 ..Default::default()
6762 },
6763 ];
6764
6765 let events = backend.apply_updates(updates, None, None).await.unwrap();
6766 let dialog = store.dialog(InlineId::new(7)).await.unwrap().unwrap();
6767 assert_eq!(dialog.title.as_deref(), Some("General"));
6768 assert_eq!(dialog.emoji.as_deref(), Some("✨"));
6769 assert_eq!(dialog.is_public, Some(true));
6770 assert_eq!(dialog.archived, Some(true));
6771 assert_eq!(
6772 dialog.notification_mode,
6773 Some(DialogNotificationMode::Mentions)
6774 );
6775 assert_eq!(dialog.follow_mode, Some(DialogFollowMode::Following));
6776 assert_eq!(
6777 dialog.pinned_message_ids,
6778 vec![InlineId::new(11), InlineId::new(10)]
6779 );
6780 assert!(events.iter().any(|event| matches!(
6781 event,
6782 ClientEvent::MessageUpserted { chat_id, message_id }
6783 if *chat_id == InlineId::new(7) && *message_id == InlineId::new(11)
6784 )));
6785 }
6786
6787 #[tokio::test]
6788 async fn apply_updates_rejects_still_unsupported_lossless_update() {
6789 let backend = SdkBackend::builder().build().unwrap();
6790 let update = proto::Update {
6791 seq: Some(1),
6792 date: Some(10),
6793 update: Some(proto::update::Update::ChatHasNewUpdates(
6794 proto::UpdateChatHasNewUpdates::default(),
6795 )),
6796 };
6797
6798 let error = backend
6799 .apply_updates(vec![update], None, None)
6800 .await
6801 .unwrap_err();
6802
6803 assert_eq!(error.category, ClientErrorCategory::Unsupported);
6804 assert!(error.message.contains("chat_has_new_updates"));
6805 }
6806
6807 #[tokio::test]
6808 async fn apply_updates_clears_history_and_applies_reply_thread_side_effects() {
6809 let store = InMemoryStore::new();
6810 for chat_id in [7, 8, 9] {
6811 store
6812 .record_dialog(DialogRecord::new(InlineId::new(chat_id)))
6813 .await
6814 .unwrap();
6815 }
6816 for (message_id, timestamp) in [(10, 100), (11, 200)] {
6817 store
6818 .record_message(MessageRecord {
6819 chat_id: InlineId::new(7),
6820 message_id: InlineId::new(message_id),
6821 sender_id: InlineId::new(2),
6822 timestamp,
6823 is_outgoing: false,
6824 content: MessageContent::Text {
6825 text: format!("message {message_id}"),
6826 },
6827 reply_to_message_id: None,
6828 transaction: None,
6829 })
6830 .await
6831 .unwrap();
6832 }
6833 let backend = SdkBackend::builder().store(store.clone()).build().unwrap();
6834 let peer = proto::Peer {
6835 r#type: Some(proto::peer::Type::Chat(proto::PeerChat { chat_id: 7 })),
6836 };
6837 let update = proto::Update {
6838 seq: Some(1),
6839 date: Some(201),
6840 update: Some(proto::update::Update::ClearChatHistory(
6841 proto::UpdateClearChatHistory {
6842 target: Some(proto::update_clear_chat_history::Target::PeerId(peer)),
6843 before_date: Some(150),
6844 delete_reply_threads: true,
6845 deleted_chat_ids: vec![8],
6846 orphaned_chat_ids: vec![9],
6847 detached_chat_ids: Vec::new(),
6848 },
6849 )),
6850 };
6851
6852 let events = backend
6853 .apply_updates(vec![update], None, None)
6854 .await
6855 .unwrap();
6856
6857 assert!(events.iter().any(|event| matches!(
6858 event,
6859 ClientEvent::ChatHistoryCleared { chat_id, before_date: Some(150) }
6860 if *chat_id == InlineId::new(7)
6861 )));
6862 assert!(events.iter().any(|event| matches!(
6863 event,
6864 ClientEvent::ChatDeleted { chat_id } if *chat_id == InlineId::new(8)
6865 )));
6866 assert!(events.iter().any(|event| matches!(
6867 event,
6868 ClientEvent::ChatUpserted { chat_id } if *chat_id == InlineId::new(9)
6869 )));
6870 assert!(store.dialog(InlineId::new(8)).await.unwrap().is_none());
6871 let history = store
6872 .history(HistoryRequest {
6873 chat_id: InlineId::new(7),
6874 limit: Some(10),
6875 before_message_id: None,
6876 after_message_id: None,
6877 })
6878 .await
6879 .unwrap();
6880 assert_eq!(history.messages[0].message_id, InlineId::new(11));
6881 }
6882
6883 #[tokio::test]
6884 async fn apply_updates_persists_spaces_members_and_user_settings() {
6885 let store = InMemoryStore::new();
6886 let backend = SdkBackend::builder().store(store.clone()).build().unwrap();
6887 let member = |user_id| proto::Member {
6888 id: user_id,
6889 space_id: 5,
6890 user_id,
6891 role: Some(proto::member::Role::Member as i32),
6892 date: 100 + user_id,
6893 can_access_public_chats: true,
6894 };
6895 let updates = vec![
6896 proto::Update {
6897 update: Some(proto::update::Update::JoinSpace(proto::UpdateJoinSpace {
6898 space: Some(proto::Space {
6899 id: 5,
6900 name: "Engineering".to_owned(),
6901 creator: true,
6902 date: 100,
6903 is_public: Some(false),
6904 }),
6905 member: Some(member(2)),
6906 })),
6907 ..Default::default()
6908 },
6909 proto::Update {
6910 update: Some(proto::update::Update::SpaceMemberAdd(
6911 proto::UpdateSpaceMemberAdd {
6912 member: Some(member(3)),
6913 user: Some(proto::User {
6914 id: 3,
6915 first_name: Some("Ada".to_owned()),
6916 ..Default::default()
6917 }),
6918 },
6919 )),
6920 ..Default::default()
6921 },
6922 proto::Update {
6923 update: Some(proto::update::Update::SpaceMemberDelete(
6924 proto::UpdateSpaceMemberDelete {
6925 space_id: 5,
6926 user_id: 3,
6927 },
6928 )),
6929 ..Default::default()
6930 },
6931 proto::Update {
6932 update: Some(proto::update::Update::UpdateUserSettings(
6933 proto::UpdateUserSettings {
6934 settings: Some(proto::UserSettings {
6935 notification_settings: Some(proto::NotificationSettings {
6936 mode: Some(proto::notification_settings::Mode::Mentions as i32),
6937 silent: Some(true),
6938 ..Default::default()
6939 }),
6940 }),
6941 },
6942 )),
6943 ..Default::default()
6944 },
6945 ];
6946
6947 let events = backend.apply_updates(updates, None, None).await.unwrap();
6948
6949 assert_eq!(
6950 store.space(InlineId::new(5)).await.unwrap().unwrap().name,
6951 "Engineering"
6952 );
6953 assert_eq!(
6954 store.space_members(InlineId::new(5)).await.unwrap(),
6955 vec![SpaceMemberRecord {
6956 space_id: InlineId::new(5),
6957 user_id: InlineId::new(2),
6958 role: Some(SpaceMemberRole::Member),
6959 date: 102,
6960 can_access_public_chats: true,
6961 }]
6962 );
6963 assert_eq!(
6964 store
6965 .user_settings()
6966 .await
6967 .unwrap()
6968 .unwrap()
6969 .notification_mode,
6970 Some(NotificationMode::Mentions)
6971 );
6972 assert!(events.iter().any(|event| matches!(
6973 event,
6974 ClientEvent::SpaceUpserted { space_id } if *space_id == InlineId::new(5)
6975 )));
6976 assert!(
6977 events
6978 .iter()
6979 .any(|event| matches!(event, ClientEvent::UserSettingsChanged { .. }))
6980 );
6981 }
6982
6983 #[test]
6984 fn rest_rate_limit_errors_keep_category_and_retry_hint() {
6985 let error = api_error_to_backend(ApiError::Status {
6986 status: 420,
6987 message: "retry after 23 seconds".to_owned(),
6988 body: None,
6989 });
6990
6991 assert_eq!(error.category, ClientErrorCategory::RateLimited);
6992 assert_eq!(error.retry_after_seconds, Some(23));
6993 }
6994
6995 fn connect_session() -> StoredSession {
6996 StoredSession {
6997 auth: AuthCredential::AccessToken {
6998 token: AuthToken::try_new("secret-token").unwrap(),
6999 },
7000 account_namespace: Some("team".to_owned()),
7001 }
7002 }
7003
7004 fn test_chat_id_from_input_peer(peer: Option<&proto::InputPeer>) -> Option<i64> {
7005 match peer?.r#type.as_ref()? {
7006 proto::input_peer::Type::Chat(chat) => Some(chat.chat_id),
7007 proto::input_peer::Type::Self_(_) | proto::input_peer::Type::User(_) => None,
7008 }
7009 }
7010
7011 async fn read_test_client_message(
7012 ws: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
7013 ) -> proto::ClientMessage {
7014 match ws.next().await.unwrap().unwrap() {
7015 WsMessage::Binary(data) => proto::ClientMessage::decode(&*data).unwrap(),
7016 other => panic!("expected binary client message, got {other:?}"),
7017 }
7018 }
7019
7020 async fn send_test_server_message(
7021 ws: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
7022 message: proto::ServerProtocolMessage,
7023 ) {
7024 ws.send(WsMessage::Binary(message.encode_to_vec().into()))
7025 .await
7026 .unwrap();
7027 }
7028
7029 fn rpc_result_message(
7030 id: u64,
7031 request_id: u64,
7032 result: proto::rpc_result::Result,
7033 ) -> proto::ServerProtocolMessage {
7034 proto::ServerProtocolMessage {
7035 id,
7036 body: Some(proto::server_protocol_message::Body::RpcResult(
7037 proto::RpcResult {
7038 req_msg_id: request_id,
7039 result: Some(result),
7040 },
7041 )),
7042 }
7043 }
7044}