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