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