1use std::{
9 collections::HashMap,
10 fmt,
11 sync::Arc,
12 time::{SystemTime, UNIX_EPOCH},
13};
14
15use futures_util::future::BoxFuture;
16use inline_sdk::{
17 ApiClient, ApiError, AuthMetadata, ClientIdentity, RealtimeClient, RealtimeError,
18 RealtimeEvent, RpcRequest, UploadFileBytesInput, UploadFileResult, UploadFileType,
19 UploadVideoMetadata, proto,
20};
21use serde_json::Value;
22use tokio::sync::Mutex;
23
24use crate::{
25 AuthContactKind, AuthCredential, AuthStartRequest, AuthStartResult, AuthToken,
26 AuthVerifyRequest, AuthVerifyResult, BackendError, BackendResult, ChatCreateParticipant,
27 ChatParticipantRecord, ChatParticipantsPage, ChatParticipantsRequest, ClientBackend,
28 ClientErrorCategory, ClientEvent, ClientStatusSnapshot, ClientStore, ConnectRequest,
29 CreateDmRequest, CreateReplyThreadRequest, CreateThreadRequest, CreatedChat,
30 DeleteMessageRequest, DialogRecord, DialogsPage, DialogsRequest, EditMessageRequest,
31 HistoryPage, HistoryRequest, InMemoryStore, InlineId, MediaKind, MessageContent,
32 MessageMutation, MessageRecord, OperationOutcome, PeerRef, RandomId, ReactRequest, ReadRequest,
33 RealtimeConnectRequest, RealtimeConnector, SdkRealtimeConnector, SendTextOutcome,
34 SendTextRequest, StoreError, StoredSession, StoredTransaction, TransactionId,
35 TransactionIdentity, TransactionState, TypingRequest, UploadRequest, UserRecord, VERSION,
36};
37
38const DEFAULT_API_BASE_URL: &str = "https://api.inline.chat/v1";
39const DEFAULT_REALTIME_URL: &str = "wss://api.inline.chat/realtime";
40
41#[derive(Debug, thiserror::Error)]
43#[non_exhaustive]
44pub enum SdkBackendBuildError {
45 #[error("failed to build Inline API client: {0}")]
47 Api(#[from] ApiError),
48}
49
50#[derive(Clone)]
52pub struct SdkBackendBuilder {
53 api_base_url: String,
54 realtime_url: String,
55 identity: ClientIdentity,
56 store: Arc<dyn ClientStore>,
57 realtime_connector: Option<Arc<dyn RealtimeConnector>>,
58}
59
60impl fmt::Debug for SdkBackendBuilder {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 f.debug_struct("SdkBackendBuilder")
63 .field("api_base_url", &self.api_base_url)
64 .field("realtime_url", &redacted_url_for_debug(&self.realtime_url))
65 .field("identity", &self.identity)
66 .field("store", &"<client-store>")
67 .field(
68 "realtime_connector",
69 &self
70 .realtime_connector
71 .as_ref()
72 .map(|_| "<realtime-connector>"),
73 )
74 .finish()
75 }
76}
77
78impl Default for SdkBackendBuilder {
79 fn default() -> Self {
80 Self {
81 api_base_url: DEFAULT_API_BASE_URL.to_owned(),
82 realtime_url: DEFAULT_REALTIME_URL.to_owned(),
83 identity: ClientIdentity::new("inline-client", VERSION),
84 store: Arc::new(InMemoryStore::new()),
85 realtime_connector: None,
86 }
87 }
88}
89
90impl SdkBackendBuilder {
91 pub fn api_base_url(mut self, api_base_url: impl Into<String>) -> Self {
93 self.api_base_url = api_base_url.into();
94 self
95 }
96
97 pub fn realtime_url(mut self, realtime_url: impl Into<String>) -> Self {
99 self.realtime_url = realtime_url.into();
100 self
101 }
102
103 pub fn identity(mut self, identity: ClientIdentity) -> Self {
105 self.identity = identity;
106 self
107 }
108
109 pub fn store(mut self, store: impl ClientStore) -> Self {
111 self.store = Arc::new(store);
112 self
113 }
114
115 pub fn shared_store(mut self, store: Arc<dyn ClientStore>) -> Self {
117 self.store = store;
118 self
119 }
120
121 pub fn enable_realtime_handshake(mut self) -> Self {
123 self.realtime_connector = Some(Arc::new(SdkRealtimeConnector::new()));
124 self
125 }
126
127 pub fn realtime_connector(mut self, connector: impl RealtimeConnector) -> Self {
129 self.realtime_connector = Some(Arc::new(connector));
130 self
131 }
132
133 pub fn shared_realtime_connector(mut self, connector: Arc<dyn RealtimeConnector>) -> Self {
135 self.realtime_connector = Some(connector);
136 self
137 }
138
139 pub fn without_realtime_handshake(mut self) -> Self {
141 self.realtime_connector = None;
142 self
143 }
144
145 pub fn build(self) -> Result<SdkBackend, SdkBackendBuildError> {
147 let api = ApiClient::try_new_with_identity(self.api_base_url, self.identity.clone())?;
148 Ok(SdkBackend {
149 api,
150 realtime_url: self.realtime_url,
151 identity: self.identity,
152 store: self.store,
153 realtime_connector: self.realtime_connector,
154 realtime: Arc::new(Mutex::new(None)),
155 event_realtime: Arc::new(Mutex::new(None)),
156 })
157 }
158}
159
160#[derive(Clone)]
162pub struct SdkBackend {
163 api: ApiClient,
164 realtime_url: String,
165 identity: ClientIdentity,
166 store: Arc<dyn ClientStore>,
167 realtime_connector: Option<Arc<dyn RealtimeConnector>>,
168 realtime: Arc<Mutex<Option<RealtimeClient>>>,
169 event_realtime: Arc<Mutex<Option<RealtimeClient>>>,
170}
171
172impl fmt::Debug for SdkBackend {
173 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174 f.debug_struct("SdkBackend")
175 .field("api", &self.api)
176 .field("realtime_url", &redacted_url_for_debug(&self.realtime_url))
177 .field("identity", &self.identity)
178 .field("store", &"<client-store>")
179 .field(
180 "realtime_connector",
181 &self
182 .realtime_connector
183 .as_ref()
184 .map(|_| "<realtime-connector>"),
185 )
186 .finish()
187 }
188}
189
190impl SdkBackend {
191 pub fn builder() -> SdkBackendBuilder {
193 SdkBackendBuilder::default()
194 }
195
196 pub fn api_client(&self) -> &ApiClient {
198 &self.api
199 }
200
201 pub fn realtime_url(&self) -> &str {
203 &self.realtime_url
204 }
205
206 pub fn identity(&self) -> &ClientIdentity {
208 &self.identity
209 }
210
211 pub fn store(&self) -> Arc<dyn ClientStore> {
213 self.store.clone()
214 }
215
216 pub fn realtime_handshake_enabled(&self) -> bool {
218 self.realtime_connector.is_some()
219 }
220
221 async fn require_session(&self) -> BackendResult<StoredSession> {
222 self.store
223 .load_session()
224 .await
225 .map_err(store_error_to_backend)?
226 .ok_or_else(|| {
227 BackendError::new(ClientErrorCategory::AuthRequired, "client is not connected")
228 })
229 }
230
231 async fn call_realtime<R>(
232 &self,
233 session: &StoredSession,
234 request: R,
235 ) -> BackendResult<R::Response>
236 where
237 R: RpcRequest,
238 {
239 let mut realtime = self.realtime.lock().await;
240 if realtime.is_none() {
241 *realtime = Some(
242 RealtimeClient::connect_with_identity(
243 &self.realtime_url,
244 session.auth.access_token().expose_secret(),
245 self.identity.clone(),
246 )
247 .await
248 .map_err(realtime_error_to_backend)?,
249 );
250 }
251
252 let Some(client) = realtime.as_mut() else {
253 return Err(BackendError::new(
254 ClientErrorCategory::Internal,
255 "realtime client was not initialized",
256 ));
257 };
258 match client.call(request).await {
259 Ok(response) => Ok(response),
260 Err(error) => {
261 *realtime = None;
262 Err(realtime_error_to_backend(error))
263 }
264 }
265 }
266
267 async fn clear_realtime(&self) {
268 *self.realtime.lock().await = None;
269 *self.event_realtime.lock().await = None;
270 }
271
272 async fn receive_realtime_event(
273 &self,
274 session: &StoredSession,
275 ) -> BackendResult<RealtimeEvent> {
276 let mut realtime = self.event_realtime.lock().await;
277 if realtime.is_none() {
278 *realtime = Some(
279 RealtimeClient::connect_with_identity(
280 &self.realtime_url,
281 session.auth.access_token().expose_secret(),
282 self.identity.clone(),
283 )
284 .await
285 .map_err(realtime_error_to_backend)?,
286 );
287 }
288
289 let Some(client) = realtime.as_mut() else {
290 return Err(BackendError::new(
291 ClientErrorCategory::Internal,
292 "realtime event client was not initialized",
293 ));
294 };
295 match client.next_event().await {
296 Ok(event) => Ok(event),
297 Err(error) => {
298 *realtime = None;
299 Err(realtime_error_to_backend(error))
300 }
301 }
302 }
303
304 async fn connect_with_auth(
305 &self,
306 request: ConnectRequest,
307 ) -> BackendResult<ClientStatusSnapshot> {
308 if let Some(connector) = &self.realtime_connector {
309 connector
310 .connect(RealtimeConnectRequest::new(
311 self.realtime_url.clone(),
312 request.auth.access_token().clone(),
313 self.identity.clone(),
314 ))
315 .await?;
316 }
317 self.clear_realtime().await;
318 self.store
319 .save_session(StoredSession {
320 auth: request.auth,
321 account_namespace: request.account_namespace,
322 })
323 .await
324 .map_err(store_error_to_backend)?;
325 Ok(ClientStatusSnapshot::current(
326 crate::ClientStatus::Connected,
327 ))
328 }
329
330 fn auth_metadata(
331 &self,
332 kind: AuthContactKind,
333 contact: &str,
334 device_name: Option<&str>,
335 ) -> AuthMetadata {
336 let device_id = format!(
337 "inline-client-{:016x}",
338 stable_hash(&format!("{}:{contact}", kind.as_str()))
339 );
340 let metadata = AuthMetadata::new(device_id, self.identity.clone());
341 match device_name
342 .map(str::trim)
343 .filter(|device_name| !device_name.is_empty())
344 {
345 Some(device_name) => metadata.with_device_name(device_name),
346 None => metadata.with_device_name(self.identity.client_type()),
347 }
348 }
349
350 async fn resume_stored_session(&self) -> BackendResult<ClientStatusSnapshot> {
351 let Some(session) = self
352 .store
353 .load_session()
354 .await
355 .map_err(store_error_to_backend)?
356 else {
357 self.clear_realtime().await;
358 return Ok(ClientStatusSnapshot::current(
359 crate::ClientStatus::AuthRequired,
360 ));
361 };
362
363 self.clear_realtime().await;
364 if let Some(connector) = &self.realtime_connector {
365 connector
366 .connect(RealtimeConnectRequest::new(
367 self.realtime_url.clone(),
368 session.auth.access_token().clone(),
369 self.identity.clone(),
370 ))
371 .await?;
372 }
373 Ok(ClientStatusSnapshot::current(
374 crate::ClientStatus::Connected,
375 ))
376 }
377}
378
379impl ClientBackend for SdkBackend {
380 fn auth_start(
381 &self,
382 request: AuthStartRequest,
383 ) -> BoxFuture<'static, BackendResult<AuthStartResult>> {
384 let backend = self.clone();
385 Box::pin(async move {
386 let contact = request.contact.trim().to_owned();
387 if contact.is_empty() {
388 return Err(BackendError::new(
389 ClientErrorCategory::InvalidInput,
390 "auth contact must not be empty",
391 ));
392 }
393 let metadata =
394 backend.auth_metadata(request.kind, &contact, request.device_name.as_deref());
395 let result = match request.kind {
396 AuthContactKind::Email => backend
397 .api
398 .send_email_code(&contact, &metadata)
399 .await
400 .map_err(api_error_to_backend)?,
401 AuthContactKind::Phone => backend
402 .api
403 .send_sms_code(&contact, &metadata)
404 .await
405 .map_err(api_error_to_backend)?,
406 };
407 Ok(AuthStartResult {
408 existing_user: result.existing_user,
409 needs_invite_code: result.needs_invite_code,
410 challenge_token: result.challenge_token,
411 })
412 })
413 }
414
415 fn auth_verify(
416 &self,
417 request: AuthVerifyRequest,
418 ) -> BoxFuture<'static, BackendResult<AuthVerifyResult>> {
419 let backend = self.clone();
420 Box::pin(async move {
421 let contact = request.contact.trim().to_owned();
422 let code = request.code.trim().to_owned();
423 if contact.is_empty() {
424 return Err(BackendError::new(
425 ClientErrorCategory::InvalidInput,
426 "auth contact must not be empty",
427 ));
428 }
429 if code.is_empty() {
430 return Err(BackendError::new(
431 ClientErrorCategory::InvalidInput,
432 "verification code must not be empty",
433 ));
434 }
435
436 let metadata =
437 backend.auth_metadata(request.kind, &contact, request.device_name.as_deref());
438 let result = match request.kind {
439 AuthContactKind::Email => backend
440 .api
441 .verify_email_code(
442 &contact,
443 &code,
444 request.challenge_token.as_deref(),
445 &metadata,
446 )
447 .await
448 .map_err(api_error_to_backend)?,
449 AuthContactKind::Phone => backend
450 .api
451 .verify_sms_code(&contact, &code, &metadata)
452 .await
453 .map_err(api_error_to_backend)?,
454 };
455 let token = AuthToken::try_new(result.token).map_err(|error| {
456 BackendError::new(ClientErrorCategory::ProtocolMismatch, error.to_string())
457 })?;
458 let account_namespace = request
459 .account_namespace
460 .map(|namespace| namespace.trim().to_owned())
461 .filter(|namespace| !namespace.is_empty())
462 .unwrap_or_else(|| result.user_id.to_string());
463 let status = backend
464 .connect_with_auth(
465 ConnectRequest::new(AuthCredential::AccessToken { token })
466 .with_account_namespace(account_namespace.clone()),
467 )
468 .await?;
469 Ok(AuthVerifyResult {
470 user_id: InlineId::new(result.user_id),
471 account_namespace,
472 status,
473 })
474 })
475 }
476
477 fn resume_session(&self) -> BoxFuture<'static, BackendResult<ClientStatusSnapshot>> {
478 let backend = self.clone();
479 Box::pin(async move { backend.resume_stored_session().await })
480 }
481
482 fn connect(
483 &self,
484 request: ConnectRequest,
485 ) -> BoxFuture<'static, BackendResult<ClientStatusSnapshot>> {
486 let backend = self.clone();
487 Box::pin(async move { backend.connect_with_auth(request).await })
488 }
489
490 fn logout(&self) -> BoxFuture<'static, BackendResult<()>> {
491 let backend = self.clone();
492 Box::pin(async move {
493 backend.clear_realtime().await;
494 backend
495 .store
496 .clear_session()
497 .await
498 .map_err(store_error_to_backend)
499 })
500 }
501
502 fn dialogs(&self, request: DialogsRequest) -> BoxFuture<'static, BackendResult<DialogsPage>> {
503 let backend = self.clone();
504 Box::pin(async move {
505 let session = backend.require_session().await?;
506 let live: BackendResult<DialogsPage> = async {
507 let result = backend
508 .call_realtime(&session, proto::GetChatsInput {})
509 .await?;
510 let page = dialogs_page_from_get_chats(&result, request.clone())?;
511 for dialog in &page.dialogs {
512 backend
513 .store
514 .record_dialog(dialog.clone())
515 .await
516 .map_err(store_error_to_backend)?;
517 }
518 backend
519 .store
520 .record_users(page.users.clone())
521 .await
522 .map_err(store_error_to_backend)?;
523 for message in result.messages {
524 let record = message_record_from_proto_message(message, None, None);
525 backend
526 .store
527 .record_message(record)
528 .await
529 .map_err(store_error_to_backend)?;
530 }
531 backend
532 .store
533 .dialogs(request.clone())
534 .await
535 .map_err(store_error_to_backend)
536 }
537 .await;
538 match live {
539 Ok(page) => Ok(page),
540 Err(error) => {
541 log::warn!(
542 "Inline realtime dialog sync failed; serving cached dialogs: {}",
543 error
544 );
545 backend
546 .store
547 .dialogs(request)
548 .await
549 .map_err(store_error_to_backend)
550 }
551 }
552 })
553 }
554
555 fn history(&self, request: HistoryRequest) -> BoxFuture<'static, BackendResult<HistoryPage>> {
556 let backend = self.clone();
557 Box::pin(async move {
558 let session = backend.require_session().await?;
559 if request.before_message_id.is_some() && request.after_message_id.is_some() {
560 return Err(BackendError::new(
561 ClientErrorCategory::InvalidInput,
562 "history request cannot specify both before_message_id and after_message_id",
563 ));
564 }
565 let limit = request.limit.unwrap_or(50).max(1);
566 let live: BackendResult<HistoryPage> = async {
567 let fetch_limit = limit.saturating_add(1).min(i32::MAX as u32) as i32;
568 let result = backend
569 .call_realtime(&session, history_input_for_request(&request, fetch_limit))
570 .await?;
571 let records = result
572 .messages
573 .into_iter()
574 .map(|message| {
575 message_record_from_proto_message(message, Some(request.chat_id), None)
576 })
577 .collect::<Vec<_>>();
578 let (records, has_more) = normalize_live_history_records(
579 records,
580 limit,
581 request.after_message_id.is_some(),
582 );
583 for message in &records {
584 backend
585 .store
586 .record_message(message.clone())
587 .await
588 .map_err(store_error_to_backend)?;
589 }
590 Ok(HistoryPage {
591 messages: records,
592 users: Vec::new(),
593 has_more,
594 next_cursor: None,
595 })
596 }
597 .await;
598 match live {
599 Ok(page) => Ok(page),
600 Err(error) => {
601 log::warn!(
602 "Inline realtime history sync failed; serving cached history for chat {}: {}",
603 request.chat_id.get(),
604 error
605 );
606 backend
607 .store
608 .history(request)
609 .await
610 .map_err(store_error_to_backend)
611 }
612 }
613 })
614 }
615
616 fn chat_participants(
617 &self,
618 request: ChatParticipantsRequest,
619 ) -> BoxFuture<'static, BackendResult<ChatParticipantsPage>> {
620 let backend = self.clone();
621 Box::pin(async move {
622 let session = backend.require_session().await?;
623 let result = backend
624 .call_realtime(
625 &session,
626 proto::GetChatParticipantsInput {
627 chat_id: request.chat_id.get(),
628 },
629 )
630 .await?;
631 let page = chat_participants_page_from_proto(result);
632 backend
633 .store
634 .record_users(page.users.clone())
635 .await
636 .map_err(store_error_to_backend)?;
637 Ok(page)
638 })
639 }
640
641 fn create_dm(
642 &self,
643 request: CreateDmRequest,
644 ) -> BoxFuture<'static, BackendResult<CreatedChat>> {
645 let backend = self.clone();
646 Box::pin(async move {
647 if request.user_id.get() <= 0 {
648 return Err(BackendError::new(
649 ClientErrorCategory::InvalidInput,
650 "user_id must be positive",
651 ));
652 }
653 let session = backend.require_session().await?;
654 let result = backend
655 .api
656 .create_private_chat(
657 session.auth.access_token().expose_secret(),
658 request.user_id.get(),
659 )
660 .await
661 .map_err(api_error_to_backend)?;
662 let (created, user) = created_chat_from_private_chat_result(
663 result.chat,
664 result.dialog,
665 result.user,
666 request.user_id,
667 )?;
668 backend
669 .store
670 .record_dialog(DialogRecord {
671 chat_id: created.chat_id,
672 peer_user_id: Some(request.user_id),
673 title: created.title.clone(),
674 last_message_id: None,
675 synced_through_message_id: None,
676 unread_count: Some(0),
677 })
678 .await
679 .map_err(store_error_to_backend)?;
680 if let Some(user) = user {
681 backend
682 .store
683 .record_users(vec![user])
684 .await
685 .map_err(store_error_to_backend)?;
686 }
687 Ok(created)
688 })
689 }
690
691 fn create_thread(
692 &self,
693 request: CreateThreadRequest,
694 ) -> BoxFuture<'static, BackendResult<CreatedChat>> {
695 let backend = self.clone();
696 Box::pin(async move {
697 validate_create_participants(&request.participants)?;
698 let session = backend.require_session().await?;
699 let result = backend
700 .call_realtime(
701 &session,
702 proto::CreateChatInput {
703 title: trimmed_option(request.title),
704 space_id: request.space_id.map(InlineId::get),
705 description: trimmed_option(request.description),
706 emoji: trimmed_option(request.emoji),
707 is_public: request.is_public,
708 participants: request
709 .participants
710 .into_iter()
711 .map(|participant| proto::InputChatParticipant {
712 user_id: participant.user_id.get(),
713 })
714 .collect(),
715 reserved_chat_id: None,
716 },
717 )
718 .await?;
719 let created = created_chat_from_proto(result.chat, result.dialog, None, None, None)?;
720 backend
721 .store
722 .record_dialog(DialogRecord {
723 chat_id: created.chat_id,
724 peer_user_id: None,
725 title: created.title.clone(),
726 last_message_id: None,
727 synced_through_message_id: None,
728 unread_count: Some(0),
729 })
730 .await
731 .map_err(store_error_to_backend)?;
732 Ok(created)
733 })
734 }
735
736 fn create_reply_thread(
737 &self,
738 request: CreateReplyThreadRequest,
739 ) -> BoxFuture<'static, BackendResult<CreatedChat>> {
740 let backend = self.clone();
741 Box::pin(async move {
742 if request.parent_chat_id.get() <= 0 {
743 return Err(BackendError::new(
744 ClientErrorCategory::InvalidInput,
745 "parent_chat_id must be positive",
746 ));
747 }
748 validate_create_participants(&request.participants)?;
749 let session = backend.require_session().await?;
750 let result = backend
751 .call_realtime(
752 &session,
753 proto::CreateSubthreadInput {
754 parent_chat_id: request.parent_chat_id.get(),
755 parent_message_id: request.parent_message_id.map(InlineId::get),
756 title: trimmed_option(request.title),
757 description: trimmed_option(request.description),
758 emoji: trimmed_option(request.emoji),
759 participants: request
760 .participants
761 .into_iter()
762 .map(|participant| proto::InputChatParticipant {
763 user_id: participant.user_id.get(),
764 })
765 .collect(),
766 },
767 )
768 .await?;
769 let created = created_chat_from_proto(
770 result.chat,
771 result.dialog,
772 result.anchor_message,
773 Some(request.parent_chat_id),
774 request.parent_message_id,
775 )?;
776 backend
777 .store
778 .record_dialog(DialogRecord {
779 chat_id: created.chat_id,
780 peer_user_id: None,
781 title: created.title.clone(),
782 last_message_id: None,
783 synced_through_message_id: None,
784 unread_count: Some(0),
785 })
786 .await
787 .map_err(store_error_to_backend)?;
788 Ok(created)
789 })
790 }
791
792 fn send_text(
793 &self,
794 request: SendTextRequest,
795 ) -> BoxFuture<'static, BackendResult<SendTextOutcome>> {
796 let backend = self.clone();
797 Box::pin(async move {
798 if request.text.trim().is_empty() {
799 return Err(BackendError::new(
800 ClientErrorCategory::InvalidInput,
801 "message text must not be empty",
802 ));
803 }
804
805 let session = backend.require_session().await?;
806 let initial_chat_id = chat_id_for_peer(request.peer);
807 let random_id = request
808 .random_id
809 .unwrap_or_else(|| random_id_for_request(&request));
810 let transaction_id = transaction_id_for_send(&request, random_id);
811 let identity = TransactionIdentity::new(
812 transaction_id.clone(),
813 request.external_id.clone(),
814 random_id,
815 );
816
817 if let Some(existing) = backend
818 .store
819 .transaction(transaction_id.clone())
820 .await
821 .map_err(store_error_to_backend)?
822 {
823 return Ok(outcome_from_stored_transaction(existing, initial_chat_id));
824 }
825
826 backend
827 .store
828 .record_transaction(
829 StoredTransaction::new(identity.clone(), TransactionState::Queued)
830 .with_chat_id(initial_chat_id),
831 )
832 .await
833 .map_err(store_error_to_backend)?;
834
835 backend
836 .store
837 .record_transaction(
838 StoredTransaction::new(identity.clone(), TransactionState::Sent)
839 .with_chat_id(initial_chat_id),
840 )
841 .await
842 .map_err(store_error_to_backend)?;
843
844 let input = proto::SendMessageInput {
845 peer_id: Some(input_peer_for_client_peer(request.peer)),
846 message: Some(request.text.clone()),
847 reply_to_msg_id: request.reply_to_message_id.map(InlineId::get),
848 random_id: Some(random_id.get()),
849 media: None,
850 temporary_send_date: Some(now_seconds()),
851 is_sticker: None,
852 has_link: None,
853 entities: None,
854 parse_markdown: Some(false),
855 send_mode: None,
856 actions: None,
857 };
858 let send_result = match backend.call_realtime(&session, input).await {
859 Ok(result) => result,
860 Err(backend_error) => {
861 backend
862 .record_failed_transaction(identity, initial_chat_id, backend_error.clone())
863 .await?;
864 return Err(backend_error);
865 }
866 };
867
868 let applied =
869 apply_send_message_updates(&request, identity, initial_chat_id, send_result);
870 if let Some(message) = &applied.message {
871 backend
872 .store
873 .record_message(message.clone())
874 .await
875 .map_err(store_error_to_backend)?;
876 }
877 backend
878 .store
879 .record_transaction(applied.transaction.clone())
880 .await
881 .map_err(store_error_to_backend)?;
882
883 Ok(SendTextOutcome::with_state(
884 MessageMutation {
885 transaction: applied.transaction.identity,
886 message_id: applied.message_id,
887 },
888 applied.chat_id,
889 applied.message_id,
890 applied.message,
891 applied.transaction.state,
892 applied.transaction.failure,
893 ))
894 })
895 }
896
897 fn send_media(
898 &self,
899 request: UploadRequest,
900 bytes: Vec<u8>,
901 ) -> BoxFuture<'static, BackendResult<SendTextOutcome>> {
902 let backend = self.clone();
903 Box::pin(async move {
904 if bytes.is_empty() {
905 return Err(BackendError::new(
906 ClientErrorCategory::InvalidInput,
907 "media bytes must not be empty",
908 ));
909 }
910
911 let session = backend.require_session().await?;
912 let initial_chat_id = chat_id_for_peer(request.peer);
913 let random_id = request
914 .random_id
915 .unwrap_or_else(|| random_id_for_upload_request(&request, bytes.len()));
916 let transaction_id = transaction_id_for_upload(&request, random_id);
917 let identity = TransactionIdentity::new(
918 transaction_id.clone(),
919 request.external_id.clone(),
920 random_id,
921 );
922
923 if let Some(existing) = backend
924 .store
925 .transaction(transaction_id.clone())
926 .await
927 .map_err(store_error_to_backend)?
928 {
929 return Ok(outcome_from_stored_transaction(existing, initial_chat_id));
930 }
931
932 backend
933 .store
934 .record_transaction(
935 StoredTransaction::new(identity.clone(), TransactionState::Queued)
936 .with_chat_id(initial_chat_id),
937 )
938 .await
939 .map_err(store_error_to_backend)?;
940
941 let upload_input = upload_input_for_request(&request, bytes);
942 let upload = match backend
943 .api
944 .upload_file_bytes(session.auth.access_token().expose_secret(), upload_input)
945 .await
946 {
947 Ok(upload) => upload,
948 Err(error) => {
949 let backend_error = api_error_to_backend(error);
950 backend
951 .record_failed_transaction(identity, initial_chat_id, backend_error.clone())
952 .await?;
953 return Err(backend_error);
954 }
955 };
956 let media = match input_media_from_upload(&upload) {
957 Ok(media) => media,
958 Err(error) => {
959 backend
960 .record_failed_transaction(identity, initial_chat_id, error.clone())
961 .await?;
962 return Err(error);
963 }
964 };
965
966 backend
967 .store
968 .record_transaction(
969 StoredTransaction::new(identity.clone(), TransactionState::Sent)
970 .with_chat_id(initial_chat_id),
971 )
972 .await
973 .map_err(store_error_to_backend)?;
974
975 let input = proto::SendMessageInput {
976 peer_id: Some(input_peer_for_client_peer(request.peer)),
977 message: request.caption.clone(),
978 reply_to_msg_id: request.reply_to_message_id.map(InlineId::get),
979 random_id: Some(random_id.get()),
980 media: Some(media),
981 temporary_send_date: Some(now_seconds()),
982 is_sticker: None,
983 has_link: None,
984 entities: None,
985 parse_markdown: Some(false),
986 send_mode: None,
987 actions: None,
988 };
989 let send_result = match backend.call_realtime(&session, input).await {
990 Ok(result) => result,
991 Err(backend_error) => {
992 backend
993 .record_failed_transaction(identity, initial_chat_id, backend_error.clone())
994 .await?;
995 return Err(backend_error);
996 }
997 };
998
999 let applied =
1000 apply_upload_message_updates(&request, identity, initial_chat_id, send_result);
1001 if let Some(message) = &applied.message {
1002 backend
1003 .store
1004 .record_message(message.clone())
1005 .await
1006 .map_err(store_error_to_backend)?;
1007 }
1008 backend
1009 .store
1010 .record_transaction(applied.transaction.clone())
1011 .await
1012 .map_err(store_error_to_backend)?;
1013
1014 Ok(SendTextOutcome::with_state(
1015 MessageMutation {
1016 transaction: applied.transaction.identity,
1017 message_id: applied.message_id,
1018 },
1019 applied.chat_id,
1020 applied.message_id,
1021 applied.message,
1022 applied.transaction.state,
1023 applied.transaction.failure,
1024 ))
1025 })
1026 }
1027
1028 fn edit_message(
1029 &self,
1030 request: EditMessageRequest,
1031 ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
1032 let backend = self.clone();
1033 Box::pin(async move {
1034 if request.text.trim().is_empty() {
1035 return Err(BackendError::new(
1036 ClientErrorCategory::InvalidInput,
1037 "message text must not be empty",
1038 ));
1039 }
1040 let session = backend.require_session().await?;
1041 let result = backend
1042 .call_realtime(
1043 &session,
1044 proto::EditMessageInput {
1045 message_id: request.message_id.get(),
1046 peer_id: Some(input_peer_for_chat(request.chat_id)),
1047 text: request.text,
1048 entities: None,
1049 parse_markdown: Some(false),
1050 actions: None,
1051 },
1052 )
1053 .await?;
1054 let events = backend
1055 .apply_updates(result.updates, Some(request.chat_id), None)
1056 .await?;
1057 Ok(OperationOutcome::with_events(events))
1058 })
1059 }
1060
1061 fn delete_message(
1062 &self,
1063 request: DeleteMessageRequest,
1064 ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
1065 let backend = self.clone();
1066 Box::pin(async move {
1067 let session = backend.require_session().await?;
1068 let result = backend
1069 .call_realtime(
1070 &session,
1071 proto::DeleteMessagesInput {
1072 message_ids: vec![request.message_id.get()],
1073 peer_id: Some(input_peer_for_chat(request.chat_id)),
1074 },
1075 )
1076 .await?;
1077 let mut events = backend
1078 .apply_updates(result.updates, Some(request.chat_id), None)
1079 .await?;
1080 if !events.iter().any(|event| {
1081 matches!(
1082 event,
1083 ClientEvent::MessageDeleted {
1084 chat_id,
1085 message_id
1086 } if *chat_id == request.chat_id && *message_id == request.message_id
1087 )
1088 }) {
1089 events.push(ClientEvent::MessageDeleted {
1090 chat_id: request.chat_id,
1091 message_id: request.message_id,
1092 });
1093 }
1094 Ok(OperationOutcome::with_events(events))
1095 })
1096 }
1097
1098 fn react(&self, request: ReactRequest) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
1099 let backend = self.clone();
1100 Box::pin(async move {
1101 let reaction = request.reaction.trim().to_owned();
1102 if reaction.is_empty() {
1103 return Err(BackendError::new(
1104 ClientErrorCategory::InvalidInput,
1105 "reaction must not be empty",
1106 ));
1107 }
1108 let session = backend.require_session().await?;
1109 let updates = if request.remove {
1110 backend
1111 .call_realtime(
1112 &session,
1113 proto::DeleteReactionInput {
1114 emoji: reaction.clone(),
1115 peer_id: Some(input_peer_for_chat(request.chat_id)),
1116 message_id: request.message_id.get(),
1117 },
1118 )
1119 .await?
1120 .updates
1121 } else {
1122 backend
1123 .call_realtime(
1124 &session,
1125 proto::AddReactionInput {
1126 emoji: reaction.clone(),
1127 message_id: request.message_id.get(),
1128 peer_id: Some(input_peer_for_chat(request.chat_id)),
1129 },
1130 )
1131 .await?
1132 .updates
1133 };
1134 let mut events = backend
1135 .apply_updates(updates, Some(request.chat_id), None)
1136 .await?;
1137 if !events.iter().any(|event| {
1138 matches!(
1139 event,
1140 ClientEvent::ReactionChanged {
1141 chat_id,
1142 message_id,
1143 ..
1144 } if *chat_id == request.chat_id && *message_id == request.message_id
1145 )
1146 }) {
1147 events.push(ClientEvent::ReactionChanged {
1148 chat_id: request.chat_id,
1149 message_id: request.message_id,
1150 user_id: InlineId::new(0),
1151 reaction,
1152 removed: request.remove,
1153 });
1154 }
1155 Ok(OperationOutcome::with_events(events))
1156 })
1157 }
1158
1159 fn read(&self, request: ReadRequest) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
1160 let backend = self.clone();
1161 Box::pin(async move {
1162 let session = backend.require_session().await?;
1163 let result = backend
1164 .call_realtime(
1165 &session,
1166 proto::ReadMessagesInput {
1167 peer_id: Some(input_peer_for_chat(request.chat_id)),
1168 max_id: request.max_message_id.map(InlineId::get),
1169 },
1170 )
1171 .await?;
1172 let mut events = backend
1173 .apply_updates(result.updates, Some(request.chat_id), None)
1174 .await?;
1175 if !events.iter().any(|event| {
1176 matches!(event, ClientEvent::ReadStateChanged { chat_id } if *chat_id == request.chat_id)
1177 }) {
1178 events.push(ClientEvent::ReadStateChanged {
1179 chat_id: request.chat_id,
1180 });
1181 }
1182 Ok(OperationOutcome::with_events(events))
1183 })
1184 }
1185
1186 fn typing(
1187 &self,
1188 request: TypingRequest,
1189 ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
1190 let backend = self.clone();
1191 Box::pin(async move {
1192 let session = backend.require_session().await?;
1193 backend
1194 .call_realtime(
1195 &session,
1196 proto::SendComposeActionInput {
1197 peer_id: Some(input_peer_for_chat(request.chat_id)),
1198 action: request
1199 .is_typing
1200 .then_some(proto::update_compose_action::ComposeAction::Typing as i32),
1201 },
1202 )
1203 .await?;
1204 Ok(OperationOutcome::empty())
1205 })
1206 }
1207
1208 fn receive_events(&self) -> BoxFuture<'static, BackendResult<Vec<ClientEvent>>> {
1209 let backend = self.clone();
1210 Box::pin(async move {
1211 let session = backend.require_session().await?;
1212 loop {
1213 match backend.receive_realtime_event(&session).await? {
1214 RealtimeEvent::Updates(updates) => {
1215 let events = backend.apply_updates(updates, None, None).await?;
1216 if !events.is_empty() {
1217 return Ok(events);
1218 }
1219 }
1220 RealtimeEvent::Ack { .. } | RealtimeEvent::Pong { .. } => {}
1221 _ => {}
1222 }
1223 }
1224 })
1225 }
1226}
1227
1228impl SdkBackend {
1229 async fn record_failed_transaction(
1230 &self,
1231 identity: TransactionIdentity,
1232 chat_id: InlineId,
1233 error: BackendError,
1234 ) -> BackendResult<()> {
1235 self.store
1236 .record_transaction(
1237 StoredTransaction::new(identity, TransactionState::Failed)
1238 .with_chat_id(chat_id)
1239 .with_failure(error.into()),
1240 )
1241 .await
1242 .map_err(store_error_to_backend)
1243 }
1244
1245 async fn apply_updates(
1246 &self,
1247 updates: Vec<proto::Update>,
1248 fallback_chat_id: Option<InlineId>,
1249 transaction: Option<TransactionIdentity>,
1250 ) -> BackendResult<Vec<ClientEvent>> {
1251 let mut events = Vec::new();
1252 for update in updates {
1253 match update.update {
1254 Some(proto::update::Update::NewMessage(update)) => {
1255 if let Some(message) = update.message {
1256 let record =
1257 message_record_from_proto_message(message, fallback_chat_id, None);
1258 self.store
1259 .record_message(record.clone())
1260 .await
1261 .map_err(store_error_to_backend)?;
1262 events.push(ClientEvent::MessageStored { message: record });
1263 }
1264 }
1265 Some(proto::update::Update::EditMessage(update)) => {
1266 if let Some(message) = update.message {
1267 let record =
1268 message_record_from_proto_message(message, fallback_chat_id, None);
1269 self.store
1270 .record_message(record.clone())
1271 .await
1272 .map_err(store_error_to_backend)?;
1273 events.push(ClientEvent::MessageStored { message: record });
1274 }
1275 }
1276 Some(proto::update::Update::DeleteMessages(update)) => {
1277 let chat_id = update
1278 .peer_id
1279 .as_ref()
1280 .and_then(chat_id_from_peer)
1281 .or(fallback_chat_id);
1282 if let Some(chat_id) = chat_id {
1283 for message_id in update.message_ids {
1284 events.push(ClientEvent::MessageDeleted {
1285 chat_id,
1286 message_id: InlineId::new(message_id),
1287 });
1288 }
1289 }
1290 }
1291 Some(proto::update::Update::UpdateReaction(update)) => {
1292 if let Some(reaction) = update.reaction {
1293 events.push(ClientEvent::ReactionChanged {
1294 chat_id: InlineId::new(reaction.chat_id),
1295 message_id: InlineId::new(reaction.message_id),
1296 user_id: InlineId::new(reaction.user_id),
1297 reaction: reaction.emoji,
1298 removed: false,
1299 });
1300 }
1301 }
1302 Some(proto::update::Update::DeleteReaction(update)) => {
1303 events.push(ClientEvent::ReactionChanged {
1304 chat_id: InlineId::new(update.chat_id),
1305 message_id: InlineId::new(update.message_id),
1306 user_id: InlineId::new(update.user_id),
1307 reaction: update.emoji,
1308 removed: true,
1309 });
1310 }
1311 Some(proto::update::Update::UpdateReadMaxId(update)) => {
1312 let chat_id = update
1313 .peer_id
1314 .as_ref()
1315 .and_then(chat_id_from_peer)
1316 .or(fallback_chat_id);
1317 if let Some(chat_id) = chat_id {
1318 events.push(ClientEvent::ReadStateChanged { chat_id });
1319 }
1320 }
1321 Some(proto::update::Update::UpdateComposeAction(update)) => {
1322 let chat_id = update
1323 .peer_id
1324 .as_ref()
1325 .and_then(chat_id_from_peer)
1326 .or(fallback_chat_id);
1327 if let Some(chat_id) = chat_id {
1328 events.push(ClientEvent::Typing {
1329 chat_id,
1330 user_id: InlineId::new(update.user_id),
1331 is_typing: update.action
1332 == proto::update_compose_action::ComposeAction::Typing as i32,
1333 });
1334 }
1335 }
1336 Some(proto::update::Update::UpdateMessageId(update)) => {
1337 if let (Some(chat_id), Some(transaction)) =
1338 (fallback_chat_id, transaction.as_ref())
1339 && update.random_id == transaction.random_id.get()
1340 {
1341 events.push(ClientEvent::MessageUpserted {
1342 chat_id,
1343 message_id: InlineId::new(update.message_id),
1344 });
1345 }
1346 }
1347 _ => {}
1348 }
1349 }
1350 Ok(events)
1351 }
1352}
1353
1354fn store_error_to_backend(error: StoreError) -> BackendError {
1355 BackendError::new(error.category, error.message)
1356}
1357
1358fn dialogs_page_from_get_chats(
1359 result: &proto::GetChatsResult,
1360 request: DialogsRequest,
1361) -> BackendResult<DialogsPage> {
1362 let users_by_id = result
1363 .users
1364 .iter()
1365 .map(|user| (user.id, user))
1366 .collect::<HashMap<_, _>>();
1367 let start = request
1368 .cursor
1369 .as_deref()
1370 .map(str::trim)
1371 .filter(|cursor| !cursor.is_empty())
1372 .map(|cursor| {
1373 cursor.parse::<usize>().map_err(|_| {
1374 BackendError::new(
1375 ClientErrorCategory::InvalidInput,
1376 "invalid pagination cursor",
1377 )
1378 })
1379 })
1380 .transpose()?
1381 .unwrap_or(0);
1382 let limit = request.limit.unwrap_or(100).max(1) as usize;
1383
1384 let mut dialogs = result
1385 .chats
1386 .iter()
1387 .map(|chat| {
1388 let dialog = dialog_for_chat(result, chat);
1389 DialogRecord {
1390 chat_id: InlineId::new(chat.id),
1391 peer_user_id: dialog_peer_user_id(dialog, chat),
1392 title: Some(chat_display_name_from_proto(chat, &users_by_id)),
1393 last_message_id: chat.last_msg_id.map(InlineId::new),
1394 synced_through_message_id: None,
1395 unread_count: dialog
1396 .and_then(|dialog| dialog.unread_count)
1397 .and_then(|count| u32::try_from(count).ok()),
1398 }
1399 })
1400 .collect::<Vec<_>>();
1401 let total = dialogs.len();
1402 if start >= dialogs.len() {
1403 dialogs.clear();
1404 } else {
1405 dialogs = dialogs.into_iter().skip(start).take(limit).collect();
1406 }
1407 Ok(DialogsPage {
1408 dialogs,
1409 users: result.users.iter().map(user_record_from_proto).collect(),
1410 next_cursor: (start + limit < total).then(|| (start + limit).to_string()),
1411 })
1412}
1413
1414fn dialog_for_chat<'a>(
1415 result: &'a proto::GetChatsResult,
1416 chat: &proto::Chat,
1417) -> Option<&'a proto::Dialog> {
1418 result.dialogs.iter().find(|dialog| {
1419 dialog.chat_id == Some(chat.id)
1420 || dialog
1421 .peer
1422 .as_ref()
1423 .zip(chat.peer_id.as_ref())
1424 .is_some_and(|(dialog_peer, chat_peer)| dialog_peer == chat_peer)
1425 })
1426}
1427
1428fn dialog_peer_user_id(dialog: Option<&proto::Dialog>, chat: &proto::Chat) -> Option<InlineId> {
1429 dialog
1430 .and_then(|dialog| dialog.peer.as_ref())
1431 .or(chat.peer_id.as_ref())
1432 .and_then(user_id_from_peer)
1433 .map(InlineId::new)
1434}
1435
1436fn chat_display_name_from_proto(
1437 chat: &proto::Chat,
1438 users_by_id: &HashMap<i64, &proto::User>,
1439) -> String {
1440 let mut name = chat
1441 .peer_id
1442 .as_ref()
1443 .and_then(user_id_from_peer)
1444 .and_then(|user_id| users_by_id.get(&user_id))
1445 .and_then(|user| user_display_name_from_proto(user))
1446 .unwrap_or_else(|| {
1447 let title = chat.title.trim();
1448 if title.is_empty() {
1449 format!("Chat {}", chat.id)
1450 } else {
1451 title.to_owned()
1452 }
1453 });
1454 if let Some(emoji) = chat
1455 .emoji
1456 .as_deref()
1457 .map(str::trim)
1458 .filter(|value| !value.is_empty())
1459 {
1460 name = format!("{emoji} {name}");
1461 }
1462 name
1463}
1464
1465fn user_record_from_proto(user: &proto::User) -> UserRecord {
1466 UserRecord {
1467 user_id: InlineId::new(user.id),
1468 display_name: user_display_name_from_proto(user),
1469 username: non_empty_option(user.username.as_deref()),
1470 first_name: non_empty_option(user.first_name.as_deref()),
1471 last_name: non_empty_option(user.last_name.as_deref()),
1472 avatar_url: user
1473 .profile_photo
1474 .as_ref()
1475 .and_then(|photo| non_empty_option(photo.cdn_url.as_deref()))
1476 .or_else(|| {
1477 user.bot_avatar
1478 .as_ref()
1479 .and_then(|avatar| non_empty_option(avatar.cdn_url.as_deref()))
1480 }),
1481 is_bot: user.bot,
1482 }
1483}
1484
1485fn chat_participants_page_from_proto(
1486 result: proto::GetChatParticipantsResult,
1487) -> ChatParticipantsPage {
1488 let mut seen = std::collections::HashSet::new();
1489 let mut participants = Vec::new();
1490 for participant in result.participants {
1491 if seen.insert(participant.user_id) {
1492 participants.push(ChatParticipantRecord {
1493 user_id: InlineId::new(participant.user_id),
1494 date: Some(participant.date),
1495 });
1496 }
1497 }
1498 participants.sort_by_key(|participant| participant.user_id.get());
1499 ChatParticipantsPage {
1500 participants,
1501 users: result.users.iter().map(user_record_from_proto).collect(),
1502 }
1503}
1504
1505fn validate_create_participants(participants: &[ChatCreateParticipant]) -> BackendResult<()> {
1506 if participants
1507 .iter()
1508 .any(|participant| participant.user_id.get() <= 0)
1509 {
1510 return Err(BackendError::new(
1511 ClientErrorCategory::InvalidInput,
1512 "participant user_id must be positive",
1513 ));
1514 }
1515 Ok(())
1516}
1517
1518fn created_chat_from_private_chat_result(
1519 chat: Value,
1520 dialog: Value,
1521 user: Value,
1522 user_id: InlineId,
1523) -> BackendResult<(CreatedChat, Option<UserRecord>)> {
1524 let chat_id = api_i64_field(&chat, &["id", "chatId", "chat_id"])
1525 .or_else(|| api_i64_field(&dialog, &["chatId", "chat_id"]))
1526 .ok_or_else(|| {
1527 BackendError::new(
1528 ClientErrorCategory::ProtocolMismatch,
1529 "create_private_chat result did not include a chat id",
1530 )
1531 })?;
1532 let user = user_record_from_api_value(&user).or(Some(UserRecord {
1533 user_id,
1534 display_name: None,
1535 username: None,
1536 first_name: None,
1537 last_name: None,
1538 avatar_url: None,
1539 is_bot: None,
1540 }));
1541 let title = api_string_field(&chat, &["title"])
1542 .or_else(|| user.as_ref().and_then(user_display_name_from_record));
1543 Ok((
1544 CreatedChat {
1545 chat_id: InlineId::new(chat_id),
1546 title,
1547 parent_chat_id: None,
1548 parent_message_id: None,
1549 },
1550 user,
1551 ))
1552}
1553
1554fn created_chat_from_proto(
1555 chat: Option<proto::Chat>,
1556 dialog: Option<proto::Dialog>,
1557 anchor_message: Option<proto::Message>,
1558 parent_chat_id: Option<InlineId>,
1559 parent_message_id: Option<InlineId>,
1560) -> BackendResult<CreatedChat> {
1561 let chat_id = chat
1562 .as_ref()
1563 .map(|chat| chat.id)
1564 .or_else(|| dialog.as_ref().and_then(|dialog| dialog.chat_id))
1565 .ok_or_else(|| {
1566 BackendError::new(
1567 ClientErrorCategory::ProtocolMismatch,
1568 "create chat result did not include a chat id",
1569 )
1570 })?;
1571 let title = chat
1572 .as_ref()
1573 .and_then(|chat| trimmed_option(Some(chat.title.clone())));
1574 let parent_chat_id = parent_chat_id.or_else(|| {
1575 chat.as_ref()
1576 .and_then(|chat| chat.parent_chat_id)
1577 .map(InlineId::new)
1578 });
1579 let parent_message_id = parent_message_id
1580 .or_else(|| {
1581 chat.as_ref()
1582 .and_then(|chat| chat.parent_message_id)
1583 .map(InlineId::new)
1584 })
1585 .or_else(|| anchor_message.map(|message| InlineId::new(message.id)));
1586
1587 Ok(CreatedChat {
1588 chat_id: InlineId::new(chat_id),
1589 title,
1590 parent_chat_id,
1591 parent_message_id,
1592 })
1593}
1594
1595fn user_record_from_api_value(value: &Value) -> Option<UserRecord> {
1596 let user_id = api_i64_field(value, &["id", "userId", "user_id"])?;
1597 Some(UserRecord {
1598 user_id: InlineId::new(user_id),
1599 display_name: api_string_field(value, &["displayName", "display_name", "name"]),
1600 username: api_string_field(value, &["username"]),
1601 first_name: api_string_field(value, &["firstName", "first_name"]),
1602 last_name: api_string_field(value, &["lastName", "last_name"]),
1603 avatar_url: api_string_field(value, &["avatarUrl", "avatar_url"])
1604 .or_else(|| api_nested_string_field(value, "profilePhoto", &["cdnUrl", "cdn_url"]))
1605 .or_else(|| api_nested_string_field(value, "profile_photo", &["cdnUrl", "cdn_url"]))
1606 .or_else(|| api_nested_string_field(value, "botAvatar", &["cdnUrl", "cdn_url"]))
1607 .or_else(|| api_nested_string_field(value, "bot_avatar", &["cdnUrl", "cdn_url"])),
1608 is_bot: api_bool_field(value, &["isBot", "is_bot", "bot"]),
1609 })
1610}
1611
1612fn user_display_name_from_record(user: &UserRecord) -> Option<String> {
1613 user.display_name
1614 .clone()
1615 .or_else(|| {
1616 let first = user.first_name.as_deref().unwrap_or_default().trim();
1617 let last = user.last_name.as_deref().unwrap_or_default().trim();
1618 let full = [first, last]
1619 .into_iter()
1620 .filter(|part| !part.is_empty())
1621 .collect::<Vec<_>>()
1622 .join(" ");
1623 (!full.is_empty()).then_some(full)
1624 })
1625 .or_else(|| user.username.clone())
1626}
1627
1628fn api_i64_field(value: &Value, fields: &[&str]) -> Option<i64> {
1629 fields
1630 .iter()
1631 .find_map(|field| value.get(*field).and_then(value_as_i64))
1632}
1633
1634fn api_bool_field(value: &Value, fields: &[&str]) -> Option<bool> {
1635 fields
1636 .iter()
1637 .find_map(|field| value.get(*field).and_then(Value::as_bool))
1638}
1639
1640fn api_string_field(value: &Value, fields: &[&str]) -> Option<String> {
1641 fields
1642 .iter()
1643 .find_map(|field| value.get(*field).and_then(value_as_string))
1644}
1645
1646fn api_nested_string_field(value: &Value, field: &str, nested_fields: &[&str]) -> Option<String> {
1647 value
1648 .get(field)
1649 .and_then(|nested| api_string_field(nested, nested_fields))
1650}
1651
1652fn value_as_i64(value: &Value) -> Option<i64> {
1653 value
1654 .as_i64()
1655 .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok()))
1656 .or_else(|| value.as_str()?.trim().parse().ok())
1657}
1658
1659fn value_as_string(value: &Value) -> Option<String> {
1660 value
1661 .as_str()
1662 .map(str::trim)
1663 .filter(|value| !value.is_empty())
1664 .map(ToOwned::to_owned)
1665}
1666
1667fn trimmed_option(value: Option<String>) -> Option<String> {
1668 value
1669 .map(|value| value.trim().to_owned())
1670 .filter(|value| !value.is_empty())
1671}
1672
1673fn user_display_name_from_proto(user: &proto::User) -> Option<String> {
1674 if let Some(bot_name) = user
1675 .bot_avatar
1676 .as_ref()
1677 .and_then(|avatar| non_empty_option(Some(&avatar.display_name)))
1678 {
1679 return Some(bot_name);
1680 }
1681 let first = user.first_name.as_deref().unwrap_or_default().trim();
1682 let last = user.last_name.as_deref().unwrap_or_default().trim();
1683 let full = [first, last]
1684 .into_iter()
1685 .filter(|part| !part.is_empty())
1686 .collect::<Vec<_>>()
1687 .join(" ");
1688 if !full.is_empty() {
1689 return Some(full);
1690 }
1691 non_empty_option(user.username.as_deref())
1692}
1693
1694fn user_id_from_peer(peer: &proto::Peer) -> Option<i64> {
1695 match &peer.r#type {
1696 Some(proto::peer::Type::User(user)) => Some(user.user_id),
1697 _ => None,
1698 }
1699}
1700
1701fn non_empty_option(value: Option<&str>) -> Option<String> {
1702 value
1703 .map(str::trim)
1704 .filter(|value| !value.is_empty())
1705 .map(ToOwned::to_owned)
1706}
1707
1708fn realtime_error_to_backend(error: RealtimeError) -> BackendError {
1709 match error {
1710 RealtimeError::InvalidUrl { message, .. } => {
1711 BackendError::new(ClientErrorCategory::InvalidInput, message)
1712 }
1713 RealtimeError::Timeout { .. } => {
1714 BackendError::new(ClientErrorCategory::Timeout, error.to_string())
1715 }
1716 RealtimeError::ConnectionError { .. } => {
1717 BackendError::new(ClientErrorCategory::AuthExpired, error.to_string())
1718 }
1719 RealtimeError::RpcError {
1720 code,
1721 error_name,
1722 friendly,
1723 ..
1724 } if code == 420 || error_name == "RATE_LIMIT" => {
1725 BackendError::new(ClientErrorCategory::RateLimited, friendly)
1726 }
1727 RealtimeError::RpcError {
1728 error_name,
1729 friendly,
1730 ..
1731 } if error_name == "UNAUTHENTICATED" => {
1732 BackendError::new(ClientErrorCategory::AuthExpired, friendly)
1733 }
1734 RealtimeError::RpcError { friendly, .. } => {
1735 BackendError::new(ClientErrorCategory::Internal, friendly)
1736 }
1737 RealtimeError::ConnectionClosed | RealtimeError::WebSocket(_) => {
1738 BackendError::new(ClientErrorCategory::Network, error.to_string())
1739 }
1740 RealtimeError::InvalidHeaderValue { .. }
1741 | RealtimeError::Protocol(_)
1742 | RealtimeError::MissingResult
1743 | RealtimeError::UnexpectedResult { .. } => {
1744 BackendError::new(ClientErrorCategory::ProtocolMismatch, error.to_string())
1745 }
1746 _ => BackendError::new(ClientErrorCategory::Internal, error.to_string()),
1747 }
1748}
1749
1750fn api_error_to_backend(error: ApiError) -> BackendError {
1751 let rendered = error.to_string();
1752 match error {
1753 ApiError::InvalidBaseUrl { message, .. } | ApiError::InvalidInput { message } => {
1754 BackendError::new(ClientErrorCategory::InvalidInput, message)
1755 }
1756 ApiError::Status { status, .. } if status == 401 || status == 403 => {
1757 BackendError::new(ClientErrorCategory::AuthExpired, rendered)
1758 }
1759 ApiError::Api {
1760 status: Some(401) | Some(403),
1761 ..
1762 } => BackendError::new(ClientErrorCategory::AuthExpired, rendered),
1763 ApiError::Http(_) | ApiError::Status { .. } => {
1764 BackendError::new(ClientErrorCategory::Network, rendered)
1765 }
1766 ApiError::Json(_) => BackendError::new(ClientErrorCategory::ProtocolMismatch, rendered),
1767 ApiError::Io(_) | ApiError::Api { .. } => {
1768 BackendError::new(ClientErrorCategory::Internal, rendered)
1769 }
1770 _ => BackendError::new(ClientErrorCategory::Internal, rendered),
1771 }
1772}
1773
1774fn input_peer_for_client_peer(peer: PeerRef) -> proto::InputPeer {
1775 proto::InputPeer {
1776 r#type: Some(match peer {
1777 PeerRef::User { user_id } => proto::input_peer::Type::User(proto::InputPeerUser {
1778 user_id: user_id.get(),
1779 }),
1780 PeerRef::Chat { chat_id } => proto::input_peer::Type::Chat(proto::InputPeerChat {
1781 chat_id: chat_id.get(),
1782 }),
1783 PeerRef::Thread { thread_id } => proto::input_peer::Type::Chat(proto::InputPeerChat {
1784 chat_id: thread_id.get(),
1785 }),
1786 }),
1787 }
1788}
1789
1790fn input_peer_for_chat(chat_id: InlineId) -> proto::InputPeer {
1791 proto::InputPeer {
1792 r#type: Some(proto::input_peer::Type::Chat(proto::InputPeerChat {
1793 chat_id: chat_id.get(),
1794 })),
1795 }
1796}
1797
1798fn history_input_for_request(
1799 request: &HistoryRequest,
1800 fetch_limit: i32,
1801) -> proto::GetChatHistoryInput {
1802 if let Some(after) = request.after_message_id {
1803 return proto::GetChatHistoryInput {
1804 peer_id: Some(input_peer_for_chat(request.chat_id)),
1805 offset_id: None,
1806 limit: Some(fetch_limit),
1807 mode: Some(proto::GetChatHistoryMode::HistoryModeNewer as i32),
1808 anchor_id: None,
1809 before_id: None,
1810 after_id: Some(after.get()),
1811 before_limit: None,
1812 after_limit: None,
1813 include_anchor: None,
1814 };
1815 }
1816
1817 proto::GetChatHistoryInput {
1818 peer_id: Some(input_peer_for_chat(request.chat_id)),
1819 offset_id: request.before_message_id.map(InlineId::get),
1820 limit: Some(fetch_limit),
1821 mode: None,
1822 anchor_id: None,
1823 before_id: None,
1824 after_id: None,
1825 before_limit: None,
1826 after_limit: None,
1827 include_anchor: None,
1828 }
1829}
1830
1831fn normalize_live_history_records(
1832 mut records: Vec<MessageRecord>,
1833 limit: u32,
1834 newer_page: bool,
1835) -> (Vec<MessageRecord>, bool) {
1836 records.sort_by_key(|message| (message.timestamp, message.message_id.get()));
1837 let limit = limit.max(1) as usize;
1838 let has_more = records.len() > limit;
1839 if has_more {
1840 if newer_page {
1841 records.truncate(limit);
1842 } else {
1843 records = records.split_off(records.len() - limit);
1844 }
1845 }
1846 (records, has_more)
1847}
1848
1849fn chat_id_from_peer(peer: &proto::Peer) -> Option<InlineId> {
1850 match &peer.r#type {
1851 Some(proto::peer::Type::Chat(chat)) => Some(InlineId::new(chat.chat_id)),
1852 Some(proto::peer::Type::User(user)) => Some(InlineId::new(user.user_id)),
1853 None => None,
1854 }
1855}
1856
1857fn chat_id_for_peer(peer: PeerRef) -> InlineId {
1858 match peer {
1859 PeerRef::User { user_id } => user_id,
1860 PeerRef::Chat { chat_id } => chat_id,
1861 PeerRef::Thread { thread_id } => thread_id,
1862 }
1863}
1864
1865fn transaction_id_for_send(request: &SendTextRequest, random_id: RandomId) -> TransactionId {
1866 let stable = request
1867 .external_id
1868 .as_ref()
1869 .map(|external| stable_hash(&format!("{}:{}", external.source(), external.id())))
1870 .unwrap_or_else(|| random_id.get() as u64);
1871 TransactionId::try_new(format!("sdk-send-{stable:016x}"))
1872 .expect("generated transaction ID should be valid")
1873}
1874
1875fn random_id_for_request(request: &SendTextRequest) -> RandomId {
1876 let seed = request
1877 .external_id
1878 .as_ref()
1879 .map(|external| format!("{}:{}:{}", external.source(), external.id(), request.text))
1880 .unwrap_or_else(|| format!("{}:{}", now_seconds(), request.text));
1881 RandomId::new((stable_hash(&seed) & 0x7fff_ffff_ffff_ffff) as i64)
1882}
1883
1884fn transaction_id_for_upload(request: &UploadRequest, random_id: RandomId) -> TransactionId {
1885 let stable = request
1886 .external_id
1887 .as_ref()
1888 .map(|external| stable_hash(&format!("{}:{}", external.source(), external.id())))
1889 .unwrap_or_else(|| random_id.get() as u64);
1890 TransactionId::try_new(format!("sdk-upload-{stable:016x}"))
1891 .expect("generated transaction ID should be valid")
1892}
1893
1894fn random_id_for_upload_request(request: &UploadRequest, size_bytes: usize) -> RandomId {
1895 let seed = request
1896 .external_id
1897 .as_ref()
1898 .map(|external| {
1899 format!(
1900 "{}:{}:{}",
1901 external.source(),
1902 external.id(),
1903 request.caption.as_deref().unwrap_or_default()
1904 )
1905 })
1906 .unwrap_or_else(|| {
1907 format!(
1908 "{}:{}:{}:{}",
1909 now_seconds(),
1910 request.file_name.as_deref().unwrap_or_default(),
1911 request.caption.as_deref().unwrap_or_default(),
1912 size_bytes
1913 )
1914 });
1915 RandomId::new((stable_hash(&seed) & 0x7fff_ffff_ffff_ffff) as i64)
1916}
1917
1918fn upload_input_for_request(request: &UploadRequest, bytes: Vec<u8>) -> UploadFileBytesInput {
1919 let file_name = request
1920 .file_name
1921 .as_deref()
1922 .map(str::trim)
1923 .filter(|value| !value.is_empty())
1924 .map(ToOwned::to_owned)
1925 .unwrap_or_else(|| default_upload_file_name(request));
1926 let file_type = upload_file_type_for_request(request);
1927 let mut input = UploadFileBytesInput::new(bytes, file_name, file_type);
1928 if let Some(mime_type) = request
1929 .mime_type
1930 .as_deref()
1931 .map(str::trim)
1932 .filter(|value| !value.is_empty())
1933 {
1934 input = input.with_mime_type(mime_type);
1935 }
1936 if file_type == UploadFileType::Video
1937 && let Some(metadata) = upload_video_metadata_for_request(request)
1938 {
1939 input = input.with_video_metadata(metadata);
1940 }
1941 input
1942}
1943
1944fn upload_file_type_for_request(request: &UploadRequest) -> UploadFileType {
1945 match request.kind {
1946 MediaKind::Photo => UploadFileType::Photo,
1947 MediaKind::Video if upload_video_metadata_for_request(request).is_some() => {
1948 UploadFileType::Video
1949 }
1950 MediaKind::Video | MediaKind::Document | MediaKind::Voice => UploadFileType::Document,
1951 }
1952}
1953
1954fn upload_video_metadata_for_request(request: &UploadRequest) -> Option<UploadVideoMetadata> {
1955 let width = i32::try_from(request.width?)
1956 .ok()
1957 .filter(|value| *value > 0)?;
1958 let height = i32::try_from(request.height?)
1959 .ok()
1960 .filter(|value| *value > 0)?;
1961 let duration = duration_ms_to_seconds_i32(request.duration_ms?)?;
1962 Some(UploadVideoMetadata::new(width, height, duration))
1963}
1964
1965fn duration_ms_to_seconds_i32(duration_ms: u64) -> Option<i32> {
1966 if duration_ms == 0 {
1967 return None;
1968 }
1969 let seconds = duration_ms.saturating_add(999) / 1_000;
1970 i32::try_from(seconds).ok().filter(|value| *value > 0)
1971}
1972
1973fn default_upload_file_name(request: &UploadRequest) -> String {
1974 let extension = match request.kind {
1975 MediaKind::Photo => extension_for_mime(request.mime_type.as_deref(), "jpg"),
1976 MediaKind::Video => extension_for_mime(request.mime_type.as_deref(), "mp4"),
1977 MediaKind::Voice => extension_for_mime(request.mime_type.as_deref(), "ogg"),
1978 MediaKind::Document => "bin",
1979 };
1980 format!("inline-upload.{}", extension.trim_start_matches('.'))
1981}
1982
1983fn extension_for_mime(mime_type: Option<&str>, fallback: &'static str) -> &'static str {
1984 match mime_type
1985 .unwrap_or_default()
1986 .trim()
1987 .to_ascii_lowercase()
1988 .as_str()
1989 {
1990 "image/png" => "png",
1991 "image/gif" => "gif",
1992 "image/webp" => "webp",
1993 "video/webm" => "webm",
1994 "audio/mpeg" => "mp3",
1995 "audio/mp4" | "audio/aac" => "m4a",
1996 _ => fallback,
1997 }
1998}
1999
2000fn input_media_from_upload(upload: &UploadFileResult) -> BackendResult<proto::InputMedia> {
2001 if let Some(photo_id) = upload.photo_id {
2002 return Ok(proto::InputMedia {
2003 media: Some(proto::input_media::Media::Photo(proto::InputMediaPhoto {
2004 photo_id,
2005 })),
2006 });
2007 }
2008 if let Some(video_id) = upload.video_id {
2009 return Ok(proto::InputMedia {
2010 media: Some(proto::input_media::Media::Video(proto::InputMediaVideo {
2011 video_id,
2012 })),
2013 });
2014 }
2015 if let Some(document_id) = upload.document_id {
2016 return Ok(proto::InputMedia {
2017 media: Some(proto::input_media::Media::Document(
2018 proto::InputMediaDocument { document_id },
2019 )),
2020 });
2021 }
2022 Err(BackendError::new(
2023 ClientErrorCategory::ProtocolMismatch,
2024 "uploadFile response did not include a media id",
2025 ))
2026}
2027
2028fn stable_hash(value: &str) -> u64 {
2029 let mut hash = 0xcbf2_9ce4_8422_2325u64;
2030 for byte in value.as_bytes() {
2031 hash ^= u64::from(*byte);
2032 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
2033 }
2034 hash
2035}
2036
2037#[derive(Debug)]
2038struct AppliedSendMessage {
2039 transaction: StoredTransaction,
2040 message: Option<MessageRecord>,
2041 chat_id: InlineId,
2042 message_id: Option<InlineId>,
2043}
2044
2045fn apply_send_message_updates(
2046 request: &SendTextRequest,
2047 identity: TransactionIdentity,
2048 fallback_chat_id: InlineId,
2049 result: proto::SendMessageResult,
2050) -> AppliedSendMessage {
2051 let mut final_message: Option<proto::Message> = None;
2052 let mut final_message_id: Option<InlineId> = None;
2053 for update in result.updates {
2054 match update.update {
2055 Some(proto::update::Update::NewMessage(update)) => {
2056 if let Some(message) = update.message {
2057 final_message_id = Some(InlineId::new(message.id));
2058 final_message = Some(message);
2059 }
2060 }
2061 Some(proto::update::Update::UpdateMessageId(update))
2062 if update.random_id == identity.random_id.get() =>
2063 {
2064 final_message_id = Some(InlineId::new(update.message_id));
2065 }
2066 _ => {}
2067 }
2068 }
2069
2070 let state = if final_message_id.is_some() {
2071 TransactionState::Completed
2072 } else {
2073 TransactionState::Acked
2074 };
2075 let mut identity = identity;
2076 if let Some(message_id) = final_message_id {
2077 identity = identity.with_final_message_id(message_id);
2078 }
2079 let message =
2080 final_message.map(|message| message_record_from_proto(message, request, identity.clone()));
2081 let chat_id = message
2082 .as_ref()
2083 .map(|message| message.chat_id)
2084 .unwrap_or(fallback_chat_id);
2085 let mut transaction = StoredTransaction::new(identity, state).with_chat_id(chat_id);
2086 if let Some(message_id) = final_message_id {
2087 transaction = transaction.with_message_id(message_id);
2088 }
2089
2090 AppliedSendMessage {
2091 transaction,
2092 message,
2093 chat_id,
2094 message_id: final_message_id,
2095 }
2096}
2097
2098fn apply_upload_message_updates(
2099 request: &UploadRequest,
2100 identity: TransactionIdentity,
2101 fallback_chat_id: InlineId,
2102 result: proto::SendMessageResult,
2103) -> AppliedSendMessage {
2104 let mut final_message: Option<proto::Message> = None;
2105 let mut final_message_id: Option<InlineId> = None;
2106 for update in result.updates {
2107 match update.update {
2108 Some(proto::update::Update::NewMessage(update)) => {
2109 if let Some(message) = update.message {
2110 final_message_id = Some(InlineId::new(message.id));
2111 final_message = Some(message);
2112 }
2113 }
2114 Some(proto::update::Update::UpdateMessageId(update))
2115 if update.random_id == identity.random_id.get() =>
2116 {
2117 final_message_id = Some(InlineId::new(update.message_id));
2118 }
2119 _ => {}
2120 }
2121 }
2122
2123 let state = if final_message_id.is_some() {
2124 TransactionState::Completed
2125 } else {
2126 TransactionState::Acked
2127 };
2128 let mut identity = identity;
2129 if let Some(message_id) = final_message_id {
2130 identity = identity.with_final_message_id(message_id);
2131 }
2132 let message = final_message.map(|message| {
2133 message_record_from_proto_message(
2134 message,
2135 Some(chat_id_for_peer(request.peer)),
2136 Some(identity.clone()),
2137 )
2138 });
2139 let chat_id = message
2140 .as_ref()
2141 .map(|message| message.chat_id)
2142 .unwrap_or(fallback_chat_id);
2143 let mut transaction = StoredTransaction::new(identity, state).with_chat_id(chat_id);
2144 if let Some(message_id) = final_message_id {
2145 transaction = transaction.with_message_id(message_id);
2146 }
2147
2148 AppliedSendMessage {
2149 transaction,
2150 message,
2151 chat_id,
2152 message_id: final_message_id,
2153 }
2154}
2155
2156fn message_record_from_proto(
2157 message: proto::Message,
2158 request: &SendTextRequest,
2159 transaction: TransactionIdentity,
2160) -> MessageRecord {
2161 message_record_from_proto_message(
2162 message,
2163 Some(chat_id_for_peer(request.peer)),
2164 Some(transaction),
2165 )
2166}
2167
2168fn message_record_from_proto_message(
2169 message: proto::Message,
2170 fallback_chat_id: Option<InlineId>,
2171 transaction: Option<TransactionIdentity>,
2172) -> MessageRecord {
2173 let chat_id = if message.chat_id != 0 {
2174 InlineId::new(message.chat_id)
2175 } else if let Some(peer) = &message.peer_id {
2176 chat_id_from_peer(peer).unwrap_or_else(|| fallback_chat_id.unwrap_or(InlineId::new(0)))
2177 } else {
2178 fallback_chat_id.unwrap_or(InlineId::new(0))
2179 };
2180 let content = content_from_proto_message(&message);
2181 MessageRecord {
2182 chat_id,
2183 message_id: InlineId::new(message.id),
2184 sender_id: InlineId::new(message.from_id),
2185 timestamp: message.date,
2186 is_outgoing: message.out,
2187 content,
2188 reply_to_message_id: message.reply_to_msg_id.map(InlineId::new),
2189 transaction,
2190 }
2191}
2192
2193fn content_from_proto_message(message: &proto::Message) -> MessageContent {
2194 match &message.media {
2195 None => match &message.message {
2196 Some(text) => MessageContent::Text { text: text.clone() },
2197 None => MessageContent::Unsupported {
2198 reason: "empty message content".to_owned(),
2199 },
2200 },
2201 Some(media) => media_content_from_proto(media, message.message.clone()),
2202 }
2203}
2204
2205fn media_content_from_proto(
2206 media: &proto::MessageMedia,
2207 caption: Option<String>,
2208) -> MessageContent {
2209 match &media.media {
2210 Some(proto::message_media::Media::Photo(photo)) => {
2211 if let Some(photo) = &photo.photo {
2212 let (url, size_bytes, width, height) = best_photo_size(photo);
2213 MessageContent::Media {
2214 kind: MediaKind::Photo,
2215 file_id: photo.id.to_string(),
2216 url,
2217 mime_type: photo_mime_type(photo),
2218 file_name: None,
2219 caption,
2220 size_bytes,
2221 width,
2222 height,
2223 duration_ms: None,
2224 }
2225 } else {
2226 empty_media_content(MediaKind::Photo, caption)
2227 }
2228 }
2229 Some(proto::message_media::Media::Video(video)) => {
2230 if let Some(video) = &video.video {
2231 MessageContent::Media {
2232 kind: MediaKind::Video,
2233 file_id: video.id.to_string(),
2234 url: video.cdn_url.clone(),
2235 mime_type: Some("video/mp4".to_owned()),
2236 file_name: None,
2237 caption,
2238 size_bytes: positive_u64(video.size),
2239 width: positive_u32(video.w),
2240 height: positive_u32(video.h),
2241 duration_ms: seconds_to_milliseconds(video.duration),
2242 }
2243 } else {
2244 empty_media_content(MediaKind::Video, caption)
2245 }
2246 }
2247 Some(proto::message_media::Media::Document(document)) => {
2248 if let Some(document) = &document.document {
2249 MessageContent::Media {
2250 kind: MediaKind::Document,
2251 file_id: document.id.to_string(),
2252 url: document.cdn_url.clone(),
2253 mime_type: non_empty_string(&document.mime_type),
2254 file_name: non_empty_string(&document.file_name),
2255 caption,
2256 size_bytes: positive_u64(document.size),
2257 width: None,
2258 height: None,
2259 duration_ms: None,
2260 }
2261 } else {
2262 empty_media_content(MediaKind::Document, caption)
2263 }
2264 }
2265 Some(proto::message_media::Media::Voice(voice)) => {
2266 if let Some(voice) = &voice.voice {
2267 MessageContent::Media {
2268 kind: MediaKind::Voice,
2269 file_id: voice.id.to_string(),
2270 url: voice.cdn_url.clone(),
2271 mime_type: non_empty_string(&voice.mime_type),
2272 file_name: None,
2273 caption,
2274 size_bytes: positive_u64(voice.size),
2275 width: None,
2276 height: None,
2277 duration_ms: seconds_to_milliseconds(voice.duration),
2278 }
2279 } else {
2280 empty_media_content(MediaKind::Voice, caption)
2281 }
2282 }
2283 Some(proto::message_media::Media::Nudge(_)) => MessageContent::Unsupported {
2284 reason: "nudge messages are not supported by inline-client yet".to_owned(),
2285 },
2286 None => MessageContent::Unsupported {
2287 reason: "empty media message".to_owned(),
2288 },
2289 }
2290}
2291
2292fn empty_media_content(kind: MediaKind, caption: Option<String>) -> MessageContent {
2293 MessageContent::Media {
2294 kind,
2295 file_id: String::new(),
2296 url: None,
2297 mime_type: None,
2298 file_name: None,
2299 caption,
2300 size_bytes: None,
2301 width: None,
2302 height: None,
2303 duration_ms: None,
2304 }
2305}
2306
2307fn best_photo_size(
2308 photo: &proto::Photo,
2309) -> (Option<String>, Option<u64>, Option<u32>, Option<u32>) {
2310 let mut best: Option<(&proto::PhotoSize, i64)> = None;
2311 for size in &photo.sizes {
2312 if size.cdn_url.is_none() {
2313 continue;
2314 }
2315 let area = i64::from(size.w) * i64::from(size.h);
2316 if best.is_none_or(|(_, best_area)| area > best_area) {
2317 best = Some((size, area));
2318 }
2319 }
2320 if let Some((size, _)) = best {
2321 return (
2322 size.cdn_url.clone(),
2323 positive_u64(size.size),
2324 positive_u32(size.w),
2325 positive_u32(size.h),
2326 );
2327 }
2328 (None, None, None, None)
2329}
2330
2331fn photo_mime_type(photo: &proto::Photo) -> Option<String> {
2332 match photo.format {
2333 1 => Some("image/jpeg".to_owned()),
2334 2 => Some("image/png".to_owned()),
2335 _ => None,
2336 }
2337}
2338
2339fn positive_u64(value: i32) -> Option<u64> {
2340 u64::try_from(value).ok().filter(|value| *value > 0)
2341}
2342
2343fn positive_u32(value: i32) -> Option<u32> {
2344 u32::try_from(value).ok().filter(|value| *value > 0)
2345}
2346
2347fn seconds_to_milliseconds(seconds: i32) -> Option<u64> {
2348 positive_u64(seconds).map(|seconds| seconds.saturating_mul(1_000))
2349}
2350
2351fn non_empty_string(value: &str) -> Option<String> {
2352 (!value.is_empty()).then(|| value.to_owned())
2353}
2354
2355fn outcome_from_stored_transaction(
2356 transaction: StoredTransaction,
2357 fallback_chat_id: InlineId,
2358) -> SendTextOutcome {
2359 let message_id = transaction
2360 .message_id
2361 .or(transaction.identity.final_message_id);
2362 SendTextOutcome::with_state(
2363 MessageMutation {
2364 transaction: transaction.identity.clone(),
2365 message_id,
2366 },
2367 transaction.chat_id.unwrap_or(fallback_chat_id),
2368 message_id,
2369 None,
2370 transaction.state,
2371 transaction.failure,
2372 )
2373}
2374
2375fn now_seconds() -> i64 {
2376 SystemTime::now()
2377 .duration_since(UNIX_EPOCH)
2378 .map(|duration| duration.as_secs() as i64)
2379 .unwrap_or_default()
2380}
2381
2382fn redacted_url_for_debug(url: &str) -> String {
2383 let without_fragment = url.split('#').next().unwrap_or(url);
2384 let without_query = without_fragment
2385 .split('?')
2386 .next()
2387 .unwrap_or(without_fragment);
2388 match without_query.split_once("://") {
2389 Some((scheme, rest)) => {
2390 let host_and_path = rest.rsplit_once('@').map_or(rest, |(_, tail)| tail);
2391 format!("{scheme}://{host_and_path}")
2392 }
2393 None => without_query.to_owned(),
2394 }
2395}
2396
2397#[cfg(test)]
2398mod tests {
2399 use futures_util::{SinkExt, StreamExt};
2400 use prost::Message;
2401 use tokio::net::TcpListener;
2402 use tokio_tungstenite::accept_async;
2403 use tokio_tungstenite::tungstenite::Message as WsMessage;
2404
2405 use crate::{
2406 AuthCredential, AuthToken, ClientBackend, DialogRecord, DialogsRequest, ExternalId,
2407 FakeRealtimeConnector, HistoryRequest, InlineId, RandomId, SendTextRequest,
2408 StoredTransaction,
2409 };
2410
2411 use super::*;
2412
2413 fn connect_request() -> ConnectRequest {
2414 ConnectRequest::new(AuthCredential::AccessToken {
2415 token: AuthToken::try_new("secret-token").unwrap(),
2416 })
2417 .with_account_namespace("team")
2418 }
2419
2420 fn test_message_record(message_id: i64) -> MessageRecord {
2421 MessageRecord {
2422 chat_id: InlineId::new(7),
2423 message_id: InlineId::new(message_id),
2424 sender_id: InlineId::new(2),
2425 timestamp: message_id,
2426 is_outgoing: false,
2427 content: MessageContent::Text {
2428 text: format!("message {message_id}"),
2429 },
2430 reply_to_message_id: None,
2431 transaction: None,
2432 }
2433 }
2434
2435 #[test]
2436 fn sdk_backend_debug_redacts_realtime_url_credentials() {
2437 let backend = SdkBackend::builder()
2438 .realtime_url("wss://user:secret@api.inline.chat/realtime?token=secret#frag")
2439 .build()
2440 .unwrap();
2441
2442 let rendered = format!("{backend:?}");
2443 assert!(rendered.contains("wss://api.inline.chat/realtime"));
2444 assert!(!rendered.contains("secret"));
2445 assert!(!rendered.contains("token="));
2446 }
2447
2448 #[test]
2449 fn sdk_backend_defaults_use_production_endpoints() {
2450 let backend = SdkBackend::builder().build().unwrap();
2451
2452 assert_eq!(
2453 backend.api_client().base_url(),
2454 "https://api.inline.chat/v1"
2455 );
2456 assert_eq!(backend.realtime_url(), "wss://api.inline.chat/realtime");
2457 }
2458
2459 #[tokio::test]
2460 async fn sdk_backend_connect_persists_session() {
2461 let store = InMemoryStore::new();
2462 let backend = SdkBackend::builder().store(store.clone()).build().unwrap();
2463
2464 let status = backend.connect(connect_request()).await.unwrap();
2465
2466 assert_eq!(status.status, crate::ClientStatus::Connected);
2467 let session = store.load_session().await.unwrap().unwrap();
2468 assert_eq!(session.account_namespace.as_deref(), Some("team"));
2469 let rendered = format!("{session:?}");
2470 assert!(!rendered.contains("secret-token"));
2471 }
2472
2473 #[tokio::test]
2474 async fn sdk_backend_connect_can_perform_realtime_handshake() {
2475 let store = InMemoryStore::new();
2476 let realtime = FakeRealtimeConnector::new();
2477 let backend = SdkBackend::builder()
2478 .store(store.clone())
2479 .realtime_connector(realtime.clone())
2480 .realtime_url("wss://api.inline.chat/realtime")
2481 .build()
2482 .unwrap();
2483
2484 assert!(backend.realtime_handshake_enabled());
2485 backend.connect(connect_request()).await.unwrap();
2486
2487 let attempts = realtime.attempts();
2488 assert_eq!(attempts.len(), 1);
2489 assert_eq!(attempts[0].realtime_url, "wss://api.inline.chat/realtime");
2490 assert!(store.load_session().await.unwrap().is_some());
2491 }
2492
2493 #[tokio::test]
2494 async fn sdk_backend_does_not_persist_session_when_realtime_handshake_fails() {
2495 let store = InMemoryStore::new();
2496 let backend = SdkBackend::builder()
2497 .store(store.clone())
2498 .realtime_connector(FakeRealtimeConnector::failing(BackendError::new(
2499 ClientErrorCategory::Network,
2500 "offline",
2501 )))
2502 .build()
2503 .unwrap();
2504
2505 let err = backend.connect(connect_request()).await.unwrap_err();
2506
2507 assert_eq!(err.category, ClientErrorCategory::Network);
2508 assert!(store.load_session().await.unwrap().is_none());
2509 }
2510
2511 #[tokio::test]
2512 async fn sdk_backend_resume_without_session_reports_auth_required() {
2513 let backend = SdkBackend::builder().build().unwrap();
2514
2515 let status = backend.resume_session().await.unwrap();
2516
2517 assert_eq!(status.status, crate::ClientStatus::AuthRequired);
2518 }
2519
2520 #[tokio::test]
2521 async fn sdk_backend_resume_uses_stored_session() {
2522 let store = InMemoryStore::new();
2523 store.save_session(connect_session()).await.unwrap();
2524 let realtime = FakeRealtimeConnector::new();
2525 let backend = SdkBackend::builder()
2526 .store(store)
2527 .realtime_connector(realtime.clone())
2528 .realtime_url("wss://api.inline.chat/realtime")
2529 .build()
2530 .unwrap();
2531
2532 let status = backend.resume_session().await.unwrap();
2533
2534 assert_eq!(status.status, crate::ClientStatus::Connected);
2535 let attempts = realtime.attempts();
2536 assert_eq!(attempts.len(), 1);
2537 assert_eq!(attempts[0].realtime_url, "wss://api.inline.chat/realtime");
2538 }
2539
2540 #[tokio::test]
2541 async fn sdk_backend_reads_dialogs_from_store_after_connect() {
2542 let store = InMemoryStore::new();
2543 store.upsert_dialog(DialogRecord {
2544 chat_id: InlineId::new(9),
2545 peer_user_id: Some(InlineId::new(10)),
2546 title: Some("general".to_owned()),
2547 last_message_id: None,
2548 synced_through_message_id: None,
2549 unread_count: Some(0),
2550 });
2551 let backend = SdkBackend::builder()
2552 .store(store)
2553 .realtime_url("not-a-websocket-url")
2554 .build()
2555 .unwrap();
2556
2557 backend.connect(connect_request()).await.unwrap();
2558 let dialogs = backend.dialogs(DialogsRequest::default()).await.unwrap();
2559
2560 assert_eq!(dialogs.dialogs.len(), 1);
2561 assert_eq!(dialogs.dialogs[0].chat_id, InlineId::new(9));
2562 }
2563
2564 #[tokio::test]
2565 async fn sdk_backend_reuses_realtime_connection_for_multiple_rpc_calls() {
2566 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2567 let addr = listener.local_addr().unwrap();
2568 let server = tokio::spawn(async move {
2569 let (stream, _) = listener.accept().await.unwrap();
2570 let mut ws = accept_async(stream).await.unwrap();
2571
2572 let init = read_test_client_message(&mut ws).await;
2573 assert!(matches!(
2574 init.body,
2575 Some(proto::client_message::Body::ConnectionInit(_))
2576 ));
2577 send_test_server_message(
2578 &mut ws,
2579 proto::ServerProtocolMessage {
2580 id: 1,
2581 body: Some(proto::server_protocol_message::Body::ConnectionOpen(
2582 proto::ConnectionOpen {},
2583 )),
2584 },
2585 )
2586 .await;
2587
2588 let get_chats = read_test_client_message(&mut ws).await;
2589 match &get_chats.body {
2590 Some(proto::client_message::Body::RpcCall(call)) => {
2591 assert_eq!(call.method, proto::Method::GetChats as i32);
2592 }
2593 other => panic!("expected getChats RPC, got {other:?}"),
2594 }
2595 send_test_server_message(
2596 &mut ws,
2597 proto::ServerProtocolMessage {
2598 id: 2,
2599 body: Some(proto::server_protocol_message::Body::RpcResult(
2600 proto::RpcResult {
2601 req_msg_id: get_chats.id,
2602 result: Some(proto::rpc_result::Result::GetChats(
2603 proto::GetChatsResult::default(),
2604 )),
2605 },
2606 )),
2607 },
2608 )
2609 .await;
2610
2611 let history = read_test_client_message(&mut ws).await;
2612 match &history.body {
2613 Some(proto::client_message::Body::RpcCall(call)) => {
2614 assert_eq!(call.method, proto::Method::GetChatHistory as i32);
2615 }
2616 other => panic!("expected getChatHistory RPC, got {other:?}"),
2617 }
2618 send_test_server_message(
2619 &mut ws,
2620 proto::ServerProtocolMessage {
2621 id: 3,
2622 body: Some(proto::server_protocol_message::Body::RpcResult(
2623 proto::RpcResult {
2624 req_msg_id: history.id,
2625 result: Some(proto::rpc_result::Result::GetChatHistory(
2626 proto::GetChatHistoryResult::default(),
2627 )),
2628 },
2629 )),
2630 },
2631 )
2632 .await;
2633 });
2634
2635 let store = InMemoryStore::new();
2636 store.save_session(connect_session()).await.unwrap();
2637 let backend = SdkBackend::builder()
2638 .store(store)
2639 .realtime_url(format!("ws://{addr}/realtime"))
2640 .without_realtime_handshake()
2641 .build()
2642 .unwrap();
2643
2644 let dialogs = backend.dialogs(DialogsRequest::default()).await.unwrap();
2645 assert!(dialogs.dialogs.is_empty());
2646 let history = tokio::time::timeout(
2647 std::time::Duration::from_secs(3),
2648 backend.history(HistoryRequest {
2649 chat_id: InlineId::new(7),
2650 limit: Some(10),
2651 before_message_id: None,
2652 after_message_id: None,
2653 }),
2654 )
2655 .await
2656 .expect("history should use the existing websocket")
2657 .unwrap();
2658 assert!(history.messages.is_empty());
2659 tokio::time::timeout(std::time::Duration::from_secs(3), server)
2660 .await
2661 .expect("server should observe both RPCs on one websocket")
2662 .unwrap();
2663 }
2664
2665 #[test]
2666 fn dialogs_page_from_get_chats_uses_user_records() {
2667 let result = proto::GetChatsResult {
2668 dialogs: vec![proto::Dialog {
2669 peer: Some(proto::Peer {
2670 r#type: Some(proto::peer::Type::User(proto::PeerUser { user_id: 42 })),
2671 }),
2672 chat_id: Some(7),
2673 unread_count: Some(3),
2674 ..Default::default()
2675 }],
2676 chats: vec![proto::Chat {
2677 id: 7,
2678 title: "Direct chat fallback".to_owned(),
2679 last_msg_id: Some(99),
2680 emoji: Some("*".to_owned()),
2681 peer_id: Some(proto::Peer {
2682 r#type: Some(proto::peer::Type::User(proto::PeerUser { user_id: 42 })),
2683 }),
2684 ..Default::default()
2685 }],
2686 users: vec![proto::User {
2687 id: 42,
2688 first_name: Some("Ada".to_owned()),
2689 last_name: Some("Lovelace".to_owned()),
2690 username: Some("ada".to_owned()),
2691 profile_photo: Some(proto::UserProfilePhoto {
2692 cdn_url: Some("https://cdn.inline.test/ada.jpg".to_owned()),
2693 ..Default::default()
2694 }),
2695 bot: Some(false),
2696 ..Default::default()
2697 }],
2698 ..Default::default()
2699 };
2700
2701 let page = dialogs_page_from_get_chats(
2702 &result,
2703 DialogsRequest {
2704 limit: Some(10),
2705 cursor: None,
2706 },
2707 )
2708 .unwrap();
2709
2710 assert_eq!(page.dialogs.len(), 1);
2711 assert_eq!(page.dialogs[0].chat_id, InlineId::new(7));
2712 assert_eq!(page.dialogs[0].title.as_deref(), Some("* Ada Lovelace"));
2713 assert_eq!(page.dialogs[0].peer_user_id, Some(InlineId::new(42)));
2714 assert_eq!(page.dialogs[0].last_message_id, Some(InlineId::new(99)));
2715 assert_eq!(page.dialogs[0].unread_count, Some(3));
2716 assert_eq!(page.users.len(), 1);
2717 assert_eq!(page.users[0].user_id, InlineId::new(42));
2718 assert_eq!(page.users[0].display_name.as_deref(), Some("Ada Lovelace"));
2719 assert_eq!(
2720 page.users[0].avatar_url.as_deref(),
2721 Some("https://cdn.inline.test/ada.jpg")
2722 );
2723 assert_eq!(page.users[0].is_bot, Some(false));
2724 }
2725
2726 #[test]
2727 fn chat_participants_page_uses_direct_participants() {
2728 let page = chat_participants_page_from_proto(proto::GetChatParticipantsResult {
2729 participants: vec![proto::ChatParticipant {
2730 user_id: 10,
2731 date: 100,
2732 }],
2733 users: vec![proto::User {
2734 id: 10,
2735 first_name: Some("Ada".to_owned()),
2736 ..Default::default()
2737 }],
2738 });
2739
2740 assert_eq!(page.participants.len(), 1);
2741 assert_eq!(page.participants[0].user_id, InlineId::new(10));
2742 assert_eq!(page.participants[0].date, Some(100));
2743 assert_eq!(page.users.len(), 1);
2744 }
2745
2746 #[tokio::test]
2747 async fn sdk_backend_requires_session_for_history() {
2748 let backend = SdkBackend::builder().build().unwrap();
2749
2750 let err = backend
2751 .history(HistoryRequest {
2752 chat_id: InlineId::new(1),
2753 limit: Some(10),
2754 before_message_id: None,
2755 after_message_id: None,
2756 })
2757 .await
2758 .expect_err("history should require connect");
2759
2760 assert_eq!(err.category, ClientErrorCategory::AuthRequired);
2761 }
2762
2763 #[tokio::test]
2764 async fn sdk_backend_send_text_requires_session() {
2765 let backend = SdkBackend::builder().build().unwrap();
2766
2767 let err = backend
2768 .send_text(SendTextRequest::new(
2769 crate::PeerRef::Chat {
2770 chat_id: InlineId::new(1),
2771 },
2772 "hello",
2773 ))
2774 .await
2775 .expect_err("send_text should require connect");
2776
2777 assert_eq!(err.category, ClientErrorCategory::AuthRequired);
2778 }
2779
2780 #[tokio::test]
2781 async fn sdk_backend_send_text_rejects_empty_text_before_network() {
2782 let backend = SdkBackend::builder().build().unwrap();
2783
2784 let err = backend
2785 .send_text(SendTextRequest::new(
2786 crate::PeerRef::Chat {
2787 chat_id: InlineId::new(1),
2788 },
2789 " ",
2790 ))
2791 .await
2792 .expect_err("empty message should fail before auth or network");
2793
2794 assert_eq!(err.category, ClientErrorCategory::InvalidInput);
2795 }
2796
2797 #[tokio::test]
2798 async fn sdk_backend_send_text_returns_existing_transaction_without_network() {
2799 let store = InMemoryStore::new();
2800 let request = SendTextRequest {
2801 peer: crate::PeerRef::Chat {
2802 chat_id: InlineId::new(7),
2803 },
2804 text: "hello".to_owned(),
2805 external_id: Some(ExternalId::try_new("host-event", "event-1").unwrap()),
2806 random_id: Some(RandomId::new(99)),
2807 reply_to_message_id: None,
2808 };
2809 let transaction_id = transaction_id_for_send(&request, request.random_id.unwrap());
2810 let identity = TransactionIdentity::new(
2811 transaction_id.clone(),
2812 request.external_id.clone(),
2813 request.random_id.unwrap(),
2814 )
2815 .with_final_message_id(InlineId::new(11));
2816 store.save_session(connect_session()).await.unwrap();
2817 store
2818 .record_transaction(
2819 StoredTransaction::new(identity, TransactionState::Completed)
2820 .with_chat_id(InlineId::new(7))
2821 .with_message_id(InlineId::new(11)),
2822 )
2823 .await
2824 .unwrap();
2825 let backend = SdkBackend::builder().store(store).build().unwrap();
2826
2827 let outcome = backend.send_text(request).await.unwrap();
2828
2829 assert_eq!(outcome.message_id, Some(InlineId::new(11)));
2830 assert_eq!(outcome.state, TransactionState::Completed);
2831 assert_eq!(outcome.mutation.transaction.transaction_id, transaction_id);
2832 }
2833
2834 #[test]
2835 fn media_content_from_proto_preserves_document_descriptor() {
2836 let content = media_content_from_proto(
2837 &proto::MessageMedia {
2838 media: Some(proto::message_media::Media::Document(
2839 proto::MessageDocument {
2840 document: Some(proto::Document {
2841 id: 55,
2842 file_name: "report.pdf".to_owned(),
2843 mime_type: "application/pdf".to_owned(),
2844 size: 12_345,
2845 cdn_url: Some("https://cdn.inline.test/report.pdf".to_owned()),
2846 ..Default::default()
2847 }),
2848 },
2849 )),
2850 },
2851 Some("quarterly report".to_owned()),
2852 );
2853
2854 match content {
2855 MessageContent::Media {
2856 kind,
2857 file_id,
2858 url,
2859 mime_type,
2860 file_name,
2861 caption,
2862 size_bytes,
2863 width,
2864 height,
2865 duration_ms,
2866 } => {
2867 assert_eq!(kind, MediaKind::Document);
2868 assert_eq!(file_id, "55");
2869 assert_eq!(url.as_deref(), Some("https://cdn.inline.test/report.pdf"));
2870 assert_eq!(mime_type.as_deref(), Some("application/pdf"));
2871 assert_eq!(file_name.as_deref(), Some("report.pdf"));
2872 assert_eq!(caption.as_deref(), Some("quarterly report"));
2873 assert_eq!(size_bytes, Some(12_345));
2874 assert_eq!(width, None);
2875 assert_eq!(height, None);
2876 assert_eq!(duration_ms, None);
2877 }
2878 other => panic!("expected media content, got {other:?}"),
2879 }
2880 }
2881
2882 #[test]
2883 fn media_content_from_proto_picks_largest_photo_descriptor() {
2884 let content = media_content_from_proto(
2885 &proto::MessageMedia {
2886 media: Some(proto::message_media::Media::Photo(proto::MessagePhoto {
2887 photo: Some(proto::Photo {
2888 id: 9,
2889 format: 2,
2890 sizes: vec![
2891 proto::PhotoSize {
2892 w: 20,
2893 h: 20,
2894 size: 100,
2895 cdn_url: Some("https://cdn.inline.test/small.png".to_owned()),
2896 ..Default::default()
2897 },
2898 proto::PhotoSize {
2899 w: 200,
2900 h: 200,
2901 size: 500,
2902 cdn_url: None,
2903 ..Default::default()
2904 },
2905 proto::PhotoSize {
2906 w: 50,
2907 h: 50,
2908 size: 200,
2909 cdn_url: Some("https://cdn.inline.test/large.png".to_owned()),
2910 ..Default::default()
2911 },
2912 ],
2913 ..Default::default()
2914 }),
2915 })),
2916 },
2917 None,
2918 );
2919
2920 match content {
2921 MessageContent::Media {
2922 kind,
2923 file_id,
2924 url,
2925 mime_type,
2926 size_bytes,
2927 width,
2928 height,
2929 ..
2930 } => {
2931 assert_eq!(kind, MediaKind::Photo);
2932 assert_eq!(file_id, "9");
2933 assert_eq!(url.as_deref(), Some("https://cdn.inline.test/large.png"));
2934 assert_eq!(mime_type.as_deref(), Some("image/png"));
2935 assert_eq!(size_bytes, Some(200));
2936 assert_eq!(width, Some(50));
2937 assert_eq!(height, Some(50));
2938 }
2939 other => panic!("expected media content, got {other:?}"),
2940 }
2941 }
2942
2943 #[test]
2944 fn upload_input_for_video_without_complete_metadata_falls_back_to_document() {
2945 let request = UploadRequest {
2946 peer: crate::PeerRef::Chat {
2947 chat_id: InlineId::new(7),
2948 },
2949 kind: MediaKind::Video,
2950 file_name: Some("clip.mp4".to_owned()),
2951 mime_type: Some("video/mp4".to_owned()),
2952 size_bytes: Some(4),
2953 caption: None,
2954 width: Some(640),
2955 height: None,
2956 duration_ms: Some(1_500),
2957 external_id: None,
2958 random_id: None,
2959 reply_to_message_id: None,
2960 };
2961
2962 let input = upload_input_for_request(&request, vec![1, 2, 3, 4]);
2963
2964 assert_eq!(input.file_type, UploadFileType::Document);
2965 assert_eq!(input.file_name, "clip.mp4");
2966 assert_eq!(input.mime_type.as_deref(), Some("video/mp4"));
2967 assert_eq!(input.video_metadata, None);
2968 }
2969
2970 #[test]
2971 fn upload_input_for_video_with_complete_metadata_uses_video() {
2972 let request = UploadRequest {
2973 peer: crate::PeerRef::Chat {
2974 chat_id: InlineId::new(7),
2975 },
2976 kind: MediaKind::Video,
2977 file_name: Some("clip.mp4".to_owned()),
2978 mime_type: Some("video/mp4".to_owned()),
2979 size_bytes: Some(4),
2980 caption: None,
2981 width: Some(640),
2982 height: Some(480),
2983 duration_ms: Some(1_500),
2984 external_id: None,
2985 random_id: None,
2986 reply_to_message_id: None,
2987 };
2988
2989 let input = upload_input_for_request(&request, vec![1, 2, 3, 4]);
2990
2991 assert_eq!(input.file_type, UploadFileType::Video);
2992 assert_eq!(
2993 input.video_metadata,
2994 Some(UploadVideoMetadata::new(640, 480, 2))
2995 );
2996 }
2997
2998 #[test]
2999 fn history_input_for_after_message_id_uses_newer_mode() {
3000 let input = history_input_for_request(
3001 &HistoryRequest {
3002 chat_id: InlineId::new(7),
3003 limit: Some(20),
3004 before_message_id: None,
3005 after_message_id: Some(InlineId::new(10)),
3006 },
3007 21,
3008 );
3009
3010 assert_eq!(input.after_id, Some(10));
3011 assert_eq!(
3012 input.mode,
3013 Some(proto::GetChatHistoryMode::HistoryModeNewer as i32)
3014 );
3015 assert_eq!(input.offset_id, None);
3016 assert_eq!(input.limit, Some(21));
3017 }
3018
3019 #[test]
3020 fn history_input_for_before_message_id_uses_offset_id() {
3021 let input = history_input_for_request(
3022 &HistoryRequest {
3023 chat_id: InlineId::new(7),
3024 limit: Some(20),
3025 before_message_id: Some(InlineId::new(30)),
3026 after_message_id: None,
3027 },
3028 21,
3029 );
3030
3031 assert_eq!(input.offset_id, Some(30));
3032 assert_eq!(input.mode, None);
3033 assert_eq!(input.after_id, None);
3034 assert_eq!(input.limit, Some(21));
3035 }
3036
3037 #[test]
3038 fn normalize_live_history_latest_keeps_newest_messages() {
3039 let records = (1..=4).rev().map(test_message_record).collect::<Vec<_>>();
3040
3041 let (records, has_more) = normalize_live_history_records(records, 3, false);
3042
3043 assert!(has_more);
3044 assert_eq!(
3045 records
3046 .iter()
3047 .map(|message| message.message_id)
3048 .collect::<Vec<_>>(),
3049 vec![InlineId::new(2), InlineId::new(3), InlineId::new(4)]
3050 );
3051 }
3052
3053 #[test]
3054 fn normalize_live_history_newer_keeps_oldest_checkpoint_window() {
3055 let records = vec![
3056 test_message_record(8),
3057 test_message_record(7),
3058 test_message_record(6),
3059 test_message_record(5),
3060 ];
3061
3062 let (records, has_more) = normalize_live_history_records(records, 3, true);
3063
3064 assert!(has_more);
3065 assert_eq!(
3066 records
3067 .iter()
3068 .map(|message| message.message_id)
3069 .collect::<Vec<_>>(),
3070 vec![InlineId::new(5), InlineId::new(6), InlineId::new(7)]
3071 );
3072 }
3073
3074 #[test]
3075 fn normalize_live_history_older_keeps_newest_messages_below_cursor() {
3076 let records = vec![
3077 test_message_record(3),
3078 test_message_record(2),
3079 test_message_record(1),
3080 ];
3081
3082 let (records, has_more) = normalize_live_history_records(records, 2, false);
3083
3084 assert!(has_more);
3085 assert_eq!(
3086 records
3087 .iter()
3088 .map(|message| message.message_id)
3089 .collect::<Vec<_>>(),
3090 vec![InlineId::new(2), InlineId::new(3)]
3091 );
3092 }
3093
3094 #[test]
3095 fn apply_send_message_updates_extracts_final_message() {
3096 let request = SendTextRequest {
3097 peer: crate::PeerRef::Chat {
3098 chat_id: InlineId::new(7),
3099 },
3100 text: "hello".to_owned(),
3101 external_id: Some(ExternalId::try_new("host-event", "event-1").unwrap()),
3102 random_id: Some(RandomId::new(99)),
3103 reply_to_message_id: None,
3104 };
3105 let identity = TransactionIdentity::new(
3106 TransactionId::try_new("txn").unwrap(),
3107 request.external_id.clone(),
3108 request.random_id.unwrap(),
3109 );
3110 let result = proto::SendMessageResult {
3111 updates: vec![proto::Update {
3112 seq: Some(1),
3113 date: Some(1),
3114 update: Some(proto::update::Update::NewMessage(proto::UpdateNewMessage {
3115 message: Some(proto::Message {
3116 id: 11,
3117 from_id: 2,
3118 peer_id: None,
3119 chat_id: 7,
3120 message: Some("hello".to_owned()),
3121 out: true,
3122 date: 123,
3123 mentioned: None,
3124 reply_to_msg_id: None,
3125 media: None,
3126 edit_date: None,
3127 grouped_id: None,
3128 attachments: None,
3129 reactions: None,
3130 is_sticker: None,
3131 has_link: None,
3132 entities: None,
3133 send_mode: None,
3134 fwd_from: None,
3135 replies: None,
3136 actions: None,
3137 rev: None,
3138 }),
3139 })),
3140 }],
3141 };
3142
3143 let applied = apply_send_message_updates(&request, identity, InlineId::new(7), result);
3144
3145 assert_eq!(applied.message_id, Some(InlineId::new(11)));
3146 assert_eq!(applied.transaction.state, TransactionState::Completed);
3147 assert_eq!(applied.message.unwrap().message_id, InlineId::new(11));
3148 }
3149
3150 fn connect_session() -> StoredSession {
3151 StoredSession {
3152 auth: AuthCredential::AccessToken {
3153 token: AuthToken::try_new("secret-token").unwrap(),
3154 },
3155 account_namespace: Some("team".to_owned()),
3156 }
3157 }
3158
3159 async fn read_test_client_message(
3160 ws: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
3161 ) -> proto::ClientMessage {
3162 match ws.next().await.unwrap().unwrap() {
3163 WsMessage::Binary(data) => proto::ClientMessage::decode(&*data).unwrap(),
3164 other => panic!("expected binary client message, got {other:?}"),
3165 }
3166 }
3167
3168 async fn send_test_server_message(
3169 ws: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
3170 message: proto::ServerProtocolMessage,
3171 ) {
3172 ws.send(WsMessage::Binary(message.encode_to_vec()))
3173 .await
3174 .unwrap();
3175 }
3176}