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