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