Skip to main content

inline_client/
sdk_backend.rs

1//! SDK-backed backend skeleton.
2//!
3//! This backend is the production-facing seam: it owns the configured
4//! `inline-sdk` clients and durable store. Network-backed realtime sync and
5//! transaction managers will be added behind this type without changing the
6//! native client API or runner shape.
7
8use 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/// Error returned when building an [`SdkBackend`].
40#[derive(Debug, thiserror::Error)]
41#[non_exhaustive]
42pub enum SdkBackendBuildError {
43    /// API client configuration failed.
44    #[error("failed to build Inline API client: {0}")]
45    Api(#[from] ApiError),
46}
47
48/// Builder for [`SdkBackend`].
49#[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    /// Sets the Inline API base URL.
90    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    /// Sets the Inline realtime WebSocket URL.
96    pub fn realtime_url(mut self, realtime_url: impl Into<String>) -> Self {
97        self.realtime_url = realtime_url.into();
98        self
99    }
100
101    /// Sets the client identity sent through SDK transports.
102    pub fn identity(mut self, identity: ClientIdentity) -> Self {
103        self.identity = identity;
104        self
105    }
106
107    /// Sets the client store.
108    pub fn store(mut self, store: impl ClientStore) -> Self {
109        self.store = Arc::new(store);
110        self
111    }
112
113    /// Sets a shared client store.
114    pub fn shared_store(mut self, store: Arc<dyn ClientStore>) -> Self {
115        self.store = store;
116        self
117    }
118
119    /// Enables realtime handshake using the SDK realtime connector.
120    pub fn enable_realtime_handshake(mut self) -> Self {
121        self.realtime_connector = Some(Arc::new(SdkRealtimeConnector::new()));
122        self
123    }
124
125    /// Sets a custom realtime connector.
126    pub fn realtime_connector(mut self, connector: impl RealtimeConnector) -> Self {
127        self.realtime_connector = Some(Arc::new(connector));
128        self
129    }
130
131    /// Sets a shared realtime connector.
132    pub fn shared_realtime_connector(mut self, connector: Arc<dyn RealtimeConnector>) -> Self {
133        self.realtime_connector = Some(connector);
134        self
135    }
136
137    /// Disables realtime handshake on connect.
138    pub fn without_realtime_handshake(mut self) -> Self {
139        self.realtime_connector = None;
140        self
141    }
142
143    /// Builds an SDK-backed backend.
144    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/// Production-facing backend composed from `inline-sdk` and a client store.
157#[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    /// Starts an SDK backend builder.
186    pub fn builder() -> SdkBackendBuilder {
187        SdkBackendBuilder::default()
188    }
189
190    /// Returns the configured API client.
191    pub fn api_client(&self) -> &ApiClient {
192        &self.api
193    }
194
195    /// Returns the configured realtime URL.
196    pub fn realtime_url(&self) -> &str {
197        &self.realtime_url
198    }
199
200    /// Returns the configured client identity.
201    pub fn identity(&self) -> &ClientIdentity {
202        &self.identity
203    }
204
205    /// Returns the shared store.
206    pub fn store(&self) -> Arc<dyn ClientStore> {
207        self.store.clone()
208    }
209
210    /// Returns whether connect will perform a realtime handshake.
211    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}