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