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,
11    fmt,
12    sync::{Arc, Mutex},
13};
14
15use futures_util::future::BoxFuture;
16
17use crate::{
18    AuthStartRequest, AuthStartResult, AuthVerifyRequest, AuthVerifyResult, ChatParticipantRecord,
19    ChatParticipantsPage, ChatParticipantsRequest, ClientErrorCategory, ClientEvent, ClientFailure,
20    ClientStatus, ClientStatusSnapshot, ConnectRequest, CreateDmRequest, CreateReplyThreadRequest,
21    CreateThreadRequest, CreatedChat, DeleteMessageRequest, DialogRecord, DialogsPage,
22    DialogsRequest, EditMessageRequest, HistoryPage, HistoryRequest, InlineId, MessageContent,
23    MessageMutation, MessageRecord, RandomId, ReactRequest, ReadRequest, SendTextRequest,
24    TransactionEvent, TransactionId, TransactionIdentity, TransactionState, TypingRequest,
25    UploadRequest,
26};
27
28/// Result type returned by client backends.
29pub type BackendResult<T> = Result<T, BackendError>;
30
31/// Redacted backend error.
32#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
33#[error("{category:?}: {message}")]
34pub struct BackendError {
35    /// Stable error category.
36    pub category: ClientErrorCategory,
37    /// Redacted message suitable for hosts.
38    pub message: String,
39}
40
41impl BackendError {
42    /// Creates a backend error.
43    pub fn new(category: ClientErrorCategory, message: impl Into<String>) -> Self {
44        Self {
45            category,
46            message: message.into(),
47        }
48    }
49}
50
51impl From<BackendError> for ClientFailure {
52    fn from(error: BackendError) -> Self {
53        Self::new(error.category, error.message)
54    }
55}
56
57/// Outcome from a text-send operation.
58#[derive(Clone, Debug, PartialEq, Eq)]
59pub struct SendTextOutcome {
60    /// Client mutation acknowledgement.
61    pub mutation: MessageMutation,
62    /// Chat containing the message.
63    pub chat_id: InlineId,
64    /// Final message ID, when known.
65    pub message_id: Option<InlineId>,
66    /// Message record applied to the client store, when known.
67    pub message: Option<MessageRecord>,
68    /// Durable transaction state after the send attempt.
69    pub state: TransactionState,
70    /// Redacted failure, when the transaction failed.
71    pub failure: Option<ClientFailure>,
72}
73
74/// Outcome from a side-effect operation.
75#[derive(Clone, Debug, Default, PartialEq, Eq)]
76pub struct OperationOutcome {
77    /// Committed client events caused by the operation.
78    pub events: Vec<ClientEvent>,
79}
80
81impl OperationOutcome {
82    /// Creates an outcome with no committed events.
83    pub fn empty() -> Self {
84        Self::default()
85    }
86
87    /// Creates an outcome with committed events.
88    pub fn with_events(events: Vec<ClientEvent>) -> Self {
89        Self { events }
90    }
91}
92
93impl SendTextOutcome {
94    /// Creates a text-send outcome.
95    pub fn new(mutation: MessageMutation, chat_id: InlineId, message_id: Option<InlineId>) -> Self {
96        Self::with_state(
97            mutation,
98            chat_id,
99            message_id,
100            None,
101            TransactionState::Completed,
102            None,
103        )
104    }
105
106    /// Creates a text-send outcome with an explicit transaction state.
107    pub fn with_state(
108        mutation: MessageMutation,
109        chat_id: InlineId,
110        message_id: Option<InlineId>,
111        message: Option<MessageRecord>,
112        state: TransactionState,
113        failure: Option<ClientFailure>,
114    ) -> Self {
115        Self {
116            mutation,
117            chat_id,
118            message_id,
119            message,
120            state,
121            failure,
122        }
123    }
124
125    /// Returns a transaction event for this send outcome.
126    pub fn transaction_event(&self) -> TransactionEvent {
127        TransactionEvent {
128            identity: self.mutation.transaction.clone(),
129            state: self.state,
130            failure: self.failure.clone(),
131        }
132    }
133}
134
135/// Async backend boundary for the client runner.
136pub trait ClientBackend: fmt::Debug + Send + Sync + 'static {
137    /// Sends an Inline login code.
138    fn auth_start(
139        &self,
140        request: AuthStartRequest,
141    ) -> BoxFuture<'static, BackendResult<AuthStartResult>>;
142
143    /// Verifies an Inline login code and persists the resulting session.
144    fn auth_verify(
145        &self,
146        request: AuthVerifyRequest,
147    ) -> BoxFuture<'static, BackendResult<AuthVerifyResult>>;
148
149    /// Resumes a previously stored session, if available.
150    fn resume_session(&self) -> BoxFuture<'static, BackendResult<ClientStatusSnapshot>>;
151
152    /// Connects or reconnects the client.
153    fn connect(
154        &self,
155        request: ConnectRequest,
156    ) -> BoxFuture<'static, BackendResult<ClientStatusSnapshot>>;
157
158    /// Logs out the current account.
159    fn logout(&self) -> BoxFuture<'static, BackendResult<()>>;
160
161    /// Lists dialogs.
162    fn dialogs(&self, request: DialogsRequest) -> BoxFuture<'static, BackendResult<DialogsPage>>;
163
164    /// Fetches chat history.
165    fn history(&self, request: HistoryRequest) -> BoxFuture<'static, BackendResult<HistoryPage>>;
166
167    /// Fetches chat participants.
168    fn chat_participants(
169        &self,
170        request: ChatParticipantsRequest,
171    ) -> BoxFuture<'static, BackendResult<ChatParticipantsPage>>;
172
173    /// Creates or opens a direct message chat.
174    fn create_dm(&self, request: CreateDmRequest)
175    -> BoxFuture<'static, BackendResult<CreatedChat>>;
176
177    /// Creates a regular Inline thread chat.
178    fn create_thread(
179        &self,
180        request: CreateThreadRequest,
181    ) -> BoxFuture<'static, BackendResult<CreatedChat>>;
182
183    /// Creates a child/reply Inline thread chat.
184    fn create_reply_thread(
185        &self,
186        request: CreateReplyThreadRequest,
187    ) -> BoxFuture<'static, BackendResult<CreatedChat>>;
188
189    /// Sends a text message.
190    fn send_text(
191        &self,
192        request: SendTextRequest,
193    ) -> BoxFuture<'static, BackendResult<SendTextOutcome>>;
194
195    /// Uploads and sends a media message.
196    fn send_media(
197        &self,
198        request: UploadRequest,
199        bytes: Vec<u8>,
200    ) -> BoxFuture<'static, BackendResult<SendTextOutcome>>;
201
202    /// Edits a text message.
203    fn edit_message(
204        &self,
205        request: EditMessageRequest,
206    ) -> BoxFuture<'static, BackendResult<OperationOutcome>>;
207
208    /// Deletes or unsends a message.
209    fn delete_message(
210        &self,
211        request: DeleteMessageRequest,
212    ) -> BoxFuture<'static, BackendResult<OperationOutcome>>;
213
214    /// Adds or removes a reaction.
215    fn react(&self, request: ReactRequest) -> BoxFuture<'static, BackendResult<OperationOutcome>>;
216
217    /// Marks messages read.
218    fn read(&self, request: ReadRequest) -> BoxFuture<'static, BackendResult<OperationOutcome>>;
219
220    /// Sends a typing state.
221    fn typing(&self, request: TypingRequest)
222    -> BoxFuture<'static, BackendResult<OperationOutcome>>;
223}
224
225/// In-memory backend for client, bridge-adapter, and runtime tests.
226#[derive(Clone, Debug, Default)]
227pub struct InMemoryBackend {
228    state: Arc<Mutex<InMemoryBackendState>>,
229}
230
231#[derive(Debug)]
232struct InMemoryBackendState {
233    connected: bool,
234    account_namespace: Option<String>,
235    dialogs: Vec<DialogRecord>,
236    participants: HashMap<i64, Vec<ChatParticipantRecord>>,
237    messages: HashMap<i64, Vec<MessageRecord>>,
238    next_chat_id: i64,
239    next_message_id: i64,
240    next_random_id: i64,
241    next_transaction_id: u64,
242}
243
244impl Default for InMemoryBackendState {
245    fn default() -> Self {
246        Self {
247            connected: false,
248            account_namespace: None,
249            dialogs: Vec::new(),
250            participants: HashMap::new(),
251            messages: HashMap::new(),
252            next_chat_id: 10_000,
253            next_message_id: 1,
254            next_random_id: 1,
255            next_transaction_id: 1,
256        }
257    }
258}
259
260impl InMemoryBackend {
261    /// Creates an empty in-memory backend.
262    pub fn new() -> Self {
263        Self::default()
264    }
265
266    /// Inserts or replaces a dialog.
267    pub fn upsert_dialog(&self, dialog: DialogRecord) {
268        let mut state = self.state.lock().expect("in-memory backend poisoned");
269        if let Some(existing) = state
270            .dialogs
271            .iter_mut()
272            .find(|existing| existing.chat_id == dialog.chat_id)
273        {
274            *existing = dialog;
275            return;
276        }
277        state.dialogs.push(dialog);
278    }
279
280    /// Replaces the participant snapshot for a chat.
281    pub fn set_chat_participants(
282        &self,
283        chat_id: InlineId,
284        participants: Vec<ChatParticipantRecord>,
285    ) {
286        let mut state = self.state.lock().expect("in-memory backend poisoned");
287        state.participants.insert(chat_id.get(), participants);
288    }
289
290    /// Inserts a message into the in-memory history.
291    pub fn insert_message(&self, message: MessageRecord) {
292        let mut state = self.state.lock().expect("in-memory backend poisoned");
293        let chat_id = message.chat_id.get();
294        state.messages.entry(chat_id).or_default().push(message);
295        if let Some(messages) = state.messages.get_mut(&chat_id) {
296            messages.sort_by_key(|message| (message.timestamp, message.message_id.get()));
297        }
298    }
299
300    /// Returns whether the backend is connected.
301    pub fn is_connected(&self) -> bool {
302        self.state
303            .lock()
304            .expect("in-memory backend poisoned")
305            .connected
306    }
307
308    fn connect_now(&self, request: ConnectRequest) -> BackendResult<ClientStatusSnapshot> {
309        let mut state = self.state.lock().expect("in-memory backend poisoned");
310        state.connected = true;
311        state.account_namespace = request.account_namespace;
312        Ok(ClientStatusSnapshot::current(ClientStatus::Connected))
313    }
314
315    fn auth_start_now(&self, request: AuthStartRequest) -> BackendResult<AuthStartResult> {
316        if request.contact.trim().is_empty() {
317            return Err(BackendError::new(
318                ClientErrorCategory::InvalidInput,
319                "auth contact must not be empty",
320            ));
321        }
322        Ok(AuthStartResult {
323            existing_user: true,
324            needs_invite_code: false,
325            challenge_token: None,
326        })
327    }
328
329    fn auth_verify_now(&self, request: AuthVerifyRequest) -> BackendResult<AuthVerifyResult> {
330        if request.contact.trim().is_empty() {
331            return Err(BackendError::new(
332                ClientErrorCategory::InvalidInput,
333                "auth contact must not be empty",
334            ));
335        }
336        if request.code.trim().is_empty() {
337            return Err(BackendError::new(
338                ClientErrorCategory::InvalidInput,
339                "verification code must not be empty",
340            ));
341        }
342        let account_namespace = request
343            .account_namespace
344            .map(|namespace| namespace.trim().to_owned())
345            .filter(|namespace| !namespace.is_empty())
346            .unwrap_or_else(|| "1".to_owned());
347        let mut state = self.state.lock().expect("in-memory backend poisoned");
348        state.connected = true;
349        state.account_namespace = Some(account_namespace.clone());
350        Ok(AuthVerifyResult {
351            user_id: InlineId::new(1),
352            account_namespace,
353            status: ClientStatusSnapshot::current(ClientStatus::Connected),
354        })
355    }
356
357    fn resume_session_now(&self) -> BackendResult<ClientStatusSnapshot> {
358        let mut state = self.state.lock().expect("in-memory backend poisoned");
359        if state.connected || state.account_namespace.is_some() {
360            state.connected = true;
361            Ok(ClientStatusSnapshot::current(ClientStatus::Connected))
362        } else {
363            Ok(ClientStatusSnapshot::current(ClientStatus::AuthRequired))
364        }
365    }
366
367    fn logout_now(&self) -> BackendResult<()> {
368        let mut state = self.state.lock().expect("in-memory backend poisoned");
369        state.connected = false;
370        Ok(())
371    }
372
373    fn dialogs_now(&self, request: DialogsRequest) -> BackendResult<DialogsPage> {
374        self.require_connected()?;
375        let state = self.state.lock().expect("in-memory backend poisoned");
376        let start = parse_cursor(request.cursor.as_deref())?;
377        let limit = request.limit.unwrap_or(50).max(1) as usize;
378        let dialogs = state
379            .dialogs
380            .iter()
381            .skip(start)
382            .take(limit)
383            .map(|dialog| {
384                let mut dialog = dialog.clone();
385                dialog.synced_through_message_id =
386                    max_message_id_from_backend(&state.messages, dialog.chat_id);
387                dialog
388            })
389            .collect::<Vec<_>>();
390        let next = start + dialogs.len();
391        Ok(DialogsPage {
392            dialogs,
393            users: Vec::new(),
394            next_cursor: (next < state.dialogs.len()).then(|| next.to_string()),
395        })
396    }
397
398    fn history_now(&self, request: HistoryRequest) -> BackendResult<HistoryPage> {
399        self.require_connected()?;
400        let state = self.state.lock().expect("in-memory backend poisoned");
401        let mut messages = state
402            .messages
403            .get(&request.chat_id.get())
404            .cloned()
405            .unwrap_or_default();
406        messages.sort_by_key(|message| (message.timestamp, message.message_id.get()));
407        if request.before_message_id.is_some() && request.after_message_id.is_some() {
408            return Err(BackendError::new(
409                ClientErrorCategory::InvalidInput,
410                "history request cannot specify both before_message_id and after_message_id",
411            ));
412        }
413        if let Some(before) = request.before_message_id {
414            messages.retain(|message| message.message_id.get() < before.get());
415        }
416        if let Some(after) = request.after_message_id {
417            messages.retain(|message| message.message_id.get() > after.get());
418        }
419        let limit = request.limit.unwrap_or(50).max(1) as usize;
420        let has_more = messages.len() > limit;
421        if has_more {
422            if request.after_message_id.is_some() {
423                messages.truncate(limit);
424            } else {
425                let start = messages.len() - limit;
426                messages = messages[start..].to_vec();
427            }
428        }
429        Ok(HistoryPage {
430            messages,
431            users: Vec::new(),
432            has_more,
433            next_cursor: None,
434        })
435    }
436
437    fn send_text_now(&self, request: SendTextRequest) -> BackendResult<SendTextOutcome> {
438        self.require_connected()?;
439        if request.text.trim().is_empty() {
440            return Err(BackendError::new(
441                ClientErrorCategory::InvalidInput,
442                "message text must not be empty",
443            ));
444        }
445
446        let mut state = self.state.lock().expect("in-memory backend poisoned");
447        let chat_id = chat_id_for_peer(request.peer);
448        let message_id = InlineId::new(state.next_message_id);
449        state.next_message_id += 1;
450        let random_id = request.random_id.unwrap_or_else(|| {
451            let id = RandomId::new(state.next_random_id);
452            state.next_random_id += 1;
453            id
454        });
455        let transaction_id = TransactionId::try_new(format!("mem-{}", state.next_transaction_id))
456            .expect("generated transaction id is valid");
457        state.next_transaction_id += 1;
458
459        let transaction = TransactionIdentity::new(transaction_id, request.external_id, random_id)
460            .with_final_message_id(message_id);
461        let message = MessageRecord {
462            chat_id,
463            message_id,
464            sender_id: InlineId::new(0),
465            timestamp: message_id.get(),
466            is_outgoing: true,
467            content: MessageContent::Text { text: request.text },
468            reply_to_message_id: request.reply_to_message_id,
469            transaction: Some(transaction.clone()),
470        };
471        state
472            .messages
473            .entry(chat_id.get())
474            .or_default()
475            .push(message.clone());
476        crate::store::upsert_dialog_last_message(&mut state.dialogs, chat_id, message_id);
477
478        Ok(SendTextOutcome::with_state(
479            MessageMutation {
480                transaction,
481                message_id: Some(message_id),
482            },
483            chat_id,
484            Some(message_id),
485            Some(message),
486            TransactionState::Completed,
487            None,
488        ))
489    }
490
491    fn create_chat_now(
492        &self,
493        title: Option<String>,
494        parent_chat_id: Option<InlineId>,
495        parent_message_id: Option<InlineId>,
496        participants: Vec<ChatParticipantRecord>,
497    ) -> BackendResult<CreatedChat> {
498        self.require_connected()?;
499        let title = title
500            .map(|title| title.trim().to_owned())
501            .filter(|title| !title.is_empty());
502        let mut state = self.state.lock().expect("in-memory backend poisoned");
503        let chat_id = InlineId::new(state.next_chat_id);
504        state.next_chat_id += 1;
505        state.dialogs.push(DialogRecord {
506            chat_id,
507            peer_user_id: None,
508            title: title.clone(),
509            last_message_id: None,
510            synced_through_message_id: None,
511            unread_count: Some(0),
512        });
513        if !participants.is_empty() {
514            state.participants.insert(chat_id.get(), participants);
515        }
516        Ok(CreatedChat {
517            chat_id,
518            title,
519            parent_chat_id,
520            parent_message_id,
521        })
522    }
523
524    fn send_media_now(
525        &self,
526        request: UploadRequest,
527        bytes: Vec<u8>,
528    ) -> BackendResult<SendTextOutcome> {
529        self.require_connected()?;
530        if bytes.is_empty() {
531            return Err(BackendError::new(
532                ClientErrorCategory::InvalidInput,
533                "media bytes must not be empty",
534            ));
535        }
536
537        let mut state = self.state.lock().expect("in-memory backend poisoned");
538        let chat_id = chat_id_for_peer(request.peer);
539        let message_id = InlineId::new(state.next_message_id);
540        state.next_message_id += 1;
541        let random_id = request.random_id.unwrap_or_else(|| {
542            let id = RandomId::new(state.next_random_id);
543            state.next_random_id += 1;
544            id
545        });
546        let transaction_id =
547            TransactionId::try_new(format!("mem-upload-{}", state.next_transaction_id))
548                .expect("generated transaction id is valid");
549        state.next_transaction_id += 1;
550
551        let transaction =
552            TransactionIdentity::new(transaction_id, request.external_id.clone(), random_id)
553                .with_final_message_id(message_id);
554        let message = MessageRecord {
555            chat_id,
556            message_id,
557            sender_id: InlineId::new(0),
558            timestamp: message_id.get(),
559            is_outgoing: true,
560            content: MessageContent::Media {
561                kind: request.kind,
562                file_id: format!("mem-file-{}", message_id.get()),
563                url: None,
564                mime_type: request.mime_type.clone(),
565                file_name: request.file_name.clone(),
566                caption: request.caption.clone(),
567                size_bytes: request.size_bytes.or(Some(bytes.len() as u64)),
568                width: request.width,
569                height: request.height,
570                duration_ms: request.duration_ms,
571            },
572            reply_to_message_id: request.reply_to_message_id,
573            transaction: Some(transaction.clone()),
574        };
575        state
576            .messages
577            .entry(chat_id.get())
578            .or_default()
579            .push(message.clone());
580        crate::store::upsert_dialog_last_message(&mut state.dialogs, chat_id, message_id);
581
582        Ok(SendTextOutcome::with_state(
583            MessageMutation {
584                transaction,
585                message_id: Some(message_id),
586            },
587            chat_id,
588            Some(message_id),
589            Some(message),
590            TransactionState::Completed,
591            None,
592        ))
593    }
594
595    fn require_connected(&self) -> BackendResult<()> {
596        if self
597            .state
598            .lock()
599            .expect("in-memory backend poisoned")
600            .connected
601        {
602            Ok(())
603        } else {
604            Err(BackendError::new(
605                ClientErrorCategory::AuthRequired,
606                "client is not connected",
607            ))
608        }
609    }
610}
611
612impl ClientBackend for InMemoryBackend {
613    fn auth_start(
614        &self,
615        request: AuthStartRequest,
616    ) -> BoxFuture<'static, BackendResult<AuthStartResult>> {
617        let backend = self.clone();
618        Box::pin(async move { backend.auth_start_now(request) })
619    }
620
621    fn auth_verify(
622        &self,
623        request: AuthVerifyRequest,
624    ) -> BoxFuture<'static, BackendResult<AuthVerifyResult>> {
625        let backend = self.clone();
626        Box::pin(async move { backend.auth_verify_now(request) })
627    }
628
629    fn resume_session(&self) -> BoxFuture<'static, BackendResult<ClientStatusSnapshot>> {
630        let backend = self.clone();
631        Box::pin(async move { backend.resume_session_now() })
632    }
633
634    fn connect(
635        &self,
636        request: ConnectRequest,
637    ) -> BoxFuture<'static, BackendResult<ClientStatusSnapshot>> {
638        let backend = self.clone();
639        Box::pin(async move { backend.connect_now(request) })
640    }
641
642    fn logout(&self) -> BoxFuture<'static, BackendResult<()>> {
643        let backend = self.clone();
644        Box::pin(async move { backend.logout_now() })
645    }
646
647    fn dialogs(&self, request: DialogsRequest) -> BoxFuture<'static, BackendResult<DialogsPage>> {
648        let backend = self.clone();
649        Box::pin(async move { backend.dialogs_now(request) })
650    }
651
652    fn history(&self, request: HistoryRequest) -> BoxFuture<'static, BackendResult<HistoryPage>> {
653        let backend = self.clone();
654        Box::pin(async move { backend.history_now(request) })
655    }
656
657    fn chat_participants(
658        &self,
659        request: ChatParticipantsRequest,
660    ) -> BoxFuture<'static, BackendResult<ChatParticipantsPage>> {
661        let backend = self.clone();
662        Box::pin(async move {
663            backend.require_connected()?;
664            let state = backend.state.lock().expect("in-memory backend poisoned");
665            Ok(ChatParticipantsPage {
666                participants: state
667                    .participants
668                    .get(&request.chat_id.get())
669                    .cloned()
670                    .unwrap_or_default(),
671                users: Vec::new(),
672            })
673        })
674    }
675
676    fn create_dm(
677        &self,
678        request: CreateDmRequest,
679    ) -> BoxFuture<'static, BackendResult<CreatedChat>> {
680        let backend = self.clone();
681        Box::pin(async move {
682            if request.user_id.get() <= 0 {
683                return Err(BackendError::new(
684                    ClientErrorCategory::InvalidInput,
685                    "user_id must be positive",
686                ));
687            }
688            backend.create_chat_now(
689                Some(format!("DM {}", request.user_id.get())),
690                None,
691                None,
692                vec![
693                    ChatParticipantRecord {
694                        user_id: InlineId::new(0),
695                        date: None,
696                    },
697                    ChatParticipantRecord {
698                        user_id: request.user_id,
699                        date: None,
700                    },
701                ],
702            )
703        })
704    }
705
706    fn create_thread(
707        &self,
708        request: CreateThreadRequest,
709    ) -> BoxFuture<'static, BackendResult<CreatedChat>> {
710        let backend = self.clone();
711        Box::pin(async move {
712            let participants = request
713                .participants
714                .into_iter()
715                .map(|participant| ChatParticipantRecord {
716                    user_id: participant.user_id,
717                    date: None,
718                })
719                .collect();
720            backend.create_chat_now(request.title, None, None, participants)
721        })
722    }
723
724    fn create_reply_thread(
725        &self,
726        request: CreateReplyThreadRequest,
727    ) -> BoxFuture<'static, BackendResult<CreatedChat>> {
728        let backend = self.clone();
729        Box::pin(async move {
730            if request.parent_chat_id.get() <= 0 {
731                return Err(BackendError::new(
732                    ClientErrorCategory::InvalidInput,
733                    "parent_chat_id must be positive",
734                ));
735            }
736            let participants = request
737                .participants
738                .into_iter()
739                .map(|participant| ChatParticipantRecord {
740                    user_id: participant.user_id,
741                    date: None,
742                })
743                .collect();
744            backend.create_chat_now(
745                request.title,
746                Some(request.parent_chat_id),
747                request.parent_message_id,
748                participants,
749            )
750        })
751    }
752
753    fn send_text(
754        &self,
755        request: SendTextRequest,
756    ) -> BoxFuture<'static, BackendResult<SendTextOutcome>> {
757        let backend = self.clone();
758        Box::pin(async move { backend.send_text_now(request) })
759    }
760
761    fn send_media(
762        &self,
763        request: UploadRequest,
764        bytes: Vec<u8>,
765    ) -> BoxFuture<'static, BackendResult<SendTextOutcome>> {
766        let backend = self.clone();
767        Box::pin(async move { backend.send_media_now(request, bytes) })
768    }
769
770    fn edit_message(
771        &self,
772        request: EditMessageRequest,
773    ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
774        let backend = self.clone();
775        Box::pin(async move {
776            backend.require_connected()?;
777            if request.text.trim().is_empty() {
778                return Err(BackendError::new(
779                    ClientErrorCategory::InvalidInput,
780                    "message text must not be empty",
781                ));
782            }
783            let mut state = backend.state.lock().expect("in-memory backend poisoned");
784            let messages = state.messages.entry(request.chat_id.get()).or_default();
785            if let Some(message) = messages
786                .iter_mut()
787                .find(|message| message.message_id == request.message_id)
788            {
789                message.content = MessageContent::Text { text: request.text };
790                return Ok(OperationOutcome::with_events(vec![
791                    ClientEvent::MessageStored {
792                        message: message.clone(),
793                    },
794                ]));
795            }
796            Err(BackendError::new(
797                ClientErrorCategory::InvalidInput,
798                "message not found",
799            ))
800        })
801    }
802
803    fn delete_message(
804        &self,
805        request: DeleteMessageRequest,
806    ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
807        let backend = self.clone();
808        Box::pin(async move {
809            backend.require_connected()?;
810            let mut state = backend.state.lock().expect("in-memory backend poisoned");
811            let messages = state.messages.entry(request.chat_id.get()).or_default();
812            messages.retain(|message| message.message_id != request.message_id);
813            Ok(OperationOutcome::with_events(vec![
814                ClientEvent::MessageDeleted {
815                    chat_id: request.chat_id,
816                    message_id: request.message_id,
817                },
818            ]))
819        })
820    }
821
822    fn react(&self, request: ReactRequest) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
823        let backend = self.clone();
824        Box::pin(async move {
825            backend.require_connected()?;
826            if request.reaction.trim().is_empty() {
827                return Err(BackendError::new(
828                    ClientErrorCategory::InvalidInput,
829                    "reaction must not be empty",
830                ));
831            }
832            Ok(OperationOutcome::with_events(vec![
833                ClientEvent::ReactionChanged {
834                    chat_id: request.chat_id,
835                    message_id: request.message_id,
836                    user_id: InlineId::new(0),
837                    reaction: request.reaction,
838                    removed: request.remove,
839                },
840            ]))
841        })
842    }
843
844    fn read(&self, request: ReadRequest) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
845        let backend = self.clone();
846        Box::pin(async move {
847            backend.require_connected()?;
848            Ok(OperationOutcome::with_events(vec![
849                ClientEvent::ReadStateChanged {
850                    chat_id: request.chat_id,
851                },
852            ]))
853        })
854    }
855
856    fn typing(
857        &self,
858        request: TypingRequest,
859    ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
860        let backend = self.clone();
861        Box::pin(async move {
862            backend.require_connected()?;
863            Ok(OperationOutcome::with_events(vec![ClientEvent::Typing {
864                chat_id: request.chat_id,
865                user_id: InlineId::new(0),
866                is_typing: request.is_typing,
867            }]))
868        })
869    }
870}
871
872fn parse_cursor(cursor: Option<&str>) -> BackendResult<usize> {
873    match cursor {
874        Some(cursor) if !cursor.trim().is_empty() => cursor.parse::<usize>().map_err(|_| {
875            BackendError::new(
876                ClientErrorCategory::InvalidInput,
877                "invalid pagination cursor",
878            )
879        }),
880        _ => Ok(0),
881    }
882}
883
884fn max_message_id_from_backend(
885    messages: &HashMap<i64, Vec<MessageRecord>>,
886    chat_id: InlineId,
887) -> Option<InlineId> {
888    messages
889        .get(&chat_id.get())?
890        .iter()
891        .map(|message| message.message_id.get())
892        .max()
893        .map(InlineId::new)
894}
895
896fn chat_id_for_peer(peer: crate::PeerRef) -> InlineId {
897    match peer {
898        crate::PeerRef::User { user_id } => user_id,
899        crate::PeerRef::Chat { chat_id } => chat_id,
900        crate::PeerRef::Thread { thread_id } => thread_id,
901    }
902}
903
904#[cfg(test)]
905mod tests {
906    use crate::{PeerRef, SendTextRequest};
907
908    use super::*;
909
910    fn token_connect() -> ConnectRequest {
911        ConnectRequest::new(crate::AuthCredential::AccessToken {
912            token: crate::AuthToken::try_new("token").unwrap(),
913        })
914    }
915
916    #[tokio::test]
917    async fn in_memory_backend_requires_connect_for_dialogs() {
918        let backend = InMemoryBackend::new();
919
920        let err = backend
921            .dialogs(DialogsRequest::default())
922            .await
923            .expect_err("dialogs should require connect");
924
925        assert_eq!(err.category, ClientErrorCategory::AuthRequired);
926    }
927
928    #[tokio::test]
929    async fn in_memory_backend_lists_dialogs_with_cursor() {
930        let backend = InMemoryBackend::new();
931        backend.upsert_dialog(DialogRecord {
932            chat_id: InlineId::new(1),
933            peer_user_id: None,
934            title: Some("one".to_owned()),
935            last_message_id: None,
936            synced_through_message_id: None,
937            unread_count: Some(0),
938        });
939        backend.upsert_dialog(DialogRecord {
940            chat_id: InlineId::new(2),
941            peer_user_id: Some(InlineId::new(3)),
942            title: Some("two".to_owned()),
943            last_message_id: None,
944            synced_through_message_id: None,
945            unread_count: Some(0),
946        });
947        backend.connect(token_connect()).await.unwrap();
948
949        let first = backend
950            .dialogs(DialogsRequest {
951                limit: Some(1),
952                cursor: None,
953            })
954            .await
955            .unwrap();
956        assert_eq!(first.dialogs[0].chat_id, InlineId::new(1));
957        assert_eq!(first.next_cursor.as_deref(), Some("1"));
958
959        let second = backend
960            .dialogs(DialogsRequest {
961                limit: Some(1),
962                cursor: first.next_cursor,
963            })
964            .await
965            .unwrap();
966        assert_eq!(second.dialogs[0].chat_id, InlineId::new(2));
967        assert_eq!(second.next_cursor, None);
968    }
969
970    #[tokio::test]
971    async fn in_memory_backend_returns_chat_participants() {
972        let backend = InMemoryBackend::new();
973        backend.set_chat_participants(
974            InlineId::new(7),
975            vec![ChatParticipantRecord {
976                user_id: InlineId::new(42),
977                date: Some(100),
978            }],
979        );
980        backend.connect(token_connect()).await.unwrap();
981
982        let page = backend
983            .chat_participants(ChatParticipantsRequest {
984                chat_id: InlineId::new(7),
985            })
986            .await
987            .unwrap();
988
989        assert_eq!(page.participants.len(), 1);
990        assert_eq!(page.participants[0].user_id, InlineId::new(42));
991        assert_eq!(page.participants[0].date, Some(100));
992    }
993
994    #[tokio::test]
995    async fn in_memory_backend_sends_text_and_records_history() {
996        let backend = InMemoryBackend::new();
997        backend.connect(token_connect()).await.unwrap();
998
999        let outcome = backend
1000            .send_text(SendTextRequest::new(
1001                PeerRef::Chat {
1002                    chat_id: InlineId::new(7),
1003                },
1004                "hello",
1005            ))
1006            .await
1007            .unwrap();
1008
1009        assert_eq!(outcome.chat_id, InlineId::new(7));
1010        assert_eq!(outcome.message_id, Some(InlineId::new(1)));
1011
1012        let history = backend
1013            .history(HistoryRequest {
1014                chat_id: InlineId::new(7),
1015                limit: Some(10),
1016                before_message_id: None,
1017                after_message_id: None,
1018            })
1019            .await
1020            .unwrap();
1021        assert_eq!(history.messages.len(), 1);
1022        assert_eq!(history.messages[0].message_id, InlineId::new(1));
1023    }
1024
1025    #[tokio::test]
1026    async fn in_memory_backend_rejects_empty_text() {
1027        let backend = InMemoryBackend::new();
1028        backend.connect(token_connect()).await.unwrap();
1029
1030        let err = backend
1031            .send_text(SendTextRequest::new(
1032                PeerRef::Chat {
1033                    chat_id: InlineId::new(7),
1034                },
1035                " ",
1036            ))
1037            .await
1038            .expect_err("empty message should fail");
1039
1040        assert_eq!(err.category, ClientErrorCategory::InvalidInput);
1041    }
1042}