1pub mod cli;
65pub mod door_listen;
66pub mod session_account;
67pub mod letter_hook;
68pub mod interrupt;
69pub mod letter_batch;
70pub mod letter_content;
71#[doc(hidden)]
72pub fn ipc_own_uid() -> Option<u32> {
73 ipc::own_uid()
74}
75mod cmd;
76mod grok_listen;
77mod ipc;
78mod machine;
79#[cfg(feature = "local-bus")]
80mod local_bus;
81#[cfg(not(feature = "local-bus"))]
82#[path = "local_bus_off.rs"]
83mod local_bus;
84mod nick;
85mod push_heal;
86mod webhook;
87pub mod wake_policy;
88pub mod provider;
89mod bus_backend;
90pub mod resolve;
91mod send;
92#[cfg(windows)]
93mod ipc_winpipe;
94pub mod store_key;
95
96use std::collections::{HashMap, HashSet};
97use std::path::{Component, Path, PathBuf};
98use std::sync::Arc;
99
100use mail4agent_messenger::store::sealed::SealedRecordCodec;
101use mail4agent_messenger::wire::Membership;
102use mail4agent_messenger::{
103 CoreConfig, CoreSecrets, ItemContent, Jitter, MessengerCore,
104 MessengerError, OutgoingRequest, OutgoingRequestKind, RecordKey, SealedRecord, SendState,
105 StoreError,
106};
107use sha2::{Digest, Sha256};
108use m4a_agent::engine::Driver;
109use m4a_agent::{AgentError, Backend};
110use zeroize::Zeroizing;
111
112pub use machine::{
113 ensure_agent_webhook_routines, ensure_agent_webhook_routines_from_env, load_agents_dir,
114 load_session_records, HostSession, MachineClient, RoutineReport, TickReport, WakeOptions,
115 WakeStatus, AGENTS_DIR_ENV, AGENT_RESCAN_SECS_ENV, DEFAULT_AGENTS_DIR,
116 LOCAL_BUS_ENV, PROFILE_NOTE_ENV, SESSIONS_DIR_ENV, SESSION_IDS_ENV, SKIP_NICKS_ENV, STORE_LOCK_FILE,
117 WAKE_KEYCHAIN_FILE, session_gate, account_sessions, REQUIRE_SESSION_CAP_ENV, ACCOUNTS_ROOT_ENV,
118};
119pub use mail4agent_messenger::{
120 CreateRoomKind, DeviceId, MessageKind, MessengerCommand, OutgoingMessage, RoomId, RoomKind,
121 UserId,
122};
123pub use cmd::{CmdReply, CmdRequest};
124pub use mail4agent_messenger::EventId;
126pub use grok_listen::{hear, GrokListener, Heard, ListenReport};
127pub use nick::{nick_from_display_name, routine_folder_id};
128pub use provider::{
129 letter_chain, HostEnv, HookFlavor, ProviderKind, ProviderSession, SessionKind, SessionRecord, Surface,
130 WakeAdapter, WakeChain, WakeError, WakeLetter, WakeOutcome, INBOX_DIR_ENV, PROVIDER_ENV,
131};
132pub use m4a_agent::engine::PushedRoomEvent;
133pub use send::{
134 load_env_file, load_env_file_named, send_cmd_via_socket, send_sock_path, send_sock_path_named,
135 send_via_socket,
136 SendReply, SendRequest, DEFAULT_SOCK_NAME, ENV_FILE_ENV, MAX_SEND_BYTES, SEND_SOCK_ENV,
137};
138
139pub fn store_seal_key(session_id: &str) -> [u8; 32] {
142 let digest = Sha256::digest(session_id.as_bytes());
143 let mut key = [0u8; 32];
144 key.copy_from_slice(&digest);
145 key
146}
147
148pub const STORE_ROOT_ENV: &str = "M4A_STORE_ROOT";
151
152pub const HOMESERVER_URL_ENV: &str = "M4A_HOMESERVER_URL";
156
157pub const CONFIG_ENV: &str = "M4A_CONFIG";
161
162pub const BOT_NAME_ENV: &str = "M4A_BOT_NAME";
165
166pub const SESSION_ID_ENV: &str = "M4A_SESSION_ID";
169
170pub const PRODUCT_URL_ENV: &str = "M4A_PRODUCT_URL";
173pub const PRODUCT_INVITE_ENV: &str = "M4A_PRODUCT_INVITE";
176pub const TIER_ENV: &str = "M4A_TIER";
178#[derive(Clone)]
182#[cfg_attr(not(any(feature = "tier-server", feature = "tier-matrix")), allow(dead_code))]
183struct IdentityAuth {
184 tier: m4a_agent::BackendKind,
185 invite: Option<Zeroizing<String>>,
186 nick_request: Option<String>,
188}
189
190pub fn session_store_dir(root: &Path, session_id: &str) -> PathBuf {
198 root.join(hex_encode(&store_seal_key(session_id)))
199}
200
201pub fn store_root() -> Result<PathBuf, ShellError> {
205 if let Some(root) = nonempty_var(STORE_ROOT_ENV) {
206 return Ok(PathBuf::from(root));
207 }
208 #[cfg(test)]
209 {
210 return Ok(std::env::temp_dir().join(format!(
211 "mail4agent-messenger-shell-root-{}",
212 std::process::id()
213 )));
214 }
215 #[cfg(not(test))]
216 {
217 Err(ShellError::StoreRoot)
218 }
219}
220
221fn hex_encode(bytes: &[u8]) -> String {
222 const HEX: &[u8; 16] = b"0123456789abcdef";
223 let mut out = String::with_capacity(bytes.len() * 2);
224 for byte in bytes {
225 out.push(HEX[(byte >> 4) as usize] as char);
226 out.push(HEX[(byte & 0x0f) as usize] as char);
227 }
228 out
229}
230
231#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct WakeAttempt {
235 pub event_id: String,
237 pub status: Option<u16>,
239}
240
241pub struct RoutineWake {
244 pub url: String,
246}
247
248pub const ROUTINE_URL_ENV: &str = "M4A_ROUTINE_URL";
250
251pub const ROUTINE_BEARER_ENV: &str = "M4A_ROUTINE_BEARER";
256
257pub const LEADER_SOCK_ENV: &str = "M4A_LEADER_SOCK";
259
260pub const LEADER_CWD_ENV: &str = "M4A_LEADER_CWD";
263
264pub struct SessionWake {
269 pub routine_url: Option<String>,
271 pub routine_bearer: Option<String>,
274 pub leader_sock: Option<PathBuf>,
276 pub leader_cwd: Option<String>,
278}
279
280impl Default for SessionWake {
281 fn default() -> Self {
282 Self {
283 routine_url: None,
284 routine_bearer: None,
285 leader_sock: None,
286 leader_cwd: None,
287 }
288 }
289}
290
291impl SessionWake {
292 pub fn web_host() -> Self {
296 Self::web_from_lookup(|key| nonempty_var(key))
297 }
298
299 pub fn web_from_lookup(mut get: impl FnMut(&str) -> Option<String>) -> Self {
302 let routine_url = get(ROUTINE_URL_ENV).filter(|value| !value.is_empty());
303 let routine_bearer = if routine_url.is_some() {
304 get(ROUTINE_BEARER_ENV).filter(|value| !value.is_empty())
305 } else {
306 None
307 };
308 Self {
309 routine_url,
310 routine_bearer,
311 leader_sock: None,
312 leader_cwd: None,
313 }
314 }
315
316 pub fn node_cli() -> Result<Self, ShellError> {
320 Self::node_from_lookup(|key| nonempty_var(key))
321 }
322
323 pub fn node_from_lookup(
325 mut get: impl FnMut(&str) -> Option<String>,
326 ) -> Result<Self, ShellError> {
327 let routine_url = get(ROUTINE_URL_ENV).filter(|value| !value.is_empty());
328 let routine_bearer = get(ROUTINE_BEARER_ENV).filter(|value| !value.is_empty());
329 if routine_url.is_some() || routine_bearer.is_some() {
330 return Err(ShellError::NodeRoutine);
331 }
332 Ok(Self {
333 routine_url: None,
334 routine_bearer: None,
335 leader_sock: get(LEADER_SOCK_ENV)
336 .filter(|value| !value.is_empty())
337 .map(PathBuf::from),
338 leader_cwd: get(LEADER_CWD_ENV).filter(|value| !value.is_empty()),
339 })
340 }
341}
342
343pub(crate) fn nonempty_var(name: &str) -> Option<String> {
344 std::env::var(name).ok().filter(|value| !value.is_empty())
345}
346
347pub struct SessionConfig {
351 homeserver_url: String,
352 session_id: String,
353 store_root: PathBuf,
354 identity: IdentityAuth,
355}
356
357impl SessionConfig {
358 pub fn new_identity(
362 product_url: impl Into<String>,
363 tier: m4a_agent::BackendKind,
364 session_id: impl Into<String>,
365 store_root: impl Into<PathBuf>,
366 invite: Option<String>,
367 ) -> Result<Self, ShellError> {
368 let homeserver_url = product_url.into();
369 parse_base_url(&homeserver_url)?;
370 let session_id = session_id.into();
371 validate_session_id(&session_id)?;
372 let store_root = store_root.into();
373 let invite = invite.filter(|i| !i.is_empty()).or_else(|| read_invite_file(&store_root, &session_id));
374 Ok(Self {
375 homeserver_url,
376 session_id,
377 store_root,
378 identity: IdentityAuth { tier, invite: invite.map(Zeroizing::new), nick_request: None },
379 })
380 }
381
382 pub fn with_nick_request(mut self, name: &str) -> Self {
386 let name = name.trim();
387 self.identity.nick_request = if name.contains('.') && crate::nick::is_node_nick(name) { Some(name.to_owned()) } else { nick_from_display_name(name).ok() };
389 self
390 }
391
392 pub fn for_session(url: &str, session_id: &str, store_root: &Path) -> Result<Self, ShellError> {
395 let tier = match nonempty_var(TIER_ENV).as_deref() {
396 None | Some("server") => m4a_agent::BackendKind::Server,
397 Some("matrix") => m4a_agent::BackendKind::Matrix,
398 Some(_) => return Err(ShellError::Register(format!("{TIER_ENV} is server or matrix"))),
399 };
400 Self::new_identity(url, tier, session_id, store_root, None)
401 }
402
403 pub fn from_env() -> Result<Self, ShellError> {
407 let toml_text = load_homeserver_toml()?;
408 Self::from_lookup(
409 |key| std::env::var(key).ok().filter(|value| !value.is_empty()),
410 toml_text.as_deref(),
411 )
412 }
413
414 pub(crate) fn from_lookup(
415 mut get: impl FnMut(&str) -> Option<String>,
416 toml_text: Option<&str>,
417 ) -> Result<Self, ShellError> {
418 let from_toml = match toml_text {
419 Some(text) => homeserver_url_from_toml(text)?,
420 None => None,
421 };
422 let url = get(PRODUCT_URL_ENV)
423 .or_else(|| get(HOMESERVER_URL_ENV))
424 .or(from_toml)
425 .ok_or(ShellError::HomeserverUrl)?;
426 let tier = match get(TIER_ENV).as_deref() {
427 None | Some("server") => m4a_agent::BackendKind::Server,
428 Some("matrix") => m4a_agent::BackendKind::Matrix,
429 Some(_) => return Err(ShellError::Register(format!("{TIER_ENV} is server or matrix"))),
430 };
431 let session_id = get(SESSION_ID_ENV).ok_or(ShellError::EmptySession)?;
432 let store_root = get(STORE_ROOT_ENV).ok_or(ShellError::StoreRoot)?;
433 let config = Self::new_identity(url, tier, session_id, store_root, get(PRODUCT_INVITE_ENV))?;
435 Ok(match get(BOT_NAME_ENV) {
436 Some(name) => config.with_nick_request(&name),
437 None => config,
438 })
439 }
440
441 pub fn homeserver_url(&self) -> &str {
443 &self.homeserver_url
444 }
445
446 pub fn store_dir(&self) -> PathBuf {
448 session_store_dir(&self.store_root, &self.session_id)
449 }
450
451 pub fn session_id(&self) -> &str {
453 &self.session_id
454 }
455}
456
457impl std::fmt::Debug for SessionConfig {
458 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
459 f.debug_struct("SessionConfig")
460 .field("homeserver_url", &self.homeserver_url)
461 .field("session_id", &self.session_id)
462 .field("store_root", &self.store_root)
463 .field("tier", &self.identity.tier)
464 .finish()
465 }
466}
467
468fn invite_path(store_root: &Path, session_id: &str) -> PathBuf {
471 store_root.join("invites").join(hex_encode(&store_seal_key(session_id)))
472}
473
474fn read_invite_file(store_root: &Path, session_id: &str) -> Option<String> {
475 let text = std::fs::read_to_string(invite_path(store_root, session_id)).ok()?;
476 let code = text.trim().to_string();
477 (!code.is_empty()).then_some(code)
478}
479
480fn validate_session_id(session_id: &str) -> Result<(), ShellError> {
481 if session_id.is_empty()
482 || session_id.len() > 128
483 || session_id.starts_with("legacy-user-")
484 || session_id
485 .chars()
486 .any(|ch| ch.is_whitespace() || ch.is_control())
487 {
488 return Err(ShellError::EmptySession);
489 }
490 Ok(())
491}
492
493fn load_homeserver_toml() -> Result<Option<String>, ShellError> {
494 let path = if let Some(configured) = nonempty_var(CONFIG_ENV) {
495 PathBuf::from(configured)
496 } else {
497 let cwd = PathBuf::from("mail4agent.toml");
498 if !cwd.exists() {
499 return Ok(None);
500 }
501 cwd
502 };
503 if !path.is_file() {
504 return Err(ShellError::Config("config file is missing".to_string()));
505 }
506 std::fs::read_to_string(&path)
507 .map(Some)
508 .map_err(|err| ShellError::Config(clip_public(err.to_string())))
509}
510
511fn homeserver_url_from_toml(text: &str) -> Result<Option<String>, ShellError> {
512 #[derive(serde::Deserialize)]
513 struct File {
514 #[serde(default)]
515 homeserver_url: Option<String>,
516 }
517 let file: File =
518 toml::from_str(text).map_err(|err| ShellError::Config(clip_public(err.to_string())))?;
519 Ok(file
520 .homeserver_url
521 .map(|value| value.trim().to_string())
522 .filter(|value| !value.is_empty()))
523}
524
525pub struct FoundSession {
527 pub nick: String,
529 pub user_id: String,
531}
532
533#[derive(Default)]
540pub struct DecryptedWake<'a> {
541 pub body: &'a str,
543 pub from: &'a str,
545 pub nick: Option<&'a str>,
547 pub event_id: &'a str,
549 pub room: Option<&'a str>,
551 pub from_nick: Option<&'a str>,
553 pub to: Option<&'a str>,
555 pub reply: Option<&'a str>,
558}
559
560pub const SEND_COMMAND: &str = "m4a-send";
562
563pub fn reply_hint(to: &str, from_nick: &str) -> String {
565 format!("{SEND_COMMAND} --as {to} --to {from_nick} '<your reply>'")
566}
567
568pub fn mxid_localpart(mxid: &str) -> &str {
570 let rest = mxid.strip_prefix('@').unwrap_or(mxid);
571 rest.split_once(':').map(|(local, _)| local).unwrap_or(rest)
572}
573
574pub struct OpenedStore {
579 dir: PathBuf,
580 driver: Driver<SealedRecordCodec>,
581 device_token: Zeroizing<String>,
582 base_url: String,
583 session_id: String,
586 nick: Option<String>,
589 routine_url: Option<String>,
590 routine_bearer: Option<Zeroizing<String>>,
591 leader_sock: Option<PathBuf>,
592 leader_cwd: Option<String>,
593 routine_sent: HashSet<String>,
595 routine_woken_seed_pending: bool,
599 routine_restart_catchup: bool,
603 reaction_floor: Option<i64>,
606 leader_sent: HashSet<String>,
608 wake_note: Option<String>,
610 wake_log: Vec<WakeAttempt>,
611 wake_retry: HashMap<String, (u32, std::time::Instant)>,
614 wake_first_seen: HashMap<String, (i64, std::time::Instant)>,
617 wake_chain: Option<(provider::ProviderSession, provider::WakeChain)>,
621 wake_route: Option<String>,
623 bus: Option<Arc<machine::LocalBus>>,
626 local_peers: Vec<(String, String)>,
629 pushed_room_events: Vec<PushedRoomEvent>,
631 history_pulled: HashSet<String>,
634 tip_pulled: HashSet<String>,
637}
638
639struct ZeroJitter;
640
641impl Jitter for ZeroJitter {
642 fn next_unit(&mut self) -> f64 {
643 0.0
644 }
645}
646
647pub struct RoomView {
649 pub room_id: String,
651 pub membership: String,
654 pub encrypted: bool,
656}
657
658pub struct TextView {
660 pub room_id: String,
662 pub body: String,
664 pub outcome: String,
666 pub event_id: Option<String>,
669}
670
671impl OpenedStore {
672 pub fn open(
681 dir: &Path,
682 session_id: &str,
683 device_id: DeviceId,
684 user_id: &str,
685 server_name: &str,
686 backend: Arc<dyn Backend>,
687 device_token: &str,
688 ) -> Result<Self, ShellError> {
689 if session_id.is_empty() {
690 return Err(ShellError::EmptySession);
691 }
692 if device_token.is_empty()
693 || device_token
694 .bytes()
695 .any(|byte| byte.is_ascii_whitespace() || !byte.is_ascii())
696 {
697 return Err(ShellError::DeviceToken);
698 }
699 let base_url = backend.server_ref().to_string();
700 at("create store dir", dir, fs_create_dir(dir))?;
701 let user_id = UserId::parse(user_id)?;
702 let secrets = CoreSecrets {
703 store_seal_key: Some(at("store key", dir, store_key::store_key(dir, session_id))?),
704 backup_key: None,
705 };
706 let config = CoreConfig {
707 user_id,
708 device_id,
709 server_name: server_name.to_string(),
710 };
711 let records = at("read records", dir, read_records(dir))?;
712 let mut core =
713 match MessengerCore::open_sealed(records, secrets, config, 0, Box::new(ZeroJitter)) {
714 Ok(core) => core,
715 Err(MessengerError::Store(err)) => return Err(ShellError::Store(err)),
716 Err(err) => return Err(ShellError::Messenger(err)),
717 };
718 at("persist records", dir, persist(dir, &mut core))?;
719 let mut opened = Self {
720 dir: dir.to_path_buf(),
721 driver: Driver::new(core, backend, {
722 let dir = dir.to_path_buf();
723 Box::new(move |core| persist(&dir, core).map_err(|e| AgentError::Store(clip_public(e.to_string()))))
724 }),
725 base_url,
726 device_token: Zeroizing::new(device_token.to_string()),
727 session_id: session_id.to_string(),
728 nick: None,
729 routine_url: None,
730 routine_bearer: None,
731 leader_sock: None,
732 leader_cwd: None,
733 routine_sent: HashSet::new(),
734 routine_woken_seed_pending: false,
735 routine_restart_catchup: false,
736 reaction_floor: None,
737 leader_sent: HashSet::new(),
738 wake_chain: None,
739 wake_route: None,
740 wake_note: None,
741 wake_log: Vec::new(),
742 wake_retry: HashMap::new(),
743 wake_first_seen: HashMap::new(),
744 bus: None,
745 local_peers: Vec::new(),
746 pushed_room_events: Vec::new(),
747 history_pulled: HashSet::new(),
748 tip_pulled: HashSet::new(),
749 };
750 opened.note_already_present();
753 Ok(opened)
754 }
755
756 pub fn connect(config: &SessionConfig) -> Result<Self, ShellError> {
765 Self::connect_with_wake(config, SessionWake::default())
766 }
767
768 pub(crate) fn connect_with_wake(config: &SessionConfig, wake: SessionWake) -> Result<Self, ShellError> {
769 let registered = register_session(config)?;
770 let server_name = registered
771 .user_id
772 .split_once(':')
773 .map(|(_, server)| server)
774 .filter(|server| !server.is_empty())
775 .ok_or(ShellError::Register("user id has no server".to_string()))?;
776 let mut opened = Self::open(
777 &config.store_dir(),
778 &config.session_id,
779 registered.device_id,
780 ®istered.user_id,
781 server_name,
782 registered.backend,
783 ®istered.bearer,
784 )?;
785 opened.nick = Some(registered.nick.clone());
786 opened.set_wake(wake);
787 opened.drive(1_000, false)?;
789 Ok(opened)
790 }
791
792 pub fn connect_from_env() -> Result<Self, ShellError> {
797 Self::connect(&SessionConfig::from_env()?)
798 }
799
800 pub fn connect_node_from_env() -> Result<Self, ShellError> {
805 let wake = SessionWake::node_cli()?;
806 Self::connect_with_wake(&SessionConfig::from_env()?, wake)
807 }
808
809 #[cfg(test)]
810 pub(crate) fn connect_node_from_lookup(
811 mut get: impl FnMut(&str) -> Option<String>,
812 toml_text: Option<&str>,
813 ) -> Result<Self, ShellError> {
814 let wake = SessionWake::node_from_lookup(&mut get)?;
815 let config = SessionConfig::from_lookup(&mut get, toml_text)?;
816 Self::connect_with_wake(&config, wake)
817 }
818
819 pub(crate) fn keep_prefix(&self) -> bool {
821 self.driver.backend().keep_prefix()
822 }
823
824 pub fn device_bearer(&self) -> &str {
825 self.device_token.as_str()
826 }
827
828 pub fn nick(&self) -> Option<&str> {
831 self.nick.as_deref()
832 }
833
834 pub fn session_id(&self) -> &str {
836 &self.session_id
837 }
838
839 pub fn homeserver_url(&self) -> &str {
841 self.base_url.trim_end_matches('/')
842 }
843
844 pub fn has_leader(&self) -> bool {
846 self.leader_sock.is_some()
847 }
848
849 pub fn store_dir(&self) -> &Path {
851 &self.dir
852 }
853
854 pub fn homeserver_hits(&self) -> u64 {
857 self.bus.as_ref().map(|bus| bus.hits()).unwrap_or(0)
858 }
859
860 pub fn pushed_room_events(&self) -> &[PushedRoomEvent] {
862 &self.pushed_room_events
863 }
864
865 pub(crate) fn record_push(&mut self, event: PushedRoomEvent) {
866 self.pushed_room_events.push(event);
867 }
868
869 pub(crate) fn attach_bus(&mut self, bus: Arc<machine::LocalBus>) {
870 let inner = Arc::clone(self.driver.backend());
871 self.driver.set_backend(Arc::new(bus_backend::BusBackend { inner, bus: Arc::clone(&bus), user_id: self.driver.core.user_id().as_str().to_string(), force_local: None }));
872 self.bus = Some(bus);
873 }
874
875 pub(crate) fn set_registered_nick(&mut self, nick: String) {
876 self.nick = Some(nick);
877 }
878
879 pub(crate) fn set_local_peers(&mut self, peers: Vec<(String, String)>) {
880 self.local_peers = peers;
881 }
882
883 pub(crate) fn abandon_inflight_sync(&mut self, now_ms: i64) -> Result<(), ShellError> {
887 if self.driver.sync_inflight() {
888 self.driver.abandon_sync(now_ms);
889 self.persist_core()?;
890 }
891 Ok(())
892 }
893
894 pub fn user_id(&self) -> &str {
896 self.driver.core.user_id().as_str()
897 }
898
899 pub fn member_joined(&self, room_id: &str, user_id: &str) -> bool {
901 let Ok(room_id) = RoomId::parse(room_id) else {
902 return false;
903 };
904 let Ok(user_id) = UserId::parse(user_id) else {
905 return false;
906 };
907 self.driver.core.room_state(&room_id).is_some_and(|state| {
908 state
909 .members
910 .get(&user_id)
911 .is_some_and(|member| member.membership == Membership::Join)
912 })
913 }
914
915 pub fn find_nick(
919 &mut self,
920 name_or_nick: &str,
921 now_ms: i64,
922 ) -> Result<FoundSession, ShellError> {
923 let needle = nick::lookup_nick(name_or_nick)?;
924 if let Some((nick, user_id)) = self
925 .local_peers
926 .iter()
927 .find(|(nick, _)| nick.eq_ignore_ascii_case(&needle))
928 {
929 return Ok(FoundSession {
930 nick: nick.clone(),
931 user_id: user_id.clone(),
932 });
933 }
934 self.dispatch(
935 MessengerCommand::SearchUsers {
936 term: needle.clone(),
937 },
938 now_ms,
939 )?;
940 self.drive(now_ms, false)?;
941 let mut hits: Vec<FoundSession> = self
942 .driver.core
943 .user_search_result()
944 .iter()
945 .filter_map(|entry| {
946 let nick = entry.display_name.as_deref()?.trim();
947 if !nick.eq_ignore_ascii_case(&needle) {
948 return None;
949 }
950 Some(FoundSession {
951 nick: nick.to_string(),
952 user_id: entry.user_id.as_str().to_string(),
953 })
954 })
955 .collect();
956 hits.sort_by(|left, right| left.user_id.cmp(&right.user_id));
957 hits.dedup_by(|left, right| left.user_id == right.user_id);
958 match hits.len() {
959 1 => Ok(hits.remove(0)),
960 0 => Err(ShellError::UnknownNick),
961 _ => Err(ShellError::UnknownNick),
962 }
963 }
964
965 pub fn ensure_dm(&mut self, name_or_nick: &str, mut now_ms: i64) -> Result<String, ShellError> {
969 let (_peer, room_id) = self.open_dm(name_or_nick, &mut now_ms)?;
970 Ok(room_id)
971 }
972
973 pub fn accept_direct_invites(&mut self, now_ms: i64) -> Result<Vec<String>, ShellError> {
979 self.accept_invites(now_ms, &AutoJoinPolicy::from_env())
980 }
981
982 pub fn accept_invites(&mut self, mut now_ms: i64, policy: &AutoJoinPolicy) -> Result<Vec<String>, ShellError> {
984 let me = self.driver.core.user_id().clone();
985 let home = server_of(me.as_str()).to_string();
986 let invited: Vec<RoomId> = self
987 .driver.core
988 .room_ids()
989 .cloned()
990 .filter(|room_id| {
991 let Some(state) = self.driver.core.room_state(room_id) else {
992 return false;
993 };
994 let Some(mine) = state.members.get(&me).filter(|m| m.membership == Membership::Invite) else {
995 return false;
996 };
997 policy.accepts(&home, me.as_str(), mine.is_direct, mine.invited_by.as_ref().map(|u| u.as_str()), state.members.keys().map(|u| u.as_str()))
998 })
999 .collect();
1000 let mut ids = Vec::new();
1001 for room_id in invited {
1002 self.dispatch(
1003 MessengerCommand::JoinRoom {
1004 room_id: room_id.clone(),
1005 },
1006 now_ms,
1007 )?;
1008 ids.push(room_id.as_str().to_string());
1009 }
1010 if ids.is_empty() {
1011 return Ok(ids);
1012 }
1013 for _ in 0..8 {
1014 now_ms += 1_000;
1015 self.drive(now_ms, false)?;
1016 let still_invited = ids.iter().any(|room_id| {
1017 self.rooms()
1018 .iter()
1019 .any(|room| room.room_id == *room_id && room.membership == "invite")
1020 });
1021 if !still_invited {
1022 break;
1023 }
1024 }
1025 Ok(ids)
1026 }
1027
1028 pub fn write_to_nick(
1037 &mut self,
1038 name_or_nick: &str,
1039 text: &str,
1040 mut now_ms: i64,
1041 ) -> Result<String, ShellError> {
1042 let (peer, room_id) = self.open_dm(name_or_nick, &mut now_ms)?;
1043 if !self.member_joined(&room_id, peer.as_str()) {
1044 return Err(ShellError::Dm);
1045 }
1046 let encrypted = self
1047 .rooms()
1048 .into_iter()
1049 .any(|room| room.room_id == room_id && room.encrypted);
1050 if !encrypted {
1051 return Err(ShellError::Dm);
1052 }
1053 self.dispatch(
1054 MessengerCommand::SendMessage {
1055 room_id: RoomId::parse(&room_id)?,
1056 message: OutgoingMessage {
1057 kind: MessageKind::Text,
1058 body: text.to_string(),
1059 reply_to: None,
1060 edit_of: None,
1061 },
1062 txn_id: None,
1063 },
1064 now_ms,
1065 )?;
1066 for _ in 0..20 {
1067 now_ms += 1_000;
1068 self.drive(now_ms, false)?;
1069 if let Some(row) = self
1070 .texts()
1071 .into_iter()
1072 .find(|row| row.room_id == room_id && row.body == text)
1073 {
1074 if row.outcome == "sent" {
1075 return Ok(room_id);
1076 }
1077 if row.outcome.starts_with("failed") {
1078 return Err(ShellError::Dm);
1079 }
1080 }
1081 }
1082 Err(ShellError::Dm)
1083 }
1084
1085 fn open_dm(
1086 &mut self,
1087 name_or_nick: &str,
1088 now_ms: &mut i64,
1089 ) -> Result<(UserId, String), ShellError> {
1090 let found = self.find_nick(name_or_nick, *now_ms)?;
1091 let peer = UserId::parse(&found.user_id)?;
1092 if &peer == self.driver.core.user_id() {
1093 return Err(ShellError::UnknownNick);
1094 }
1095 let room_id = if let Some(room_id) = self.dm_room(&peer) {
1096 room_id
1097 } else {
1098 self.dispatch(
1099 MessengerCommand::CreateRoom {
1100 kind: CreateRoomKind::Dm { peer: peer.clone() },
1101 },
1102 *now_ms,
1103 )?;
1104 self.wait_for_dm(&peer, now_ms)?
1105 };
1106 Ok((peer, room_id))
1107 }
1108
1109 fn wait_for_dm(&mut self, peer: &UserId, now_ms: &mut i64) -> Result<String, ShellError> {
1110 for attempt in 0..6 {
1111 *now_ms += 1_000;
1112 self.drive(*now_ms, attempt == 5)?;
1113 if let Some(room_id) = self.dm_room(peer) {
1114 return Ok(room_id);
1115 }
1116 }
1117 Err(ShellError::Dm)
1118 }
1119
1120 fn dm_room(&self, peer: &UserId) -> Option<String> {
1121 let me = self.driver.core.user_id().clone();
1122 let ids: Vec<RoomId> = self.driver.core.room_ids().cloned().collect();
1123 for room_id in &ids {
1124 let (joined, has_peer) = {
1125 let Some(state) = self.driver.core.room_state(room_id) else {
1126 continue;
1127 };
1128 let joined = state
1129 .members
1130 .get(&me)
1131 .is_some_and(|member| member.membership == Membership::Join);
1132 let has_peer = state.members.contains_key(peer);
1133 (joined, has_peer)
1134 };
1135 if !joined || !has_peer {
1136 continue;
1137 }
1138 if self.driver.core.room_kind(room_id) == Some(RoomKind::Dm) {
1139 return Some(room_id.as_str().to_string());
1140 }
1141 }
1142 None
1143 }
1144
1145 pub fn set_wake(&mut self, wake: SessionWake) {
1150 self.routine_url = wake.routine_url.filter(|url| !url.is_empty());
1151 self.routine_bearer = wake
1152 .routine_bearer
1153 .filter(|token| !token.is_empty())
1154 .map(Zeroizing::new);
1155 self.leader_sock = wake.leader_sock.filter(|path| !path.as_os_str().is_empty());
1156 self.leader_cwd = wake.leader_cwd.filter(|cwd| !cwd.is_empty());
1157 }
1158
1159 pub fn set_wake_chain(
1163 &mut self,
1164 session: provider::ProviderSession,
1165 chain: provider::WakeChain,
1166 ) {
1167 self.wake_chain = Some((session, chain));
1168 }
1169
1170 pub fn wake_chain_ids(&self) -> Vec<&'static str> {
1172 self.wake_chain
1173 .as_ref()
1174 .map(|(_, chain)| chain.ids())
1175 .unwrap_or_default()
1176 }
1177
1178 pub fn wake_route(&self) -> Option<&str> {
1180 self.wake_route.as_deref()
1181 }
1182
1183 pub fn take_wake_route(&mut self) -> Option<String> {
1185 self.wake_route.take()
1186 }
1187
1188 pub fn has_routine(&self) -> bool {
1190 self.routine_url.is_some()
1191 }
1192
1193 pub fn wake_note(&self) -> Option<&str> {
1197 self.wake_note.as_deref()
1198 }
1199
1200 pub fn wake_log(&self) -> &[WakeAttempt] {
1203 &self.wake_log
1204 }
1205
1206 pub fn dispatch(&mut self, command: MessengerCommand, now_ms: i64) -> Result<(), ShellError> {
1209 self.driver.core.dispatch(command, now_ms)?;
1210 self.persist_core()?;
1211 Ok(())
1212 }
1213
1214 pub(crate) fn dispatch_send_with_content(
1217 &mut self,
1218 room_id: RoomId,
1219 message: OutgoingMessage,
1220 extra_content: Option<serde_json::Map<String, serde_json::Value>>,
1221 now_ms: i64,
1222 ) -> Result<(), ShellError> {
1223 match extra_content {
1224 Some(extra) => self.driver.core.send_message_with_content(room_id, message, extra, None, now_ms)?,
1225 None => self.driver.core.dispatch(MessengerCommand::SendMessage { room_id, message, txn_id: None }, now_ms)?,
1226 }
1227 self.persist_core()?;
1228 Ok(())
1229 }
1230
1231 pub fn send_room_message(
1238 &mut self,
1239 room_id: &str,
1240 text: &str,
1241 now_ms: i64,
1242 ) -> Result<Vec<OutgoingRequest>, ShellError> {
1243 let room_id = RoomId::parse(room_id)?;
1244 self.driver.core.dispatch(
1245 MessengerCommand::SendMessage {
1246 room_id,
1247 message: OutgoingMessage {
1248 kind: MessageKind::Text,
1249 body: text.to_string(),
1250 reply_to: None,
1251 edit_of: None,
1252 },
1253 txn_id: None,
1254 },
1255 now_ms,
1256 )?;
1257 self.persist_core()?;
1258 let mut released = self.driver.core.releasable_requests(now_ms);
1259 self.persist_core()?;
1260 if !released
1261 .iter()
1262 .any(|request| request.kind == OutgoingRequestKind::RoomSend)
1263 {
1264 released.extend(self.driver.core.releasable_requests(now_ms));
1265 self.persist_core()?;
1266 }
1267 Ok(released)
1268 }
1269
1270 pub fn drive(&mut self, now_ms: i64, wait_for_sync: bool) -> Result<(), ShellError> {
1280 self.driver.core.decrypt_loaded_timeline();
1281 if self.reaction_floor.is_none() {
1282 let path = self.store_dir().join("reaction-wake-floor");
1285 let _ = now_ms;
1287 let floor = std::fs::read_to_string(&path).ok().and_then(|t| t.trim().parse::<i64>().ok()).unwrap_or_else(|| {
1288 let wall = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0);
1289 let _ = std::fs::write(&path, wall.to_string());
1290 wall
1291 });
1292 self.reaction_floor = Some(floor);
1293 }
1294 let trace_at = self.driver.http_trace.len();
1295 let history = self.request_missing_history(now_ms)?;
1296 self.pull_room_tips()?;
1297 let mut waited_long_poll = false;
1298 if wait_for_sync && self.driver.sync_inflight() {
1299 waited_long_poll = self.driver.harvest_sync(now_ms, true)?;
1302 } else {
1303 self.driver.harvest_sync(now_ms, false)?;
1304 }
1305 self.wake_inbound(now_ms);
1306 for _ in 0..24 {
1307 if !self.driver.step(now_ms, wait_for_sync, &mut waited_long_poll)? {
1308 break;
1309 }
1310 self.wake_inbound(now_ms);
1311 }
1312 self.note_history_pages(&history, trace_at);
1313 self.note_room_tips();
1314 self.routine_restart_catchup = false;
1315 Ok(())
1316 }
1317
1318 fn pull_room_tips(&mut self) -> Result<(), ShellError> {
1321 let me = self.driver.core.user_id().clone();
1322 let ids: Vec<RoomId> = self.driver.core.room_ids().cloned().collect();
1323 for room_id in ids {
1324 if self.tip_pulled.contains(room_id.as_str()) {
1325 continue;
1326 }
1327 if self
1328 .driver.core
1329 .timeline(&room_id)
1330 .is_some_and(|timeline| !timeline.items().is_empty())
1331 {
1332 self.tip_pulled.insert(room_id.as_str().to_string());
1333 continue;
1334 }
1335 let joined = self.driver.core.room_state(&room_id).is_some_and(|state| {
1336 state
1337 .members
1338 .get(&me)
1339 .is_some_and(|member| matches!(member.membership, Membership::Join))
1340 });
1341 if !joined {
1342 continue;
1343 }
1344 self.driver.core.pull_latest_page(room_id)?;
1345 }
1346 Ok(())
1347 }
1348
1349 fn note_room_tips(&mut self) {
1350 let ids: Vec<RoomId> = self.driver.core.room_ids().cloned().collect();
1351 for room_id in ids {
1352 if self
1353 .driver.core
1354 .timeline(&room_id)
1355 .is_some_and(|timeline| !timeline.items().is_empty())
1356 {
1357 self.tip_pulled.insert(room_id.as_str().to_string());
1358 }
1359 }
1360 }
1361
1362 fn request_missing_history(&mut self, now_ms: i64) -> Result<Vec<String>, ShellError> {
1366 let me = self.driver.core.user_id().clone();
1367 let ids: Vec<RoomId> = self.driver.core.room_ids().cloned().collect();
1368 let mut requested = Vec::new();
1369 for room_id in ids {
1370 if !self.room_needs_history(&room_id, &me) {
1371 continue;
1372 }
1373 let label = room_id.as_str().to_string();
1374 self.driver.core
1375 .dispatch(MessengerCommand::LoadOlder { room_id }, now_ms)?;
1376 requested.push(label);
1377 }
1378 Ok(requested)
1379 }
1380
1381 fn room_needs_history(&self, room_id: &RoomId, me: &UserId) -> bool {
1382 if self.history_pulled.contains(room_id.as_str()) {
1383 return false;
1384 }
1385 let joined = match self
1386 .driver.core
1387 .room_state(room_id)
1388 .and_then(|state| state.members.get(me))
1389 {
1390 Some(member) => matches!(member.membership, Membership::Join),
1391 None => false,
1392 };
1393 if !joined {
1394 return false;
1395 }
1396 match self.driver.core.timeline(room_id) {
1397 Some(timeline) => timeline.items().is_empty(),
1398 None => true,
1399 }
1400 }
1401
1402 fn note_history_pages(&mut self, requested: &[String], trace_at: usize) {
1403 if requested.is_empty() {
1404 return;
1405 }
1406 let ok = self.driver.http_trace[trace_at..]
1407 .iter()
1408 .filter(|(kind, status)| {
1409 *kind == OutgoingRequestKind::RoomMessages && (200..300).contains(status)
1410 })
1411 .count();
1412 if ok < requested.len() {
1413 return;
1414 }
1415 for room_id in requested {
1416 self.history_pulled.insert(room_id.clone());
1417 }
1418 }
1419
1420 pub fn rooms(&self) -> Vec<RoomView> {
1422 let me = self.driver.core.user_id().clone();
1423 let mut rooms: Vec<RoomView> = self
1424 .driver.core
1425 .room_ids()
1426 .map(|room_id| {
1427 let state = self.driver.core.room_state(room_id);
1428 let membership = state
1429 .and_then(|state| state.members.get(&me))
1430 .map(|member| membership_name(&member.membership).to_string())
1431 .unwrap_or_else(|| "absent".to_string());
1432 let encrypted = state.and_then(|state| state.encryption.as_ref()).is_some();
1433 RoomView {
1434 room_id: room_id.as_str().to_string(),
1435 membership,
1436 encrypted,
1437 }
1438 })
1439 .collect();
1440 rooms.sort_by(|left, right| left.room_id.cmp(&right.room_id));
1441 rooms
1442 }
1443
1444 pub fn texts(&self) -> Vec<TextView> {
1447 let mut out = Vec::new();
1448 for room_id in self.driver.core.room_ids() {
1449 let Some(timeline) = self.driver.core.timeline(room_id) else {
1450 continue;
1451 };
1452 for item in timeline.items() {
1453 match &item.content {
1454 ItemContent::Text(text)
1455 | ItemContent::Notice(text)
1456 | ItemContent::Emote(text) => {
1457 out.push(TextView {
1458 room_id: room_id.as_str().to_string(),
1459 body: text.body.clone(),
1460 outcome: outcome_name(&item.send_state),
1461 event_id: item.event_id.as_ref().map(|id| id.as_str().to_string()),
1462 });
1463 }
1464 ItemContent::Undecryptable { reason } => out.push(TextView {
1465 room_id: room_id.as_str().to_string(),
1466 body: String::new(),
1467 outcome: format!("undecryptable:{reason}"),
1468 event_id: item.event_id.as_ref().map(|id| id.as_str().to_string()),
1469 }),
1470 _ => {}
1471 }
1472 }
1473 }
1474 out
1475 }
1476
1477 pub fn sync_inflight(&self) -> bool {
1479 self.driver.sync_inflight()
1480 }
1481
1482 pub fn http_trace(&self) -> Vec<String> {
1483 self.driver.http_trace
1484 .iter()
1485 .map(|(kind, status)| format!("{kind:?} {status}"))
1486 .collect()
1487 }
1488
1489 pub fn take_ingest_error(&mut self) -> Option<String> {
1491 self.driver.core.take_ingest_error().map(clip_public)
1492 }
1493
1494 fn persist_core(&mut self) -> Result<(), ShellError> {
1495 self.driver.persist().map_err(ShellError::from)
1496 }
1497
1498 pub fn take_security_alerts(&mut self) -> Vec<String> {
1500 std::mem::take(&mut self.driver.security_alerts)
1501 }
1502
1503 fn inbound_plaintexts(&self) -> Vec<InboundPlaintext> {
1504 let me = self.driver.core.user_id();
1505 let mut out = Vec::new();
1506 for room_id in self.driver.core.room_ids() {
1507 let Some(timeline) = self.driver.core.timeline(room_id) else {
1508 continue;
1509 };
1510 for item in timeline.items() {
1511 if item.redacted || &item.sender == me || item.send_state != SendState::Sent {
1512 continue;
1513 }
1514 let mut body = match &item.content {
1515 ItemContent::Text(text)
1516 | ItemContent::Notice(text)
1517 | ItemContent::Emote(text) => text.body.clone(),
1518 _ => continue,
1519 };
1520 if let Some(edit) = &item.relations.latest_edit {
1522 if let mail4agent_messenger::wire::events::RoomMessageContent::Text(t)
1523 | mail4agent_messenger::wire::events::RoomMessageContent::Notice(t)
1524 | mail4agent_messenger::wire::events::RoomMessageContent::Emote(t) = &edit.content
1525 {
1526 body = t.body.clone();
1527 }
1528 }
1529 let Some(event_id) = item.event_id.as_ref() else {
1530 continue;
1531 };
1532 let key = format!("{}\n{}", room_id.as_str(), event_id.as_str());
1533 out.push(InboundPlaintext {
1534 room_id: room_id.as_str().to_string(),
1535 key,
1536 body,
1537 from: item.sender.as_str().to_string(),
1538 nick: self.sender_nick(room_id, &item.sender),
1539 event_id: event_id.as_str().to_string(),
1540 reaction: None,
1541 ts: item.origin_server_ts,
1542 hatchery: letter_content::hatchery_fields(item.event_content()),
1543 });
1544 }
1545 }
1546 out
1547 }
1548
1549 fn reaction_items(&self, floor: i64) -> Vec<InboundPlaintext> {
1553 let me = self.driver.core.user_id();
1554 let mut out = Vec::new();
1555 for room_id in self.driver.core.room_ids() {
1556 let Some(timeline) = self.driver.core.timeline(room_id) else { continue };
1557 let direct = self.room_is_direct(room_id.as_str());
1558 for item in timeline.items() {
1559 let Some(target) = item.event_id.as_ref() else { continue };
1560 if item.redacted || !(direct || &item.sender == me) {
1561 continue;
1562 }
1563 let snippet = match &item.content {
1564 ItemContent::Text(t) | ItemContent::Notice(t) | ItemContent::Emote(t) => {
1565 let one: String = t.body.split_whitespace().collect::<Vec<_>>().join(" ");
1566 if one.chars().count() > 80 { format!("{}โฆ", one.chars().take(80).collect::<String>()) } else { one }
1567 }
1568 _ => String::new(),
1569 };
1570 for r in timeline.relating_events_for(target) {
1571 let mail4agent_messenger::wire::events::RelatesTo::Annotation { key, .. } = &r.relation else { continue };
1572 if &r.sender == me || r.origin_server_ts <= floor {
1573 continue;
1574 }
1575 let who = mxid_localpart(r.sender.as_str()).to_string();
1576 let whose = if &item.sender == me { "your message".to_string() } else { format!("{}'s message", mxid_localpart(item.sender.as_str())) };
1577 out.push(InboundPlaintext {
1578 room_id: room_id.as_str().to_string(),
1579 key: format!("{}\n{}", room_id.as_str(), r.event_id.as_str()),
1580 body: format!("{who} reacted {key} to {whose}: \"{snippet}\""),
1581 from: r.sender.as_str().to_string(),
1582 nick: None,
1583 event_id: r.event_id.as_str().to_string(),
1584 reaction: Some((key.clone(), target.as_str().to_string())),
1585 ts: r.origin_server_ts,
1586 hatchery: None,
1587 });
1588 }
1589 }
1590 }
1591 out
1592 }
1593
1594 fn sender_nick(&self, room_id: &RoomId, sender: &UserId) -> Option<String> {
1598 let name = self
1599 .driver.core
1600 .room_state(room_id)?
1601 .members
1602 .get(sender)?
1603 .displayname
1604 .as_deref()?
1605 .trim();
1606 if name.is_empty() {
1607 None
1608 } else {
1609 Some(name.to_string())
1610 }
1611 }
1612
1613 fn note_already_present(&mut self) {
1614 self.load_routine_woken();
1615 self.load_leader_prompted();
1616 }
1617
1618 fn load_routine_woken(&mut self) {
1621 let path = self.dir.join(ROUTINE_WOKEN_FILE);
1622 match std::fs::read_to_string(&path) {
1623 Ok(text) if !text.trim().is_empty() => {
1624 for line in text.lines() {
1625 let Some((room, event)) = line.split_once('\t') else {
1626 continue;
1627 };
1628 if room.is_empty() || event.is_empty() {
1629 continue;
1630 }
1631 self.routine_sent.insert(format!("{room}\n{event}"));
1632 }
1633 self.routine_woken_seed_pending = false;
1634 self.routine_restart_catchup = true;
1637 }
1638 Ok(_) | Err(_) => {
1639 self.routine_woken_seed_pending = true;
1641 self.routine_restart_catchup = false;
1642 }
1643 }
1644 }
1645
1646 fn seed_routine_woken_from_timeline(&mut self) {
1651 let path = self.dir.join(ROUTINE_WOKEN_FILE);
1652 let items = self.inbound_plaintexts();
1653 let mut newest: std::collections::HashMap<String, String> =
1654 std::collections::HashMap::new();
1655 for item in &items {
1656 newest.insert(item.room_id.clone(), item.key.clone());
1657 }
1658 let mut lines = String::new();
1659 for item in &items {
1660 if newest.get(&item.room_id) == Some(&item.key) {
1661 continue;
1662 }
1663 self.routine_sent.insert(item.key.clone());
1664 if let Some((room, event)) = item.key.split_once('\n') {
1665 lines.push_str(room);
1666 lines.push('\t');
1667 lines.push_str(event);
1668 lines.push('\n');
1669 }
1670 }
1671 let _ = std::fs::write(path, lines);
1672 self.routine_woken_seed_pending = false;
1673 }
1674
1675 fn remember_routine_wake(&mut self, key: &str) {
1676 self.routine_sent.insert(key.to_string());
1677 let Some((room, event)) = key.split_once('\n') else {
1678 return;
1679 };
1680 if room.is_empty() || event.is_empty() {
1681 return;
1682 }
1683 let path = self.dir.join(ROUTINE_WOKEN_FILE);
1684 let mut file = match std::fs::OpenOptions::new()
1685 .create(true)
1686 .append(true)
1687 .open(&path)
1688 {
1689 Ok(file) => file,
1690 Err(_) => return,
1691 };
1692 use std::io::Write;
1693 let _ = write!(file, "{room}\t{event}\n");
1694 }
1695
1696 fn load_leader_prompted(&mut self) {
1700 let path = self.dir.join(LEADER_PROMPTED_FILE);
1701 match std::fs::read_to_string(&path) {
1702 Ok(text) => {
1703 for line in text.lines() {
1704 let Some((room, event)) = line.split_once('\t') else {
1705 continue;
1706 };
1707 if room.is_empty() || event.is_empty() {
1708 continue;
1709 }
1710 self.leader_sent.insert(format!("{room}\n{event}"));
1711 }
1712 }
1713 Err(_) => self.seed_leader_prompted_except_newest(&path),
1714 }
1715 }
1716
1717 fn seed_leader_prompted_except_newest(&mut self, path: &Path) {
1718 let items = self.inbound_plaintexts();
1719 let mut newest: std::collections::HashMap<String, String> =
1720 std::collections::HashMap::new();
1721 for item in &items {
1722 newest.insert(item.room_id.clone(), item.key.clone());
1723 }
1724 let mut lines = String::new();
1725 for item in &items {
1726 if newest.get(&item.room_id) == Some(&item.key) {
1727 continue;
1728 }
1729 self.leader_sent.insert(item.key.clone());
1730 if let Some((room, event)) = item.key.split_once('\n') {
1731 lines.push_str(room);
1732 lines.push('\t');
1733 lines.push_str(event);
1734 lines.push('\n');
1735 }
1736 }
1737 let _ = std::fs::write(path, lines);
1738 }
1739
1740 fn remember_leader_prompt(&mut self, key: &str) {
1741 self.leader_sent.insert(key.to_string());
1742 let Some((room, event)) = key.split_once('\n') else {
1743 return;
1744 };
1745 if room.is_empty() || event.is_empty() {
1746 return;
1747 }
1748 let path = self.dir.join(LEADER_PROMPTED_FILE);
1749 let mut file = match std::fs::OpenOptions::new()
1750 .create(true)
1751 .append(true)
1752 .open(&path)
1753 {
1754 Ok(file) => file,
1755 Err(_) => return,
1756 };
1757 use std::io::Write;
1758 let _ = write!(file, "{room}\t{event}\n");
1759 }
1760
1761 fn arm_unprompted_newest(&mut self) {
1769 if (self.leader_sock.is_none() && self.wake_chain.is_none()) || !self.leader_sent.is_empty() {
1770 return;
1771 }
1772 if self.inbound_plaintexts().is_empty() {
1773 return;
1774 }
1775 let path = self.dir.join(LEADER_PROMPTED_FILE);
1776 self.seed_leader_prompted_except_newest(&path);
1777 }
1778
1779 fn interrupt_fields(
1783 &self,
1784 item: &InboundPlaintext,
1785 me_id: &str,
1786 policy: Option<&crate::interrupt::InterruptPolicy>,
1787 ) -> (Option<serde_json::Value>, bool) {
1788 let (mut fields, asked, allowed) = crate::interrupt::authorize(item.hatchery.clone(), &item.from, me_id, policy);
1789 if !asked {
1790 return (fields, false);
1791 }
1792 let addressed = self.room_is_direct(&item.room_id)
1793 || crate::wake_policy::addresses(&item.body, self.nick.as_deref(), me_id);
1794 if allowed && !addressed {
1795 if let Some(map) = fields.as_mut().and_then(serde_json::Value::as_object_mut) {
1796 map.remove(crate::interrupt::INTERRUPT_KEY);
1797 }
1798 return (fields, false);
1799 }
1800 (fields, true)
1801 }
1802
1803 fn give_interrupt_receipt(
1806 &mut self,
1807 item: &InboundPlaintext,
1808 from_nick: &str,
1809 receipt: crate::interrupt::InterruptReceipt,
1810 now_ms: i64,
1811 ) {
1812 crate::wake_policy::note_interrupt(self.store_dir(), from_nick, &item.event_id, receipt.name());
1813 let (Ok(room_id), Ok(target)) = (RoomId::parse(&item.room_id), crate::EventId::parse(&item.event_id)) else {
1814 return;
1815 };
1816 let _ = self.dispatch(MessengerCommand::React { room_id, target, key: receipt.emoji().to_string() }, now_ms);
1817 }
1818
1819 fn room_is_direct(&self, room_id: &str) -> bool {
1821 let Some(id) = self.driver.core.room_ids().find(|r| r.as_str() == room_id) else { return false };
1822 let Some(state) = self.driver.core.room_state(id) else { return false };
1823 let live = state.members.values().filter(|m| matches!(membership_name(&m.membership), "join" | "invite")).count();
1824 state.name.is_none() && live <= 2
1825 }
1826
1827 fn default_letter_chain(&self) -> (provider::ProviderSession, provider::WakeChain) {
1831 let kind = if self.routine_url.is_some() && self.leader_sock.is_none() {
1832 provider::SessionKind::web(provider::ProviderKind::Grok)
1833 } else {
1834 provider::SessionKind::local(provider::ProviderKind::Grok)
1835 };
1836 let session = provider::ProviderSession {
1837 kind,
1838 session_id: self.session_id.clone(),
1839 nick: self.nick.clone().unwrap_or_default(),
1840 cwd: self.leader_cwd.clone().map(PathBuf::from),
1841 headless: false,
1842 };
1843 let root = self.dir.parent().map(Path::to_path_buf);
1844 let inbox = root.map(|r| provider::registry::inbox_dir(&r, &self.session_id));
1845 let mut config = provider::AdapterConfig::from_env(inbox.clone());
1846 config.routine_url = self.routine_url.clone();
1847 config.routine_bearer = self.routine_bearer.as_ref().map(|b| b.as_str().to_owned());
1848 if let Some(sock) = &self.leader_sock {
1849 config.leader_sock = Some(PathBuf::from(sock));
1850 }
1851 (session.clone(), crate::provider::letter_chain(kind, inbox, &config))
1852 }
1853
1854 fn schedule_wake_retry(&mut self, key: &str) {
1857 let n = self.wake_retry.get(key).map(|(n, _)| *n + 1).unwrap_or(1);
1858 let secs = (30u64 << (n - 1).min(5)).min(900);
1859 self.wake_retry.insert(key.to_owned(), (n, std::time::Instant::now() + std::time::Duration::from_secs(secs)));
1860 }
1861
1862 pub fn pending_wake_retries(&self) -> usize {
1864 self.wake_retry.len()
1865 }
1866
1867 fn wake_inbound(&mut self, now_ms: i64) {
1868 if self.routine_url.is_none() && self.leader_sock.is_none() && self.wake_chain.is_none() {
1869 return;
1870 }
1871 if self.routine_restart_catchup && self.routine_url.is_some() {
1872 for item in self.inbound_plaintexts() {
1875 if !self.routine_sent.contains(&item.key) {
1876 self.remember_routine_wake(&item.key);
1877 self.remember_leader_prompt(&item.key);
1878 }
1879 }
1880 } else if self.routine_woken_seed_pending && self.routine_url.is_some() {
1881 if !self.inbound_plaintexts().is_empty() {
1882 self.seed_routine_woken_from_timeline();
1883 }
1884 }
1885 self.arm_unprompted_newest();
1886 let policy = crate::wake_policy::WakePolicy::load(self.store_dir());
1887 let interrupt_policy = crate::interrupt::InterruptPolicy::load(self.store_dir());
1888 let me_id = self.driver.core.user_id().as_str().to_string();
1889 let mut items = self.inbound_plaintexts();
1890 items.retain(|item| {
1893 let direct = self.room_is_direct(&item.room_id);
1894 let mentioned = crate::wake_policy::addresses(&item.body, self.nick.as_deref(), &me_id);
1895 if policy.allows(direct, mentioned) {
1896 return true;
1897 }
1898 self.remember_routine_wake(&item.key);
1899 self.remember_leader_prompt(&item.key);
1900 false
1901 });
1902 let floor = self.reaction_floor.unwrap_or(i64::MAX);
1904 let reactions = self.reaction_items(floor);
1905 items.extend(reactions.into_iter().filter(|r| policy.allows(self.room_is_direct(&r.room_id), true)));
1906 let wall = std::time::Instant::now();
1910 let mut solo = Vec::new();
1911 let mut batch = Vec::new();
1912 for item in items {
1913 if self.leader_sent.contains(&item.key) || self.routine_sent.contains(&item.key) {
1914 self.wake_first_seen.remove(&item.key);
1915 continue;
1916 }
1917 self.wake_first_seen.entry(item.key.clone()).or_insert((now_ms, wall));
1918 let retry = self.wake_retry.get(&item.key).copied();
1919 if retry.is_some_and(|(_, next)| wall < next) {
1920 continue;
1921 }
1922 if letter_batch::goes_alone(item.hatchery.as_ref()) {
1923 solo.push((item, retry));
1924 } else {
1925 batch.push((item, retry));
1926 }
1927 }
1928 solo.sort_by_key(|(item, _)| item.ts);
1929 for (item, retry) in solo {
1930 self.wake_one(&[(item.key.clone(), retry)], &item, None, (&me_id, interrupt_policy.as_ref()), now_ms);
1931 }
1932 if batch.is_empty() {
1933 return;
1934 }
1935 batch.sort_by_key(|(item, _)| item.ts);
1936 let debounce = letter_batch::debounce_ms();
1937 let seen: Vec<(i64, std::time::Instant)> = batch.iter().filter_map(|(item, _)| self.wake_first_seen.get(&item.key).copied()).collect();
1938 let newest = seen.iter().map(|s| s.0).max().unwrap_or(now_ms);
1939 let oldest = seen.iter().map(|s| s.0).min().unwrap_or(now_ms);
1940 let wall_quiet = seen.iter().map(|s| wall.duration_since(s.1).as_millis() as i64).min().unwrap_or(0);
1941 let wall_oldest = seen.iter().map(|s| wall.duration_since(s.1).as_millis() as i64).max().unwrap_or(0);
1942 let due = letter_batch::batch_is_due(newest, oldest, now_ms, debounce)
1944 || (debounce > 0 && (wall_quiet >= debounce || wall_oldest >= letter_batch::MAX_WAIT_MS.max(debounce)));
1945 if !due {
1946 return;
1947 }
1948 let keys: Vec<(String, Option<(u32, std::time::Instant)>)> = batch.iter().map(|(item, retry)| (item.key.clone(), *retry)).collect();
1949 if batch.len() == 1 {
1950 let (item, _) = &batch[0];
1951 self.wake_one(&keys, item, None, (&me_id, interrupt_policy.as_ref()), now_ms);
1952 return;
1953 }
1954 let entries: Vec<letter_batch::BatchEntry> = batch
1955 .iter()
1956 .map(|(item, _)| letter_batch::BatchEntry {
1957 event_id: item.event_id.clone(),
1958 from_nick: mxid_localpart(&item.from).to_string(),
1959 room: Some(item.room_id.clone()),
1960 ts_ms: item.ts,
1961 body: item.body.clone(),
1962 reaction: item.reaction.clone(),
1963 content: item.hatchery.clone(),
1964 })
1965 .collect();
1966 let newest_item = &batch[batch.len() - 1].0;
1967 let combined = InboundPlaintext {
1968 room_id: letter_batch::shared_room(&entries).unwrap_or_default(),
1969 key: newest_item.key.clone(),
1970 body: letter_batch::render(&entries),
1971 from: newest_item.from.clone(),
1972 nick: newest_item.nick.clone(),
1973 event_id: newest_item.event_id.clone(),
1974 reaction: None,
1975 ts: newest_item.ts,
1976 hatchery: None,
1977 };
1978 self.wake_one(&keys, &combined, Some(entries), (&me_id, interrupt_policy.as_ref()), now_ms);
1979 }
1980
1981 fn wake_one(
1985 &mut self,
1986 keys: &[(String, Option<(u32, std::time::Instant)>)],
1987 item: &InboundPlaintext,
1988 entries: Option<Vec<letter_batch::BatchEntry>>,
1989 (me_id, interrupt_policy): (&str, Option<&crate::interrupt::InterruptPolicy>),
1990 now_ms: i64,
1991 ) {
1992 let (session, mut chain) = match self.wake_chain.take() {
1993 Some(installed) => installed,
1994 None => self.default_letter_chain(),
1995 };
1996 let from_nick = mxid_localpart(&item.from).to_string();
1997 let room = (!item.room_id.is_empty()).then_some(item.room_id.as_str());
1998 let letter = provider::WakeLetter {
1999 body: &item.body,
2000 from_nick: &from_nick,
2001 event_id: &item.event_id,
2002 room,
2003 reaction: item.reaction.as_ref().map(|(key, target)| provider::ReactionNote { key, target }),
2004 };
2005 let (fields, interrupt_asked) = if entries.is_some() { (None, false) } else { self.interrupt_fields(item, me_id, interrupt_policy) };
2008 let interrupt_ok = interrupt_asked && fields.as_ref().is_some_and(|f| f.get(crate::interrupt::INTERRUPT_KEY).is_some());
2009 let retrying = keys.iter().any(|(key, _)| self.wake_retry.contains_key(key));
2010 if interrupt_asked && !interrupt_ok && !retrying {
2011 eprintln!("m4a: interrupt from {} ignored (not on the interrupt list); letter {} goes as an ordinary letter", from_nick, item.event_id);
2012 }
2013 let result = letter_batch::scoped(entries, || letter_content::scoped(fields, || chain.wake(&session, &letter)));
2014 let interrupted = interrupt_ok && letter_content::was_interrupted();
2015 if interrupt_asked && result.as_ref().is_ok_and(|(id, outcome)| {
2016 matches!(outcome, provider::WakeOutcome::Delivered) || chain.ids().first().is_none_or(|first| first == id)
2017 }) {
2018 let receipt = if !interrupt_ok {
2019 crate::interrupt::InterruptReceipt::Denied
2020 } else if interrupted {
2021 crate::interrupt::InterruptReceipt::Interrupted
2022 } else {
2023 crate::interrupt::InterruptReceipt::Delivered
2024 };
2025 self.give_interrupt_receipt(item, &from_nick, receipt, now_ms);
2026 }
2027 match result {
2028 Ok((id, outcome)) => {
2029 let delivered = matches!(outcome, provider::WakeOutcome::Delivered);
2030 let fell_back = !delivered && chain.ids().first().is_some_and(|first| *first != id);
2033 if fell_back {
2034 for (key, _) in keys {
2035 self.schedule_wake_retry(key);
2036 }
2037 self.wake_note = Some(format!("wake {} queued; live deliver will be retried", item.event_id));
2038 crate::wake_policy::note_attempt(self.store_dir(), None);
2039 self.wake_log.push(WakeAttempt { event_id: item.event_id.clone(), status: None });
2040 } else {
2041 for (key, _) in keys {
2042 self.wake_retry.remove(key);
2043 self.wake_first_seen.remove(key);
2044 self.remember_leader_prompt(key);
2045 self.remember_routine_wake(key);
2046 }
2047 let status = delivered.then_some(200);
2048 crate::wake_policy::note_attempt(self.store_dir(), status);
2049 self.wake_log.push(WakeAttempt { event_id: item.event_id.clone(), status });
2050 }
2051 self.wake_route = Some(format!("{id} {}", if delivered { "delivered" } else { "queued" }));
2052 }
2053 Err(err) => {
2054 for (key, retry) in keys {
2055 if retry.is_some() {
2056 self.schedule_wake_retry(key);
2057 }
2058 }
2059 self.wake_note = Some(clip_public(err.to_string()));
2060 }
2061 }
2062 self.wake_chain = Some((session, chain));
2063 }
2064}
2065
2066#[allow(dead_code)]
2067struct InboundPlaintext {
2068 room_id: String,
2069 key: String,
2070 body: String,
2071 from: String,
2072 nick: Option<String>,
2073 event_id: String,
2074 reaction: Option<(String, String)>,
2076 ts: i64,
2077 hatchery: Option<serde_json::Value>,
2079}
2080
2081fn membership_name(membership: &Membership) -> &'static str {
2082 match membership {
2083 Membership::Invite => "invite",
2084 Membership::Join => "join",
2085 Membership::Knock => "knock",
2086 Membership::Leave => "leave",
2087 Membership::Ban => "ban",
2088 Membership::Unknown => "unknown",
2089 }
2090}
2091
2092fn outcome_name(state: &SendState) -> String {
2093 match state {
2094 SendState::Sent => "sent".to_string(),
2095 SendState::Sending | SendState::LocalEcho => "sending".to_string(),
2096 SendState::Failed { reason } => format!("failed:{reason}"),
2097 }
2098}
2099
2100fn parse_base_url(raw: &str) -> Result<reqwest::Url, ShellError> {
2101 let url = reqwest::Url::parse(raw).map_err(|_| ShellError::BaseUrl)?;
2102 match url.scheme() {
2103 "http" | "https" => {}
2104 _ => return Err(ShellError::BaseUrl),
2105 }
2106 if url.host_str().is_none()
2107 || url.query().is_some()
2108 || url.fragment().is_some()
2109 || !url.username().is_empty()
2110 || url.password().is_some()
2111 {
2112 return Err(ShellError::BaseUrl);
2113 }
2114 Ok(url)
2115}
2116
2117struct RegisteredSession {
2118 user_id: String,
2119 device_id: DeviceId,
2120 bearer: Zeroizing<String>,
2121 nick: String,
2123 backend: Arc<dyn Backend>,
2125}
2126
2127#[cfg(any(feature = "tier-server", feature = "tier-matrix"))]
2129fn identity_session(config: &SessionConfig, auth: &IdentityAuth) -> Result<RegisteredSession, ShellError> {
2130 at("open identity", &config.store_root, identity_session_inner(config, auth))
2131}
2132
2133#[cfg(any(feature = "tier-server", feature = "tier-matrix"))]
2134fn identity_session_inner(config: &SessionConfig, auth: &IdentityAuth) -> Result<RegisteredSession, ShellError> {
2135 let fail = |e: AgentError| ShellError::Register(clip_public(e.to_string()));
2136 parse_base_url(&config.homeserver_url)?;
2137 let ids = m4a_agent::IdentityStore::new(store_key::vault(&config.store_root)?);
2138 let backend: Arc<dyn Backend> = match auth.tier {
2139 #[cfg(feature = "tier-server")]
2140 m4a_agent::BackendKind::Server => Arc::new(m4a_agent::backend::server::ServerBackend::new(&config.homeserver_url).map_err(fail)?),
2141 #[cfg(feature = "tier-matrix")]
2144 m4a_agent::BackendKind::Matrix => Arc::new(m4a_agent::backend::matrix::MatrixBackend::discover(&config.homeserver_url).map_err(fail)?),
2145 #[allow(unreachable_patterns)]
2146 _ => return Err(ShellError::Register("this build does not include that tier".into())),
2147 };
2148 let mut id = ids.resolve(&config.session_id, auth.tier, backend.server_ref()).map_err(fail)?;
2149 if !id.enrolled && id.requested_nick != auth.nick_request {
2150 id.requested_nick = auth.nick_request.clone();
2151 }
2152 let session = backend.ensure_session(&ids, &mut id, auth.invite.as_ref().map(|i| i.as_str())).map_err(fail)?;
2153 let _ = std::fs::remove_file(invite_path(&config.store_root, &config.session_id));
2155 ids.adopt_nick(&mut id, &session.nick).map_err(fail)?;
2156 let (user_id, device_raw) = match (&session.user_id, &session.device_id) {
2157 (Some(u), Some(d)) => (u.clone(), d.clone()),
2158 _ => {
2159 backend.whoami().map_err(fail)?
2160 }
2161 };
2162 if user_id.is_empty() || device_raw.is_empty() {
2163 return Err(ShellError::Register("whoami missed an id".into()));
2164 }
2165 Ok(RegisteredSession { user_id, device_id: DeviceId::parse(&device_raw)?, bearer: session.token.clone(), nick: session.nick, backend })
2166}
2167
2168#[cfg(not(any(feature = "tier-server", feature = "tier-matrix")))]
2169fn identity_session(_: &SessionConfig, _: &IdentityAuth) -> Result<RegisteredSession, ShellError> {
2170 Err(ShellError::Register("this build has no tier for identity login".into()))
2171}
2172
2173fn register_session(config: &SessionConfig) -> Result<RegisteredSession, ShellError> {
2174 identity_session(config, &config.identity)
2175}
2176
2177fn clip_public(text: String) -> String {
2178 let mut out = String::new();
2179 for token in text.split_whitespace() {
2180 if token.len() > 80 {
2181 out.push_str("[omitted]");
2182 } else {
2183 out.push_str(token);
2184 }
2185 out.push(' ');
2186 if out.len() > 240 {
2187 break;
2188 }
2189 }
2190 out
2191}
2192
2193pub(crate) fn at<T>(step: &str, path: &Path, r: Result<T, ShellError>) -> Result<T, ShellError> {
2195 r.map_err(|e| match e {
2196 ShellError::Io(io) => ShellError::Io(std::io::Error::new(io.kind(), format!("{step} at {}: {io}", path.display()))),
2197 other => other,
2198 })
2199}
2200
2201fn fs_create_dir(dir: &Path) -> Result<(), ShellError> {
2202 std::fs::create_dir_all(dir)?;
2203 Ok(())
2204}
2205
2206#[derive(Debug, thiserror::Error)]
2209pub enum ShellError {
2210 #[error("session id is empty")]
2212 EmptySession,
2213 #[error("store root is unset")]
2215 StoreRoot,
2216 #[error("homeserver url is unset")]
2218 HomeserverUrl,
2219 #[error("bot display name is unset")]
2221 BotName,
2222 #[error("nick could not be derived from the bot display name")]
2224 Nick,
2225 #[error("session list: {0}")]
2227 SessionList(String),
2228 #[error("homeserver register failed: {0}")]
2230 Register(String),
2231 #[error("no session with that nick")]
2233 UnknownNick,
2234 #[error("direct room was not ready")]
2236 Dm,
2237 #[error("config: {0}")]
2239 Config(String),
2240 #[error("device token is empty or not a single header value")]
2242 DeviceToken,
2243 #[error("base url must be http or https")]
2245 BaseUrl,
2246 #[error("record key escapes the store directory")]
2248 BadRecordKey,
2249 #[error("routine url is not an http or https url")]
2251 RoutineUrl,
2252 #[error("node cli does not take a routine url")]
2254 NodeRoutine,
2255 #[error("gateway: {0}")]
2257 Gateway(String),
2258 #[error("routine bearer is empty or not a single header value")]
2260 RoutineBearer,
2261 #[error("routine status {0}")]
2263 RoutineStatus(u16),
2264 #[error("routine post failed: {0}")]
2266 RoutineTransport(String),
2267 #[error("homeserver http failed: {0}")]
2269 Http(String),
2270 #[error("homeserver response was not ingested: {0}")]
2272 Ingest(String),
2273 #[error("io: {0}")]
2275 Io(#[from] std::io::Error),
2276 #[error(transparent)]
2278 Store(#[from] StoreError),
2279 #[error(transparent)]
2281 Messenger(#[from] MessengerError),
2282}
2283
2284impl From<AgentError> for ShellError {
2285 fn from(err: AgentError) -> Self {
2286 match err {
2287 AgentError::Transport(text) => ShellError::Http(text),
2288 AgentError::Ingest(text) => ShellError::Ingest(text),
2289 other => ShellError::Register(clip_public(other.to_string())),
2290 }
2291 }
2292}
2293
2294fn persist(dir: &Path, core: &mut MessengerCore<SealedRecordCodec>) -> Result<(), ShellError> {
2295 while let Some(batch) = core.take_flush_batch() {
2296 for record in &batch.records {
2297 let path = record_path(dir, &record.key)?;
2298 if let Some(parent) = path.parent() {
2299 std::fs::create_dir_all(parent)?;
2300 }
2301 std::fs::write(&path, &record.bytes)?;
2302 #[cfg(unix)]
2303 {
2304 use std::os::unix::fs::PermissionsExt;
2305 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
2306 }
2307 }
2308 for key in &batch.deletes {
2309 let path = record_path(dir, key)?;
2310 if path.exists() {
2311 std::fs::remove_file(path)?;
2312 }
2313 }
2314 core.ack_flush(batch.id);
2315 }
2316 Ok(())
2317}
2318
2319const NOT_RECORDS: &[&str] = &[
2321 machine::WAKE_KEYCHAIN_FILE,
2322 machine::STORE_LOCK_FILE,
2323 LEADER_PROMPTED_FILE,
2324 ROUTINE_WOKEN_FILE,
2325];
2326
2327const LEADER_PROMPTED_FILE: &str = "leader-prompted";
2330const ROUTINE_WOKEN_FILE: &str = "routine-woken";
2333
2334fn read_records(dir: &Path) -> Result<Vec<SealedRecord>, ShellError> {
2335 let mut records = Vec::new();
2336 if dir.exists() {
2337 walk(dir, dir, &mut records)?;
2338 }
2339 Ok(records)
2340}
2341
2342fn walk(dir: &Path, root: &Path, records: &mut Vec<SealedRecord>) -> Result<(), ShellError> {
2343 for entry in std::fs::read_dir(dir)? {
2344 let entry = entry?;
2345 let path = entry.path();
2346 if path.is_dir() {
2347 walk(&path, root, records)?;
2348 continue;
2349 }
2350 if dir == root
2353 && path
2354 .file_name()
2355 .and_then(|name| name.to_str())
2356 .is_some_and(|name| NOT_RECORDS.contains(&name))
2357 {
2358 continue;
2359 }
2360 let rel = path
2361 .strip_prefix(root)
2362 .map_err(|_| ShellError::BadRecordKey)?;
2363 let key = rel
2364 .components()
2365 .map(|component| component.as_os_str().to_string_lossy())
2366 .collect::<Vec<_>>()
2367 .join("/");
2368 let bytes = std::fs::read(&path)?;
2369 records.push(SealedRecord {
2370 key: RecordKey::new(key),
2371 bytes,
2372 });
2373 }
2374 Ok(())
2375}
2376
2377fn record_path(dir: &Path, key: &RecordKey) -> Result<PathBuf, ShellError> {
2378 let rel = Path::new(key.as_str());
2379 if rel.is_absolute()
2380 || rel.components().any(|component| {
2381 matches!(
2382 component,
2383 Component::ParentDir | Component::RootDir | Component::Prefix(_)
2384 )
2385 })
2386 {
2387 return Err(ShellError::BadRecordKey);
2388 }
2389 Ok(dir.join(rel))
2390}
2391
2392#[cfg(test)]
2393mod tests {
2394 #[allow(unused_imports)]
2395 use std::time::Duration;
2396 use super::*;
2397
2398 #[test]
2399 fn session_config_is_identity_only_and_reads_no_secret() {
2400 let get = |extra: &'static [(&'static str, &'static str)]| {
2401 move |key: &str| {
2402 extra.iter().find(|(k, _)| *k == key).map(|(_, v)| v.to_string()).or_else(|| match key {
2403 SESSION_ID_ENV => Some("web-session-1".to_string()),
2404 STORE_ROOT_ENV => Some("/tmp/m4a-root".to_string()),
2405 "M4A_PRODUCT_PASSWORD" | "M4A_PRODUCT_TOKEN" | "M4A_DEVICE_TOKEN" => Some("must-not-be-read".to_string()),
2407 _ => None,
2408 })
2409 }
2410 };
2411 let config = SessionConfig::from_lookup(get(&[(HOMESERVER_URL_ENV, "http://127.0.0.1:9")]), None).expect("config");
2412 assert_eq!(config.store_dir(), session_store_dir(Path::new("/tmp/m4a-root"), "web-session-1"));
2413 assert!(!format!("{config:?}").contains("must-not-be-read"));
2414 assert_eq!(config.identity.tier, m4a_agent::BackendKind::Server);
2415 let matrix = SessionConfig::from_lookup(get(&[(PRODUCT_URL_ENV, "http://127.0.0.1:8"), (TIER_ENV, "matrix")]), None).expect("matrix");
2416 assert_eq!(matrix.identity.tier, m4a_agent::BackendKind::Matrix);
2417 assert!(SessionConfig::from_lookup(get(&[(TIER_ENV, "other"), (PRODUCT_URL_ENV, "http://127.0.0.1:8")]), None).is_err());
2418 let from_toml = SessionConfig::from_lookup(get(&[]), Some("homeserver_url = \"http://127.0.0.1:9\"\nother = \"ignored\"\n")).expect("toml url");
2419 assert_eq!(from_toml.homeserver_url, "http://127.0.0.1:9");
2420 assert!(SessionConfig::from_lookup(get(&[]), None).is_err(), "no server given");
2421 }
2422 use std::io::{Read, Write};
2423 use std::net::TcpListener;
2424 use std::sync::atomic::{AtomicBool, Ordering};
2425 use std::sync::{Arc, Mutex};
2426 use std::thread;
2427 use std::time::Instant;
2428
2429 fn plain(base: &str, token: &str) -> Arc<dyn Backend> {
2430 Arc::new(m4a_agent::backend::attached::AttachedBackend::with_prefix(m4a_agent::BackendKind::Server, base, token, false).expect("backend"))
2431 }
2432
2433 struct TempDir(PathBuf);
2434
2435 impl Drop for TempDir {
2436 fn drop(&mut self) {
2437 let _ = std::fs::remove_dir_all(&self.0);
2438 }
2439 }
2440
2441 fn temp_dir(name: &str) -> TempDir {
2442 let dir = TempDir(std::env::temp_dir().join(format!(
2443 "mail4agent-messenger-shell-{}-{name}",
2444 std::process::id()
2445 )));
2446 let _ = std::fs::remove_dir_all(&dir.0);
2447 dir
2448 }
2449
2450 fn open_alice(dir: &Path, session: &str) -> OpenedStore {
2451 let device = DeviceId::parse("DEVICE1").expect("device id");
2452 OpenedStore::open(
2453 dir,
2454 session,
2455 device,
2456 "@alice:localhost",
2457 "localhost",
2458 plain("http://127.0.0.1:9", "fake-token"),
2459 "fake-token",
2460 )
2461 .expect("open")
2462 }
2463
2464 #[test]
2465 fn same_session_opens_and_a_different_session_fails_the_seal() {
2466 let dir = temp_dir("seal");
2467 open_alice(&dir.0, "session-a");
2468 open_alice(&dir.0, "session-a");
2469
2470 let device = DeviceId::parse("DEVICE1").expect("device id");
2471 match OpenedStore::open(
2472 &dir.0,
2473 "session-b",
2474 device,
2475 "@alice:localhost",
2476 "localhost",
2477 plain("http://127.0.0.1:9", "fake-token"),
2478 "fake-token",
2479 ) {
2480 Err(ShellError::Store(StoreError::CodecOpen { .. })) => {}
2481 Ok(_) => panic!("different session opened the sealed store"),
2482 Err(err) => panic!("expected seal auth failure, got {err}"),
2483 }
2484 }
2485
2486 #[test]
2487 fn two_sessions_under_one_root_seal_and_reopen_only_with_their_own_id() {
2488 let root = temp_dir("isolate");
2489 let dir_a = session_store_dir(&root.0, "session-a");
2490 let dir_b = session_store_dir(&root.0, "session-b");
2491 assert_ne!(dir_a, dir_b);
2492 assert_eq!(dir_a.parent(), Some(root.0.as_path()));
2493 assert_eq!(dir_b.parent(), Some(root.0.as_path()));
2494 assert_eq!(session_store_dir(&root.0, "session-a"), dir_a);
2495 let slipped = session_store_dir(&root.0, "../session-a");
2496 assert_eq!(slipped.parent(), Some(root.0.as_path()));
2497 let name = slipped.file_name().expect("name").to_string_lossy();
2498 assert_eq!(name.len(), 64);
2499 assert!(name.chars().all(|ch| ch.is_ascii_hexdigit()));
2500
2501 let bearer = "isolation-bearer-7c2e";
2502 let device = DeviceId::parse("DEVICE1").expect("device id");
2503 let open_with = |dir: &Path, session: &str| {
2504 OpenedStore::open(
2505 dir,
2506 session,
2507 device.clone(),
2508 "@alice:localhost",
2509 "localhost",
2510 plain("http://127.0.0.1:9", bearer),
2511 bearer,
2512 )
2513 };
2514 open_with(&dir_a, "session-a").expect("seal a");
2515 open_with(&dir_b, "session-b").expect("seal b");
2516 open_with(&dir_a, "session-a").expect("reopen a");
2517 open_with(&dir_b, "session-b").expect("reopen b");
2518
2519 for (dir, session) in [(&dir_a, "session-b"), (&dir_b, "session-a")] {
2520 match open_with(dir, session) {
2521 Err(ShellError::Store(StoreError::CodecOpen { .. })) => {}
2522 Ok(_) => panic!("{session} opened the other sealed store"),
2523 Err(err) => panic!("expected seal auth failure, got {err}"),
2524 }
2525 }
2526
2527 fn contains_bearer(dir: &Path, needle: &str) -> bool {
2528 if !dir.exists() {
2529 return false;
2530 }
2531 for entry in std::fs::read_dir(dir).expect("read") {
2532 let entry = entry.expect("entry");
2533 let path = entry.path();
2534 if path.to_string_lossy().contains(needle) {
2535 return true;
2536 }
2537 if path.is_dir() {
2538 if contains_bearer(&path, needle) {
2539 return true;
2540 }
2541 } else if std::fs::read(&path)
2542 .expect("file")
2543 .windows(needle.len())
2544 .any(|window| window == needle.as_bytes())
2545 {
2546 return true;
2547 }
2548 }
2549 false
2550 }
2551 assert!(
2552 !contains_bearer(&root.0, bearer),
2553 "raw bearer was written under the store root"
2554 );
2555 }
2556
2557
2558
2559 #[test]
2560 fn a_base_url_carries_no_fragment_credentials_or_query() {
2561 assert!(parse_base_url("http://127.0.0.1:9").is_ok());
2562 assert!(parse_base_url("http://127.0.0.1:9/#other").is_err());
2563 assert!(parse_base_url("http://u:p@127.0.0.1:9").is_err());
2564 assert!(parse_base_url("ftp://127.0.0.1:9").is_err());
2565 }
2566
2567 #[test]
2568 fn send_message_releases_a_matrix_room_send() {
2569 let dir = temp_dir("send");
2570 let mut store = open_alice(&dir.0, "session-a");
2571 let released = store
2572 .send_room_message("!room:localhost", "hello room", 0)
2573 .expect("release");
2574 let send = released
2575 .iter()
2576 .find(|request| request.kind == OutgoingRequestKind::RoomSend)
2577 .unwrap_or_else(|| {
2578 panic!(
2579 "engine did not release a RoomSend: {:?}",
2580 released
2581 .iter()
2582 .map(|request| request.kind)
2583 .collect::<Vec<_>>()
2584 )
2585 });
2586 assert!(
2587 send.path.starts_with("/_matrix/client/v3/rooms/")
2588 && send.path.contains("/send/m.room.message/"),
2589 "not a matrix room send: {}",
2590 send.path
2591 );
2592 assert!(!send.path.contains("/mail/send"), "{}", send.path);
2593 assert!(!send.path.contains("/admin/listener"), "{}", send.path);
2594 let body = send.body.as_ref().expect("room send body");
2595 assert_eq!(body["msgtype"], "m.text");
2596 assert_eq!(body["body"], "hello room");
2597 }
2598
2599 #[test]
2600 fn drive_performs_the_released_request_with_bearer_and_no_matrix_prefix() {
2601 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2602 let addr = listener.local_addr().expect("addr");
2603 let seen = Arc::new(Mutex::new(Vec::<String>::new()));
2604 let seen_worker = Arc::clone(&seen);
2605 listener.set_nonblocking(true).expect("nonblocking");
2606 let server = thread::spawn(move || {
2607 let deadline = std::time::Instant::now() + Duration::from_secs(3);
2608 loop {
2609 if std::time::Instant::now() > deadline {
2610 break;
2611 }
2612 let sock = match listener.accept() {
2613 Ok((sock, _)) => sock,
2614 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
2615 thread::sleep(Duration::from_millis(20));
2616 continue;
2617 }
2618 Err(_) => break,
2619 };
2620 let mut sock = sock;
2621 let _ = sock.set_nonblocking(false);
2622 let _ = sock.set_read_timeout(Some(Duration::from_secs(2)));
2623 let mut buf = Vec::new();
2624 let mut tmp = [0u8; 2048];
2625 loop {
2626 let n = sock.read(&mut tmp).unwrap_or(0);
2627 if n == 0 {
2628 break;
2629 }
2630 buf.extend_from_slice(&tmp[..n]);
2631 if let Some(header_end) =
2632 buf.windows(4).position(|window| window == b"\r\n\r\n")
2633 {
2634 let headers = String::from_utf8_lossy(&buf[..header_end]).to_string();
2635 let length = headers
2636 .lines()
2637 .find_map(|line| {
2638 let (name, value) = line.split_once(':')?;
2639 if name.eq_ignore_ascii_case("content-length") {
2640 value.trim().parse::<usize>().ok()
2641 } else {
2642 None
2643 }
2644 })
2645 .unwrap_or(0);
2646 if buf.len() >= header_end + 4 + length {
2647 let body = buf[header_end + 4..header_end + 4 + length].to_vec();
2648 let first = headers.lines().next().unwrap_or("").to_string();
2649 let bearer_ok = headers.lines().any(|line| {
2650 let (name, value) = line.split_once(':').unwrap_or(("", ""));
2651 name.eq_ignore_ascii_case("authorization")
2652 && value.trim() == "Bearer fake-token"
2653 });
2654 let note = format!(
2655 "{first} bearer_ok={bearer_ok} body={}",
2656 String::from_utf8_lossy(&body)
2657 );
2658 seen_worker.lock().expect("seen").push(note);
2659 let request = first;
2660 let response_body = if request.contains("/send/") {
2661 br#"{"event_id":"$e:localhost"}"#.to_vec()
2662 } else if request.contains("/keys/") {
2663 br#"{"one_time_key_counts":{"signed_curve25519":50}}"#.to_vec()
2664 } else {
2665 br#"{"next_batch":"s1","device_one_time_keys_count":{"signed_curve25519":50},"device_unused_fallback_key_types":["signed_curve25519"]}"#.to_vec()
2666 };
2667 let head = format!(
2668 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2669 response_body.len()
2670 );
2671 let _ = sock.write_all(head.as_bytes());
2672 let _ = sock.write_all(&response_body);
2673 break;
2674 }
2675 }
2676 }
2677 }
2678 });
2679
2680 let dir = temp_dir("drive");
2681 let device = DeviceId::parse("DEVICE1").expect("device id");
2682 let mut store = OpenedStore::open(
2683 &dir.0,
2684 "session-a",
2685 device,
2686 "@alice:localhost",
2687 "localhost",
2688 plain(&format!("http://{addr}"), "fake-token"),
2689 "fake-token",
2690 )
2691 .expect("open");
2692 store
2693 .dispatch(
2694 MessengerCommand::SendMessage {
2695 room_id: RoomId::parse("!room:localhost").expect("room"),
2696 message: OutgoingMessage {
2697 kind: MessageKind::Text,
2698 body: "hello room".to_string(),
2699 reply_to: None,
2700 edit_of: None,
2701 },
2702 txn_id: None,
2703 },
2704 0,
2705 )
2706 .expect("dispatch");
2707 store.drive(0, false).expect("drive");
2708 thread::sleep(Duration::from_millis(200));
2709 drop(store);
2710 let _ = server.join();
2711 let seen = seen.lock().expect("seen");
2712 assert!(
2713 seen.iter().any(|line| {
2714 line.contains("PUT /client/v3/rooms/")
2715 && line.contains("/send/m.room.message/")
2716 && line.contains("bearer_ok=true")
2717 && line.contains("hello room")
2718 && !line.contains("/_matrix")
2719 }),
2720 "released room send was not performed: {seen:?}"
2721 );
2722 assert!(
2723 seen.iter().all(|line| !line.contains("/_matrix")),
2724 "prefix was not stripped: {seen:?}"
2725 );
2726 }
2727
2728 #[allow(dead_code)]
2729 struct Hit {
2730 headers: String,
2731 body: Vec<u8>,
2732 }
2733
2734 fn read_http(sock: &mut std::net::TcpStream) -> Option<(String, Vec<u8>)> {
2735 let _ = sock.set_read_timeout(Some(Duration::from_secs(2)));
2736 let mut buf = Vec::new();
2737 let mut tmp = [0u8; 2048];
2738 loop {
2739 let n = sock.read(&mut tmp).unwrap_or(0);
2740 if n == 0 {
2741 break;
2742 }
2743 buf.extend_from_slice(&tmp[..n]);
2744 if let Some(header_end) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
2745 let headers = String::from_utf8_lossy(&buf[..header_end]).to_string();
2746 let length = headers
2747 .lines()
2748 .find_map(|line| {
2749 let (name, value) = line.split_once(':')?;
2750 if name.eq_ignore_ascii_case("content-length") {
2751 value.trim().parse::<usize>().ok()
2752 } else {
2753 None
2754 }
2755 })
2756 .unwrap_or(0);
2757 if buf.len() >= header_end + 4 + length {
2758 let body = buf[header_end + 4..header_end + 4 + length].to_vec();
2759 return Some((headers, body));
2760 }
2761 }
2762 }
2763 None
2764 }
2765
2766 fn write_http(sock: &mut std::net::TcpStream, status: &str, body: &[u8]) {
2767 let head = format!(
2768 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2769 body.len()
2770 );
2771 let _ = sock.write_all(head.as_bytes());
2772 let _ = sock.write_all(body);
2773 }
2774
2775 fn sync_with_text_nick(body: &str, nick: Option<&str>) -> Vec<u8> {
2776 let mut state = Vec::new();
2777 if let Some(nick) = nick {
2778 state.push(serde_json::json!({
2779 "event_id": "$mem:localhost",
2780 "type": "m.room.member",
2781 "state_key": "@bob:localhost",
2782 "sender": "@bob:localhost",
2783 "origin_server_ts": 1,
2784 "content": { "membership": "join", "displayname": nick }
2785 }));
2786 }
2787 serde_json::json!({
2788 "next_batch": "s1",
2789 "rooms": {
2790 "join": {
2791 "!r:localhost": {
2792 "state": { "events": state },
2793 "timeline": {
2794 "events": [{
2795 "event_id": "$m1:localhost",
2796 "type": "m.room.message",
2797 "sender": "@bob:localhost",
2798 "origin_server_ts": 10,
2799 "content": { "msgtype": "m.text", "body": body }
2800 }]
2801 }
2802 }
2803 }
2804 },
2805 "device_one_time_keys_count": { "signed_curve25519": 50 },
2806 "device_unused_fallback_key_types": ["signed_curve25519"]
2807 })
2808 .to_string()
2809 .into_bytes()
2810 }
2811
2812 fn sync_empty() -> Vec<u8> {
2813 serde_json::json!({
2814 "next_batch": "s2",
2815 "device_one_time_keys_count": { "signed_curve25519": 50 },
2816 "device_unused_fallback_key_types": ["signed_curve25519"]
2817 })
2818 .to_string()
2819 .into_bytes()
2820 }
2821
2822 fn spawn_homeserver(text: &'static str) -> (String, Arc<AtomicBool>, thread::JoinHandle<()>) {
2823 spawn_homeserver_nick(text, None)
2824 }
2825
2826 fn spawn_homeserver_nick(
2827 text: &'static str,
2828 nick: Option<&'static str>,
2829 ) -> (String, Arc<AtomicBool>, thread::JoinHandle<()>) {
2830 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2831 let addr = listener.local_addr().expect("addr");
2832 listener.set_nonblocking(true).expect("nonblocking");
2833 let done = Arc::new(AtomicBool::new(false));
2834 let flag = Arc::clone(&done);
2835 let handle = thread::spawn(move || {
2836 let deadline = Instant::now() + Duration::from_secs(8);
2837 while !flag.load(Ordering::Relaxed) && Instant::now() < deadline {
2838 match listener.accept() {
2839 Ok((mut sock, _)) => {
2840 let _ = sock.set_nonblocking(false);
2841 let Some((headers, _)) = read_http(&mut sock) else {
2842 continue;
2843 };
2844 let line = headers.lines().next().unwrap_or("");
2845 let resp = if line.contains("/sync") && line.contains("since=") {
2846 sync_empty()
2847 } else if line.contains("/sync") {
2848 sync_with_text_nick(text, nick)
2849 } else if line.contains("/keys/") {
2850 br#"{"one_time_key_counts":{"signed_curve25519":50}}"#.to_vec()
2851 } else {
2852 b"{}".to_vec()
2853 };
2854 write_http(&mut sock, "200 OK", &resp);
2855 }
2856 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
2857 thread::sleep(Duration::from_millis(15));
2858 }
2859 Err(_) => break,
2860 }
2861 }
2862 });
2863 (format!("http://{addr}"), done, handle)
2864 }
2865
2866 fn spawn_routine() -> (
2867 String,
2868 Arc<Mutex<Vec<Hit>>>,
2869 Arc<AtomicBool>,
2870 thread::JoinHandle<()>,
2871 ) {
2872 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2873 let addr = listener.local_addr().expect("addr");
2874 listener.set_nonblocking(true).expect("nonblocking");
2875 let hits = Arc::new(Mutex::new(Vec::new()));
2876 let recorded = Arc::clone(&hits);
2877 let done = Arc::new(AtomicBool::new(false));
2878 let flag = Arc::clone(&done);
2879 let handle = thread::spawn(move || {
2880 let deadline = Instant::now() + Duration::from_secs(8);
2881 while !flag.load(Ordering::Relaxed) && Instant::now() < deadline {
2882 match listener.accept() {
2883 Ok((mut sock, _)) => {
2884 let _ = sock.set_nonblocking(false);
2885 if let Some((headers, body)) = read_http(&mut sock) {
2886 recorded.lock().expect("hits").push(Hit { headers, body });
2887 let _ = sock.write_all(
2888 b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
2889 );
2890 }
2891 }
2892 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
2893 thread::sleep(Duration::from_millis(15));
2894 }
2895 Err(_) => break,
2896 }
2897 }
2898 });
2899 (format!("http://{addr}/routine"), hits, done, handle)
2900 }
2901
2902 fn stop(flag: &AtomicBool, handle: thread::JoinHandle<()>) {
2903 flag.store(true, Ordering::Relaxed);
2904 let _ = handle.join();
2905 }
2906
2907 fn open_against(base: &str, dir: &Path, session: &str) -> OpenedStore {
2908 let device = DeviceId::parse("DEVICE1").expect("device id");
2909 OpenedStore::open(
2910 dir,
2911 session,
2912 device,
2913 "@alice:localhost",
2914 "localhost",
2915 plain(base, "fake-token"),
2916 "fake-token",
2917 )
2918 .expect("open")
2919 }
2920
2921 #[test]
2922 fn inbound_room_text_goes_through_the_provider_chain_once() {
2923 let (base, home_done, home) = spawn_homeserver("wake-chain");
2924 let dir = temp_dir("wake-chain");
2925 let inbox_dir = dir.0.join("inbox");
2926 let mut store = open_against(&base, &dir.0, "session-c");
2927 let session = provider::ProviderSession {
2928 kind: provider::SessionKind::local(provider::ProviderKind::Codex),
2929 session_id: "thread-c".into(),
2930 nick: "builder".into(),
2931 cwd: None,
2932 headless: false,
2933 };
2934 let chain = provider::letter_chain(session.kind, Some(inbox_dir.clone()), &provider::AdapterConfig::from_env(Some(inbox_dir.clone())));
2935 store.set_wake_chain(session, chain);
2936 assert_eq!(store.wake_chain_ids(), vec!["deliver", "inbox-queue"], "g4a deliver, then the inbox");
2937 store.drive(1_000, false).expect("drive");
2938 store.drive(3_000, false).expect("drive again");
2939 let route = store.take_wake_route();
2940 let note = store.wake_note().unwrap_or("").to_string();
2941 drop(store);
2942 stop(&home_done, home);
2943 assert_eq!(route.as_deref(), Some("inbox-queue queued"), "note={note}");
2945 let letters = provider::inbox::drain(&inbox_dir);
2946 assert_eq!(letters.len(), 1);
2947 assert!(letters[0].letter.prompt.ends_with("wake-chain"));
2948 assert!(letters[0].letter.prompt.contains("--as builder"));
2949 }
2950
2951
2952
2953
2954 #[test]
2955 fn the_owners_policy_silences_a_wake_and_the_silenced_message_is_never_replayed() {
2956 let (base, home_done, home) = spawn_homeserver("wake-plain");
2957 let (routine, hits, routine_done, routine_thread) = spawn_routine();
2958 let dir = temp_dir("wake-policy");
2959 let mut store = open_against(&base, &dir.0, "session-a");
2960 store.set_wake(SessionWake { routine_url: Some(routine), ..SessionWake::default() });
2961 let off = wake_policy::WakePolicy { enabled: false, mode: wake_policy::WakeMode::Mention };
2962 off.save(store.store_dir()).expect("policy");
2963 store.drive(1_000, false).expect("drive");
2964 assert!(store.texts().iter().any(|t| t.body == "wake-plain"), "the text still arrives and can be read");
2965 assert!(hits.lock().expect("hits").is_empty(), "a disabled wake posted");
2966 wake_policy::WakePolicy::default().save(store.store_dir()).expect("policy");
2968 store.drive(3_000, false).expect("drive again");
2969 let hits_now = hits.lock().expect("hits").len();
2970 drop(store);
2971 stop(&routine_done, routine_thread);
2972 stop(&home_done, home);
2973 assert_eq!(hits_now, 0, "the silenced message was replayed");
2974 }
2975
2976 #[test]
2977 fn a_web_client_with_a_routine_url_wakes_it_exactly_once_through_g4a() {
2978 let (base, home_done, home) = spawn_homeserver("wake-plain");
2979 let (routine, hits, routine_done, routine_thread) = spawn_routine();
2980 let dir = temp_dir("wake-routine-once");
2981 let mut store = open_against(&base, &dir.0, "session-a");
2982 store.set_wake(SessionWake { routine_url: Some(routine), routine_bearer: Some("k-test".into()), ..SessionWake::default() });
2983 store.drive(1_000, false).expect("drive");
2984 store.drive(3_000, false).expect("drive again");
2985 let route = store.take_wake_route();
2986 let note = store.wake_note().unwrap_or("").to_string();
2987 drop(store);
2988 stop(&routine_done, routine_thread);
2989 stop(&home_done, home);
2990 assert_eq!(hits.lock().expect("hits").len(), 1, "one letter, one routine wake; note={note}");
2991 assert_eq!(route.as_deref(), Some("deliver delivered"), "note={note}");
2992 let inbox = provider::registry::inbox_dir(dir.0.parent().expect("parent"), "session-a");
2993 assert!(provider::inbox::drain(&inbox).is_empty(), "a delivered letter is not also queued");
2994 }
2995
2996 fn spawn_homeserver_events(events: serde_json::Value) -> (String, Arc<AtomicBool>, thread::JoinHandle<()>) {
2997 let first = serde_json::json!({
2998 "next_batch": "s1",
2999 "rooms": { "join": { "!r:localhost": { "state": { "events": [] }, "timeline": { "events": events } } } },
3000 "device_one_time_keys_count": { "signed_curve25519": 50 },
3001 "device_unused_fallback_key_types": ["signed_curve25519"]
3002 }).to_string().into_bytes();
3003 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
3004 let addr = listener.local_addr().expect("addr");
3005 listener.set_nonblocking(true).expect("nonblocking");
3006 let done = Arc::new(AtomicBool::new(false));
3007 let flag = Arc::clone(&done);
3008 let handle = thread::spawn(move || {
3009 let deadline = Instant::now() + Duration::from_secs(8);
3010 while !flag.load(Ordering::Relaxed) && Instant::now() < deadline {
3011 match listener.accept() {
3012 Ok((mut sock, _)) => {
3013 let _ = sock.set_nonblocking(false);
3014 let Some((headers, _)) = read_http(&mut sock) else { continue };
3015 let line = headers.lines().next().unwrap_or("");
3016 let resp = if line.contains("/sync") && line.contains("since=") {
3017 sync_empty()
3018 } else if line.contains("/sync") {
3019 first.clone()
3020 } else if line.contains("/keys/") {
3021 br#"{"one_time_key_counts":{"signed_curve25519":50}}"#.to_vec()
3022 } else {
3023 b"{}".to_vec()
3024 };
3025 write_http(&mut sock, "200 OK", &resp);
3026 }
3027 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => thread::sleep(Duration::from_millis(15)),
3028 Err(_) => break,
3029 }
3030 }
3031 });
3032 (format!("http://{addr}"), done, handle)
3033 }
3034
3035 fn text_ev(id: &str, sender: &str, ts: i64, body: &str) -> serde_json::Value {
3036 serde_json::json!({ "event_id": id, "type": "m.room.message", "sender": sender, "origin_server_ts": ts, "content": { "msgtype": "m.text", "body": body } })
3037 }
3038 fn redaction_ev(id: &str, sender: &str, ts: i64, target: &str) -> serde_json::Value {
3039 serde_json::json!({ "event_id": id, "type": "m.room.redaction", "sender": sender, "origin_server_ts": ts, "redacts": target, "content": { "redacts": target } })
3040 }
3041 fn later(ms: i64) -> i64 {
3043 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0) + 3_600_000 + ms
3044 }
3045 fn reaction_ev(id: &str, sender: &str, ts: i64, target: &str, key: &str) -> serde_json::Value {
3046 serde_json::json!({ "event_id": id, "type": "m.reaction", "sender": sender, "origin_server_ts": ts, "content": { "m.relates_to": { "rel_type": "m.annotation", "event_id": target, "key": key } } })
3047 }
3048
3049 fn wakes_for(name: &str, events: serde_json::Value, restart: bool) -> Vec<serde_json::Value> {
3051 let (base, home_done, home) = spawn_homeserver_events(events);
3052 let (routine, hits, routine_done, routine_thread) = spawn_routine();
3053 let dir = temp_dir(name);
3054 let mut store = open_against(&base, &dir.0, "session-a");
3055 store.set_wake(SessionWake { routine_url: Some(routine.clone()), ..SessionWake::default() });
3056 store.drive(1_000, false).expect("drive");
3057 store.drive(3_000, false).expect("drive again");
3058 drop(store);
3059 if restart {
3060 let mut store = open_against(&base, &dir.0, "session-a");
3061 store.set_wake(SessionWake { routine_url: Some(routine), ..SessionWake::default() });
3062 store.drive(5_000, false).expect("drive after restart");
3063 store.drive(7_000, false).expect("drive after restart again");
3064 }
3065 stop(&routine_done, routine_thread);
3066 stop(&home_done, home);
3067 let hits = hits.lock().expect("hits");
3068 hits.iter().map(|h| serde_json::from_slice(&h.body).unwrap_or(serde_json::Value::Null)).collect()
3069 }
3070
3071 type SeenLetter = (String, Option<serde_json::Value>, serde_json::Value);
3072
3073 fn in_process_wakes(name: &str, events: serde_json::Value) -> Vec<SeenLetter> {
3076 in_process_wakes_with(name, events, None).0
3077 }
3078
3079 fn in_process_wakes_with(name: &str, events: serde_json::Value, interrupt_from: Option<&[&str]>) -> (Vec<SeenLetter>, serde_json::Value) {
3082 struct Record(Arc<Mutex<Vec<SeenLetter>>>);
3083 impl provider::WakeAdapter for Record {
3084 fn kind(&self) -> provider::SessionKind {
3085 provider::SessionKind::local(provider::ProviderKind::Grok)
3086 }
3087 fn probe(&self, _: &provider::ProviderSession) -> Result<(), provider::WakeError> {
3088 Ok(())
3089 }
3090 fn wake(&mut self, s: &provider::ProviderSession, l: &provider::WakeLetter<'_>) -> Result<provider::WakeOutcome, provider::WakeError> {
3091 let hook_json = crate::letter_hook::letter_event(s, l);
3092 self.0.lock().expect("seen").push((l.event_id.to_string(), crate::letter_content::current(), hook_json));
3093 if crate::letter_content::current_is_interrupt() {
3094 crate::letter_content::mark_interrupted();
3095 }
3096 Ok(provider::WakeOutcome::Delivered)
3097 }
3098 }
3099 let (base, home_done, home) = spawn_homeserver_events(events);
3100 let dir = temp_dir(name);
3101 let mut store = open_against(&base, &dir.0, "session-h");
3102 if let Some(from) = interrupt_from {
3103 std::fs::write(store.store_dir().join("interrupt.json"), serde_json::json!({ "from": from }).to_string()).expect("interrupt.json");
3104 }
3105 let session = provider::ProviderSession {
3106 kind: provider::SessionKind::local(provider::ProviderKind::Grok),
3107 session_id: "thread-h".into(),
3108 nick: "builder".into(),
3109 cwd: None,
3110 headless: false,
3111 };
3112 let seen = Arc::new(Mutex::new(Vec::new()));
3113 let mut chain = provider::WakeChain::new();
3114 chain.push("record", Box::new(Record(Arc::clone(&seen))));
3115 store.set_wake_chain(session, chain);
3116 store.drive(1_000, false).expect("drive");
3117 store.drive(3_000, false).expect("drive again");
3118 let status = std::fs::read_to_string(store.store_dir().join(crate::wake_policy::INTERRUPT_STATUS_FILE))
3119 .ok()
3120 .and_then(|t| serde_json::from_str(&t).ok())
3121 .unwrap_or(serde_json::Value::Null);
3122 drop(store);
3123 stop(&home_done, home);
3124 assert_eq!(crate::letter_content::current(), None, "the fields do not leak past the wake");
3125 let seen = seen.lock().expect("seen").clone();
3126 (seen, status)
3127 }
3128
3129 fn interrupt_ev(id: &str, sender: &str) -> serde_json::Value {
3130 serde_json::json!({ "event_id": id, "type": "m.room.message", "sender": sender, "origin_server_ts": 10,
3131 "content": { "msgtype": "m.text", "body": "stop and read this", "fyi.hatchery.interrupt": true, "fyi.hatchery.task": 7, "fyi.other": 1 } })
3132 }
3133
3134 #[test]
3135 fn an_in_process_adapter_sees_the_letters_hatchery_fields() {
3136 let (seen, status) = in_process_wakes_with("wake-hatchery-fields", serde_json::json!([interrupt_ev("$i1:localhost", "@bob:localhost")]), Some(&["bob"]));
3137 assert_eq!(seen.len(), 1, "{seen:?}");
3138 let (id, fields, hook) = &seen[0];
3139 assert_eq!(id, "$i1:localhost");
3140 assert_eq!(fields, &Some(serde_json::json!({ "fyi.hatchery.interrupt": true, "fyi.hatchery.task": 7 })), "only fyi.hatchery.* keys");
3141 assert_eq!(hook["content"], serde_json::json!({ "fyi.hatchery.interrupt": true, "fyi.hatchery.task": 7 }));
3142 assert_eq!(status["result"], "interrupted", "{status}");
3143 assert_eq!(status["from"], "bob");
3144 }
3145
3146 #[test]
3147 fn an_interrupt_from_a_sender_off_the_list_is_delivered_as_an_ordinary_letter() {
3148 let (seen, status) = in_process_wakes_with("wake-interrupt-denied", serde_json::json!([interrupt_ev("$d1:localhost", "@mallory:localhost")]), Some(&["bob"]));
3149 assert_eq!(seen.len(), 1, "the letter is still delivered: {seen:?}");
3150 let (_, fields, hook) = &seen[0];
3151 assert_eq!(fields, &Some(serde_json::json!({ "fyi.hatchery.interrupt_denied": true, "fyi.hatchery.task": 7 })), "no adapter can act on a denied flag");
3152 assert!(hook["content"].get("fyi.hatchery.interrupt").is_none(), "{hook}");
3153 assert_eq!(status["result"], "denied", "{status}");
3154 }
3155
3156 #[test]
3157 fn with_no_interrupt_list_nobody_interrupts() {
3158 let (seen, status) = in_process_wakes_with("wake-interrupt-no-list", serde_json::json!([interrupt_ev("$n1:localhost", "@bob:localhost")]), None);
3159 assert_eq!(seen.len(), 1, "{seen:?}");
3160 assert_eq!(seen[0].1, Some(serde_json::json!({ "fyi.hatchery.interrupt_denied": true, "fyi.hatchery.task": 7 })));
3161 assert_eq!(status["result"], "denied", "{status}");
3162 }
3163
3164 #[test]
3165 fn a_plain_letter_has_no_hatchery_fields_and_no_content_key() {
3166 let seen = in_process_wakes("wake-no-hatchery-fields", serde_json::json!([
3167 text_ev("$p1:localhost", "@bob:localhost", 10, "plain letter"),
3168 ]));
3169 assert_eq!(seen.len(), 1, "{seen:?}");
3170 let (_, fields, hook) = &seen[0];
3171 assert_eq!(fields, &None);
3172 assert!(hook.get("content").is_none(), "older readers see the old JSON: {hook}");
3173 }
3174
3175 #[test]
3176 fn a_message_sent_and_redacted_while_the_agent_was_offline_wakes_nothing() {
3177 let w = wakes_for("coalesce-redact", serde_json::json!([
3178 text_ev("$m1:localhost", "@bob:localhost", 10, "oops wrong chat"),
3179 redaction_ev("$x1:localhost", "@bob:localhost", 11, "$m1:localhost"),
3180 ]), false);
3181 assert!(w.is_empty(), "redacted-before-delivery must produce no wake: {w:?}");
3182 }
3183
3184 #[test]
3185 fn an_edit_before_the_wake_delivers_only_the_final_version() {
3186 let w = wakes_for("coalesce-edit", serde_json::json!([
3187 text_ev("$m1:localhost", "@bob:localhost", 10, "draft text"),
3188 { "event_id": "$e1:localhost", "type": "m.room.message", "sender": "@bob:localhost", "origin_server_ts": 11,
3189 "content": { "msgtype": "m.text", "body": "* final text", "m.new_content": { "msgtype": "m.text", "body": "final text" },
3190 "m.relates_to": { "rel_type": "m.replace", "event_id": "$m1:localhost" } } },
3191 ]), false);
3192 assert_eq!(w.len(), 1, "one wake for the edited message: {w:?}");
3193 assert_eq!(w[0]["body"], "final text");
3194 assert_eq!(w[0]["event_id"], "$m1:localhost");
3195 }
3196
3197 #[test]
3198 fn a_reaction_to_my_message_wakes_once_low_priority_and_mine_never() {
3199 let w = wakes_for("reaction-wake", serde_json::json!([
3200 text_ev("$a1:localhost", "@alice:localhost", 10, "deploy finished"),
3201 reaction_ev("$r1:localhost", "@bob:localhost", later(0), "$a1:localhost", "๐"),
3202 reaction_ev("$r2:localhost", "@alice:localhost", later(1), "$a1:localhost", "๐"),
3203 ]), true);
3204 assert_eq!(w.len(), 1, "exactly one wake (bob's reaction), none for my own, no replay after restart: {w:?}");
3205 assert_eq!(w[0]["kind"], "reaction");
3206 assert_eq!(w[0]["reaction"], "๐");
3207 assert_eq!(w[0]["target_event_id"], "$a1:localhost");
3208 assert_eq!(w[0]["event_id"], "$r1:localhost");
3209 assert_eq!(w[0]["reply_needed"], false);
3210 assert_eq!(w[0]["priority"], "low");
3211 assert!(w[0].get("reply").is_none());
3212 assert!(w[0]["body"].as_str().unwrap_or("").contains("deploy finished"));
3213 }
3214
3215 #[test]
3216 fn reactions_from_before_the_first_run_never_wake() {
3217 let w = wakes_for("reaction-history", serde_json::json!([
3218 text_ev("$a1:localhost", "@alice:localhost", 10, "old"),
3219 reaction_ev("$r1:localhost", "@bob:localhost", 11, "$a1:localhost", "๐"),
3220 ]), false);
3221 assert!(w.is_empty(), "history replayed: {w:?}");
3222 }
3223
3224 #[test]
3225 fn a_reaction_added_and_removed_before_the_wake_produces_nothing() {
3226 let w = wakes_for("reaction-undo", serde_json::json!([
3227 text_ev("$a1:localhost", "@alice:localhost", 10, "hello"),
3228 reaction_ev("$r1:localhost", "@bob:localhost", later(0), "$a1:localhost", "๐"),
3229 redaction_ev("$x1:localhost", "@bob:localhost", later(1), "$r1:localhost"),
3230 ]), false);
3231 assert!(w.is_empty(), "{w:?}");
3232 }
3233
3234 fn spawn_homeserver_burst(burst: serde_json::Value, release: Arc<AtomicBool>) -> (String, Arc<AtomicBool>, thread::JoinHandle<()>) {
3237 let room = |events: serde_json::Value, next: &str| serde_json::json!({
3238 "next_batch": next,
3239 "rooms": { "join": { "!r:localhost": { "state": { "events": [] }, "timeline": { "events": events } } } },
3240 "device_one_time_keys_count": { "signed_curve25519": 50 },
3241 "device_unused_fallback_key_types": ["signed_curve25519"]
3242 }).to_string().into_bytes();
3243 let first = room(serde_json::json!([text_ev("$seed:localhost", "@bob:localhost", 5, "seed")]), "s1");
3244 let burst = room(burst, "s3");
3245 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
3246 let addr = listener.local_addr().expect("addr");
3247 listener.set_nonblocking(true).expect("nonblocking");
3248 let done = Arc::new(AtomicBool::new(false));
3249 let flag = Arc::clone(&done);
3250 let handle = thread::spawn(move || {
3251 let mut burst_served = false;
3252 let deadline = Instant::now() + Duration::from_secs(10);
3253 while !flag.load(Ordering::Relaxed) && Instant::now() < deadline {
3254 match listener.accept() {
3255 Ok((mut sock, _)) => {
3256 let _ = sock.set_nonblocking(false);
3257 let Some((headers, _)) = read_http(&mut sock) else { continue };
3258 let line = headers.lines().next().unwrap_or("");
3259 let resp = if line.contains("/sync") && line.contains("since=") {
3260 if !burst_served && release.load(Ordering::Relaxed) {
3261 burst_served = true;
3262 burst.clone()
3263 } else {
3264 sync_empty()
3265 }
3266 } else if line.contains("/sync") {
3267 first.clone()
3268 } else if line.contains("/keys/") {
3269 br#"{"one_time_key_counts":{"signed_curve25519":50}}"#.to_vec()
3270 } else {
3271 b"{}".to_vec()
3272 };
3273 write_http(&mut sock, "200 OK", &resp);
3274 }
3275 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => thread::sleep(Duration::from_millis(10)),
3276 Err(_) => break,
3277 }
3278 }
3279 });
3280 (format!("http://{addr}"), done, handle)
3281 }
3282
3283 type BatchSeen = (String, Option<serde_json::Value>, Option<Vec<letter_batch::BatchEntry>>, String, i64);
3285
3286 fn drive_burst(store: &mut OpenedStore, release: &AtomicBool, clock: &std::sync::atomic::AtomicI64, done: &dyn Fn() -> bool) -> Option<i64> {
3290 let mut now = 1_000;
3291 let mut burst_at = None;
3292 let deadline = Instant::now() + Duration::from_secs(9);
3293 let step = |store: &mut OpenedStore, now: i64| {
3294 clock.store(now, Ordering::Relaxed);
3295 store.drive(now, false).expect("drive");
3296 };
3297 while Instant::now() < deadline {
3298 step(store, now);
3299 let texts = store.texts().len();
3300 if !release.load(Ordering::Relaxed) && texts >= 1 {
3301 for _ in 0..12 {
3303 now += 250;
3304 step(store, now);
3305 }
3306 release.store(true, Ordering::Relaxed);
3307 } else if release.load(Ordering::Relaxed) && burst_at.is_none() && texts > 1 {
3308 burst_at = Some(now);
3309 }
3310 if burst_at.is_some() && done() {
3311 for _ in 0..12 {
3312 now += 250;
3313 step(store, now);
3314 }
3315 break;
3316 }
3317 now += 250;
3318 thread::sleep(Duration::from_millis(15));
3319 }
3320 burst_at
3321 }
3322
3323 fn batch_wakes(name: &str, burst: serde_json::Value, done: impl Fn(&[BatchSeen]) -> bool) -> (Vec<BatchSeen>, Option<i64>) {
3326 struct Record(Arc<Mutex<Vec<BatchSeen>>>, Arc<std::sync::atomic::AtomicI64>);
3327 impl provider::WakeAdapter for Record {
3328 fn kind(&self) -> provider::SessionKind {
3329 provider::SessionKind::local(provider::ProviderKind::Grok)
3330 }
3331 fn probe(&self, _: &provider::ProviderSession) -> Result<(), provider::WakeError> {
3332 Ok(())
3333 }
3334 fn wake(&mut self, _: &provider::ProviderSession, l: &provider::WakeLetter<'_>) -> Result<provider::WakeOutcome, provider::WakeError> {
3335 let at = self.1.load(Ordering::Relaxed);
3336 self.0.lock().expect("seen").push((l.event_id.to_string(), crate::letter_content::current(), crate::letter_batch::current(), l.body.to_string(), at));
3337 Ok(provider::WakeOutcome::Delivered)
3338 }
3339 }
3340 let release = Arc::new(AtomicBool::new(false));
3341 let (base, home_done, home) = spawn_homeserver_burst(burst, Arc::clone(&release));
3342 let dir = temp_dir(name);
3343 let mut store = open_against(&base, &dir.0, "session-b");
3344 std::fs::write(store.store_dir().join("interrupt.json"), serde_json::json!({ "from": ["boss"] }).to_string()).expect("interrupt.json");
3345 let session = provider::ProviderSession {
3346 kind: provider::SessionKind::local(provider::ProviderKind::Grok),
3347 session_id: "thread-b".into(),
3348 nick: "builder".into(),
3349 cwd: None,
3350 headless: false,
3351 };
3352 let seen = Arc::new(Mutex::new(Vec::new()));
3353 let clock = Arc::new(std::sync::atomic::AtomicI64::new(0));
3354 let mut chain = provider::WakeChain::new();
3355 chain.push("record", Box::new(Record(Arc::clone(&seen), Arc::clone(&clock))));
3356 store.set_wake_chain(session, chain);
3357 let after_seed = |s: &[BatchSeen]| -> Vec<BatchSeen> { s.iter().filter(|w| w.0 != "$seed:localhost").cloned().collect() };
3358 let burst_at = drive_burst(&mut store, &release, &clock, &|| done(&after_seed(&seen.lock().expect("seen"))));
3359 drop(store);
3360 stop(&home_done, home);
3361 assert_eq!(crate::letter_batch::current(), None, "the batch does not leak past the wake");
3362 let all = seen.lock().expect("seen").clone();
3363 assert_eq!(all.first().map(|w| w.0.as_str()), Some("$seed:localhost"), "the seed letter woke first, alone: {all:?}");
3364 (after_seed(&all), burst_at)
3365 }
3366
3367 #[test]
3368 fn a_burst_of_letters_is_one_wake_with_every_letter_oldest_first() {
3369 let (seen, first) = batch_wakes("batch-burst", serde_json::json!([
3370 text_ev("$b3:localhost", "@carol:localhost", 30, "third"),
3371 text_ev("$b1:localhost", "@bob:localhost", 10, "first"),
3372 text_ev("$b2:localhost", "@bob:localhost", 20, "second"),
3373 ]), |s| !s.is_empty());
3374 assert_eq!(seen.len(), 1, "one wake for the burst: {seen:?}");
3375 let (id, fields, entries, body, at) = &seen[0];
3376 assert!(*at - first.expect("texts arrived") >= letter_batch::DEFAULT_DEBOUNCE_MS, "the burst waited for the debounce");
3377 assert_eq!(id, "$b3:localhost", "the batch is named by its newest letter");
3378 assert_eq!(fields, &None);
3379 let entries = entries.as_ref().expect("batch entries");
3380 let ids: Vec<&str> = entries.iter().map(|e| e.event_id.as_str()).collect();
3381 assert_eq!(ids, ["$b1:localhost", "$b2:localhost", "$b3:localhost"]);
3382 assert_eq!(entries.iter().map(|e| e.ts_ms).collect::<Vec<_>>(), [10, 20, 30]);
3383 assert_eq!(entries[2].from_nick, "carol");
3384 assert_eq!(entries[0].room.as_deref(), Some("!r:localhost"));
3385 assert!(body.starts_with("[m4a] 3 letters"), "{body}");
3386 for (i, (word, ts)) in [("first", 10), ("second", 20), ("third", 30)].iter().enumerate() {
3387 assert!(body.contains(&format!("--- {}/3 ยท received {}", i + 1, letter_batch::format_ts(*ts))), "{body}");
3388 assert!(body.contains(word));
3389 }
3390 assert!(body.find("first").unwrap() < body.find("second").unwrap() && body.find("second").unwrap() < body.find("third").unwrap());
3391 }
3392
3393 #[test]
3394 fn an_interrupt_letter_bypasses_the_batch_and_the_debounce() {
3395 let (seen, first) = batch_wakes("batch-interrupt", serde_json::json!([
3396 text_ev("$n1:localhost", "@bob:localhost", 10, "normal one"),
3397 { "event_id": "$i1:localhost", "type": "m.room.message", "sender": "@boss:localhost", "origin_server_ts": 15,
3398 "content": { "msgtype": "m.text", "body": "builder: stop now", "fyi.hatchery.interrupt": true } },
3399 text_ev("$n2:localhost", "@bob:localhost", 20, "normal two"),
3400 ]), |s| s.len() >= 2);
3401 assert_eq!(seen.len(), 2, "interrupt alone, then one batch: {seen:?}");
3402 let first = first.expect("texts arrived");
3403 assert_eq!(seen[0].0, "$i1:localhost");
3404 assert_eq!(seen[0].1, Some(serde_json::json!({ "fyi.hatchery.interrupt": true })));
3405 assert_eq!(seen[0].2, None, "an interrupt is never in a batch");
3406 assert_eq!(seen[0].4, first, "the interrupt went out on arrival, without the debounce");
3407 let ids: Vec<String> = seen[1].2.as_ref().expect("batch").iter().map(|e| e.event_id.clone()).collect();
3408 assert_eq!(ids, ["$n1:localhost", "$n2:localhost"]);
3409 assert!(seen[1].4 - first >= letter_batch::DEFAULT_DEBOUNCE_MS);
3410 }
3411
3412 #[test]
3413 fn a_single_letter_is_still_the_plain_letter() {
3414 let (seen, _) = batch_wakes("batch-single", serde_json::json!([
3415 text_ev("$s1:localhost", "@bob:localhost", 10, "just one"),
3416 ]), |s| !s.is_empty());
3417 assert_eq!(seen.len(), 1);
3418 assert_eq!(seen[0].2, None, "a batch of one is not a batch");
3419 assert_eq!(seen[0].3, "just one");
3420 }
3421
3422 #[test]
3423 fn a_burst_to_a_routine_is_one_webhook() {
3424 let release = Arc::new(AtomicBool::new(false));
3425 let (base, home_done, home) = spawn_homeserver_burst(serde_json::json!([
3426 text_ev("$w1:localhost", "@bob:localhost", 10, "alpha"),
3427 text_ev("$w2:localhost", "@bob:localhost", 11, "beta"),
3428 text_ev("$w3:localhost", "@carol:localhost", 12, "gamma"),
3429 text_ev("$w4:localhost", "@carol:localhost", 13, "delta"),
3430 ]), Arc::clone(&release));
3431 let (routine, hits, routine_done, routine_thread) = spawn_routine();
3432 let dir = temp_dir("batch-routine");
3433 let mut store = open_against(&base, &dir.0, "session-a");
3434 store.set_wake(SessionWake { routine_url: Some(routine), ..SessionWake::default() });
3435 let clock = std::sync::atomic::AtomicI64::new(0);
3436 drive_burst(&mut store, &release, &clock, &|| hits.lock().expect("hits").len() >= 2);
3437 drop(store);
3438 stop(&routine_done, routine_thread);
3439 stop(&home_done, home);
3440 let w: Vec<serde_json::Value> = hits.lock().expect("hits").iter().map(|h| serde_json::from_slice(&h.body).unwrap_or(serde_json::Value::Null)).collect();
3441 assert_eq!(w.len(), 2, "the seed, then one routine POST for four letters: {w:?}");
3442 assert_eq!(w[0]["body"], "seed");
3443 let body = w[1]["body"].as_str().unwrap_or("");
3444 assert!(body.starts_with("[m4a] 4 letters"), "{body}");
3445 for word in ["alpha", "beta", "gamma", "delta"] {
3446 assert!(body.contains(word), "{body}");
3447 }
3448 assert_eq!(w[1]["event_id"], "$w4:localhost");
3449 }
3450
3451 #[test]
3452 fn no_routine_url_posts_nothing() {
3453 let (base, home_done, home) = spawn_homeserver("wake-plain");
3454 let (_routine, hits, routine_done, routine_thread) = spawn_routine();
3455 let dir = temp_dir("wake-none");
3456 let mut store = open_against(&base, &dir.0, "session-a");
3457 store.set_wake(SessionWake::default());
3458 store.drive(1_000, false).expect("drive");
3459 let saw_text = store.texts().iter().any(|text| text.body == "wake-plain");
3460 let trace = store.http_trace();
3461 drop(store);
3462 stop(&routine_done, routine_thread);
3463 stop(&home_done, home);
3464 assert!(
3465 saw_text,
3466 "missing routine url dropped the inbound text; http={trace:?}"
3467 );
3468 let hits = hits.lock().expect("hits");
3469 assert!(hits.is_empty(), "a post happened with no routine url");
3470 }
3471
3472
3473
3474}
3475
3476fn server_of(user_id: &str) -> &str {
3478 user_id.split_once(':').map(|(_, server)| server).unwrap_or("")
3479}
3480
3481#[derive(Debug, Clone, PartialEq, Eq)]
3486pub struct AutoJoinPolicy {
3487 pub dms: bool,
3488 pub rooms: bool,
3489 pub room_inviters: Vec<String>,
3491}
3492
3493impl Default for AutoJoinPolicy {
3494 fn default() -> Self {
3495 Self { dms: true, rooms: true, room_inviters: Vec::new() }
3496 }
3497}
3498
3499impl AutoJoinPolicy {
3500 pub fn from_env() -> Self {
3501 let flag = |k: &str| std::env::var(k).map(|v| !matches!(v.trim(), "0" | "false" | "off" | "no")).unwrap_or(true);
3502 let from = std::env::var("M4A_AUTO_JOIN_FROM").unwrap_or_default();
3503 Self {
3504 dms: flag("M4A_AUTO_JOIN_DMS"),
3505 rooms: flag("M4A_AUTO_JOIN_ROOMS"),
3506 room_inviters: from.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
3507 }
3508 }
3509
3510 pub fn accepts<'a>(&self, home: &str, me: &str, is_direct: bool, inviter: Option<&str>, members: impl Iterator<Item = &'a str>) -> bool {
3512 if home.is_empty() {
3513 return false;
3514 }
3515 if is_direct {
3516 let others: Vec<&str> = members.filter(|u| *u != me).collect();
3517 let peers_home = !others.is_empty() && others.iter().all(|u| server_of(u) == home);
3518 return self.dms && peers_home && inviter.is_none_or(|i| server_of(i) == home);
3519 }
3520 let Some(inviter) = inviter else { return false };
3521 if !self.rooms || server_of(inviter) != home || inviter == me {
3522 return false;
3523 }
3524 self.room_inviters.is_empty()
3525 || self.room_inviters.iter().any(|a| a.eq_ignore_ascii_case(inviter) || a.eq_ignore_ascii_case(inviter.trim_start_matches('@').split(':').next().unwrap_or("")))
3526 }
3527}
3528
3529#[cfg(test)]
3530mod auto_join_tests {
3531 use super::AutoJoinPolicy;
3532
3533 const HOME: &str = "example.org";
3534 const ME: &str = "@agent-a:example.org";
3535
3536 fn ok(p: &AutoJoinPolicy, direct: bool, inviter: Option<&str>, members: &[&str]) -> bool {
3537 p.accepts(HOME, ME, direct, inviter, members.iter().copied())
3538 }
3539
3540 #[test]
3541 fn group_room_invite_from_our_server_is_joined() {
3542 let p = AutoJoinPolicy::default();
3543 assert!(ok(&p, false, Some("@operator:example.org"), &[ME, "@operator:example.org", "@x:other.org"]));
3544 }
3545
3546 #[test]
3547 fn group_room_invite_from_another_server_or_unknown_inviter_is_left() {
3548 let p = AutoJoinPolicy::default();
3549 assert!(!ok(&p, false, Some("@evil:other.org"), &[ME, "@evil:other.org"]));
3550 assert!(!ok(&p, false, None, &[ME, "@operator:example.org"]));
3551 }
3552
3553 #[test]
3554 fn dm_policy_is_unchanged() {
3555 let p = AutoJoinPolicy::default();
3556 assert!(ok(&p, true, Some("@agent-b:example.org"), &[ME, "@agent-b:example.org"]));
3557 assert!(ok(&p, true, None, &[ME, "@agent-b:example.org"]));
3558 assert!(!ok(&p, true, Some("@x:other.org"), &[ME, "@x:other.org"]));
3559 }
3560
3561 #[test]
3562 fn switches_and_allow_list() {
3563 let off = AutoJoinPolicy { rooms: false, ..AutoJoinPolicy::default() };
3564 assert!(!ok(&off, false, Some("@operator:example.org"), &[ME]));
3565 let nodm = AutoJoinPolicy { dms: false, ..AutoJoinPolicy::default() };
3566 assert!(!ok(&nodm, true, Some("@agent-b:example.org"), &[ME, "@agent-b:example.org"]));
3567 let only_op = AutoJoinPolicy { room_inviters: vec!["operator".into()], ..AutoJoinPolicy::default() };
3568 assert!(ok(&only_op, false, Some("@operator:example.org"), &[ME]));
3569 assert!(!ok(&only_op, false, Some("@agent-b:example.org"), &[ME]));
3570 }
3571}