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 mut 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 records.sort_by_key(|message| (message.timestamp, message.message_id.get()));
510 let has_more = records.len() > limit as usize;
511 if has_more {
512 records.truncate(limit as usize);
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 chat_id_from_peer(peer: &proto::Peer) -> Option<InlineId> {
1745 match &peer.r#type {
1746 Some(proto::peer::Type::Chat(chat)) => Some(InlineId::new(chat.chat_id)),
1747 Some(proto::peer::Type::User(user)) => Some(InlineId::new(user.user_id)),
1748 None => None,
1749 }
1750}
1751
1752fn chat_id_for_peer(peer: PeerRef) -> InlineId {
1753 match peer {
1754 PeerRef::User { user_id } => user_id,
1755 PeerRef::Chat { chat_id } => chat_id,
1756 PeerRef::Thread { thread_id } => thread_id,
1757 }
1758}
1759
1760fn transaction_id_for_send(request: &SendTextRequest, random_id: RandomId) -> TransactionId {
1761 let stable = request
1762 .external_id
1763 .as_ref()
1764 .map(|external| stable_hash(&format!("{}:{}", external.source(), external.id())))
1765 .unwrap_or_else(|| random_id.get() as u64);
1766 TransactionId::try_new(format!("sdk-send-{stable:016x}"))
1767 .expect("generated transaction ID should be valid")
1768}
1769
1770fn random_id_for_request(request: &SendTextRequest) -> RandomId {
1771 let seed = request
1772 .external_id
1773 .as_ref()
1774 .map(|external| format!("{}:{}:{}", external.source(), external.id(), request.text))
1775 .unwrap_or_else(|| format!("{}:{}", now_seconds(), request.text));
1776 RandomId::new((stable_hash(&seed) & 0x7fff_ffff_ffff_ffff) as i64)
1777}
1778
1779fn transaction_id_for_upload(request: &UploadRequest, random_id: RandomId) -> TransactionId {
1780 let stable = request
1781 .external_id
1782 .as_ref()
1783 .map(|external| stable_hash(&format!("{}:{}", external.source(), external.id())))
1784 .unwrap_or_else(|| random_id.get() as u64);
1785 TransactionId::try_new(format!("sdk-upload-{stable:016x}"))
1786 .expect("generated transaction ID should be valid")
1787}
1788
1789fn random_id_for_upload_request(request: &UploadRequest, size_bytes: usize) -> RandomId {
1790 let seed = request
1791 .external_id
1792 .as_ref()
1793 .map(|external| {
1794 format!(
1795 "{}:{}:{}",
1796 external.source(),
1797 external.id(),
1798 request.caption.as_deref().unwrap_or_default()
1799 )
1800 })
1801 .unwrap_or_else(|| {
1802 format!(
1803 "{}:{}:{}:{}",
1804 now_seconds(),
1805 request.file_name.as_deref().unwrap_or_default(),
1806 request.caption.as_deref().unwrap_or_default(),
1807 size_bytes
1808 )
1809 });
1810 RandomId::new((stable_hash(&seed) & 0x7fff_ffff_ffff_ffff) as i64)
1811}
1812
1813fn upload_input_for_request(request: &UploadRequest, bytes: Vec<u8>) -> UploadFileBytesInput {
1814 let file_name = request
1815 .file_name
1816 .as_deref()
1817 .map(str::trim)
1818 .filter(|value| !value.is_empty())
1819 .map(ToOwned::to_owned)
1820 .unwrap_or_else(|| default_upload_file_name(request));
1821 let file_type = upload_file_type_for_request(request);
1822 let mut input = UploadFileBytesInput::new(bytes, file_name, file_type);
1823 if let Some(mime_type) = request
1824 .mime_type
1825 .as_deref()
1826 .map(str::trim)
1827 .filter(|value| !value.is_empty())
1828 {
1829 input = input.with_mime_type(mime_type);
1830 }
1831 if file_type == UploadFileType::Video
1832 && let Some(metadata) = upload_video_metadata_for_request(request)
1833 {
1834 input = input.with_video_metadata(metadata);
1835 }
1836 input
1837}
1838
1839fn upload_file_type_for_request(request: &UploadRequest) -> UploadFileType {
1840 match request.kind {
1841 MediaKind::Photo => UploadFileType::Photo,
1842 MediaKind::Video if upload_video_metadata_for_request(request).is_some() => {
1843 UploadFileType::Video
1844 }
1845 MediaKind::Video | MediaKind::Document | MediaKind::Voice => UploadFileType::Document,
1846 }
1847}
1848
1849fn upload_video_metadata_for_request(request: &UploadRequest) -> Option<UploadVideoMetadata> {
1850 let width = i32::try_from(request.width?)
1851 .ok()
1852 .filter(|value| *value > 0)?;
1853 let height = i32::try_from(request.height?)
1854 .ok()
1855 .filter(|value| *value > 0)?;
1856 let duration = duration_ms_to_seconds_i32(request.duration_ms?)?;
1857 Some(UploadVideoMetadata::new(width, height, duration))
1858}
1859
1860fn duration_ms_to_seconds_i32(duration_ms: u64) -> Option<i32> {
1861 if duration_ms == 0 {
1862 return None;
1863 }
1864 let seconds = duration_ms.saturating_add(999) / 1_000;
1865 i32::try_from(seconds).ok().filter(|value| *value > 0)
1866}
1867
1868fn default_upload_file_name(request: &UploadRequest) -> String {
1869 let extension = match request.kind {
1870 MediaKind::Photo => extension_for_mime(request.mime_type.as_deref(), "jpg"),
1871 MediaKind::Video => extension_for_mime(request.mime_type.as_deref(), "mp4"),
1872 MediaKind::Voice => extension_for_mime(request.mime_type.as_deref(), "ogg"),
1873 MediaKind::Document => "bin",
1874 };
1875 format!("inline-upload.{}", extension.trim_start_matches('.'))
1876}
1877
1878fn extension_for_mime(mime_type: Option<&str>, fallback: &'static str) -> &'static str {
1879 match mime_type
1880 .unwrap_or_default()
1881 .trim()
1882 .to_ascii_lowercase()
1883 .as_str()
1884 {
1885 "image/png" => "png",
1886 "image/gif" => "gif",
1887 "image/webp" => "webp",
1888 "video/webm" => "webm",
1889 "audio/mpeg" => "mp3",
1890 "audio/mp4" | "audio/aac" => "m4a",
1891 _ => fallback,
1892 }
1893}
1894
1895fn input_media_from_upload(upload: &UploadFileResult) -> BackendResult<proto::InputMedia> {
1896 if let Some(photo_id) = upload.photo_id {
1897 return Ok(proto::InputMedia {
1898 media: Some(proto::input_media::Media::Photo(proto::InputMediaPhoto {
1899 photo_id,
1900 })),
1901 });
1902 }
1903 if let Some(video_id) = upload.video_id {
1904 return Ok(proto::InputMedia {
1905 media: Some(proto::input_media::Media::Video(proto::InputMediaVideo {
1906 video_id,
1907 })),
1908 });
1909 }
1910 if let Some(document_id) = upload.document_id {
1911 return Ok(proto::InputMedia {
1912 media: Some(proto::input_media::Media::Document(
1913 proto::InputMediaDocument { document_id },
1914 )),
1915 });
1916 }
1917 Err(BackendError::new(
1918 ClientErrorCategory::ProtocolMismatch,
1919 "uploadFile response did not include a media id",
1920 ))
1921}
1922
1923fn stable_hash(value: &str) -> u64 {
1924 let mut hash = 0xcbf2_9ce4_8422_2325u64;
1925 for byte in value.as_bytes() {
1926 hash ^= u64::from(*byte);
1927 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
1928 }
1929 hash
1930}
1931
1932#[derive(Debug)]
1933struct AppliedSendMessage {
1934 transaction: StoredTransaction,
1935 message: Option<MessageRecord>,
1936 chat_id: InlineId,
1937 message_id: Option<InlineId>,
1938}
1939
1940fn apply_send_message_updates(
1941 request: &SendTextRequest,
1942 identity: TransactionIdentity,
1943 fallback_chat_id: InlineId,
1944 result: proto::SendMessageResult,
1945) -> AppliedSendMessage {
1946 let mut final_message: Option<proto::Message> = None;
1947 let mut final_message_id: Option<InlineId> = None;
1948 for update in result.updates {
1949 match update.update {
1950 Some(proto::update::Update::NewMessage(update)) => {
1951 if let Some(message) = update.message {
1952 final_message_id = Some(InlineId::new(message.id));
1953 final_message = Some(message);
1954 }
1955 }
1956 Some(proto::update::Update::UpdateMessageId(update))
1957 if update.random_id == identity.random_id.get() =>
1958 {
1959 final_message_id = Some(InlineId::new(update.message_id));
1960 }
1961 _ => {}
1962 }
1963 }
1964
1965 let state = if final_message_id.is_some() {
1966 TransactionState::Completed
1967 } else {
1968 TransactionState::Acked
1969 };
1970 let mut identity = identity;
1971 if let Some(message_id) = final_message_id {
1972 identity = identity.with_final_message_id(message_id);
1973 }
1974 let message =
1975 final_message.map(|message| message_record_from_proto(message, request, identity.clone()));
1976 let chat_id = message
1977 .as_ref()
1978 .map(|message| message.chat_id)
1979 .unwrap_or(fallback_chat_id);
1980 let mut transaction = StoredTransaction::new(identity, state).with_chat_id(chat_id);
1981 if let Some(message_id) = final_message_id {
1982 transaction = transaction.with_message_id(message_id);
1983 }
1984
1985 AppliedSendMessage {
1986 transaction,
1987 message,
1988 chat_id,
1989 message_id: final_message_id,
1990 }
1991}
1992
1993fn apply_upload_message_updates(
1994 request: &UploadRequest,
1995 identity: TransactionIdentity,
1996 fallback_chat_id: InlineId,
1997 result: proto::SendMessageResult,
1998) -> AppliedSendMessage {
1999 let mut final_message: Option<proto::Message> = None;
2000 let mut final_message_id: Option<InlineId> = None;
2001 for update in result.updates {
2002 match update.update {
2003 Some(proto::update::Update::NewMessage(update)) => {
2004 if let Some(message) = update.message {
2005 final_message_id = Some(InlineId::new(message.id));
2006 final_message = Some(message);
2007 }
2008 }
2009 Some(proto::update::Update::UpdateMessageId(update))
2010 if update.random_id == identity.random_id.get() =>
2011 {
2012 final_message_id = Some(InlineId::new(update.message_id));
2013 }
2014 _ => {}
2015 }
2016 }
2017
2018 let state = if final_message_id.is_some() {
2019 TransactionState::Completed
2020 } else {
2021 TransactionState::Acked
2022 };
2023 let mut identity = identity;
2024 if let Some(message_id) = final_message_id {
2025 identity = identity.with_final_message_id(message_id);
2026 }
2027 let message = final_message.map(|message| {
2028 message_record_from_proto_message(
2029 message,
2030 Some(chat_id_for_peer(request.peer)),
2031 Some(identity.clone()),
2032 )
2033 });
2034 let chat_id = message
2035 .as_ref()
2036 .map(|message| message.chat_id)
2037 .unwrap_or(fallback_chat_id);
2038 let mut transaction = StoredTransaction::new(identity, state).with_chat_id(chat_id);
2039 if let Some(message_id) = final_message_id {
2040 transaction = transaction.with_message_id(message_id);
2041 }
2042
2043 AppliedSendMessage {
2044 transaction,
2045 message,
2046 chat_id,
2047 message_id: final_message_id,
2048 }
2049}
2050
2051fn message_record_from_proto(
2052 message: proto::Message,
2053 request: &SendTextRequest,
2054 transaction: TransactionIdentity,
2055) -> MessageRecord {
2056 message_record_from_proto_message(
2057 message,
2058 Some(chat_id_for_peer(request.peer)),
2059 Some(transaction),
2060 )
2061}
2062
2063fn message_record_from_proto_message(
2064 message: proto::Message,
2065 fallback_chat_id: Option<InlineId>,
2066 transaction: Option<TransactionIdentity>,
2067) -> MessageRecord {
2068 let chat_id = if message.chat_id != 0 {
2069 InlineId::new(message.chat_id)
2070 } else if let Some(peer) = &message.peer_id {
2071 chat_id_from_peer(peer).unwrap_or_else(|| fallback_chat_id.unwrap_or(InlineId::new(0)))
2072 } else {
2073 fallback_chat_id.unwrap_or(InlineId::new(0))
2074 };
2075 let content = content_from_proto_message(&message);
2076 MessageRecord {
2077 chat_id,
2078 message_id: InlineId::new(message.id),
2079 sender_id: InlineId::new(message.from_id),
2080 timestamp: message.date,
2081 is_outgoing: message.out,
2082 content,
2083 reply_to_message_id: message.reply_to_msg_id.map(InlineId::new),
2084 transaction,
2085 }
2086}
2087
2088fn content_from_proto_message(message: &proto::Message) -> MessageContent {
2089 match &message.media {
2090 None => match &message.message {
2091 Some(text) => MessageContent::Text { text: text.clone() },
2092 None => MessageContent::Unsupported {
2093 reason: "empty message content".to_owned(),
2094 },
2095 },
2096 Some(media) => media_content_from_proto(media, message.message.clone()),
2097 }
2098}
2099
2100fn media_content_from_proto(
2101 media: &proto::MessageMedia,
2102 caption: Option<String>,
2103) -> MessageContent {
2104 match &media.media {
2105 Some(proto::message_media::Media::Photo(photo)) => {
2106 if let Some(photo) = &photo.photo {
2107 let (url, size_bytes, width, height) = best_photo_size(photo);
2108 MessageContent::Media {
2109 kind: MediaKind::Photo,
2110 file_id: photo.id.to_string(),
2111 url,
2112 mime_type: photo_mime_type(photo),
2113 file_name: None,
2114 caption,
2115 size_bytes,
2116 width,
2117 height,
2118 duration_ms: None,
2119 }
2120 } else {
2121 empty_media_content(MediaKind::Photo, caption)
2122 }
2123 }
2124 Some(proto::message_media::Media::Video(video)) => {
2125 if let Some(video) = &video.video {
2126 MessageContent::Media {
2127 kind: MediaKind::Video,
2128 file_id: video.id.to_string(),
2129 url: video.cdn_url.clone(),
2130 mime_type: Some("video/mp4".to_owned()),
2131 file_name: None,
2132 caption,
2133 size_bytes: positive_u64(video.size),
2134 width: positive_u32(video.w),
2135 height: positive_u32(video.h),
2136 duration_ms: seconds_to_milliseconds(video.duration),
2137 }
2138 } else {
2139 empty_media_content(MediaKind::Video, caption)
2140 }
2141 }
2142 Some(proto::message_media::Media::Document(document)) => {
2143 if let Some(document) = &document.document {
2144 MessageContent::Media {
2145 kind: MediaKind::Document,
2146 file_id: document.id.to_string(),
2147 url: document.cdn_url.clone(),
2148 mime_type: non_empty_string(&document.mime_type),
2149 file_name: non_empty_string(&document.file_name),
2150 caption,
2151 size_bytes: positive_u64(document.size),
2152 width: None,
2153 height: None,
2154 duration_ms: None,
2155 }
2156 } else {
2157 empty_media_content(MediaKind::Document, caption)
2158 }
2159 }
2160 Some(proto::message_media::Media::Voice(voice)) => {
2161 if let Some(voice) = &voice.voice {
2162 MessageContent::Media {
2163 kind: MediaKind::Voice,
2164 file_id: voice.id.to_string(),
2165 url: voice.cdn_url.clone(),
2166 mime_type: non_empty_string(&voice.mime_type),
2167 file_name: None,
2168 caption,
2169 size_bytes: positive_u64(voice.size),
2170 width: None,
2171 height: None,
2172 duration_ms: seconds_to_milliseconds(voice.duration),
2173 }
2174 } else {
2175 empty_media_content(MediaKind::Voice, caption)
2176 }
2177 }
2178 Some(proto::message_media::Media::Nudge(_)) => MessageContent::Unsupported {
2179 reason: "nudge messages are not supported by inline-client yet".to_owned(),
2180 },
2181 None => MessageContent::Unsupported {
2182 reason: "empty media message".to_owned(),
2183 },
2184 }
2185}
2186
2187fn empty_media_content(kind: MediaKind, caption: Option<String>) -> MessageContent {
2188 MessageContent::Media {
2189 kind,
2190 file_id: String::new(),
2191 url: None,
2192 mime_type: None,
2193 file_name: None,
2194 caption,
2195 size_bytes: None,
2196 width: None,
2197 height: None,
2198 duration_ms: None,
2199 }
2200}
2201
2202fn best_photo_size(
2203 photo: &proto::Photo,
2204) -> (Option<String>, Option<u64>, Option<u32>, Option<u32>) {
2205 let mut best: Option<(&proto::PhotoSize, i64)> = None;
2206 for size in &photo.sizes {
2207 if size.cdn_url.is_none() {
2208 continue;
2209 }
2210 let area = i64::from(size.w) * i64::from(size.h);
2211 if best.is_none_or(|(_, best_area)| area > best_area) {
2212 best = Some((size, area));
2213 }
2214 }
2215 if let Some((size, _)) = best {
2216 return (
2217 size.cdn_url.clone(),
2218 positive_u64(size.size),
2219 positive_u32(size.w),
2220 positive_u32(size.h),
2221 );
2222 }
2223 (None, None, None, None)
2224}
2225
2226fn photo_mime_type(photo: &proto::Photo) -> Option<String> {
2227 match photo.format {
2228 1 => Some("image/jpeg".to_owned()),
2229 2 => Some("image/png".to_owned()),
2230 _ => None,
2231 }
2232}
2233
2234fn positive_u64(value: i32) -> Option<u64> {
2235 u64::try_from(value).ok().filter(|value| *value > 0)
2236}
2237
2238fn positive_u32(value: i32) -> Option<u32> {
2239 u32::try_from(value).ok().filter(|value| *value > 0)
2240}
2241
2242fn seconds_to_milliseconds(seconds: i32) -> Option<u64> {
2243 positive_u64(seconds).map(|seconds| seconds.saturating_mul(1_000))
2244}
2245
2246fn non_empty_string(value: &str) -> Option<String> {
2247 (!value.is_empty()).then(|| value.to_owned())
2248}
2249
2250fn outcome_from_stored_transaction(
2251 transaction: StoredTransaction,
2252 fallback_chat_id: InlineId,
2253) -> SendTextOutcome {
2254 let message_id = transaction
2255 .message_id
2256 .or(transaction.identity.final_message_id);
2257 SendTextOutcome::with_state(
2258 MessageMutation {
2259 transaction: transaction.identity.clone(),
2260 message_id,
2261 },
2262 transaction.chat_id.unwrap_or(fallback_chat_id),
2263 message_id,
2264 None,
2265 transaction.state,
2266 transaction.failure,
2267 )
2268}
2269
2270fn now_seconds() -> i64 {
2271 SystemTime::now()
2272 .duration_since(UNIX_EPOCH)
2273 .map(|duration| duration.as_secs() as i64)
2274 .unwrap_or_default()
2275}
2276
2277fn redacted_url_for_debug(url: &str) -> String {
2278 let without_fragment = url.split('#').next().unwrap_or(url);
2279 let without_query = without_fragment
2280 .split('?')
2281 .next()
2282 .unwrap_or(without_fragment);
2283 match without_query.split_once("://") {
2284 Some((scheme, rest)) => {
2285 let host_and_path = rest.rsplit_once('@').map_or(rest, |(_, tail)| tail);
2286 format!("{scheme}://{host_and_path}")
2287 }
2288 None => without_query.to_owned(),
2289 }
2290}
2291
2292#[cfg(test)]
2293mod tests {
2294 use crate::{
2295 AuthCredential, AuthToken, ClientBackend, DialogRecord, DialogsRequest, ExternalId,
2296 FakeRealtimeConnector, HistoryRequest, InlineId, RandomId, SendTextRequest,
2297 StoredTransaction,
2298 };
2299
2300 use super::*;
2301
2302 fn connect_request() -> ConnectRequest {
2303 ConnectRequest::new(AuthCredential::AccessToken {
2304 token: AuthToken::try_new("secret-token").unwrap(),
2305 })
2306 .with_account_namespace("team")
2307 }
2308
2309 #[test]
2310 fn sdk_backend_debug_redacts_realtime_url_credentials() {
2311 let backend = SdkBackend::builder()
2312 .realtime_url("wss://user:secret@api.inline.chat/realtime?token=secret#frag")
2313 .build()
2314 .unwrap();
2315
2316 let rendered = format!("{backend:?}");
2317 assert!(rendered.contains("wss://api.inline.chat/realtime"));
2318 assert!(!rendered.contains("secret"));
2319 assert!(!rendered.contains("token="));
2320 }
2321
2322 #[test]
2323 fn sdk_backend_defaults_use_production_endpoints() {
2324 let backend = SdkBackend::builder().build().unwrap();
2325
2326 assert_eq!(
2327 backend.api_client().base_url(),
2328 "https://api.inline.chat/v1"
2329 );
2330 assert_eq!(backend.realtime_url(), "wss://api.inline.chat/realtime");
2331 }
2332
2333 #[tokio::test]
2334 async fn sdk_backend_connect_persists_session() {
2335 let store = InMemoryStore::new();
2336 let backend = SdkBackend::builder().store(store.clone()).build().unwrap();
2337
2338 let status = backend.connect(connect_request()).await.unwrap();
2339
2340 assert_eq!(status.status, crate::ClientStatus::Connected);
2341 let session = store.load_session().await.unwrap().unwrap();
2342 assert_eq!(session.account_namespace.as_deref(), Some("team"));
2343 let rendered = format!("{session:?}");
2344 assert!(!rendered.contains("secret-token"));
2345 }
2346
2347 #[tokio::test]
2348 async fn sdk_backend_connect_can_perform_realtime_handshake() {
2349 let store = InMemoryStore::new();
2350 let realtime = FakeRealtimeConnector::new();
2351 let backend = SdkBackend::builder()
2352 .store(store.clone())
2353 .realtime_connector(realtime.clone())
2354 .realtime_url("wss://api.inline.chat/realtime")
2355 .build()
2356 .unwrap();
2357
2358 assert!(backend.realtime_handshake_enabled());
2359 backend.connect(connect_request()).await.unwrap();
2360
2361 let attempts = realtime.attempts();
2362 assert_eq!(attempts.len(), 1);
2363 assert_eq!(attempts[0].realtime_url, "wss://api.inline.chat/realtime");
2364 assert!(store.load_session().await.unwrap().is_some());
2365 }
2366
2367 #[tokio::test]
2368 async fn sdk_backend_does_not_persist_session_when_realtime_handshake_fails() {
2369 let store = InMemoryStore::new();
2370 let backend = SdkBackend::builder()
2371 .store(store.clone())
2372 .realtime_connector(FakeRealtimeConnector::failing(BackendError::new(
2373 ClientErrorCategory::Network,
2374 "offline",
2375 )))
2376 .build()
2377 .unwrap();
2378
2379 let err = backend.connect(connect_request()).await.unwrap_err();
2380
2381 assert_eq!(err.category, ClientErrorCategory::Network);
2382 assert!(store.load_session().await.unwrap().is_none());
2383 }
2384
2385 #[tokio::test]
2386 async fn sdk_backend_resume_without_session_reports_auth_required() {
2387 let backend = SdkBackend::builder().build().unwrap();
2388
2389 let status = backend.resume_session().await.unwrap();
2390
2391 assert_eq!(status.status, crate::ClientStatus::AuthRequired);
2392 }
2393
2394 #[tokio::test]
2395 async fn sdk_backend_resume_uses_stored_session() {
2396 let store = InMemoryStore::new();
2397 store.save_session(connect_session()).await.unwrap();
2398 let realtime = FakeRealtimeConnector::new();
2399 let backend = SdkBackend::builder()
2400 .store(store)
2401 .realtime_connector(realtime.clone())
2402 .realtime_url("wss://api.inline.chat/realtime")
2403 .build()
2404 .unwrap();
2405
2406 let status = backend.resume_session().await.unwrap();
2407
2408 assert_eq!(status.status, crate::ClientStatus::Connected);
2409 let attempts = realtime.attempts();
2410 assert_eq!(attempts.len(), 1);
2411 assert_eq!(attempts[0].realtime_url, "wss://api.inline.chat/realtime");
2412 }
2413
2414 #[tokio::test]
2415 async fn sdk_backend_reads_dialogs_from_store_after_connect() {
2416 let store = InMemoryStore::new();
2417 store.upsert_dialog(DialogRecord {
2418 chat_id: InlineId::new(9),
2419 peer_user_id: Some(InlineId::new(10)),
2420 title: Some("general".to_owned()),
2421 last_message_id: None,
2422 synced_through_message_id: None,
2423 unread_count: Some(0),
2424 });
2425 let backend = SdkBackend::builder()
2426 .store(store)
2427 .realtime_url("not-a-websocket-url")
2428 .build()
2429 .unwrap();
2430
2431 backend.connect(connect_request()).await.unwrap();
2432 let dialogs = backend.dialogs(DialogsRequest::default()).await.unwrap();
2433
2434 assert_eq!(dialogs.dialogs.len(), 1);
2435 assert_eq!(dialogs.dialogs[0].chat_id, InlineId::new(9));
2436 }
2437
2438 #[test]
2439 fn dialogs_page_from_get_chats_uses_user_records() {
2440 let result = proto::GetChatsResult {
2441 dialogs: vec![proto::Dialog {
2442 peer: Some(proto::Peer {
2443 r#type: Some(proto::peer::Type::User(proto::PeerUser { user_id: 42 })),
2444 }),
2445 chat_id: Some(7),
2446 unread_count: Some(3),
2447 ..Default::default()
2448 }],
2449 chats: vec![proto::Chat {
2450 id: 7,
2451 title: "Direct chat fallback".to_owned(),
2452 last_msg_id: Some(99),
2453 emoji: Some("*".to_owned()),
2454 peer_id: Some(proto::Peer {
2455 r#type: Some(proto::peer::Type::User(proto::PeerUser { user_id: 42 })),
2456 }),
2457 ..Default::default()
2458 }],
2459 users: vec![proto::User {
2460 id: 42,
2461 first_name: Some("Ada".to_owned()),
2462 last_name: Some("Lovelace".to_owned()),
2463 username: Some("ada".to_owned()),
2464 profile_photo: Some(proto::UserProfilePhoto {
2465 cdn_url: Some("https://cdn.inline.test/ada.jpg".to_owned()),
2466 ..Default::default()
2467 }),
2468 bot: Some(false),
2469 ..Default::default()
2470 }],
2471 ..Default::default()
2472 };
2473
2474 let page = dialogs_page_from_get_chats(
2475 &result,
2476 DialogsRequest {
2477 limit: Some(10),
2478 cursor: None,
2479 },
2480 )
2481 .unwrap();
2482
2483 assert_eq!(page.dialogs.len(), 1);
2484 assert_eq!(page.dialogs[0].chat_id, InlineId::new(7));
2485 assert_eq!(page.dialogs[0].title.as_deref(), Some("* Ada Lovelace"));
2486 assert_eq!(page.dialogs[0].peer_user_id, Some(InlineId::new(42)));
2487 assert_eq!(page.dialogs[0].last_message_id, Some(InlineId::new(99)));
2488 assert_eq!(page.dialogs[0].unread_count, Some(3));
2489 assert_eq!(page.users.len(), 1);
2490 assert_eq!(page.users[0].user_id, InlineId::new(42));
2491 assert_eq!(page.users[0].display_name.as_deref(), Some("Ada Lovelace"));
2492 assert_eq!(
2493 page.users[0].avatar_url.as_deref(),
2494 Some("https://cdn.inline.test/ada.jpg")
2495 );
2496 assert_eq!(page.users[0].is_bot, Some(false));
2497 }
2498
2499 #[test]
2500 fn chat_participants_page_uses_direct_participants() {
2501 let page = chat_participants_page_from_proto(proto::GetChatParticipantsResult {
2502 participants: vec![proto::ChatParticipant {
2503 user_id: 10,
2504 date: 100,
2505 }],
2506 users: vec![proto::User {
2507 id: 10,
2508 first_name: Some("Ada".to_owned()),
2509 ..Default::default()
2510 }],
2511 });
2512
2513 assert_eq!(page.participants.len(), 1);
2514 assert_eq!(page.participants[0].user_id, InlineId::new(10));
2515 assert_eq!(page.participants[0].date, Some(100));
2516 assert_eq!(page.users.len(), 1);
2517 }
2518
2519 #[tokio::test]
2520 async fn sdk_backend_requires_session_for_history() {
2521 let backend = SdkBackend::builder().build().unwrap();
2522
2523 let err = backend
2524 .history(HistoryRequest {
2525 chat_id: InlineId::new(1),
2526 limit: Some(10),
2527 before_message_id: None,
2528 after_message_id: None,
2529 })
2530 .await
2531 .expect_err("history should require connect");
2532
2533 assert_eq!(err.category, ClientErrorCategory::AuthRequired);
2534 }
2535
2536 #[tokio::test]
2537 async fn sdk_backend_send_text_requires_session() {
2538 let backend = SdkBackend::builder().build().unwrap();
2539
2540 let err = backend
2541 .send_text(SendTextRequest::new(
2542 crate::PeerRef::Chat {
2543 chat_id: InlineId::new(1),
2544 },
2545 "hello",
2546 ))
2547 .await
2548 .expect_err("send_text should require connect");
2549
2550 assert_eq!(err.category, ClientErrorCategory::AuthRequired);
2551 }
2552
2553 #[tokio::test]
2554 async fn sdk_backend_send_text_rejects_empty_text_before_network() {
2555 let backend = SdkBackend::builder().build().unwrap();
2556
2557 let err = backend
2558 .send_text(SendTextRequest::new(
2559 crate::PeerRef::Chat {
2560 chat_id: InlineId::new(1),
2561 },
2562 " ",
2563 ))
2564 .await
2565 .expect_err("empty message should fail before auth or network");
2566
2567 assert_eq!(err.category, ClientErrorCategory::InvalidInput);
2568 }
2569
2570 #[tokio::test]
2571 async fn sdk_backend_send_text_returns_existing_transaction_without_network() {
2572 let store = InMemoryStore::new();
2573 let request = SendTextRequest {
2574 peer: crate::PeerRef::Chat {
2575 chat_id: InlineId::new(7),
2576 },
2577 text: "hello".to_owned(),
2578 external_id: Some(ExternalId::try_new("host-event", "event-1").unwrap()),
2579 random_id: Some(RandomId::new(99)),
2580 reply_to_message_id: None,
2581 };
2582 let transaction_id = transaction_id_for_send(&request, request.random_id.unwrap());
2583 let identity = TransactionIdentity::new(
2584 transaction_id.clone(),
2585 request.external_id.clone(),
2586 request.random_id.unwrap(),
2587 )
2588 .with_final_message_id(InlineId::new(11));
2589 store.save_session(connect_session()).await.unwrap();
2590 store
2591 .record_transaction(
2592 StoredTransaction::new(identity, TransactionState::Completed)
2593 .with_chat_id(InlineId::new(7))
2594 .with_message_id(InlineId::new(11)),
2595 )
2596 .await
2597 .unwrap();
2598 let backend = SdkBackend::builder().store(store).build().unwrap();
2599
2600 let outcome = backend.send_text(request).await.unwrap();
2601
2602 assert_eq!(outcome.message_id, Some(InlineId::new(11)));
2603 assert_eq!(outcome.state, TransactionState::Completed);
2604 assert_eq!(outcome.mutation.transaction.transaction_id, transaction_id);
2605 }
2606
2607 #[test]
2608 fn media_content_from_proto_preserves_document_descriptor() {
2609 let content = media_content_from_proto(
2610 &proto::MessageMedia {
2611 media: Some(proto::message_media::Media::Document(
2612 proto::MessageDocument {
2613 document: Some(proto::Document {
2614 id: 55,
2615 file_name: "report.pdf".to_owned(),
2616 mime_type: "application/pdf".to_owned(),
2617 size: 12_345,
2618 cdn_url: Some("https://cdn.inline.test/report.pdf".to_owned()),
2619 ..Default::default()
2620 }),
2621 },
2622 )),
2623 },
2624 Some("quarterly report".to_owned()),
2625 );
2626
2627 match content {
2628 MessageContent::Media {
2629 kind,
2630 file_id,
2631 url,
2632 mime_type,
2633 file_name,
2634 caption,
2635 size_bytes,
2636 width,
2637 height,
2638 duration_ms,
2639 } => {
2640 assert_eq!(kind, MediaKind::Document);
2641 assert_eq!(file_id, "55");
2642 assert_eq!(url.as_deref(), Some("https://cdn.inline.test/report.pdf"));
2643 assert_eq!(mime_type.as_deref(), Some("application/pdf"));
2644 assert_eq!(file_name.as_deref(), Some("report.pdf"));
2645 assert_eq!(caption.as_deref(), Some("quarterly report"));
2646 assert_eq!(size_bytes, Some(12_345));
2647 assert_eq!(width, None);
2648 assert_eq!(height, None);
2649 assert_eq!(duration_ms, None);
2650 }
2651 other => panic!("expected media content, got {other:?}"),
2652 }
2653 }
2654
2655 #[test]
2656 fn media_content_from_proto_picks_largest_photo_descriptor() {
2657 let content = media_content_from_proto(
2658 &proto::MessageMedia {
2659 media: Some(proto::message_media::Media::Photo(proto::MessagePhoto {
2660 photo: Some(proto::Photo {
2661 id: 9,
2662 format: 2,
2663 sizes: vec![
2664 proto::PhotoSize {
2665 w: 20,
2666 h: 20,
2667 size: 100,
2668 cdn_url: Some("https://cdn.inline.test/small.png".to_owned()),
2669 ..Default::default()
2670 },
2671 proto::PhotoSize {
2672 w: 200,
2673 h: 200,
2674 size: 500,
2675 cdn_url: None,
2676 ..Default::default()
2677 },
2678 proto::PhotoSize {
2679 w: 50,
2680 h: 50,
2681 size: 200,
2682 cdn_url: Some("https://cdn.inline.test/large.png".to_owned()),
2683 ..Default::default()
2684 },
2685 ],
2686 ..Default::default()
2687 }),
2688 })),
2689 },
2690 None,
2691 );
2692
2693 match content {
2694 MessageContent::Media {
2695 kind,
2696 file_id,
2697 url,
2698 mime_type,
2699 size_bytes,
2700 width,
2701 height,
2702 ..
2703 } => {
2704 assert_eq!(kind, MediaKind::Photo);
2705 assert_eq!(file_id, "9");
2706 assert_eq!(url.as_deref(), Some("https://cdn.inline.test/large.png"));
2707 assert_eq!(mime_type.as_deref(), Some("image/png"));
2708 assert_eq!(size_bytes, Some(200));
2709 assert_eq!(width, Some(50));
2710 assert_eq!(height, Some(50));
2711 }
2712 other => panic!("expected media content, got {other:?}"),
2713 }
2714 }
2715
2716 #[test]
2717 fn upload_input_for_video_without_complete_metadata_falls_back_to_document() {
2718 let request = UploadRequest {
2719 peer: crate::PeerRef::Chat {
2720 chat_id: InlineId::new(7),
2721 },
2722 kind: MediaKind::Video,
2723 file_name: Some("clip.mp4".to_owned()),
2724 mime_type: Some("video/mp4".to_owned()),
2725 size_bytes: Some(4),
2726 caption: None,
2727 width: Some(640),
2728 height: None,
2729 duration_ms: Some(1_500),
2730 external_id: None,
2731 random_id: None,
2732 reply_to_message_id: None,
2733 };
2734
2735 let input = upload_input_for_request(&request, vec![1, 2, 3, 4]);
2736
2737 assert_eq!(input.file_type, UploadFileType::Document);
2738 assert_eq!(input.file_name, "clip.mp4");
2739 assert_eq!(input.mime_type.as_deref(), Some("video/mp4"));
2740 assert_eq!(input.video_metadata, None);
2741 }
2742
2743 #[test]
2744 fn upload_input_for_video_with_complete_metadata_uses_video() {
2745 let request = UploadRequest {
2746 peer: crate::PeerRef::Chat {
2747 chat_id: InlineId::new(7),
2748 },
2749 kind: MediaKind::Video,
2750 file_name: Some("clip.mp4".to_owned()),
2751 mime_type: Some("video/mp4".to_owned()),
2752 size_bytes: Some(4),
2753 caption: None,
2754 width: Some(640),
2755 height: Some(480),
2756 duration_ms: Some(1_500),
2757 external_id: None,
2758 random_id: None,
2759 reply_to_message_id: None,
2760 };
2761
2762 let input = upload_input_for_request(&request, vec![1, 2, 3, 4]);
2763
2764 assert_eq!(input.file_type, UploadFileType::Video);
2765 assert_eq!(
2766 input.video_metadata,
2767 Some(UploadVideoMetadata::new(640, 480, 2))
2768 );
2769 }
2770
2771 #[test]
2772 fn history_input_for_after_message_id_uses_newer_mode() {
2773 let input = history_input_for_request(
2774 &HistoryRequest {
2775 chat_id: InlineId::new(7),
2776 limit: Some(20),
2777 before_message_id: None,
2778 after_message_id: Some(InlineId::new(10)),
2779 },
2780 21,
2781 );
2782
2783 assert_eq!(input.after_id, Some(10));
2784 assert_eq!(
2785 input.mode,
2786 Some(proto::GetChatHistoryMode::HistoryModeNewer as i32)
2787 );
2788 assert_eq!(input.offset_id, None);
2789 assert_eq!(input.limit, Some(21));
2790 }
2791
2792 #[test]
2793 fn apply_send_message_updates_extracts_final_message() {
2794 let request = SendTextRequest {
2795 peer: crate::PeerRef::Chat {
2796 chat_id: InlineId::new(7),
2797 },
2798 text: "hello".to_owned(),
2799 external_id: Some(ExternalId::try_new("host-event", "event-1").unwrap()),
2800 random_id: Some(RandomId::new(99)),
2801 reply_to_message_id: None,
2802 };
2803 let identity = TransactionIdentity::new(
2804 TransactionId::try_new("txn").unwrap(),
2805 request.external_id.clone(),
2806 request.random_id.unwrap(),
2807 );
2808 let result = proto::SendMessageResult {
2809 updates: vec![proto::Update {
2810 seq: Some(1),
2811 date: Some(1),
2812 update: Some(proto::update::Update::NewMessage(proto::UpdateNewMessage {
2813 message: Some(proto::Message {
2814 id: 11,
2815 from_id: 2,
2816 peer_id: None,
2817 chat_id: 7,
2818 message: Some("hello".to_owned()),
2819 out: true,
2820 date: 123,
2821 mentioned: None,
2822 reply_to_msg_id: None,
2823 media: None,
2824 edit_date: None,
2825 grouped_id: None,
2826 attachments: None,
2827 reactions: None,
2828 is_sticker: None,
2829 has_link: None,
2830 entities: None,
2831 send_mode: None,
2832 fwd_from: None,
2833 replies: None,
2834 actions: None,
2835 rev: None,
2836 }),
2837 })),
2838 }],
2839 };
2840
2841 let applied = apply_send_message_updates(&request, identity, InlineId::new(7), result);
2842
2843 assert_eq!(applied.message_id, Some(InlineId::new(11)));
2844 assert_eq!(applied.transaction.state, TransactionState::Completed);
2845 assert_eq!(applied.message.unwrap().message_id, InlineId::new(11));
2846 }
2847
2848 fn connect_session() -> StoredSession {
2849 StoredSession {
2850 auth: AuthCredential::AccessToken {
2851 token: AuthToken::try_new("secret-token").unwrap(),
2852 },
2853 account_namespace: Some("team".to_owned()),
2854 }
2855 }
2856}