Skip to main content

inline_client/
backend.rs

1//! Backend boundary for client runtime operations.
2//!
3//! The runtime talks to this trait instead of directly embedding transport,
4//! sync, or storage behavior. The in-memory backend is useful for client and
5//! bridge-adapter tests; the production backend will compose the real Inline SDK,
6//! realtime transport, store, sync engine, and transaction manager behind the
7//! same shape.
8
9use std::{
10    collections::{HashMap, VecDeque},
11    fmt,
12    sync::{Arc, Mutex},
13    time::Duration,
14};
15
16use futures_util::future::BoxFuture;
17
18use crate::store::select_history_window;
19use crate::{
20    AccountStateSnapshot, AddChatParticipantRequest, AuthStartRequest, AuthStartResult,
21    AuthVerifyRequest, AuthVerifyResult, ChatParticipantRecord, ChatParticipantsPage,
22    ChatParticipantsRequest, ChatStateSnapshot, ClientErrorCategory, ClientEvent,
23    ClientEventDelivery, ClientFailure, ClientStatus, ClientStatusSnapshot, ConnectRequest,
24    CreateDmRequest, CreateReplyThreadRequest, CreateThreadRequest, CreatedChat, DeleteChatRequest,
25    DeleteMessageRequest, DialogRecord, DialogsOrder, DialogsPage, DialogsRequest,
26    EditMessageRequest, HistoryPage, HistoryRequest, InlineId, MessageContent, MessageMutation,
27    MessageRecord, RandomId, ReactRequest, ReadRequest, RemoveChatParticipantRequest,
28    SendTextRequest, SetMarkedUnreadRequest, TransactionEvent, TransactionId, TransactionIdentity,
29    TransactionState, TypingRequest, UpdateChatInfoRequest, UpdateDialogNotificationsRequest,
30    UploadRequest,
31};
32
33/// Result type returned by client backends.
34pub type BackendResult<T> = Result<T, BackendError>;
35
36/// Redacted backend error.
37#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
38#[error("{category:?}: {message}")]
39#[non_exhaustive]
40pub struct BackendError {
41    /// Stable error category.
42    pub category: ClientErrorCategory,
43    /// Redacted message suitable for hosts.
44    pub message: String,
45    /// Server-requested retry delay, when the failure is rate limited.
46    pub retry_after_seconds: Option<u64>,
47}
48
49impl BackendError {
50    /// Creates a backend error.
51    pub fn new(category: ClientErrorCategory, message: impl Into<String>) -> Self {
52        Self {
53            category,
54            message: message.into(),
55            retry_after_seconds: None,
56        }
57    }
58
59    /// Attaches a server-requested retry delay.
60    pub fn with_retry_after_seconds(mut self, seconds: u64) -> Self {
61        self.retry_after_seconds = (seconds > 0).then_some(seconds);
62        self
63    }
64}
65
66impl From<BackendError> for ClientFailure {
67    fn from(error: BackendError) -> Self {
68        Self::new(error.category, error.message)
69    }
70}
71
72pub(crate) fn retry_after_seconds_from_message(message: &str) -> Option<u64> {
73    let normalized = message.to_ascii_lowercase();
74    for marker in ["retry after ", "retry_after=", "flood_wait_"] {
75        let Some((_, suffix)) = normalized.split_once(marker) else {
76            continue;
77        };
78        let Ok(seconds) = suffix
79            .chars()
80            .take_while(char::is_ascii_digit)
81            .collect::<String>()
82            .parse::<u64>()
83        else {
84            continue;
85        };
86        if seconds > 0 {
87            return Some(seconds);
88        }
89    }
90    None
91}
92
93/// Outcome from a text-send operation.
94#[derive(Clone, Debug, PartialEq, Eq)]
95pub struct SendTextOutcome {
96    /// Client mutation acknowledgement.
97    pub mutation: MessageMutation,
98    /// Chat containing the message.
99    pub chat_id: InlineId,
100    /// Final message ID, when known.
101    pub message_id: Option<InlineId>,
102    /// Message record applied to the client store, when known.
103    pub message: Option<MessageRecord>,
104    /// Durable transaction state after the send attempt.
105    pub state: TransactionState,
106    /// Redacted failure, when the transaction failed.
107    pub failure: Option<ClientFailure>,
108}
109
110/// Outcome from a side-effect operation.
111#[derive(Clone, Debug, Default, PartialEq, Eq)]
112pub struct OperationOutcome {
113    /// Committed client events caused by the operation.
114    pub events: Vec<ClientEvent>,
115}
116
117impl OperationOutcome {
118    /// Creates an outcome with no committed events.
119    pub fn empty() -> Self {
120        Self::default()
121    }
122
123    /// Creates an outcome with committed events.
124    pub fn with_events(events: Vec<ClientEvent>) -> Self {
125        Self { events }
126    }
127}
128
129impl SendTextOutcome {
130    /// Creates a text-send outcome.
131    pub fn new(mutation: MessageMutation, chat_id: InlineId, message_id: Option<InlineId>) -> Self {
132        Self::with_state(
133            mutation,
134            chat_id,
135            message_id,
136            None,
137            TransactionState::Completed,
138            None,
139        )
140    }
141
142    /// Creates a text-send outcome with an explicit transaction state.
143    pub fn with_state(
144        mutation: MessageMutation,
145        chat_id: InlineId,
146        message_id: Option<InlineId>,
147        message: Option<MessageRecord>,
148        state: TransactionState,
149        failure: Option<ClientFailure>,
150    ) -> Self {
151        Self {
152            mutation,
153            chat_id,
154            message_id,
155            message,
156            state,
157            failure,
158        }
159    }
160
161    /// Returns a transaction event for this send outcome.
162    pub fn transaction_event(&self) -> TransactionEvent {
163        TransactionEvent {
164            identity: self.mutation.transaction.clone(),
165            state: self.state,
166            failure: self.failure.clone(),
167        }
168    }
169}
170
171/// Async backend boundary for the client runner.
172pub trait ClientBackend: fmt::Debug + Send + Sync + 'static {
173    /// Sends an Inline login code.
174    fn auth_start(
175        &self,
176        request: AuthStartRequest,
177    ) -> BoxFuture<'static, BackendResult<AuthStartResult>>;
178
179    /// Verifies an Inline login code and persists the resulting session.
180    fn auth_verify(
181        &self,
182        request: AuthVerifyRequest,
183    ) -> BoxFuture<'static, BackendResult<AuthVerifyResult>>;
184
185    /// Resumes a previously stored session, if available.
186    fn resume_session(&self) -> BoxFuture<'static, BackendResult<ClientStatusSnapshot>>;
187
188    /// Connects or reconnects the client.
189    fn connect(
190        &self,
191        request: ConnectRequest,
192    ) -> BoxFuture<'static, BackendResult<ClientStatusSnapshot>>;
193
194    /// Logs out the current account.
195    fn logout(&self) -> BoxFuture<'static, BackendResult<()>>;
196
197    /// Lists dialogs.
198    fn dialogs(&self, request: DialogsRequest) -> BoxFuture<'static, BackendResult<DialogsPage>>;
199
200    /// Lists only durable cached dialogs without a network request.
201    fn cached_dialogs(
202        &self,
203        request: DialogsRequest,
204    ) -> BoxFuture<'static, BackendResult<DialogsPage>>;
205
206    /// Loads account-level durable state used for consumer reconciliation.
207    fn account_state(&self) -> BoxFuture<'static, BackendResult<AccountStateSnapshot>>;
208
209    /// Loads durable state for one chat used for consumer reconciliation.
210    fn chat_state(&self, chat_id: InlineId)
211    -> BoxFuture<'static, BackendResult<ChatStateSnapshot>>;
212
213    /// Fetches chat history.
214    fn history(&self, request: HistoryRequest) -> BoxFuture<'static, BackendResult<HistoryPage>>;
215
216    /// Fetches only the client's durable cached history without making a
217    /// network request. This is intended for offline consumers and state
218    /// reconciliation; ordinary callers should prefer [`Self::history`].
219    fn cached_history(
220        &self,
221        request: HistoryRequest,
222    ) -> BoxFuture<'static, BackendResult<HistoryPage>>;
223
224    /// Fetches chat participants.
225    fn chat_participants(
226        &self,
227        request: ChatParticipantsRequest,
228    ) -> BoxFuture<'static, BackendResult<ChatParticipantsPage>>;
229
230    /// Adds a user to a chat.
231    fn add_chat_participant(
232        &self,
233        request: AddChatParticipantRequest,
234    ) -> BoxFuture<'static, BackendResult<OperationOutcome>>;
235
236    /// Removes a user from a chat.
237    fn remove_chat_participant(
238        &self,
239        request: RemoveChatParticipantRequest,
240    ) -> BoxFuture<'static, BackendResult<OperationOutcome>>;
241
242    /// Updates mutable chat metadata.
243    fn update_chat_info(
244        &self,
245        request: UpdateChatInfoRequest,
246    ) -> BoxFuture<'static, BackendResult<OperationOutcome>>;
247
248    /// Deletes a chat when permitted by the service.
249    fn delete_chat(
250        &self,
251        request: DeleteChatRequest,
252    ) -> BoxFuture<'static, BackendResult<OperationOutcome>>;
253
254    /// Creates or opens a direct message chat.
255    fn create_dm(&self, request: CreateDmRequest)
256    -> BoxFuture<'static, BackendResult<CreatedChat>>;
257
258    /// Creates a regular Inline thread chat.
259    fn create_thread(
260        &self,
261        request: CreateThreadRequest,
262    ) -> BoxFuture<'static, BackendResult<CreatedChat>>;
263
264    /// Creates a child/reply Inline thread chat.
265    fn create_reply_thread(
266        &self,
267        request: CreateReplyThreadRequest,
268    ) -> BoxFuture<'static, BackendResult<CreatedChat>>;
269
270    /// Sends a text message.
271    fn send_text(
272        &self,
273        request: SendTextRequest,
274    ) -> BoxFuture<'static, BackendResult<SendTextOutcome>>;
275
276    /// Uploads and sends a media message.
277    fn send_media(
278        &self,
279        request: UploadRequest,
280        bytes: Vec<u8>,
281    ) -> BoxFuture<'static, BackendResult<SendTextOutcome>>;
282
283    /// Edits a text message.
284    fn edit_message(
285        &self,
286        request: EditMessageRequest,
287    ) -> BoxFuture<'static, BackendResult<OperationOutcome>>;
288
289    /// Deletes or unsends a message.
290    fn delete_message(
291        &self,
292        request: DeleteMessageRequest,
293    ) -> BoxFuture<'static, BackendResult<OperationOutcome>>;
294
295    /// Adds or removes a reaction.
296    fn react(&self, request: ReactRequest) -> BoxFuture<'static, BackendResult<OperationOutcome>>;
297
298    /// Marks messages read.
299    fn read(&self, request: ReadRequest) -> BoxFuture<'static, BackendResult<OperationOutcome>>;
300
301    /// Sets the explicit marked-unread state for a chat.
302    fn set_marked_unread(
303        &self,
304        request: SetMarkedUnreadRequest,
305    ) -> BoxFuture<'static, BackendResult<OperationOutcome>>;
306
307    /// Sets or clears a per-dialog notification override.
308    fn update_dialog_notifications(
309        &self,
310        request: UpdateDialogNotificationsRequest,
311    ) -> BoxFuture<'static, BackendResult<OperationOutcome>>;
312
313    /// Sends a typing state.
314    fn typing(&self, request: TypingRequest)
315    -> BoxFuture<'static, BackendResult<OperationOutcome>>;
316
317    /// Receives the next batch of server-pushed client events.
318    fn receive_events(&self) -> BoxFuture<'static, BackendResult<Vec<ClientEvent>>>;
319
320    /// Receives the next event batch with durable delivery identifiers when
321    /// the backend supports crash-safe host acknowledgement.
322    fn receive_event_deliveries(
323        &self,
324    ) -> BoxFuture<'static, BackendResult<Vec<ClientEventDelivery>>> {
325        let receive = self.receive_events();
326        Box::pin(async move {
327            receive.await.map(|events| {
328                events
329                    .into_iter()
330                    .map(ClientEventDelivery::ephemeral)
331                    .collect()
332            })
333        })
334    }
335
336    /// Stages locally produced operation/status events for lossless delivery.
337    fn stage_client_events(
338        &self,
339        events: Vec<ClientEvent>,
340    ) -> BoxFuture<'static, BackendResult<Vec<ClientEventDelivery>>> {
341        Box::pin(async move {
342            Ok(events
343                .into_iter()
344                .map(ClientEventDelivery::ephemeral)
345                .collect())
346        })
347    }
348
349    /// Acknowledges one durable event after the host consumer has committed it.
350    fn acknowledge_event_delivery(
351        &self,
352        _delivery_id: u64,
353    ) -> BoxFuture<'static, BackendResult<()>> {
354        Box::pin(async { Ok(()) })
355    }
356
357    /// Releases a process-local claim when a consumer drops a delivery without
358    /// acknowledging it. The durable store entry remains pending.
359    fn release_event_delivery(&self, _delivery_id: u64) {}
360
361    /// Releases process-local delivery claims after the receiver task stops.
362    /// Durable events remain in the store and can be claimed after reconnect.
363    fn reset_event_delivery_claims(&self) -> BoxFuture<'static, BackendResult<()>> {
364        Box::pin(async { Ok(()) })
365    }
366}
367
368/// In-memory backend for client, bridge-adapter, and runtime tests.
369#[derive(Clone, Debug, Default)]
370pub struct InMemoryBackend {
371    state: Arc<Mutex<InMemoryBackendState>>,
372}
373
374#[derive(Debug)]
375struct InMemoryBackendState {
376    connected: bool,
377    account_namespace: Option<String>,
378    dialogs: Vec<DialogRecord>,
379    participants: HashMap<i64, Vec<ChatParticipantRecord>>,
380    messages: HashMap<i64, Vec<MessageRecord>>,
381    event_batches: VecDeque<BackendResult<Vec<ClientEvent>>>,
382    next_chat_id: i64,
383    next_message_id: i64,
384    next_random_id: i64,
385    next_transaction_id: u64,
386}
387
388impl Default for InMemoryBackendState {
389    fn default() -> Self {
390        Self {
391            connected: false,
392            account_namespace: None,
393            dialogs: Vec::new(),
394            participants: HashMap::new(),
395            messages: HashMap::new(),
396            event_batches: VecDeque::new(),
397            next_chat_id: 10_000,
398            next_message_id: 1,
399            next_random_id: 1,
400            next_transaction_id: 1,
401        }
402    }
403}
404
405impl InMemoryBackend {
406    /// Creates an empty in-memory backend.
407    pub fn new() -> Self {
408        Self::default()
409    }
410
411    /// Inserts or replaces a dialog.
412    pub fn upsert_dialog(&self, dialog: DialogRecord) {
413        let mut state = self.state.lock().expect("in-memory backend poisoned");
414        if let Some(existing) = state
415            .dialogs
416            .iter_mut()
417            .find(|existing| existing.chat_id == dialog.chat_id)
418        {
419            *existing = dialog;
420            return;
421        }
422        state.dialogs.push(dialog);
423    }
424
425    /// Replaces the participant snapshot for a chat.
426    pub fn set_chat_participants(
427        &self,
428        chat_id: InlineId,
429        participants: Vec<ChatParticipantRecord>,
430    ) {
431        let mut state = self.state.lock().expect("in-memory backend poisoned");
432        state.participants.insert(chat_id.get(), participants);
433    }
434
435    /// Inserts a message into the in-memory history.
436    pub fn insert_message(&self, message: MessageRecord) {
437        let mut state = self.state.lock().expect("in-memory backend poisoned");
438        let chat_id = message.chat_id.get();
439        state.messages.entry(chat_id).or_default().push(message);
440        if let Some(messages) = state.messages.get_mut(&chat_id) {
441            messages.sort_by_key(|message| (message.timestamp, message.message_id.get()));
442        }
443    }
444
445    /// Queues a server-pushed event batch for [`ClientBackend::receive_events`].
446    pub fn push_event_batch(&self, events: Vec<ClientEvent>) {
447        self.state
448            .lock()
449            .expect("in-memory backend poisoned")
450            .event_batches
451            .push_back(Ok(events));
452    }
453
454    /// Queues a server-pushed receive error for [`ClientBackend::receive_events`].
455    pub fn push_event_error(&self, error: BackendError) {
456        self.state
457            .lock()
458            .expect("in-memory backend poisoned")
459            .event_batches
460            .push_back(Err(error));
461    }
462
463    /// Returns whether the backend is connected.
464    pub fn is_connected(&self) -> bool {
465        self.state
466            .lock()
467            .expect("in-memory backend poisoned")
468            .connected
469    }
470
471    fn connect_now(&self, request: ConnectRequest) -> BackendResult<ClientStatusSnapshot> {
472        let mut state = self.state.lock().expect("in-memory backend poisoned");
473        state.connected = true;
474        state.account_namespace = request.account_namespace;
475        Ok(ClientStatusSnapshot::current(ClientStatus::Connected))
476    }
477
478    fn auth_start_now(&self, request: AuthStartRequest) -> BackendResult<AuthStartResult> {
479        if request.contact.trim().is_empty() {
480            return Err(BackendError::new(
481                ClientErrorCategory::InvalidInput,
482                "auth contact must not be empty",
483            ));
484        }
485        Ok(AuthStartResult {
486            existing_user: true,
487            needs_invite_code: false,
488            challenge_token: None,
489        })
490    }
491
492    fn auth_verify_now(&self, request: AuthVerifyRequest) -> BackendResult<AuthVerifyResult> {
493        if request.contact.trim().is_empty() {
494            return Err(BackendError::new(
495                ClientErrorCategory::InvalidInput,
496                "auth contact must not be empty",
497            ));
498        }
499        if request.code.trim().is_empty() {
500            return Err(BackendError::new(
501                ClientErrorCategory::InvalidInput,
502                "verification code must not be empty",
503            ));
504        }
505        let account_namespace = request
506            .account_namespace
507            .map(|namespace| namespace.trim().to_owned())
508            .filter(|namespace| !namespace.is_empty())
509            .unwrap_or_else(|| "1".to_owned());
510        let mut state = self.state.lock().expect("in-memory backend poisoned");
511        state.connected = true;
512        state.account_namespace = Some(account_namespace.clone());
513        Ok(AuthVerifyResult {
514            user_id: InlineId::new(1),
515            account_namespace,
516            status: ClientStatusSnapshot::current(ClientStatus::Connected),
517        })
518    }
519
520    fn resume_session_now(&self) -> BackendResult<ClientStatusSnapshot> {
521        let mut state = self.state.lock().expect("in-memory backend poisoned");
522        if state.connected || state.account_namespace.is_some() {
523            state.connected = true;
524            Ok(ClientStatusSnapshot::current(ClientStatus::Connected))
525        } else {
526            Ok(ClientStatusSnapshot::current(ClientStatus::AuthRequired))
527        }
528    }
529
530    fn logout_now(&self) -> BackendResult<()> {
531        let mut state = self.state.lock().expect("in-memory backend poisoned");
532        state.connected = false;
533        Ok(())
534    }
535
536    fn dialogs_now(&self, request: DialogsRequest) -> BackendResult<DialogsPage> {
537        self.require_connected()?;
538        let state = self.state.lock().expect("in-memory backend poisoned");
539        let limit = request.limit.unwrap_or(50).max(1) as usize;
540        let (dialogs, next_cursor) = match request.order {
541            DialogsOrder::RecentActivity => {
542                let start = parse_cursor(request.cursor.as_deref())?;
543                let dialogs = state
544                    .dialogs
545                    .iter()
546                    .skip(start)
547                    .take(limit)
548                    .cloned()
549                    .collect::<Vec<_>>();
550                let next = start + dialogs.len();
551                (
552                    dialogs,
553                    (next < state.dialogs.len()).then(|| next.to_string()),
554                )
555            }
556            DialogsOrder::StableChatId => {
557                let after = request
558                    .cursor
559                    .as_deref()
560                    .unwrap_or("0")
561                    .parse::<i64>()
562                    .map_err(|_| {
563                        BackendError::new(
564                            ClientErrorCategory::InvalidInput,
565                            "invalid stable dialog pagination cursor",
566                        )
567                    })?;
568                let mut dialogs = state
569                    .dialogs
570                    .iter()
571                    .filter(|dialog| dialog.chat_id.get() > after)
572                    .cloned()
573                    .collect::<Vec<_>>();
574                dialogs.sort_by_key(|dialog| dialog.chat_id.get());
575                let has_more = dialogs.len() > limit;
576                dialogs.truncate(limit);
577                let next = has_more
578                    .then(|| {
579                        dialogs
580                            .last()
581                            .map(|dialog| dialog.chat_id.get().to_string())
582                    })
583                    .flatten();
584                (dialogs, next)
585            }
586        };
587        let dialogs = dialogs
588            .into_iter()
589            .map(|dialog| {
590                let mut dialog = dialog.clone();
591                dialog.synced_through_message_id =
592                    max_message_id_from_backend(&state.messages, dialog.chat_id);
593                dialog
594            })
595            .collect::<Vec<_>>();
596        Ok(DialogsPage {
597            dialogs,
598            users: Vec::new(),
599            next_cursor,
600        })
601    }
602
603    fn history_now(&self, request: HistoryRequest) -> BackendResult<HistoryPage> {
604        self.require_connected()?;
605        let state = self.state.lock().expect("in-memory backend poisoned");
606        let messages = state
607            .messages
608            .get(&request.chat_id.get())
609            .cloned()
610            .unwrap_or_default();
611        if request.before_message_id.is_some() && request.after_message_id.is_some() {
612            return Err(BackendError::new(
613                ClientErrorCategory::InvalidInput,
614                "history request cannot specify both before_message_id and after_message_id",
615            ));
616        }
617        let limit = request.limit.unwrap_or(50).max(1) as usize;
618        let (messages, has_more) = select_history_window(
619            messages,
620            limit,
621            request.before_message_id,
622            request.after_message_id,
623        );
624        Ok(HistoryPage {
625            messages,
626            users: Vec::new(),
627            has_more,
628            next_cursor: None,
629        })
630    }
631
632    fn send_text_now(&self, request: SendTextRequest) -> BackendResult<SendTextOutcome> {
633        self.require_connected()?;
634        if request.text.trim().is_empty() {
635            return Err(BackendError::new(
636                ClientErrorCategory::InvalidInput,
637                "message text must not be empty",
638            ));
639        }
640
641        let mut state = self.state.lock().expect("in-memory backend poisoned");
642        let chat_id = chat_id_for_peer(request.peer);
643        let message_id = InlineId::new(state.next_message_id);
644        state.next_message_id += 1;
645        let random_id = request.random_id.unwrap_or_else(|| {
646            let id = RandomId::new(state.next_random_id);
647            state.next_random_id += 1;
648            id
649        });
650        let transaction_id = TransactionId::try_new(format!("mem-{}", state.next_transaction_id))
651            .expect("generated transaction id is valid");
652        state.next_transaction_id += 1;
653
654        let transaction = TransactionIdentity::new(transaction_id, request.external_id, random_id)
655            .with_final_message_id(message_id);
656        let message = MessageRecord {
657            chat_id,
658            message_id,
659            sender_id: InlineId::new(0),
660            timestamp: message_id.get(),
661            is_outgoing: true,
662            content: MessageContent::Text { text: request.text },
663            reply_to_message_id: request.reply_to_message_id,
664            transaction: Some(transaction.clone()),
665        };
666        state
667            .messages
668            .entry(chat_id.get())
669            .or_default()
670            .push(message.clone());
671        crate::store::upsert_dialog_last_message(&mut state.dialogs, chat_id, message_id);
672
673        Ok(SendTextOutcome::with_state(
674            MessageMutation {
675                transaction,
676                message_id: Some(message_id),
677                state: None,
678                failure: None,
679            },
680            chat_id,
681            Some(message_id),
682            Some(message),
683            TransactionState::Completed,
684            None,
685        ))
686    }
687
688    fn create_chat_now(
689        &self,
690        title: Option<String>,
691        parent_chat_id: Option<InlineId>,
692        parent_message_id: Option<InlineId>,
693        participants: Vec<ChatParticipantRecord>,
694    ) -> BackendResult<CreatedChat> {
695        self.require_connected()?;
696        let title = title
697            .map(|title| title.trim().to_owned())
698            .filter(|title| !title.is_empty());
699        let mut state = self.state.lock().expect("in-memory backend poisoned");
700        let chat_id = InlineId::new(state.next_chat_id);
701        state.next_chat_id += 1;
702        state.dialogs.push(DialogRecord {
703            chat_id,
704            peer_user_id: None,
705            title: title.clone(),
706            last_message_id: None,
707            synced_through_message_id: None,
708            unread_count: Some(0),
709            ..DialogRecord::new(chat_id)
710        });
711        if !participants.is_empty() {
712            state.participants.insert(chat_id.get(), participants);
713        }
714        Ok(CreatedChat {
715            chat_id,
716            title,
717            parent_chat_id,
718            parent_message_id,
719        })
720    }
721
722    fn send_media_now(
723        &self,
724        request: UploadRequest,
725        bytes: Vec<u8>,
726    ) -> BackendResult<SendTextOutcome> {
727        self.require_connected()?;
728        if bytes.is_empty() {
729            return Err(BackendError::new(
730                ClientErrorCategory::InvalidInput,
731                "media bytes must not be empty",
732            ));
733        }
734
735        let mut state = self.state.lock().expect("in-memory backend poisoned");
736        let chat_id = chat_id_for_peer(request.peer);
737        let message_id = InlineId::new(state.next_message_id);
738        state.next_message_id += 1;
739        let random_id = request.random_id.unwrap_or_else(|| {
740            let id = RandomId::new(state.next_random_id);
741            state.next_random_id += 1;
742            id
743        });
744        let transaction_id =
745            TransactionId::try_new(format!("mem-upload-{}", state.next_transaction_id))
746                .expect("generated transaction id is valid");
747        state.next_transaction_id += 1;
748
749        let transaction =
750            TransactionIdentity::new(transaction_id, request.external_id.clone(), random_id)
751                .with_final_message_id(message_id);
752        let message = MessageRecord {
753            chat_id,
754            message_id,
755            sender_id: InlineId::new(0),
756            timestamp: message_id.get(),
757            is_outgoing: true,
758            content: MessageContent::Media {
759                kind: request.kind,
760                file_id: format!("mem-file-{}", message_id.get()),
761                url: None,
762                mime_type: request.mime_type.clone(),
763                file_name: request.file_name.clone(),
764                caption: request.caption.clone(),
765                size_bytes: request.size_bytes.or(Some(bytes.len() as u64)),
766                width: request.width,
767                height: request.height,
768                duration_ms: request.duration_ms,
769            },
770            reply_to_message_id: request.reply_to_message_id,
771            transaction: Some(transaction.clone()),
772        };
773        state
774            .messages
775            .entry(chat_id.get())
776            .or_default()
777            .push(message.clone());
778        crate::store::upsert_dialog_last_message(&mut state.dialogs, chat_id, message_id);
779
780        Ok(SendTextOutcome::with_state(
781            MessageMutation {
782                transaction,
783                message_id: Some(message_id),
784                state: None,
785                failure: None,
786            },
787            chat_id,
788            Some(message_id),
789            Some(message),
790            TransactionState::Completed,
791            None,
792        ))
793    }
794
795    fn require_connected(&self) -> BackendResult<()> {
796        if self
797            .state
798            .lock()
799            .expect("in-memory backend poisoned")
800            .connected
801        {
802            Ok(())
803        } else {
804            Err(BackendError::new(
805                ClientErrorCategory::AuthRequired,
806                "client is not connected",
807            ))
808        }
809    }
810}
811
812impl ClientBackend for InMemoryBackend {
813    fn auth_start(
814        &self,
815        request: AuthStartRequest,
816    ) -> BoxFuture<'static, BackendResult<AuthStartResult>> {
817        let backend = self.clone();
818        Box::pin(async move { backend.auth_start_now(request) })
819    }
820
821    fn auth_verify(
822        &self,
823        request: AuthVerifyRequest,
824    ) -> BoxFuture<'static, BackendResult<AuthVerifyResult>> {
825        let backend = self.clone();
826        Box::pin(async move { backend.auth_verify_now(request) })
827    }
828
829    fn resume_session(&self) -> BoxFuture<'static, BackendResult<ClientStatusSnapshot>> {
830        let backend = self.clone();
831        Box::pin(async move { backend.resume_session_now() })
832    }
833
834    fn connect(
835        &self,
836        request: ConnectRequest,
837    ) -> BoxFuture<'static, BackendResult<ClientStatusSnapshot>> {
838        let backend = self.clone();
839        Box::pin(async move { backend.connect_now(request) })
840    }
841
842    fn logout(&self) -> BoxFuture<'static, BackendResult<()>> {
843        let backend = self.clone();
844        Box::pin(async move { backend.logout_now() })
845    }
846
847    fn dialogs(&self, request: DialogsRequest) -> BoxFuture<'static, BackendResult<DialogsPage>> {
848        let backend = self.clone();
849        Box::pin(async move { backend.dialogs_now(request) })
850    }
851
852    fn cached_dialogs(
853        &self,
854        request: DialogsRequest,
855    ) -> BoxFuture<'static, BackendResult<DialogsPage>> {
856        let backend = self.clone();
857        Box::pin(async move { backend.dialogs_now(request) })
858    }
859
860    fn account_state(&self) -> BoxFuture<'static, BackendResult<AccountStateSnapshot>> {
861        let backend = self.clone();
862        Box::pin(async move {
863            backend.require_connected()?;
864            Ok(AccountStateSnapshot::default())
865        })
866    }
867
868    fn chat_state(
869        &self,
870        chat_id: InlineId,
871    ) -> BoxFuture<'static, BackendResult<ChatStateSnapshot>> {
872        let backend = self.clone();
873        Box::pin(async move {
874            backend.require_connected()?;
875            let state = backend.state.lock().expect("in-memory backend poisoned");
876            let dialog = state
877                .dialogs
878                .iter()
879                .find(|dialog| dialog.chat_id == chat_id)
880                .cloned();
881            let participants = state
882                .participants
883                .get(&chat_id.get())
884                .cloned()
885                .unwrap_or_default();
886            Ok(ChatStateSnapshot {
887                chat_id,
888                dialog,
889                deleted: false,
890                deleted_message_ids: Vec::new(),
891                reactions: Vec::new(),
892                reaction_snapshot_message_ids: Vec::new(),
893                read_state: None,
894                participants,
895                participants_complete: true,
896            })
897        })
898    }
899
900    fn history(&self, request: HistoryRequest) -> BoxFuture<'static, BackendResult<HistoryPage>> {
901        let backend = self.clone();
902        Box::pin(async move { backend.history_now(request) })
903    }
904
905    fn cached_history(
906        &self,
907        request: HistoryRequest,
908    ) -> BoxFuture<'static, BackendResult<HistoryPage>> {
909        let backend = self.clone();
910        Box::pin(async move { backend.history_now(request) })
911    }
912
913    fn chat_participants(
914        &self,
915        request: ChatParticipantsRequest,
916    ) -> BoxFuture<'static, BackendResult<ChatParticipantsPage>> {
917        let backend = self.clone();
918        Box::pin(async move {
919            backend.require_connected()?;
920            let state = backend.state.lock().expect("in-memory backend poisoned");
921            Ok(ChatParticipantsPage {
922                participants: state
923                    .participants
924                    .get(&request.chat_id.get())
925                    .cloned()
926                    .unwrap_or_default(),
927                users: Vec::new(),
928            })
929        })
930    }
931
932    fn add_chat_participant(
933        &self,
934        request: AddChatParticipantRequest,
935    ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
936        let backend = self.clone();
937        Box::pin(async move {
938            backend.require_connected()?;
939            if request.chat_id.get() <= 0 || request.user_id.get() <= 0 {
940                return Err(BackendError::new(
941                    ClientErrorCategory::InvalidInput,
942                    "chat_id and user_id must be positive",
943                ));
944            }
945            let mut state = backend.state.lock().expect("in-memory backend poisoned");
946            let participants = state.participants.entry(request.chat_id.get()).or_default();
947            if !participants
948                .iter()
949                .any(|participant| participant.user_id == request.user_id)
950            {
951                participants.push(ChatParticipantRecord {
952                    user_id: request.user_id,
953                    date: None,
954                });
955            }
956            Ok(OperationOutcome::with_events(vec![
957                ClientEvent::ChatParticipantsChanged {
958                    chat_id: request.chat_id,
959                },
960            ]))
961        })
962    }
963
964    fn remove_chat_participant(
965        &self,
966        request: RemoveChatParticipantRequest,
967    ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
968        let backend = self.clone();
969        Box::pin(async move {
970            backend.require_connected()?;
971            let mut state = backend.state.lock().expect("in-memory backend poisoned");
972            state
973                .participants
974                .entry(request.chat_id.get())
975                .or_default()
976                .retain(|participant| participant.user_id != request.user_id);
977            Ok(OperationOutcome::with_events(vec![
978                ClientEvent::ChatParticipantsChanged {
979                    chat_id: request.chat_id,
980                },
981            ]))
982        })
983    }
984
985    fn update_chat_info(
986        &self,
987        request: UpdateChatInfoRequest,
988    ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
989        let backend = self.clone();
990        Box::pin(async move {
991            backend.require_connected()?;
992            if request.title.is_none() && request.emoji.is_none() {
993                return Err(BackendError::new(
994                    ClientErrorCategory::InvalidInput,
995                    "at least one chat info field must be provided",
996                ));
997            }
998            if let Some(title) = request.title {
999                if title.trim().is_empty() {
1000                    return Err(BackendError::new(
1001                        ClientErrorCategory::InvalidInput,
1002                        "chat title must not be empty",
1003                    ));
1004                }
1005                if let Some(dialog) = backend
1006                    .state
1007                    .lock()
1008                    .expect("in-memory backend poisoned")
1009                    .dialogs
1010                    .iter_mut()
1011                    .find(|dialog| dialog.chat_id == request.chat_id)
1012                {
1013                    dialog.title = Some(title.trim().to_owned());
1014                }
1015            }
1016            Ok(OperationOutcome::with_events(vec![
1017                ClientEvent::ChatUpserted {
1018                    chat_id: request.chat_id,
1019                },
1020            ]))
1021        })
1022    }
1023
1024    fn delete_chat(
1025        &self,
1026        request: DeleteChatRequest,
1027    ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
1028        let backend = self.clone();
1029        Box::pin(async move {
1030            backend.require_connected()?;
1031            let mut state = backend.state.lock().expect("in-memory backend poisoned");
1032            state
1033                .dialogs
1034                .retain(|dialog| dialog.chat_id != request.chat_id);
1035            state.participants.remove(&request.chat_id.get());
1036            state.messages.remove(&request.chat_id.get());
1037            Ok(OperationOutcome::with_events(vec![
1038                ClientEvent::ChatDeleted {
1039                    chat_id: request.chat_id,
1040                },
1041            ]))
1042        })
1043    }
1044
1045    fn create_dm(
1046        &self,
1047        request: CreateDmRequest,
1048    ) -> BoxFuture<'static, BackendResult<CreatedChat>> {
1049        let backend = self.clone();
1050        Box::pin(async move {
1051            if request.user_id.get() <= 0 {
1052                return Err(BackendError::new(
1053                    ClientErrorCategory::InvalidInput,
1054                    "user_id must be positive",
1055                ));
1056            }
1057            backend.create_chat_now(
1058                Some(format!("DM {}", request.user_id.get())),
1059                None,
1060                None,
1061                vec![
1062                    ChatParticipantRecord {
1063                        user_id: InlineId::new(0),
1064                        date: None,
1065                    },
1066                    ChatParticipantRecord {
1067                        user_id: request.user_id,
1068                        date: None,
1069                    },
1070                ],
1071            )
1072        })
1073    }
1074
1075    fn create_thread(
1076        &self,
1077        request: CreateThreadRequest,
1078    ) -> BoxFuture<'static, BackendResult<CreatedChat>> {
1079        let backend = self.clone();
1080        Box::pin(async move {
1081            let participants = request
1082                .participants
1083                .into_iter()
1084                .map(|participant| ChatParticipantRecord {
1085                    user_id: participant.user_id,
1086                    date: None,
1087                })
1088                .collect();
1089            backend.create_chat_now(request.title, None, None, participants)
1090        })
1091    }
1092
1093    fn create_reply_thread(
1094        &self,
1095        request: CreateReplyThreadRequest,
1096    ) -> BoxFuture<'static, BackendResult<CreatedChat>> {
1097        let backend = self.clone();
1098        Box::pin(async move {
1099            if request.parent_chat_id.get() <= 0 {
1100                return Err(BackendError::new(
1101                    ClientErrorCategory::InvalidInput,
1102                    "parent_chat_id must be positive",
1103                ));
1104            }
1105            let participants = request
1106                .participants
1107                .into_iter()
1108                .map(|participant| ChatParticipantRecord {
1109                    user_id: participant.user_id,
1110                    date: None,
1111                })
1112                .collect();
1113            backend.create_chat_now(
1114                request.title,
1115                Some(request.parent_chat_id),
1116                request.parent_message_id,
1117                participants,
1118            )
1119        })
1120    }
1121
1122    fn send_text(
1123        &self,
1124        request: SendTextRequest,
1125    ) -> BoxFuture<'static, BackendResult<SendTextOutcome>> {
1126        let backend = self.clone();
1127        Box::pin(async move { backend.send_text_now(request) })
1128    }
1129
1130    fn send_media(
1131        &self,
1132        request: UploadRequest,
1133        bytes: Vec<u8>,
1134    ) -> BoxFuture<'static, BackendResult<SendTextOutcome>> {
1135        let backend = self.clone();
1136        Box::pin(async move { backend.send_media_now(request, bytes) })
1137    }
1138
1139    fn edit_message(
1140        &self,
1141        request: EditMessageRequest,
1142    ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
1143        let backend = self.clone();
1144        Box::pin(async move {
1145            backend.require_connected()?;
1146            if request.text.trim().is_empty() {
1147                return Err(BackendError::new(
1148                    ClientErrorCategory::InvalidInput,
1149                    "message text must not be empty",
1150                ));
1151            }
1152            let mut state = backend.state.lock().expect("in-memory backend poisoned");
1153            let messages = state.messages.entry(request.chat_id.get()).or_default();
1154            if let Some(message) = messages
1155                .iter_mut()
1156                .find(|message| message.message_id == request.message_id)
1157            {
1158                message.content = MessageContent::Text { text: request.text };
1159                return Ok(OperationOutcome::with_events(vec![
1160                    ClientEvent::MessageStored {
1161                        message: message.clone(),
1162                    },
1163                ]));
1164            }
1165            Err(BackendError::new(
1166                ClientErrorCategory::InvalidInput,
1167                "message not found",
1168            ))
1169        })
1170    }
1171
1172    fn delete_message(
1173        &self,
1174        request: DeleteMessageRequest,
1175    ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
1176        let backend = self.clone();
1177        Box::pin(async move {
1178            backend.require_connected()?;
1179            let mut state = backend.state.lock().expect("in-memory backend poisoned");
1180            let messages = state.messages.entry(request.chat_id.get()).or_default();
1181            messages.retain(|message| message.message_id != request.message_id);
1182            Ok(OperationOutcome::with_events(vec![
1183                ClientEvent::MessageDeleted {
1184                    chat_id: request.chat_id,
1185                    message_id: request.message_id,
1186                },
1187            ]))
1188        })
1189    }
1190
1191    fn react(&self, request: ReactRequest) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
1192        let backend = self.clone();
1193        Box::pin(async move {
1194            backend.require_connected()?;
1195            if request.reaction.trim().is_empty() {
1196                return Err(BackendError::new(
1197                    ClientErrorCategory::InvalidInput,
1198                    "reaction must not be empty",
1199                ));
1200            }
1201            Ok(OperationOutcome::with_events(vec![
1202                ClientEvent::ReactionChanged {
1203                    chat_id: request.chat_id,
1204                    message_id: request.message_id,
1205                    user_id: InlineId::new(0),
1206                    reaction: request.reaction,
1207                    removed: request.remove,
1208                },
1209            ]))
1210        })
1211    }
1212
1213    fn read(&self, request: ReadRequest) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
1214        let backend = self.clone();
1215        Box::pin(async move {
1216            backend.require_connected()?;
1217            Ok(OperationOutcome::with_events(vec![
1218                ClientEvent::ReadStateChanged {
1219                    chat_id: request.chat_id,
1220                },
1221            ]))
1222        })
1223    }
1224
1225    fn set_marked_unread(
1226        &self,
1227        request: SetMarkedUnreadRequest,
1228    ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
1229        let backend = self.clone();
1230        Box::pin(async move {
1231            backend.require_connected()?;
1232            Ok(OperationOutcome::with_events(vec![
1233                ClientEvent::ReadStateChanged {
1234                    chat_id: request.chat_id,
1235                },
1236            ]))
1237        })
1238    }
1239
1240    fn update_dialog_notifications(
1241        &self,
1242        request: UpdateDialogNotificationsRequest,
1243    ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
1244        let backend = self.clone();
1245        Box::pin(async move {
1246            backend.require_connected()?;
1247            if let Some(dialog) = backend
1248                .state
1249                .lock()
1250                .expect("in-memory backend poisoned")
1251                .dialogs
1252                .iter_mut()
1253                .find(|dialog| dialog.chat_id == request.chat_id)
1254            {
1255                dialog.notification_mode = request.mode;
1256            }
1257            Ok(OperationOutcome::with_events(vec![
1258                ClientEvent::ChatUpserted {
1259                    chat_id: request.chat_id,
1260                },
1261            ]))
1262        })
1263    }
1264
1265    fn typing(
1266        &self,
1267        request: TypingRequest,
1268    ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
1269        let backend = self.clone();
1270        Box::pin(async move {
1271            backend.require_connected()?;
1272            Ok(OperationOutcome::with_events(vec![ClientEvent::Typing {
1273                chat_id: request.chat_id,
1274                user_id: InlineId::new(0),
1275                is_typing: request.is_typing,
1276            }]))
1277        })
1278    }
1279
1280    fn receive_events(&self) -> BoxFuture<'static, BackendResult<Vec<ClientEvent>>> {
1281        let backend = self.clone();
1282        Box::pin(async move {
1283            loop {
1284                {
1285                    let mut state = backend.state.lock().expect("in-memory backend poisoned");
1286                    if !state.connected {
1287                        return Err(BackendError::new(
1288                            ClientErrorCategory::AuthRequired,
1289                            "client is not connected",
1290                        ));
1291                    }
1292                    if let Some(events) = state.event_batches.pop_front() {
1293                        return events;
1294                    }
1295                }
1296                tokio::time::sleep(Duration::from_millis(10)).await;
1297            }
1298        })
1299    }
1300}
1301
1302fn parse_cursor(cursor: Option<&str>) -> BackendResult<usize> {
1303    match cursor {
1304        Some(cursor) if !cursor.trim().is_empty() => cursor.parse::<usize>().map_err(|_| {
1305            BackendError::new(
1306                ClientErrorCategory::InvalidInput,
1307                "invalid pagination cursor",
1308            )
1309        }),
1310        _ => Ok(0),
1311    }
1312}
1313
1314fn max_message_id_from_backend(
1315    messages: &HashMap<i64, Vec<MessageRecord>>,
1316    chat_id: InlineId,
1317) -> Option<InlineId> {
1318    messages
1319        .get(&chat_id.get())?
1320        .iter()
1321        .map(|message| message.message_id.get())
1322        .max()
1323        .map(InlineId::new)
1324}
1325
1326fn chat_id_for_peer(peer: crate::PeerRef) -> InlineId {
1327    match peer {
1328        crate::PeerRef::User { user_id } => user_id,
1329        crate::PeerRef::Chat { chat_id } => chat_id,
1330        crate::PeerRef::Thread { thread_id } => thread_id,
1331    }
1332}
1333
1334#[cfg(test)]
1335mod tests {
1336    use crate::{PeerRef, SendTextRequest};
1337
1338    use super::*;
1339
1340    fn token_connect() -> ConnectRequest {
1341        ConnectRequest::new(crate::AuthCredential::AccessToken {
1342            token: crate::AuthToken::try_new("token").unwrap(),
1343        })
1344    }
1345
1346    #[tokio::test]
1347    async fn in_memory_backend_requires_connect_for_dialogs() {
1348        let backend = InMemoryBackend::new();
1349
1350        let err = backend
1351            .dialogs(DialogsRequest::default())
1352            .await
1353            .expect_err("dialogs should require connect");
1354
1355        assert_eq!(err.category, ClientErrorCategory::AuthRequired);
1356    }
1357
1358    #[tokio::test]
1359    async fn in_memory_backend_lists_dialogs_with_cursor() {
1360        let backend = InMemoryBackend::new();
1361        backend.upsert_dialog(DialogRecord {
1362            chat_id: InlineId::new(1),
1363            peer_user_id: None,
1364            title: Some("one".to_owned()),
1365            last_message_id: None,
1366            synced_through_message_id: None,
1367            unread_count: Some(0),
1368            ..DialogRecord::new(InlineId::new(1))
1369        });
1370        backend.upsert_dialog(DialogRecord {
1371            chat_id: InlineId::new(2),
1372            peer_user_id: Some(InlineId::new(3)),
1373            title: Some("two".to_owned()),
1374            last_message_id: None,
1375            synced_through_message_id: None,
1376            unread_count: Some(0),
1377            ..DialogRecord::new(InlineId::new(2))
1378        });
1379        backend.connect(token_connect()).await.unwrap();
1380
1381        let first = backend
1382            .dialogs(DialogsRequest {
1383                limit: Some(1),
1384                cursor: None,
1385                order: DialogsOrder::RecentActivity,
1386            })
1387            .await
1388            .unwrap();
1389        assert_eq!(first.dialogs[0].chat_id, InlineId::new(1));
1390        assert_eq!(first.next_cursor.as_deref(), Some("1"));
1391
1392        let second = backend
1393            .dialogs(DialogsRequest {
1394                limit: Some(1),
1395                cursor: first.next_cursor,
1396                order: DialogsOrder::RecentActivity,
1397            })
1398            .await
1399            .unwrap();
1400        assert_eq!(second.dialogs[0].chat_id, InlineId::new(2));
1401        assert_eq!(second.next_cursor, None);
1402    }
1403
1404    #[tokio::test]
1405    async fn in_memory_backend_returns_chat_participants() {
1406        let backend = InMemoryBackend::new();
1407        backend.set_chat_participants(
1408            InlineId::new(7),
1409            vec![ChatParticipantRecord {
1410                user_id: InlineId::new(42),
1411                date: Some(100),
1412            }],
1413        );
1414        backend.connect(token_connect()).await.unwrap();
1415
1416        let page = backend
1417            .chat_participants(ChatParticipantsRequest {
1418                chat_id: InlineId::new(7),
1419            })
1420            .await
1421            .unwrap();
1422
1423        assert_eq!(page.participants.len(), 1);
1424        assert_eq!(page.participants[0].user_id, InlineId::new(42));
1425        assert_eq!(page.participants[0].date, Some(100));
1426    }
1427
1428    #[tokio::test]
1429    async fn in_memory_backend_sends_text_and_records_history() {
1430        let backend = InMemoryBackend::new();
1431        backend.connect(token_connect()).await.unwrap();
1432
1433        let outcome = backend
1434            .send_text(SendTextRequest::new(
1435                PeerRef::Chat {
1436                    chat_id: InlineId::new(7),
1437                },
1438                "hello",
1439            ))
1440            .await
1441            .unwrap();
1442
1443        assert_eq!(outcome.chat_id, InlineId::new(7));
1444        assert_eq!(outcome.message_id, Some(InlineId::new(1)));
1445
1446        let history = backend
1447            .history(HistoryRequest {
1448                chat_id: InlineId::new(7),
1449                limit: Some(10),
1450                before_message_id: None,
1451                after_message_id: None,
1452            })
1453            .await
1454            .unwrap();
1455        assert_eq!(history.messages.len(), 1);
1456        assert_eq!(history.messages[0].message_id, InlineId::new(1));
1457    }
1458
1459    #[tokio::test]
1460    async fn in_memory_backend_rejects_empty_text() {
1461        let backend = InMemoryBackend::new();
1462        backend.connect(token_connect()).await.unwrap();
1463
1464        let err = backend
1465            .send_text(SendTextRequest::new(
1466                PeerRef::Chat {
1467                    chat_id: InlineId::new(7),
1468                },
1469                " ",
1470            ))
1471            .await
1472            .expect_err("empty message should fail");
1473
1474        assert_eq!(err.category, ClientErrorCategory::InvalidInput);
1475    }
1476}