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            title: title.clone(),
508            last_message_id: None,
509            synced_through_message_id: None,
510            unread_count: Some(0),
511        });
512        if !participants.is_empty() {
513            state.participants.insert(chat_id.get(), participants);
514        }
515        Ok(CreatedChat {
516            chat_id,
517            title,
518            parent_chat_id,
519            parent_message_id,
520        })
521    }
522
523    fn send_media_now(
524        &self,
525        request: UploadRequest,
526        bytes: Vec<u8>,
527    ) -> BackendResult<SendTextOutcome> {
528        self.require_connected()?;
529        if bytes.is_empty() {
530            return Err(BackendError::new(
531                ClientErrorCategory::InvalidInput,
532                "media bytes must not be empty",
533            ));
534        }
535
536        let mut state = self.state.lock().expect("in-memory backend poisoned");
537        let chat_id = chat_id_for_peer(request.peer);
538        let message_id = InlineId::new(state.next_message_id);
539        state.next_message_id += 1;
540        let random_id = request.random_id.unwrap_or_else(|| {
541            let id = RandomId::new(state.next_random_id);
542            state.next_random_id += 1;
543            id
544        });
545        let transaction_id =
546            TransactionId::try_new(format!("mem-upload-{}", state.next_transaction_id))
547                .expect("generated transaction id is valid");
548        state.next_transaction_id += 1;
549
550        let transaction =
551            TransactionIdentity::new(transaction_id, request.external_id.clone(), random_id)
552                .with_final_message_id(message_id);
553        let message = MessageRecord {
554            chat_id,
555            message_id,
556            sender_id: InlineId::new(0),
557            timestamp: message_id.get(),
558            is_outgoing: true,
559            content: MessageContent::Media {
560                kind: request.kind,
561                file_id: format!("mem-file-{}", message_id.get()),
562                url: None,
563                mime_type: request.mime_type.clone(),
564                file_name: request.file_name.clone(),
565                caption: request.caption.clone(),
566                size_bytes: request.size_bytes.or(Some(bytes.len() as u64)),
567                width: request.width,
568                height: request.height,
569                duration_ms: request.duration_ms,
570            },
571            reply_to_message_id: request.reply_to_message_id,
572            transaction: Some(transaction.clone()),
573        };
574        state
575            .messages
576            .entry(chat_id.get())
577            .or_default()
578            .push(message.clone());
579        crate::store::upsert_dialog_last_message(&mut state.dialogs, chat_id, message_id);
580
581        Ok(SendTextOutcome::with_state(
582            MessageMutation {
583                transaction,
584                message_id: Some(message_id),
585            },
586            chat_id,
587            Some(message_id),
588            Some(message),
589            TransactionState::Completed,
590            None,
591        ))
592    }
593
594    fn require_connected(&self) -> BackendResult<()> {
595        if self
596            .state
597            .lock()
598            .expect("in-memory backend poisoned")
599            .connected
600        {
601            Ok(())
602        } else {
603            Err(BackendError::new(
604                ClientErrorCategory::AuthRequired,
605                "client is not connected",
606            ))
607        }
608    }
609}
610
611impl ClientBackend for InMemoryBackend {
612    fn auth_start(
613        &self,
614        request: AuthStartRequest,
615    ) -> BoxFuture<'static, BackendResult<AuthStartResult>> {
616        let backend = self.clone();
617        Box::pin(async move { backend.auth_start_now(request) })
618    }
619
620    fn auth_verify(
621        &self,
622        request: AuthVerifyRequest,
623    ) -> BoxFuture<'static, BackendResult<AuthVerifyResult>> {
624        let backend = self.clone();
625        Box::pin(async move { backend.auth_verify_now(request) })
626    }
627
628    fn resume_session(&self) -> BoxFuture<'static, BackendResult<ClientStatusSnapshot>> {
629        let backend = self.clone();
630        Box::pin(async move { backend.resume_session_now() })
631    }
632
633    fn connect(
634        &self,
635        request: ConnectRequest,
636    ) -> BoxFuture<'static, BackendResult<ClientStatusSnapshot>> {
637        let backend = self.clone();
638        Box::pin(async move { backend.connect_now(request) })
639    }
640
641    fn logout(&self) -> BoxFuture<'static, BackendResult<()>> {
642        let backend = self.clone();
643        Box::pin(async move { backend.logout_now() })
644    }
645
646    fn dialogs(&self, request: DialogsRequest) -> BoxFuture<'static, BackendResult<DialogsPage>> {
647        let backend = self.clone();
648        Box::pin(async move { backend.dialogs_now(request) })
649    }
650
651    fn history(&self, request: HistoryRequest) -> BoxFuture<'static, BackendResult<HistoryPage>> {
652        let backend = self.clone();
653        Box::pin(async move { backend.history_now(request) })
654    }
655
656    fn chat_participants(
657        &self,
658        request: ChatParticipantsRequest,
659    ) -> BoxFuture<'static, BackendResult<ChatParticipantsPage>> {
660        let backend = self.clone();
661        Box::pin(async move {
662            backend.require_connected()?;
663            let state = backend.state.lock().expect("in-memory backend poisoned");
664            Ok(ChatParticipantsPage {
665                participants: state
666                    .participants
667                    .get(&request.chat_id.get())
668                    .cloned()
669                    .unwrap_or_default(),
670                users: Vec::new(),
671            })
672        })
673    }
674
675    fn create_dm(
676        &self,
677        request: CreateDmRequest,
678    ) -> BoxFuture<'static, BackendResult<CreatedChat>> {
679        let backend = self.clone();
680        Box::pin(async move {
681            if request.user_id.get() <= 0 {
682                return Err(BackendError::new(
683                    ClientErrorCategory::InvalidInput,
684                    "user_id must be positive",
685                ));
686            }
687            backend.create_chat_now(
688                Some(format!("DM {}", request.user_id.get())),
689                None,
690                None,
691                vec![
692                    ChatParticipantRecord {
693                        user_id: InlineId::new(0),
694                        date: None,
695                    },
696                    ChatParticipantRecord {
697                        user_id: request.user_id,
698                        date: None,
699                    },
700                ],
701            )
702        })
703    }
704
705    fn create_thread(
706        &self,
707        request: CreateThreadRequest,
708    ) -> BoxFuture<'static, BackendResult<CreatedChat>> {
709        let backend = self.clone();
710        Box::pin(async move {
711            let participants = request
712                .participants
713                .into_iter()
714                .map(|participant| ChatParticipantRecord {
715                    user_id: participant.user_id,
716                    date: None,
717                })
718                .collect();
719            backend.create_chat_now(request.title, None, None, participants)
720        })
721    }
722
723    fn create_reply_thread(
724        &self,
725        request: CreateReplyThreadRequest,
726    ) -> BoxFuture<'static, BackendResult<CreatedChat>> {
727        let backend = self.clone();
728        Box::pin(async move {
729            if request.parent_chat_id.get() <= 0 {
730                return Err(BackendError::new(
731                    ClientErrorCategory::InvalidInput,
732                    "parent_chat_id must be positive",
733                ));
734            }
735            let participants = request
736                .participants
737                .into_iter()
738                .map(|participant| ChatParticipantRecord {
739                    user_id: participant.user_id,
740                    date: None,
741                })
742                .collect();
743            backend.create_chat_now(
744                request.title,
745                Some(request.parent_chat_id),
746                request.parent_message_id,
747                participants,
748            )
749        })
750    }
751
752    fn send_text(
753        &self,
754        request: SendTextRequest,
755    ) -> BoxFuture<'static, BackendResult<SendTextOutcome>> {
756        let backend = self.clone();
757        Box::pin(async move { backend.send_text_now(request) })
758    }
759
760    fn send_media(
761        &self,
762        request: UploadRequest,
763        bytes: Vec<u8>,
764    ) -> BoxFuture<'static, BackendResult<SendTextOutcome>> {
765        let backend = self.clone();
766        Box::pin(async move { backend.send_media_now(request, bytes) })
767    }
768
769    fn edit_message(
770        &self,
771        request: EditMessageRequest,
772    ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
773        let backend = self.clone();
774        Box::pin(async move {
775            backend.require_connected()?;
776            if request.text.trim().is_empty() {
777                return Err(BackendError::new(
778                    ClientErrorCategory::InvalidInput,
779                    "message text must not be empty",
780                ));
781            }
782            let mut state = backend.state.lock().expect("in-memory backend poisoned");
783            let messages = state.messages.entry(request.chat_id.get()).or_default();
784            if let Some(message) = messages
785                .iter_mut()
786                .find(|message| message.message_id == request.message_id)
787            {
788                message.content = MessageContent::Text { text: request.text };
789                return Ok(OperationOutcome::with_events(vec![
790                    ClientEvent::MessageStored {
791                        message: message.clone(),
792                    },
793                ]));
794            }
795            Err(BackendError::new(
796                ClientErrorCategory::InvalidInput,
797                "message not found",
798            ))
799        })
800    }
801
802    fn delete_message(
803        &self,
804        request: DeleteMessageRequest,
805    ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
806        let backend = self.clone();
807        Box::pin(async move {
808            backend.require_connected()?;
809            let mut state = backend.state.lock().expect("in-memory backend poisoned");
810            let messages = state.messages.entry(request.chat_id.get()).or_default();
811            messages.retain(|message| message.message_id != request.message_id);
812            Ok(OperationOutcome::with_events(vec![
813                ClientEvent::MessageDeleted {
814                    chat_id: request.chat_id,
815                    message_id: request.message_id,
816                },
817            ]))
818        })
819    }
820
821    fn react(&self, request: ReactRequest) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
822        let backend = self.clone();
823        Box::pin(async move {
824            backend.require_connected()?;
825            if request.reaction.trim().is_empty() {
826                return Err(BackendError::new(
827                    ClientErrorCategory::InvalidInput,
828                    "reaction must not be empty",
829                ));
830            }
831            Ok(OperationOutcome::with_events(vec![
832                ClientEvent::ReactionChanged {
833                    chat_id: request.chat_id,
834                    message_id: request.message_id,
835                    user_id: InlineId::new(0),
836                    reaction: request.reaction,
837                    removed: request.remove,
838                },
839            ]))
840        })
841    }
842
843    fn read(&self, request: ReadRequest) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
844        let backend = self.clone();
845        Box::pin(async move {
846            backend.require_connected()?;
847            Ok(OperationOutcome::with_events(vec![
848                ClientEvent::ReadStateChanged {
849                    chat_id: request.chat_id,
850                },
851            ]))
852        })
853    }
854
855    fn typing(
856        &self,
857        request: TypingRequest,
858    ) -> BoxFuture<'static, BackendResult<OperationOutcome>> {
859        let backend = self.clone();
860        Box::pin(async move {
861            backend.require_connected()?;
862            Ok(OperationOutcome::with_events(vec![ClientEvent::Typing {
863                chat_id: request.chat_id,
864                user_id: InlineId::new(0),
865                is_typing: request.is_typing,
866            }]))
867        })
868    }
869}
870
871fn parse_cursor(cursor: Option<&str>) -> BackendResult<usize> {
872    match cursor {
873        Some(cursor) if !cursor.trim().is_empty() => cursor.parse::<usize>().map_err(|_| {
874            BackendError::new(
875                ClientErrorCategory::InvalidInput,
876                "invalid pagination cursor",
877            )
878        }),
879        _ => Ok(0),
880    }
881}
882
883fn max_message_id_from_backend(
884    messages: &HashMap<i64, Vec<MessageRecord>>,
885    chat_id: InlineId,
886) -> Option<InlineId> {
887    messages
888        .get(&chat_id.get())?
889        .iter()
890        .map(|message| message.message_id.get())
891        .max()
892        .map(InlineId::new)
893}
894
895fn chat_id_for_peer(peer: crate::PeerRef) -> InlineId {
896    match peer {
897        crate::PeerRef::User { user_id } => user_id,
898        crate::PeerRef::Chat { chat_id } => chat_id,
899        crate::PeerRef::Thread { thread_id } => thread_id,
900    }
901}
902
903#[cfg(test)]
904mod tests {
905    use crate::{PeerRef, SendTextRequest};
906
907    use super::*;
908
909    fn token_connect() -> ConnectRequest {
910        ConnectRequest::new(crate::AuthCredential::AccessToken {
911            token: crate::AuthToken::try_new("token").unwrap(),
912        })
913    }
914
915    #[tokio::test]
916    async fn in_memory_backend_requires_connect_for_dialogs() {
917        let backend = InMemoryBackend::new();
918
919        let err = backend
920            .dialogs(DialogsRequest::default())
921            .await
922            .expect_err("dialogs should require connect");
923
924        assert_eq!(err.category, ClientErrorCategory::AuthRequired);
925    }
926
927    #[tokio::test]
928    async fn in_memory_backend_lists_dialogs_with_cursor() {
929        let backend = InMemoryBackend::new();
930        backend.upsert_dialog(DialogRecord {
931            chat_id: InlineId::new(1),
932            title: Some("one".to_owned()),
933            last_message_id: None,
934            synced_through_message_id: None,
935            unread_count: Some(0),
936        });
937        backend.upsert_dialog(DialogRecord {
938            chat_id: InlineId::new(2),
939            title: Some("two".to_owned()),
940            last_message_id: None,
941            synced_through_message_id: None,
942            unread_count: Some(0),
943        });
944        backend.connect(token_connect()).await.unwrap();
945
946        let first = backend
947            .dialogs(DialogsRequest {
948                limit: Some(1),
949                cursor: None,
950            })
951            .await
952            .unwrap();
953        assert_eq!(first.dialogs[0].chat_id, InlineId::new(1));
954        assert_eq!(first.next_cursor.as_deref(), Some("1"));
955
956        let second = backend
957            .dialogs(DialogsRequest {
958                limit: Some(1),
959                cursor: first.next_cursor,
960            })
961            .await
962            .unwrap();
963        assert_eq!(second.dialogs[0].chat_id, InlineId::new(2));
964        assert_eq!(second.next_cursor, None);
965    }
966
967    #[tokio::test]
968    async fn in_memory_backend_returns_chat_participants() {
969        let backend = InMemoryBackend::new();
970        backend.set_chat_participants(
971            InlineId::new(7),
972            vec![ChatParticipantRecord {
973                user_id: InlineId::new(42),
974                date: Some(100),
975            }],
976        );
977        backend.connect(token_connect()).await.unwrap();
978
979        let page = backend
980            .chat_participants(ChatParticipantsRequest {
981                chat_id: InlineId::new(7),
982            })
983            .await
984            .unwrap();
985
986        assert_eq!(page.participants.len(), 1);
987        assert_eq!(page.participants[0].user_id, InlineId::new(42));
988        assert_eq!(page.participants[0].date, Some(100));
989    }
990
991    #[tokio::test]
992    async fn in_memory_backend_sends_text_and_records_history() {
993        let backend = InMemoryBackend::new();
994        backend.connect(token_connect()).await.unwrap();
995
996        let outcome = backend
997            .send_text(SendTextRequest::new(
998                PeerRef::Chat {
999                    chat_id: InlineId::new(7),
1000                },
1001                "hello",
1002            ))
1003            .await
1004            .unwrap();
1005
1006        assert_eq!(outcome.chat_id, InlineId::new(7));
1007        assert_eq!(outcome.message_id, Some(InlineId::new(1)));
1008
1009        let history = backend
1010            .history(HistoryRequest {
1011                chat_id: InlineId::new(7),
1012                limit: Some(10),
1013                before_message_id: None,
1014                after_message_id: None,
1015            })
1016            .await
1017            .unwrap();
1018        assert_eq!(history.messages.len(), 1);
1019        assert_eq!(history.messages[0].message_id, InlineId::new(1));
1020    }
1021
1022    #[tokio::test]
1023    async fn in_memory_backend_rejects_empty_text() {
1024        let backend = InMemoryBackend::new();
1025        backend.connect(token_connect()).await.unwrap();
1026
1027        let err = backend
1028            .send_text(SendTextRequest::new(
1029                PeerRef::Chat {
1030                    chat_id: InlineId::new(7),
1031                },
1032                " ",
1033            ))
1034            .await
1035            .expect_err("empty message should fail");
1036
1037        assert_eq!(err.category, ClientErrorCategory::InvalidInput);
1038    }
1039}