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