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