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