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