1pub mod cli;
65pub mod door_listen;
66pub mod session_account;
67pub mod letter_hook;
68#[doc(hidden)]
69pub fn ipc_own_uid() -> Option<u32> {
70 ipc::own_uid()
71}
72mod cmd;
73mod grok_listen;
74mod ipc;
75mod machine;
76#[cfg(feature = "local-bus")]
77mod local_bus;
78#[cfg(not(feature = "local-bus"))]
79#[path = "local_bus_off.rs"]
80mod local_bus;
81mod nick;
82mod webhook;
83pub mod wake_policy;
84pub mod provider;
85mod bus_backend;
86pub mod resolve;
87mod send;
88#[cfg(windows)]
89mod ipc_winpipe;
90pub mod store_key;
91
92use std::collections::{HashMap, HashSet};
93use std::path::{Component, Path, PathBuf};
94use std::sync::Arc;
95
96use mail4agent_messenger::store::sealed::SealedRecordCodec;
97use mail4agent_messenger::wire::Membership;
98use mail4agent_messenger::{
99 CoreConfig, CoreSecrets, ItemContent, Jitter, MessengerCore,
100 MessengerError, OutgoingRequest, OutgoingRequestKind, RecordKey, SealedRecord, SendState,
101 StoreError,
102};
103use sha2::{Digest, Sha256};
104use m4a_agent::engine::Driver;
105use m4a_agent::{AgentError, Backend};
106use zeroize::Zeroizing;
107
108pub use machine::{
109 ensure_agent_webhook_routines, ensure_agent_webhook_routines_from_env, load_agents_dir,
110 load_session_records, HostSession, MachineClient, RoutineReport, TickReport, WakeOptions,
111 WakeStatus, AGENTS_DIR_ENV, AGENT_RESCAN_SECS_ENV, DEFAULT_AGENTS_DIR,
112 LOCAL_BUS_ENV, PROFILE_NOTE_ENV, SESSIONS_DIR_ENV, SESSION_IDS_ENV, SKIP_NICKS_ENV, STORE_LOCK_FILE,
113 WAKE_KEYCHAIN_FILE, session_gate, account_sessions, REQUIRE_SESSION_CAP_ENV, ACCOUNTS_ROOT_ENV,
114};
115pub use mail4agent_messenger::{
116 CreateRoomKind, DeviceId, MessageKind, MessengerCommand, OutgoingMessage, RoomId, RoomKind,
117 UserId,
118};
119pub use cmd::{CmdReply, CmdRequest};
120pub use mail4agent_messenger::EventId;
122pub use grok_listen::{hear, GrokListener, Heard, ListenReport};
123pub use nick::{nick_from_display_name, routine_folder_id};
124pub use provider::{
125 letter_chain, HostEnv, HookFlavor, ProviderKind, ProviderSession, SessionKind, SessionRecord, Surface,
126 WakeAdapter, WakeChain, WakeError, WakeLetter, WakeOutcome, INBOX_DIR_ENV, PROVIDER_ENV,
127};
128pub use m4a_agent::engine::PushedRoomEvent;
129pub use send::{
130 load_env_file, load_env_file_named, send_cmd_via_socket, send_sock_path, send_sock_path_named,
131 send_via_socket,
132 SendReply, SendRequest, DEFAULT_SOCK_NAME, ENV_FILE_ENV, MAX_SEND_BYTES, SEND_SOCK_ENV,
133};
134
135pub fn store_seal_key(session_id: &str) -> [u8; 32] {
138 let digest = Sha256::digest(session_id.as_bytes());
139 let mut key = [0u8; 32];
140 key.copy_from_slice(&digest);
141 key
142}
143
144pub const STORE_ROOT_ENV: &str = "M4A_STORE_ROOT";
147
148pub const HOMESERVER_URL_ENV: &str = "M4A_HOMESERVER_URL";
152
153pub const CONFIG_ENV: &str = "M4A_CONFIG";
157
158pub const BOT_NAME_ENV: &str = "M4A_BOT_NAME";
161
162pub const SESSION_ID_ENV: &str = "M4A_SESSION_ID";
165
166pub const PRODUCT_URL_ENV: &str = "M4A_PRODUCT_URL";
169pub const PRODUCT_INVITE_ENV: &str = "M4A_PRODUCT_INVITE";
172pub const TIER_ENV: &str = "M4A_TIER";
174#[derive(Clone)]
178#[cfg_attr(not(any(feature = "tier-server", feature = "tier-matrix")), allow(dead_code))]
179struct IdentityAuth {
180 tier: m4a_agent::BackendKind,
181 invite: Option<Zeroizing<String>>,
182 nick_request: Option<String>,
184}
185
186pub fn session_store_dir(root: &Path, session_id: &str) -> PathBuf {
194 root.join(hex_encode(&store_seal_key(session_id)))
195}
196
197pub fn store_root() -> Result<PathBuf, ShellError> {
201 if let Some(root) = nonempty_var(STORE_ROOT_ENV) {
202 return Ok(PathBuf::from(root));
203 }
204 #[cfg(test)]
205 {
206 return Ok(std::env::temp_dir().join(format!(
207 "mail4agent-messenger-shell-root-{}",
208 std::process::id()
209 )));
210 }
211 #[cfg(not(test))]
212 {
213 Err(ShellError::StoreRoot)
214 }
215}
216
217fn hex_encode(bytes: &[u8]) -> String {
218 const HEX: &[u8; 16] = b"0123456789abcdef";
219 let mut out = String::with_capacity(bytes.len() * 2);
220 for byte in bytes {
221 out.push(HEX[(byte >> 4) as usize] as char);
222 out.push(HEX[(byte & 0x0f) as usize] as char);
223 }
224 out
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct WakeAttempt {
231 pub event_id: String,
233 pub status: Option<u16>,
235}
236
237pub struct RoutineWake {
240 pub url: String,
242}
243
244pub const ROUTINE_URL_ENV: &str = "M4A_ROUTINE_URL";
246
247pub const ROUTINE_BEARER_ENV: &str = "M4A_ROUTINE_BEARER";
252
253pub const LEADER_SOCK_ENV: &str = "M4A_LEADER_SOCK";
255
256pub const LEADER_CWD_ENV: &str = "M4A_LEADER_CWD";
259
260pub struct SessionWake {
265 pub routine_url: Option<String>,
267 pub routine_bearer: Option<String>,
270 pub leader_sock: Option<PathBuf>,
272 pub leader_cwd: Option<String>,
274}
275
276impl Default for SessionWake {
277 fn default() -> Self {
278 Self {
279 routine_url: None,
280 routine_bearer: None,
281 leader_sock: None,
282 leader_cwd: None,
283 }
284 }
285}
286
287impl SessionWake {
288 pub fn web_host() -> Self {
292 Self::web_from_lookup(|key| nonempty_var(key))
293 }
294
295 pub fn web_from_lookup(mut get: impl FnMut(&str) -> Option<String>) -> Self {
298 let routine_url = get(ROUTINE_URL_ENV).filter(|value| !value.is_empty());
299 let routine_bearer = if routine_url.is_some() {
300 get(ROUTINE_BEARER_ENV).filter(|value| !value.is_empty())
301 } else {
302 None
303 };
304 Self {
305 routine_url,
306 routine_bearer,
307 leader_sock: None,
308 leader_cwd: None,
309 }
310 }
311
312 pub fn node_cli() -> Result<Self, ShellError> {
316 Self::node_from_lookup(|key| nonempty_var(key))
317 }
318
319 pub fn node_from_lookup(
321 mut get: impl FnMut(&str) -> Option<String>,
322 ) -> Result<Self, ShellError> {
323 let routine_url = get(ROUTINE_URL_ENV).filter(|value| !value.is_empty());
324 let routine_bearer = get(ROUTINE_BEARER_ENV).filter(|value| !value.is_empty());
325 if routine_url.is_some() || routine_bearer.is_some() {
326 return Err(ShellError::NodeRoutine);
327 }
328 Ok(Self {
329 routine_url: None,
330 routine_bearer: None,
331 leader_sock: get(LEADER_SOCK_ENV)
332 .filter(|value| !value.is_empty())
333 .map(PathBuf::from),
334 leader_cwd: get(LEADER_CWD_ENV).filter(|value| !value.is_empty()),
335 })
336 }
337}
338
339pub(crate) fn nonempty_var(name: &str) -> Option<String> {
340 std::env::var(name).ok().filter(|value| !value.is_empty())
341}
342
343pub struct SessionConfig {
347 homeserver_url: String,
348 session_id: String,
349 store_root: PathBuf,
350 identity: IdentityAuth,
351}
352
353impl SessionConfig {
354 pub fn new_identity(
358 product_url: impl Into<String>,
359 tier: m4a_agent::BackendKind,
360 session_id: impl Into<String>,
361 store_root: impl Into<PathBuf>,
362 invite: Option<String>,
363 ) -> Result<Self, ShellError> {
364 let homeserver_url = product_url.into();
365 parse_base_url(&homeserver_url)?;
366 let session_id = session_id.into();
367 validate_session_id(&session_id)?;
368 let store_root = store_root.into();
369 let invite = invite.filter(|i| !i.is_empty()).or_else(|| read_invite_file(&store_root, &session_id));
370 Ok(Self {
371 homeserver_url,
372 session_id,
373 store_root,
374 identity: IdentityAuth { tier, invite: invite.map(Zeroizing::new), nick_request: None },
375 })
376 }
377
378 pub fn with_nick_request(mut self, name: &str) -> Self {
382 let name = name.trim();
383 self.identity.nick_request = if name.contains('.') && crate::nick::is_node_nick(name) { Some(name.to_owned()) } else { nick_from_display_name(name).ok() };
385 self
386 }
387
388 pub fn for_session(url: &str, session_id: &str, store_root: &Path) -> Result<Self, ShellError> {
391 let tier = match nonempty_var(TIER_ENV).as_deref() {
392 None | Some("server") => m4a_agent::BackendKind::Server,
393 Some("matrix") => m4a_agent::BackendKind::Matrix,
394 Some(_) => return Err(ShellError::Register(format!("{TIER_ENV} is server or matrix"))),
395 };
396 Self::new_identity(url, tier, session_id, store_root, None)
397 }
398
399 pub fn from_env() -> Result<Self, ShellError> {
403 let toml_text = load_homeserver_toml()?;
404 Self::from_lookup(
405 |key| std::env::var(key).ok().filter(|value| !value.is_empty()),
406 toml_text.as_deref(),
407 )
408 }
409
410 pub(crate) fn from_lookup(
411 mut get: impl FnMut(&str) -> Option<String>,
412 toml_text: Option<&str>,
413 ) -> Result<Self, ShellError> {
414 let from_toml = match toml_text {
415 Some(text) => homeserver_url_from_toml(text)?,
416 None => None,
417 };
418 let url = get(PRODUCT_URL_ENV)
419 .or_else(|| get(HOMESERVER_URL_ENV))
420 .or(from_toml)
421 .ok_or(ShellError::HomeserverUrl)?;
422 let tier = match get(TIER_ENV).as_deref() {
423 None | Some("server") => m4a_agent::BackendKind::Server,
424 Some("matrix") => m4a_agent::BackendKind::Matrix,
425 Some(_) => return Err(ShellError::Register(format!("{TIER_ENV} is server or matrix"))),
426 };
427 let session_id = get(SESSION_ID_ENV).ok_or(ShellError::EmptySession)?;
428 let store_root = get(STORE_ROOT_ENV).ok_or(ShellError::StoreRoot)?;
429 let config = Self::new_identity(url, tier, session_id, store_root, get(PRODUCT_INVITE_ENV))?;
431 Ok(match get(BOT_NAME_ENV) {
432 Some(name) => config.with_nick_request(&name),
433 None => config,
434 })
435 }
436
437 pub fn homeserver_url(&self) -> &str {
439 &self.homeserver_url
440 }
441
442 pub fn store_dir(&self) -> PathBuf {
444 session_store_dir(&self.store_root, &self.session_id)
445 }
446
447 pub fn session_id(&self) -> &str {
449 &self.session_id
450 }
451}
452
453impl std::fmt::Debug for SessionConfig {
454 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
455 f.debug_struct("SessionConfig")
456 .field("homeserver_url", &self.homeserver_url)
457 .field("session_id", &self.session_id)
458 .field("store_root", &self.store_root)
459 .field("tier", &self.identity.tier)
460 .finish()
461 }
462}
463
464fn invite_path(store_root: &Path, session_id: &str) -> PathBuf {
467 store_root.join("invites").join(hex_encode(&store_seal_key(session_id)))
468}
469
470fn read_invite_file(store_root: &Path, session_id: &str) -> Option<String> {
471 let text = std::fs::read_to_string(invite_path(store_root, session_id)).ok()?;
472 let code = text.trim().to_string();
473 (!code.is_empty()).then_some(code)
474}
475
476fn validate_session_id(session_id: &str) -> Result<(), ShellError> {
477 if session_id.is_empty()
478 || session_id.len() > 128
479 || session_id.starts_with("legacy-user-")
480 || session_id
481 .chars()
482 .any(|ch| ch.is_whitespace() || ch.is_control())
483 {
484 return Err(ShellError::EmptySession);
485 }
486 Ok(())
487}
488
489fn load_homeserver_toml() -> Result<Option<String>, ShellError> {
490 let path = if let Some(configured) = nonempty_var(CONFIG_ENV) {
491 PathBuf::from(configured)
492 } else {
493 let cwd = PathBuf::from("mail4agent.toml");
494 if !cwd.exists() {
495 return Ok(None);
496 }
497 cwd
498 };
499 if !path.is_file() {
500 return Err(ShellError::Config("config file is missing".to_string()));
501 }
502 std::fs::read_to_string(&path)
503 .map(Some)
504 .map_err(|err| ShellError::Config(clip_public(err.to_string())))
505}
506
507fn homeserver_url_from_toml(text: &str) -> Result<Option<String>, ShellError> {
508 #[derive(serde::Deserialize)]
509 struct File {
510 #[serde(default)]
511 homeserver_url: Option<String>,
512 }
513 let file: File =
514 toml::from_str(text).map_err(|err| ShellError::Config(clip_public(err.to_string())))?;
515 Ok(file
516 .homeserver_url
517 .map(|value| value.trim().to_string())
518 .filter(|value| !value.is_empty()))
519}
520
521pub struct FoundSession {
523 pub nick: String,
525 pub user_id: String,
527}
528
529#[derive(Default)]
536pub struct DecryptedWake<'a> {
537 pub body: &'a str,
539 pub from: &'a str,
541 pub nick: Option<&'a str>,
543 pub event_id: &'a str,
545 pub room: Option<&'a str>,
547 pub from_nick: Option<&'a str>,
549 pub to: Option<&'a str>,
551 pub reply: Option<&'a str>,
554}
555
556pub const SEND_COMMAND: &str = "m4a-send";
558
559pub fn reply_hint(to: &str, from_nick: &str) -> String {
561 format!("{SEND_COMMAND} --as {to} --to {from_nick} '<your reply>'")
562}
563
564pub fn mxid_localpart(mxid: &str) -> &str {
566 let rest = mxid.strip_prefix('@').unwrap_or(mxid);
567 rest.split_once(':').map(|(local, _)| local).unwrap_or(rest)
568}
569
570pub struct OpenedStore {
575 dir: PathBuf,
576 driver: Driver<SealedRecordCodec>,
577 device_token: Zeroizing<String>,
578 base_url: String,
579 session_id: String,
582 nick: Option<String>,
585 routine_url: Option<String>,
586 routine_bearer: Option<Zeroizing<String>>,
587 leader_sock: Option<PathBuf>,
588 leader_cwd: Option<String>,
589 routine_sent: HashSet<String>,
591 routine_woken_seed_pending: bool,
595 routine_restart_catchup: bool,
599 reaction_floor: Option<i64>,
602 leader_sent: HashSet<String>,
604 wake_note: Option<String>,
606 wake_log: Vec<WakeAttempt>,
607 wake_retry: HashMap<String, (u32, std::time::Instant)>,
610 wake_chain: Option<(provider::ProviderSession, provider::WakeChain)>,
614 wake_route: Option<String>,
616 bus: Option<Arc<machine::LocalBus>>,
619 local_peers: Vec<(String, String)>,
622 pushed_room_events: Vec<PushedRoomEvent>,
624 history_pulled: HashSet<String>,
627 tip_pulled: HashSet<String>,
630}
631
632struct ZeroJitter;
633
634impl Jitter for ZeroJitter {
635 fn next_unit(&mut self) -> f64 {
636 0.0
637 }
638}
639
640pub struct RoomView {
642 pub room_id: String,
644 pub membership: String,
647 pub encrypted: bool,
649}
650
651pub struct TextView {
653 pub room_id: String,
655 pub body: String,
657 pub outcome: String,
659 pub event_id: Option<String>,
662}
663
664impl OpenedStore {
665 pub fn open(
674 dir: &Path,
675 session_id: &str,
676 device_id: DeviceId,
677 user_id: &str,
678 server_name: &str,
679 backend: Arc<dyn Backend>,
680 device_token: &str,
681 ) -> Result<Self, ShellError> {
682 if session_id.is_empty() {
683 return Err(ShellError::EmptySession);
684 }
685 if device_token.is_empty()
686 || device_token
687 .bytes()
688 .any(|byte| byte.is_ascii_whitespace() || !byte.is_ascii())
689 {
690 return Err(ShellError::DeviceToken);
691 }
692 let base_url = backend.server_ref().to_string();
693 at("create store dir", dir, fs_create_dir(dir))?;
694 let user_id = UserId::parse(user_id)?;
695 let secrets = CoreSecrets {
696 store_seal_key: Some(at("store key", dir, store_key::store_key(dir, session_id))?),
697 backup_key: None,
698 };
699 let config = CoreConfig {
700 user_id,
701 device_id,
702 server_name: server_name.to_string(),
703 };
704 let records = at("read records", dir, read_records(dir))?;
705 let mut core =
706 match MessengerCore::open_sealed(records, secrets, config, 0, Box::new(ZeroJitter)) {
707 Ok(core) => core,
708 Err(MessengerError::Store(err)) => return Err(ShellError::Store(err)),
709 Err(err) => return Err(ShellError::Messenger(err)),
710 };
711 at("persist records", dir, persist(dir, &mut core))?;
712 let mut opened = Self {
713 dir: dir.to_path_buf(),
714 driver: Driver::new(core, backend, {
715 let dir = dir.to_path_buf();
716 Box::new(move |core| persist(&dir, core).map_err(|e| AgentError::Store(clip_public(e.to_string()))))
717 }),
718 base_url,
719 device_token: Zeroizing::new(device_token.to_string()),
720 session_id: session_id.to_string(),
721 nick: None,
722 routine_url: None,
723 routine_bearer: None,
724 leader_sock: None,
725 leader_cwd: None,
726 routine_sent: HashSet::new(),
727 routine_woken_seed_pending: false,
728 routine_restart_catchup: false,
729 reaction_floor: None,
730 leader_sent: HashSet::new(),
731 wake_chain: None,
732 wake_route: None,
733 wake_note: None,
734 wake_log: Vec::new(),
735 wake_retry: HashMap::new(),
736 bus: None,
737 local_peers: Vec::new(),
738 pushed_room_events: Vec::new(),
739 history_pulled: HashSet::new(),
740 tip_pulled: HashSet::new(),
741 };
742 opened.note_already_present();
745 Ok(opened)
746 }
747
748 pub fn connect(config: &SessionConfig) -> Result<Self, ShellError> {
757 Self::connect_with_wake(config, SessionWake::default())
758 }
759
760 pub(crate) fn connect_with_wake(config: &SessionConfig, wake: SessionWake) -> Result<Self, ShellError> {
761 let registered = register_session(config)?;
762 let server_name = registered
763 .user_id
764 .split_once(':')
765 .map(|(_, server)| server)
766 .filter(|server| !server.is_empty())
767 .ok_or(ShellError::Register("user id has no server".to_string()))?;
768 let mut opened = Self::open(
769 &config.store_dir(),
770 &config.session_id,
771 registered.device_id,
772 ®istered.user_id,
773 server_name,
774 registered.backend,
775 ®istered.bearer,
776 )?;
777 opened.nick = Some(registered.nick.clone());
778 opened.set_wake(wake);
779 opened.drive(1_000, false)?;
781 Ok(opened)
782 }
783
784 pub fn connect_from_env() -> Result<Self, ShellError> {
789 Self::connect(&SessionConfig::from_env()?)
790 }
791
792 pub fn connect_node_from_env() -> Result<Self, ShellError> {
797 let wake = SessionWake::node_cli()?;
798 Self::connect_with_wake(&SessionConfig::from_env()?, wake)
799 }
800
801 #[cfg(test)]
802 pub(crate) fn connect_node_from_lookup(
803 mut get: impl FnMut(&str) -> Option<String>,
804 toml_text: Option<&str>,
805 ) -> Result<Self, ShellError> {
806 let wake = SessionWake::node_from_lookup(&mut get)?;
807 let config = SessionConfig::from_lookup(&mut get, toml_text)?;
808 Self::connect_with_wake(&config, wake)
809 }
810
811 pub(crate) fn keep_prefix(&self) -> bool {
813 self.driver.backend().keep_prefix()
814 }
815
816 pub fn device_bearer(&self) -> &str {
817 self.device_token.as_str()
818 }
819
820 pub fn nick(&self) -> Option<&str> {
823 self.nick.as_deref()
824 }
825
826 pub fn session_id(&self) -> &str {
828 &self.session_id
829 }
830
831 pub fn homeserver_url(&self) -> &str {
833 self.base_url.trim_end_matches('/')
834 }
835
836 pub fn has_leader(&self) -> bool {
838 self.leader_sock.is_some()
839 }
840
841 pub fn store_dir(&self) -> &Path {
843 &self.dir
844 }
845
846 pub fn homeserver_hits(&self) -> u64 {
849 self.bus.as_ref().map(|bus| bus.hits()).unwrap_or(0)
850 }
851
852 pub fn pushed_room_events(&self) -> &[PushedRoomEvent] {
854 &self.pushed_room_events
855 }
856
857 pub(crate) fn record_push(&mut self, event: PushedRoomEvent) {
858 self.pushed_room_events.push(event);
859 }
860
861 pub(crate) fn attach_bus(&mut self, bus: Arc<machine::LocalBus>) {
862 let inner = Arc::clone(self.driver.backend());
863 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 }));
864 self.bus = Some(bus);
865 }
866
867 pub(crate) fn set_registered_nick(&mut self, nick: String) {
868 self.nick = Some(nick);
869 }
870
871 pub(crate) fn set_local_peers(&mut self, peers: Vec<(String, String)>) {
872 self.local_peers = peers;
873 }
874
875 pub(crate) fn abandon_inflight_sync(&mut self, now_ms: i64) -> Result<(), ShellError> {
879 if self.driver.sync_inflight() {
880 self.driver.abandon_sync(now_ms);
881 self.persist_core()?;
882 }
883 Ok(())
884 }
885
886 pub fn user_id(&self) -> &str {
888 self.driver.core.user_id().as_str()
889 }
890
891 pub fn member_joined(&self, room_id: &str, user_id: &str) -> bool {
893 let Ok(room_id) = RoomId::parse(room_id) else {
894 return false;
895 };
896 let Ok(user_id) = UserId::parse(user_id) else {
897 return false;
898 };
899 self.driver.core.room_state(&room_id).is_some_and(|state| {
900 state
901 .members
902 .get(&user_id)
903 .is_some_and(|member| member.membership == Membership::Join)
904 })
905 }
906
907 pub fn find_nick(
911 &mut self,
912 name_or_nick: &str,
913 now_ms: i64,
914 ) -> Result<FoundSession, ShellError> {
915 let needle = nick::lookup_nick(name_or_nick)?;
916 if let Some((nick, user_id)) = self
917 .local_peers
918 .iter()
919 .find(|(nick, _)| nick.eq_ignore_ascii_case(&needle))
920 {
921 return Ok(FoundSession {
922 nick: nick.clone(),
923 user_id: user_id.clone(),
924 });
925 }
926 self.dispatch(
927 MessengerCommand::SearchUsers {
928 term: needle.clone(),
929 },
930 now_ms,
931 )?;
932 self.drive(now_ms, false)?;
933 let mut hits: Vec<FoundSession> = self
934 .driver.core
935 .user_search_result()
936 .iter()
937 .filter_map(|entry| {
938 let nick = entry.display_name.as_deref()?.trim();
939 if !nick.eq_ignore_ascii_case(&needle) {
940 return None;
941 }
942 Some(FoundSession {
943 nick: nick.to_string(),
944 user_id: entry.user_id.as_str().to_string(),
945 })
946 })
947 .collect();
948 hits.sort_by(|left, right| left.user_id.cmp(&right.user_id));
949 hits.dedup_by(|left, right| left.user_id == right.user_id);
950 match hits.len() {
951 1 => Ok(hits.remove(0)),
952 0 => Err(ShellError::UnknownNick),
953 _ => Err(ShellError::UnknownNick),
954 }
955 }
956
957 pub fn ensure_dm(&mut self, name_or_nick: &str, mut now_ms: i64) -> Result<String, ShellError> {
961 let (_peer, room_id) = self.open_dm(name_or_nick, &mut now_ms)?;
962 Ok(room_id)
963 }
964
965 pub fn accept_direct_invites(&mut self, now_ms: i64) -> Result<Vec<String>, ShellError> {
971 self.accept_invites(now_ms, &AutoJoinPolicy::from_env())
972 }
973
974 pub fn accept_invites(&mut self, mut now_ms: i64, policy: &AutoJoinPolicy) -> Result<Vec<String>, ShellError> {
976 let me = self.driver.core.user_id().clone();
977 let home = server_of(me.as_str()).to_string();
978 let invited: Vec<RoomId> = self
979 .driver.core
980 .room_ids()
981 .cloned()
982 .filter(|room_id| {
983 let Some(state) = self.driver.core.room_state(room_id) else {
984 return false;
985 };
986 let Some(mine) = state.members.get(&me).filter(|m| m.membership == Membership::Invite) else {
987 return false;
988 };
989 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()))
990 })
991 .collect();
992 let mut ids = Vec::new();
993 for room_id in invited {
994 self.dispatch(
995 MessengerCommand::JoinRoom {
996 room_id: room_id.clone(),
997 },
998 now_ms,
999 )?;
1000 ids.push(room_id.as_str().to_string());
1001 }
1002 if ids.is_empty() {
1003 return Ok(ids);
1004 }
1005 for _ in 0..8 {
1006 now_ms += 1_000;
1007 self.drive(now_ms, false)?;
1008 let still_invited = ids.iter().any(|room_id| {
1009 self.rooms()
1010 .iter()
1011 .any(|room| room.room_id == *room_id && room.membership == "invite")
1012 });
1013 if !still_invited {
1014 break;
1015 }
1016 }
1017 Ok(ids)
1018 }
1019
1020 pub fn write_to_nick(
1029 &mut self,
1030 name_or_nick: &str,
1031 text: &str,
1032 mut now_ms: i64,
1033 ) -> Result<String, ShellError> {
1034 let (peer, room_id) = self.open_dm(name_or_nick, &mut now_ms)?;
1035 if !self.member_joined(&room_id, peer.as_str()) {
1036 return Err(ShellError::Dm);
1037 }
1038 let encrypted = self
1039 .rooms()
1040 .into_iter()
1041 .any(|room| room.room_id == room_id && room.encrypted);
1042 if !encrypted {
1043 return Err(ShellError::Dm);
1044 }
1045 self.dispatch(
1046 MessengerCommand::SendMessage {
1047 room_id: RoomId::parse(&room_id)?,
1048 message: OutgoingMessage {
1049 kind: MessageKind::Text,
1050 body: text.to_string(),
1051 reply_to: None,
1052 edit_of: None,
1053 },
1054 txn_id: None,
1055 },
1056 now_ms,
1057 )?;
1058 for _ in 0..20 {
1059 now_ms += 1_000;
1060 self.drive(now_ms, false)?;
1061 if let Some(row) = self
1062 .texts()
1063 .into_iter()
1064 .find(|row| row.room_id == room_id && row.body == text)
1065 {
1066 if row.outcome == "sent" {
1067 return Ok(room_id);
1068 }
1069 if row.outcome.starts_with("failed") {
1070 return Err(ShellError::Dm);
1071 }
1072 }
1073 }
1074 Err(ShellError::Dm)
1075 }
1076
1077 fn open_dm(
1078 &mut self,
1079 name_or_nick: &str,
1080 now_ms: &mut i64,
1081 ) -> Result<(UserId, String), ShellError> {
1082 let found = self.find_nick(name_or_nick, *now_ms)?;
1083 let peer = UserId::parse(&found.user_id)?;
1084 if &peer == self.driver.core.user_id() {
1085 return Err(ShellError::UnknownNick);
1086 }
1087 let room_id = if let Some(room_id) = self.dm_room(&peer) {
1088 room_id
1089 } else {
1090 self.dispatch(
1091 MessengerCommand::CreateRoom {
1092 kind: CreateRoomKind::Dm { peer: peer.clone() },
1093 },
1094 *now_ms,
1095 )?;
1096 self.wait_for_dm(&peer, now_ms)?
1097 };
1098 Ok((peer, room_id))
1099 }
1100
1101 fn wait_for_dm(&mut self, peer: &UserId, now_ms: &mut i64) -> Result<String, ShellError> {
1102 for attempt in 0..6 {
1103 *now_ms += 1_000;
1104 self.drive(*now_ms, attempt == 5)?;
1105 if let Some(room_id) = self.dm_room(peer) {
1106 return Ok(room_id);
1107 }
1108 }
1109 Err(ShellError::Dm)
1110 }
1111
1112 fn dm_room(&self, peer: &UserId) -> Option<String> {
1113 let me = self.driver.core.user_id().clone();
1114 let ids: Vec<RoomId> = self.driver.core.room_ids().cloned().collect();
1115 for room_id in &ids {
1116 let (joined, has_peer) = {
1117 let Some(state) = self.driver.core.room_state(room_id) else {
1118 continue;
1119 };
1120 let joined = state
1121 .members
1122 .get(&me)
1123 .is_some_and(|member| member.membership == Membership::Join);
1124 let has_peer = state.members.contains_key(peer);
1125 (joined, has_peer)
1126 };
1127 if !joined || !has_peer {
1128 continue;
1129 }
1130 if self.driver.core.room_kind(room_id) == Some(RoomKind::Dm) {
1131 return Some(room_id.as_str().to_string());
1132 }
1133 }
1134 None
1135 }
1136
1137 pub fn set_wake(&mut self, wake: SessionWake) {
1142 self.routine_url = wake.routine_url.filter(|url| !url.is_empty());
1143 self.routine_bearer = wake
1144 .routine_bearer
1145 .filter(|token| !token.is_empty())
1146 .map(Zeroizing::new);
1147 self.leader_sock = wake.leader_sock.filter(|path| !path.as_os_str().is_empty());
1148 self.leader_cwd = wake.leader_cwd.filter(|cwd| !cwd.is_empty());
1149 }
1150
1151 pub fn set_wake_chain(
1155 &mut self,
1156 session: provider::ProviderSession,
1157 chain: provider::WakeChain,
1158 ) {
1159 self.wake_chain = Some((session, chain));
1160 }
1161
1162 pub fn wake_chain_ids(&self) -> Vec<&'static str> {
1164 self.wake_chain
1165 .as_ref()
1166 .map(|(_, chain)| chain.ids())
1167 .unwrap_or_default()
1168 }
1169
1170 pub fn wake_route(&self) -> Option<&str> {
1172 self.wake_route.as_deref()
1173 }
1174
1175 pub fn take_wake_route(&mut self) -> Option<String> {
1177 self.wake_route.take()
1178 }
1179
1180 pub fn has_routine(&self) -> bool {
1182 self.routine_url.is_some()
1183 }
1184
1185 pub fn wake_note(&self) -> Option<&str> {
1189 self.wake_note.as_deref()
1190 }
1191
1192 pub fn wake_log(&self) -> &[WakeAttempt] {
1195 &self.wake_log
1196 }
1197
1198 pub fn dispatch(&mut self, command: MessengerCommand, now_ms: i64) -> Result<(), ShellError> {
1201 self.driver.core.dispatch(command, now_ms)?;
1202 self.persist_core()?;
1203 Ok(())
1204 }
1205
1206 pub fn send_room_message(
1213 &mut self,
1214 room_id: &str,
1215 text: &str,
1216 now_ms: i64,
1217 ) -> Result<Vec<OutgoingRequest>, ShellError> {
1218 let room_id = RoomId::parse(room_id)?;
1219 self.driver.core.dispatch(
1220 MessengerCommand::SendMessage {
1221 room_id,
1222 message: OutgoingMessage {
1223 kind: MessageKind::Text,
1224 body: text.to_string(),
1225 reply_to: None,
1226 edit_of: None,
1227 },
1228 txn_id: None,
1229 },
1230 now_ms,
1231 )?;
1232 self.persist_core()?;
1233 let mut released = self.driver.core.releasable_requests(now_ms);
1234 self.persist_core()?;
1235 if !released
1236 .iter()
1237 .any(|request| request.kind == OutgoingRequestKind::RoomSend)
1238 {
1239 released.extend(self.driver.core.releasable_requests(now_ms));
1240 self.persist_core()?;
1241 }
1242 Ok(released)
1243 }
1244
1245 pub fn drive(&mut self, now_ms: i64, wait_for_sync: bool) -> Result<(), ShellError> {
1255 self.driver.core.decrypt_loaded_timeline();
1256 if self.reaction_floor.is_none() {
1257 let path = self.store_dir().join("reaction-wake-floor");
1260 let _ = now_ms;
1262 let floor = std::fs::read_to_string(&path).ok().and_then(|t| t.trim().parse::<i64>().ok()).unwrap_or_else(|| {
1263 let wall = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0);
1264 let _ = std::fs::write(&path, wall.to_string());
1265 wall
1266 });
1267 self.reaction_floor = Some(floor);
1268 }
1269 let trace_at = self.driver.http_trace.len();
1270 let history = self.request_missing_history(now_ms)?;
1271 self.pull_room_tips()?;
1272 let mut waited_long_poll = false;
1273 if wait_for_sync && self.driver.sync_inflight() {
1274 waited_long_poll = self.driver.harvest_sync(now_ms, true)?;
1277 } else {
1278 self.driver.harvest_sync(now_ms, false)?;
1279 }
1280 self.wake_inbound();
1281 for _ in 0..24 {
1282 if !self.driver.step(now_ms, wait_for_sync, &mut waited_long_poll)? {
1283 break;
1284 }
1285 self.wake_inbound();
1286 }
1287 self.note_history_pages(&history, trace_at);
1288 self.note_room_tips();
1289 self.routine_restart_catchup = false;
1290 Ok(())
1291 }
1292
1293 fn pull_room_tips(&mut self) -> Result<(), ShellError> {
1296 let me = self.driver.core.user_id().clone();
1297 let ids: Vec<RoomId> = self.driver.core.room_ids().cloned().collect();
1298 for room_id in ids {
1299 if self.tip_pulled.contains(room_id.as_str()) {
1300 continue;
1301 }
1302 if self
1303 .driver.core
1304 .timeline(&room_id)
1305 .is_some_and(|timeline| !timeline.items().is_empty())
1306 {
1307 self.tip_pulled.insert(room_id.as_str().to_string());
1308 continue;
1309 }
1310 let joined = self.driver.core.room_state(&room_id).is_some_and(|state| {
1311 state
1312 .members
1313 .get(&me)
1314 .is_some_and(|member| matches!(member.membership, Membership::Join))
1315 });
1316 if !joined {
1317 continue;
1318 }
1319 self.driver.core.pull_latest_page(room_id)?;
1320 }
1321 Ok(())
1322 }
1323
1324 fn note_room_tips(&mut self) {
1325 let ids: Vec<RoomId> = self.driver.core.room_ids().cloned().collect();
1326 for room_id in ids {
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 }
1334 }
1335 }
1336
1337 fn request_missing_history(&mut self, now_ms: i64) -> Result<Vec<String>, ShellError> {
1341 let me = self.driver.core.user_id().clone();
1342 let ids: Vec<RoomId> = self.driver.core.room_ids().cloned().collect();
1343 let mut requested = Vec::new();
1344 for room_id in ids {
1345 if !self.room_needs_history(&room_id, &me) {
1346 continue;
1347 }
1348 let label = room_id.as_str().to_string();
1349 self.driver.core
1350 .dispatch(MessengerCommand::LoadOlder { room_id }, now_ms)?;
1351 requested.push(label);
1352 }
1353 Ok(requested)
1354 }
1355
1356 fn room_needs_history(&self, room_id: &RoomId, me: &UserId) -> bool {
1357 if self.history_pulled.contains(room_id.as_str()) {
1358 return false;
1359 }
1360 let joined = match self
1361 .driver.core
1362 .room_state(room_id)
1363 .and_then(|state| state.members.get(me))
1364 {
1365 Some(member) => matches!(member.membership, Membership::Join),
1366 None => false,
1367 };
1368 if !joined {
1369 return false;
1370 }
1371 match self.driver.core.timeline(room_id) {
1372 Some(timeline) => timeline.items().is_empty(),
1373 None => true,
1374 }
1375 }
1376
1377 fn note_history_pages(&mut self, requested: &[String], trace_at: usize) {
1378 if requested.is_empty() {
1379 return;
1380 }
1381 let ok = self.driver.http_trace[trace_at..]
1382 .iter()
1383 .filter(|(kind, status)| {
1384 *kind == OutgoingRequestKind::RoomMessages && (200..300).contains(status)
1385 })
1386 .count();
1387 if ok < requested.len() {
1388 return;
1389 }
1390 for room_id in requested {
1391 self.history_pulled.insert(room_id.clone());
1392 }
1393 }
1394
1395 pub fn rooms(&self) -> Vec<RoomView> {
1397 let me = self.driver.core.user_id().clone();
1398 let mut rooms: Vec<RoomView> = self
1399 .driver.core
1400 .room_ids()
1401 .map(|room_id| {
1402 let state = self.driver.core.room_state(room_id);
1403 let membership = state
1404 .and_then(|state| state.members.get(&me))
1405 .map(|member| membership_name(&member.membership).to_string())
1406 .unwrap_or_else(|| "absent".to_string());
1407 let encrypted = state.and_then(|state| state.encryption.as_ref()).is_some();
1408 RoomView {
1409 room_id: room_id.as_str().to_string(),
1410 membership,
1411 encrypted,
1412 }
1413 })
1414 .collect();
1415 rooms.sort_by(|left, right| left.room_id.cmp(&right.room_id));
1416 rooms
1417 }
1418
1419 pub fn texts(&self) -> Vec<TextView> {
1422 let mut out = Vec::new();
1423 for room_id in self.driver.core.room_ids() {
1424 let Some(timeline) = self.driver.core.timeline(room_id) else {
1425 continue;
1426 };
1427 for item in timeline.items() {
1428 match &item.content {
1429 ItemContent::Text(text)
1430 | ItemContent::Notice(text)
1431 | ItemContent::Emote(text) => {
1432 out.push(TextView {
1433 room_id: room_id.as_str().to_string(),
1434 body: text.body.clone(),
1435 outcome: outcome_name(&item.send_state),
1436 event_id: item.event_id.as_ref().map(|id| id.as_str().to_string()),
1437 });
1438 }
1439 ItemContent::Undecryptable { reason } => out.push(TextView {
1440 room_id: room_id.as_str().to_string(),
1441 body: String::new(),
1442 outcome: format!("undecryptable:{reason}"),
1443 event_id: item.event_id.as_ref().map(|id| id.as_str().to_string()),
1444 }),
1445 _ => {}
1446 }
1447 }
1448 }
1449 out
1450 }
1451
1452 pub fn sync_inflight(&self) -> bool {
1454 self.driver.sync_inflight()
1455 }
1456
1457 pub fn http_trace(&self) -> Vec<String> {
1458 self.driver.http_trace
1459 .iter()
1460 .map(|(kind, status)| format!("{kind:?} {status}"))
1461 .collect()
1462 }
1463
1464 pub fn take_ingest_error(&mut self) -> Option<String> {
1466 self.driver.core.take_ingest_error().map(clip_public)
1467 }
1468
1469 fn persist_core(&mut self) -> Result<(), ShellError> {
1470 self.driver.persist().map_err(ShellError::from)
1471 }
1472
1473 pub fn take_security_alerts(&mut self) -> Vec<String> {
1475 std::mem::take(&mut self.driver.security_alerts)
1476 }
1477
1478 fn inbound_plaintexts(&self) -> Vec<InboundPlaintext> {
1479 let me = self.driver.core.user_id();
1480 let mut out = Vec::new();
1481 for room_id in self.driver.core.room_ids() {
1482 let Some(timeline) = self.driver.core.timeline(room_id) else {
1483 continue;
1484 };
1485 for item in timeline.items() {
1486 if item.redacted || &item.sender == me || item.send_state != SendState::Sent {
1487 continue;
1488 }
1489 let mut body = match &item.content {
1490 ItemContent::Text(text)
1491 | ItemContent::Notice(text)
1492 | ItemContent::Emote(text) => text.body.clone(),
1493 _ => continue,
1494 };
1495 if let Some(edit) = &item.relations.latest_edit {
1497 if let mail4agent_messenger::wire::events::RoomMessageContent::Text(t)
1498 | mail4agent_messenger::wire::events::RoomMessageContent::Notice(t)
1499 | mail4agent_messenger::wire::events::RoomMessageContent::Emote(t) = &edit.content
1500 {
1501 body = t.body.clone();
1502 }
1503 }
1504 let Some(event_id) = item.event_id.as_ref() else {
1505 continue;
1506 };
1507 let key = format!("{}\n{}", room_id.as_str(), event_id.as_str());
1508 out.push(InboundPlaintext {
1509 room_id: room_id.as_str().to_string(),
1510 key,
1511 body,
1512 from: item.sender.as_str().to_string(),
1513 nick: self.sender_nick(room_id, &item.sender),
1514 event_id: event_id.as_str().to_string(),
1515 reaction: None,
1516 ts: item.origin_server_ts,
1517 });
1518 }
1519 }
1520 out
1521 }
1522
1523 fn reaction_items(&self, floor: i64) -> Vec<InboundPlaintext> {
1527 let me = self.driver.core.user_id();
1528 let mut out = Vec::new();
1529 for room_id in self.driver.core.room_ids() {
1530 let Some(timeline) = self.driver.core.timeline(room_id) else { continue };
1531 let direct = self.room_is_direct(room_id.as_str());
1532 for item in timeline.items() {
1533 let Some(target) = item.event_id.as_ref() else { continue };
1534 if item.redacted || !(direct || &item.sender == me) {
1535 continue;
1536 }
1537 let snippet = match &item.content {
1538 ItemContent::Text(t) | ItemContent::Notice(t) | ItemContent::Emote(t) => {
1539 let one: String = t.body.split_whitespace().collect::<Vec<_>>().join(" ");
1540 if one.chars().count() > 80 { format!("{}…", one.chars().take(80).collect::<String>()) } else { one }
1541 }
1542 _ => String::new(),
1543 };
1544 for r in timeline.relating_events_for(target) {
1545 let mail4agent_messenger::wire::events::RelatesTo::Annotation { key, .. } = &r.relation else { continue };
1546 if &r.sender == me || r.origin_server_ts <= floor {
1547 continue;
1548 }
1549 let who = mxid_localpart(r.sender.as_str()).to_string();
1550 let whose = if &item.sender == me { "your message".to_string() } else { format!("{}'s message", mxid_localpart(item.sender.as_str())) };
1551 out.push(InboundPlaintext {
1552 room_id: room_id.as_str().to_string(),
1553 key: format!("{}\n{}", room_id.as_str(), r.event_id.as_str()),
1554 body: format!("{who} reacted {key} to {whose}: \"{snippet}\""),
1555 from: r.sender.as_str().to_string(),
1556 nick: None,
1557 event_id: r.event_id.as_str().to_string(),
1558 reaction: Some((key.clone(), target.as_str().to_string())),
1559 ts: r.origin_server_ts,
1560 });
1561 }
1562 }
1563 }
1564 out
1565 }
1566
1567 fn sender_nick(&self, room_id: &RoomId, sender: &UserId) -> Option<String> {
1571 let name = self
1572 .driver.core
1573 .room_state(room_id)?
1574 .members
1575 .get(sender)?
1576 .displayname
1577 .as_deref()?
1578 .trim();
1579 if name.is_empty() {
1580 None
1581 } else {
1582 Some(name.to_string())
1583 }
1584 }
1585
1586 fn note_already_present(&mut self) {
1587 self.load_routine_woken();
1588 self.load_leader_prompted();
1589 }
1590
1591 fn load_routine_woken(&mut self) {
1594 let path = self.dir.join(ROUTINE_WOKEN_FILE);
1595 match std::fs::read_to_string(&path) {
1596 Ok(text) if !text.trim().is_empty() => {
1597 for line in text.lines() {
1598 let Some((room, event)) = line.split_once('\t') else {
1599 continue;
1600 };
1601 if room.is_empty() || event.is_empty() {
1602 continue;
1603 }
1604 self.routine_sent.insert(format!("{room}\n{event}"));
1605 }
1606 self.routine_woken_seed_pending = false;
1607 self.routine_restart_catchup = true;
1610 }
1611 Ok(_) | Err(_) => {
1612 self.routine_woken_seed_pending = true;
1614 self.routine_restart_catchup = false;
1615 }
1616 }
1617 }
1618
1619 fn seed_routine_woken_from_timeline(&mut self) {
1624 let path = self.dir.join(ROUTINE_WOKEN_FILE);
1625 let items = self.inbound_plaintexts();
1626 let mut newest: std::collections::HashMap<String, String> =
1627 std::collections::HashMap::new();
1628 for item in &items {
1629 newest.insert(item.room_id.clone(), item.key.clone());
1630 }
1631 let mut lines = String::new();
1632 for item in &items {
1633 if newest.get(&item.room_id) == Some(&item.key) {
1634 continue;
1635 }
1636 self.routine_sent.insert(item.key.clone());
1637 if let Some((room, event)) = item.key.split_once('\n') {
1638 lines.push_str(room);
1639 lines.push('\t');
1640 lines.push_str(event);
1641 lines.push('\n');
1642 }
1643 }
1644 let _ = std::fs::write(path, lines);
1645 self.routine_woken_seed_pending = false;
1646 }
1647
1648 fn remember_routine_wake(&mut self, key: &str) {
1649 self.routine_sent.insert(key.to_string());
1650 let Some((room, event)) = key.split_once('\n') else {
1651 return;
1652 };
1653 if room.is_empty() || event.is_empty() {
1654 return;
1655 }
1656 let path = self.dir.join(ROUTINE_WOKEN_FILE);
1657 let mut file = match std::fs::OpenOptions::new()
1658 .create(true)
1659 .append(true)
1660 .open(&path)
1661 {
1662 Ok(file) => file,
1663 Err(_) => return,
1664 };
1665 use std::io::Write;
1666 let _ = write!(file, "{room}\t{event}\n");
1667 }
1668
1669 fn load_leader_prompted(&mut self) {
1673 let path = self.dir.join(LEADER_PROMPTED_FILE);
1674 match std::fs::read_to_string(&path) {
1675 Ok(text) => {
1676 for line in text.lines() {
1677 let Some((room, event)) = line.split_once('\t') else {
1678 continue;
1679 };
1680 if room.is_empty() || event.is_empty() {
1681 continue;
1682 }
1683 self.leader_sent.insert(format!("{room}\n{event}"));
1684 }
1685 }
1686 Err(_) => self.seed_leader_prompted_except_newest(&path),
1687 }
1688 }
1689
1690 fn seed_leader_prompted_except_newest(&mut self, path: &Path) {
1691 let items = self.inbound_plaintexts();
1692 let mut newest: std::collections::HashMap<String, String> =
1693 std::collections::HashMap::new();
1694 for item in &items {
1695 newest.insert(item.room_id.clone(), item.key.clone());
1696 }
1697 let mut lines = String::new();
1698 for item in &items {
1699 if newest.get(&item.room_id) == Some(&item.key) {
1700 continue;
1701 }
1702 self.leader_sent.insert(item.key.clone());
1703 if let Some((room, event)) = item.key.split_once('\n') {
1704 lines.push_str(room);
1705 lines.push('\t');
1706 lines.push_str(event);
1707 lines.push('\n');
1708 }
1709 }
1710 let _ = std::fs::write(path, lines);
1711 }
1712
1713 fn remember_leader_prompt(&mut self, key: &str) {
1714 self.leader_sent.insert(key.to_string());
1715 let Some((room, event)) = key.split_once('\n') else {
1716 return;
1717 };
1718 if room.is_empty() || event.is_empty() {
1719 return;
1720 }
1721 let path = self.dir.join(LEADER_PROMPTED_FILE);
1722 let mut file = match std::fs::OpenOptions::new()
1723 .create(true)
1724 .append(true)
1725 .open(&path)
1726 {
1727 Ok(file) => file,
1728 Err(_) => return,
1729 };
1730 use std::io::Write;
1731 let _ = write!(file, "{room}\t{event}\n");
1732 }
1733
1734 fn arm_unprompted_newest(&mut self) {
1742 if (self.leader_sock.is_none() && self.wake_chain.is_none()) || !self.leader_sent.is_empty() {
1743 return;
1744 }
1745 if self.inbound_plaintexts().is_empty() {
1746 return;
1747 }
1748 let path = self.dir.join(LEADER_PROMPTED_FILE);
1749 self.seed_leader_prompted_except_newest(&path);
1750 }
1751
1752 fn room_is_direct(&self, room_id: &str) -> bool {
1754 let Some(id) = self.driver.core.room_ids().find(|r| r.as_str() == room_id) else { return false };
1755 let Some(state) = self.driver.core.room_state(id) else { return false };
1756 let live = state.members.values().filter(|m| matches!(membership_name(&m.membership), "join" | "invite")).count();
1757 state.name.is_none() && live <= 2
1758 }
1759
1760 fn default_letter_chain(&self) -> (provider::ProviderSession, provider::WakeChain) {
1764 let kind = if self.routine_url.is_some() && self.leader_sock.is_none() {
1765 provider::SessionKind::web(provider::ProviderKind::Grok)
1766 } else {
1767 provider::SessionKind::local(provider::ProviderKind::Grok)
1768 };
1769 let session = provider::ProviderSession {
1770 kind,
1771 session_id: self.session_id.clone(),
1772 nick: self.nick.clone().unwrap_or_default(),
1773 cwd: self.leader_cwd.clone().map(PathBuf::from),
1774 headless: false,
1775 };
1776 let root = self.dir.parent().map(Path::to_path_buf);
1777 let inbox = root.map(|r| provider::registry::inbox_dir(&r, &self.session_id));
1778 let mut config = provider::AdapterConfig::from_env(inbox.clone());
1779 config.routine_url = self.routine_url.clone();
1780 config.routine_bearer = self.routine_bearer.as_ref().map(|b| b.as_str().to_owned());
1781 if let Some(sock) = &self.leader_sock {
1782 config.leader_sock = Some(PathBuf::from(sock));
1783 }
1784 (session.clone(), crate::provider::letter_chain(kind, inbox, &config))
1785 }
1786
1787 fn schedule_wake_retry(&mut self, key: &str) {
1790 let n = self.wake_retry.get(key).map(|(n, _)| *n + 1).unwrap_or(1);
1791 let secs = (30u64 << (n - 1).min(5)).min(900);
1792 self.wake_retry.insert(key.to_owned(), (n, std::time::Instant::now() + std::time::Duration::from_secs(secs)));
1793 }
1794
1795 pub fn pending_wake_retries(&self) -> usize {
1797 self.wake_retry.len()
1798 }
1799
1800 fn wake_inbound(&mut self) {
1801 if self.routine_url.is_none() && self.leader_sock.is_none() && self.wake_chain.is_none() {
1802 return;
1803 }
1804 if self.routine_restart_catchup && self.routine_url.is_some() {
1805 for item in self.inbound_plaintexts() {
1808 if !self.routine_sent.contains(&item.key) {
1809 self.remember_routine_wake(&item.key);
1810 self.remember_leader_prompt(&item.key);
1811 }
1812 }
1813 } else if self.routine_woken_seed_pending && self.routine_url.is_some() {
1814 if !self.inbound_plaintexts().is_empty() {
1815 self.seed_routine_woken_from_timeline();
1816 }
1817 }
1818 self.arm_unprompted_newest();
1819 let policy = crate::wake_policy::WakePolicy::load(self.store_dir());
1820 let me_id = self.driver.core.user_id().as_str().to_string();
1821 let mut items = self.inbound_plaintexts();
1822 items.retain(|item| {
1825 let direct = self.room_is_direct(&item.room_id);
1826 let mentioned = crate::wake_policy::addresses(&item.body, self.nick.as_deref(), &me_id);
1827 if policy.allows(direct, mentioned) {
1828 return true;
1829 }
1830 self.remember_routine_wake(&item.key);
1831 self.remember_leader_prompt(&item.key);
1832 false
1833 });
1834 let floor = self.reaction_floor.unwrap_or(i64::MAX);
1836 let reactions = self.reaction_items(floor);
1837 items.extend(reactions.into_iter().filter(|r| policy.allows(self.room_is_direct(&r.room_id), true)));
1838 for item in items {
1839 if self.leader_sent.contains(&item.key) || self.routine_sent.contains(&item.key) {
1840 continue;
1841 }
1842 let retry = self.wake_retry.get(&item.key).copied();
1843 if retry.is_some_and(|(_, next)| std::time::Instant::now() < next) {
1844 continue;
1845 }
1846 let (session, mut chain) = match self.wake_chain.take() {
1847 Some(installed) => installed,
1848 None => self.default_letter_chain(),
1849 };
1850 let from_nick = mxid_localpart(&item.from).to_string();
1851 let letter = provider::WakeLetter {
1852 body: &item.body,
1853 from_nick: &from_nick,
1854 event_id: &item.event_id,
1855 room: Some(&item.room_id),
1856 reaction: item.reaction.as_ref().map(|(key, target)| provider::ReactionNote { key, target }),
1857 };
1858 let result = chain.wake(&session, &letter);
1861 match result {
1862 Ok((id, outcome)) => {
1863 let delivered = matches!(outcome, provider::WakeOutcome::Delivered);
1864 let fell_back = !delivered && chain.ids().first().is_some_and(|first| *first != id);
1867 if fell_back {
1868 self.schedule_wake_retry(&item.key);
1869 self.wake_note = Some(format!("wake {} queued; live deliver will be retried", item.event_id));
1870 crate::wake_policy::note_attempt(self.store_dir(), None);
1871 self.wake_log.push(WakeAttempt { event_id: item.event_id.clone(), status: None });
1872 } else {
1873 self.wake_retry.remove(&item.key);
1874 self.remember_leader_prompt(&item.key);
1875 self.remember_routine_wake(&item.key);
1876 let status = delivered.then_some(200);
1877 crate::wake_policy::note_attempt(self.store_dir(), status);
1878 self.wake_log.push(WakeAttempt { event_id: item.event_id.clone(), status });
1879 }
1880 self.wake_route = Some(format!("{id} {}", if delivered { "delivered" } else { "queued" }));
1881 }
1882 Err(err) => {
1883 if retry.is_some() {
1884 self.schedule_wake_retry(&item.key);
1885 }
1886 self.wake_note = Some(clip_public(err.to_string()));
1887 }
1888 }
1889 self.wake_chain = Some((session, chain));
1890 }
1891 }
1892}
1893
1894#[allow(dead_code)]
1895struct InboundPlaintext {
1896 room_id: String,
1897 key: String,
1898 body: String,
1899 from: String,
1900 nick: Option<String>,
1901 event_id: String,
1902 reaction: Option<(String, String)>,
1904 ts: i64,
1905}
1906
1907fn membership_name(membership: &Membership) -> &'static str {
1908 match membership {
1909 Membership::Invite => "invite",
1910 Membership::Join => "join",
1911 Membership::Knock => "knock",
1912 Membership::Leave => "leave",
1913 Membership::Ban => "ban",
1914 Membership::Unknown => "unknown",
1915 }
1916}
1917
1918fn outcome_name(state: &SendState) -> String {
1919 match state {
1920 SendState::Sent => "sent".to_string(),
1921 SendState::Sending | SendState::LocalEcho => "sending".to_string(),
1922 SendState::Failed { reason } => format!("failed:{reason}"),
1923 }
1924}
1925
1926fn parse_base_url(raw: &str) -> Result<reqwest::Url, ShellError> {
1927 let url = reqwest::Url::parse(raw).map_err(|_| ShellError::BaseUrl)?;
1928 match url.scheme() {
1929 "http" | "https" => {}
1930 _ => return Err(ShellError::BaseUrl),
1931 }
1932 if url.host_str().is_none()
1933 || url.query().is_some()
1934 || url.fragment().is_some()
1935 || !url.username().is_empty()
1936 || url.password().is_some()
1937 {
1938 return Err(ShellError::BaseUrl);
1939 }
1940 Ok(url)
1941}
1942
1943struct RegisteredSession {
1944 user_id: String,
1945 device_id: DeviceId,
1946 bearer: Zeroizing<String>,
1947 nick: String,
1949 backend: Arc<dyn Backend>,
1951}
1952
1953#[cfg(any(feature = "tier-server", feature = "tier-matrix"))]
1955fn identity_session(config: &SessionConfig, auth: &IdentityAuth) -> Result<RegisteredSession, ShellError> {
1956 at("open identity", &config.store_root, identity_session_inner(config, auth))
1957}
1958
1959#[cfg(any(feature = "tier-server", feature = "tier-matrix"))]
1960fn identity_session_inner(config: &SessionConfig, auth: &IdentityAuth) -> Result<RegisteredSession, ShellError> {
1961 let fail = |e: AgentError| ShellError::Register(clip_public(e.to_string()));
1962 parse_base_url(&config.homeserver_url)?;
1963 let ids = m4a_agent::IdentityStore::new(store_key::vault(&config.store_root)?);
1964 let backend: Arc<dyn Backend> = match auth.tier {
1965 #[cfg(feature = "tier-server")]
1966 m4a_agent::BackendKind::Server => Arc::new(m4a_agent::backend::server::ServerBackend::new(&config.homeserver_url).map_err(fail)?),
1967 #[cfg(feature = "tier-matrix")]
1970 m4a_agent::BackendKind::Matrix => Arc::new(m4a_agent::backend::matrix::MatrixBackend::discover(&config.homeserver_url).map_err(fail)?),
1971 #[allow(unreachable_patterns)]
1972 _ => return Err(ShellError::Register("this build does not include that tier".into())),
1973 };
1974 let mut id = ids.resolve(&config.session_id, auth.tier, backend.server_ref()).map_err(fail)?;
1975 if !id.enrolled && id.requested_nick != auth.nick_request {
1976 id.requested_nick = auth.nick_request.clone();
1977 }
1978 let session = backend.ensure_session(&ids, &mut id, auth.invite.as_ref().map(|i| i.as_str())).map_err(fail)?;
1979 let _ = std::fs::remove_file(invite_path(&config.store_root, &config.session_id));
1981 ids.adopt_nick(&mut id, &session.nick).map_err(fail)?;
1982 let (user_id, device_raw) = match (&session.user_id, &session.device_id) {
1983 (Some(u), Some(d)) => (u.clone(), d.clone()),
1984 _ => {
1985 backend.whoami().map_err(fail)?
1986 }
1987 };
1988 if user_id.is_empty() || device_raw.is_empty() {
1989 return Err(ShellError::Register("whoami missed an id".into()));
1990 }
1991 Ok(RegisteredSession { user_id, device_id: DeviceId::parse(&device_raw)?, bearer: session.token.clone(), nick: session.nick, backend })
1992}
1993
1994#[cfg(not(any(feature = "tier-server", feature = "tier-matrix")))]
1995fn identity_session(_: &SessionConfig, _: &IdentityAuth) -> Result<RegisteredSession, ShellError> {
1996 Err(ShellError::Register("this build has no tier for identity login".into()))
1997}
1998
1999fn register_session(config: &SessionConfig) -> Result<RegisteredSession, ShellError> {
2000 identity_session(config, &config.identity)
2001}
2002
2003fn clip_public(text: String) -> String {
2004 let mut out = String::new();
2005 for token in text.split_whitespace() {
2006 if token.len() > 80 {
2007 out.push_str("[omitted]");
2008 } else {
2009 out.push_str(token);
2010 }
2011 out.push(' ');
2012 if out.len() > 240 {
2013 break;
2014 }
2015 }
2016 out
2017}
2018
2019pub(crate) fn at<T>(step: &str, path: &Path, r: Result<T, ShellError>) -> Result<T, ShellError> {
2021 r.map_err(|e| match e {
2022 ShellError::Io(io) => ShellError::Io(std::io::Error::new(io.kind(), format!("{step} at {}: {io}", path.display()))),
2023 other => other,
2024 })
2025}
2026
2027fn fs_create_dir(dir: &Path) -> Result<(), ShellError> {
2028 std::fs::create_dir_all(dir)?;
2029 Ok(())
2030}
2031
2032#[derive(Debug, thiserror::Error)]
2035pub enum ShellError {
2036 #[error("session id is empty")]
2038 EmptySession,
2039 #[error("store root is unset")]
2041 StoreRoot,
2042 #[error("homeserver url is unset")]
2044 HomeserverUrl,
2045 #[error("bot display name is unset")]
2047 BotName,
2048 #[error("nick could not be derived from the bot display name")]
2050 Nick,
2051 #[error("session list: {0}")]
2053 SessionList(String),
2054 #[error("homeserver register failed: {0}")]
2056 Register(String),
2057 #[error("no session with that nick")]
2059 UnknownNick,
2060 #[error("direct room was not ready")]
2062 Dm,
2063 #[error("config: {0}")]
2065 Config(String),
2066 #[error("device token is empty or not a single header value")]
2068 DeviceToken,
2069 #[error("base url must be http or https")]
2071 BaseUrl,
2072 #[error("record key escapes the store directory")]
2074 BadRecordKey,
2075 #[error("routine url is not an http or https url")]
2077 RoutineUrl,
2078 #[error("node cli does not take a routine url")]
2080 NodeRoutine,
2081 #[error("gateway: {0}")]
2083 Gateway(String),
2084 #[error("routine bearer is empty or not a single header value")]
2086 RoutineBearer,
2087 #[error("routine status {0}")]
2089 RoutineStatus(u16),
2090 #[error("routine post failed: {0}")]
2092 RoutineTransport(String),
2093 #[error("homeserver http failed: {0}")]
2095 Http(String),
2096 #[error("homeserver response was not ingested: {0}")]
2098 Ingest(String),
2099 #[error("io: {0}")]
2101 Io(#[from] std::io::Error),
2102 #[error(transparent)]
2104 Store(#[from] StoreError),
2105 #[error(transparent)]
2107 Messenger(#[from] MessengerError),
2108}
2109
2110impl From<AgentError> for ShellError {
2111 fn from(err: AgentError) -> Self {
2112 match err {
2113 AgentError::Transport(text) => ShellError::Http(text),
2114 AgentError::Ingest(text) => ShellError::Ingest(text),
2115 other => ShellError::Register(clip_public(other.to_string())),
2116 }
2117 }
2118}
2119
2120fn persist(dir: &Path, core: &mut MessengerCore<SealedRecordCodec>) -> Result<(), ShellError> {
2121 while let Some(batch) = core.take_flush_batch() {
2122 for record in &batch.records {
2123 let path = record_path(dir, &record.key)?;
2124 if let Some(parent) = path.parent() {
2125 std::fs::create_dir_all(parent)?;
2126 }
2127 std::fs::write(&path, &record.bytes)?;
2128 #[cfg(unix)]
2129 {
2130 use std::os::unix::fs::PermissionsExt;
2131 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
2132 }
2133 }
2134 for key in &batch.deletes {
2135 let path = record_path(dir, key)?;
2136 if path.exists() {
2137 std::fs::remove_file(path)?;
2138 }
2139 }
2140 core.ack_flush(batch.id);
2141 }
2142 Ok(())
2143}
2144
2145const NOT_RECORDS: &[&str] = &[
2147 machine::WAKE_KEYCHAIN_FILE,
2148 machine::STORE_LOCK_FILE,
2149 LEADER_PROMPTED_FILE,
2150 ROUTINE_WOKEN_FILE,
2151];
2152
2153const LEADER_PROMPTED_FILE: &str = "leader-prompted";
2156const ROUTINE_WOKEN_FILE: &str = "routine-woken";
2159
2160fn read_records(dir: &Path) -> Result<Vec<SealedRecord>, ShellError> {
2161 let mut records = Vec::new();
2162 if dir.exists() {
2163 walk(dir, dir, &mut records)?;
2164 }
2165 Ok(records)
2166}
2167
2168fn walk(dir: &Path, root: &Path, records: &mut Vec<SealedRecord>) -> Result<(), ShellError> {
2169 for entry in std::fs::read_dir(dir)? {
2170 let entry = entry?;
2171 let path = entry.path();
2172 if path.is_dir() {
2173 walk(&path, root, records)?;
2174 continue;
2175 }
2176 if dir == root
2179 && path
2180 .file_name()
2181 .and_then(|name| name.to_str())
2182 .is_some_and(|name| NOT_RECORDS.contains(&name))
2183 {
2184 continue;
2185 }
2186 let rel = path
2187 .strip_prefix(root)
2188 .map_err(|_| ShellError::BadRecordKey)?;
2189 let key = rel
2190 .components()
2191 .map(|component| component.as_os_str().to_string_lossy())
2192 .collect::<Vec<_>>()
2193 .join("/");
2194 let bytes = std::fs::read(&path)?;
2195 records.push(SealedRecord {
2196 key: RecordKey::new(key),
2197 bytes,
2198 });
2199 }
2200 Ok(())
2201}
2202
2203fn record_path(dir: &Path, key: &RecordKey) -> Result<PathBuf, ShellError> {
2204 let rel = Path::new(key.as_str());
2205 if rel.is_absolute()
2206 || rel.components().any(|component| {
2207 matches!(
2208 component,
2209 Component::ParentDir | Component::RootDir | Component::Prefix(_)
2210 )
2211 })
2212 {
2213 return Err(ShellError::BadRecordKey);
2214 }
2215 Ok(dir.join(rel))
2216}
2217
2218#[cfg(test)]
2219mod tests {
2220 #[allow(unused_imports)]
2221 use std::time::Duration;
2222 use super::*;
2223
2224 #[test]
2225 fn session_config_is_identity_only_and_reads_no_secret() {
2226 let get = |extra: &'static [(&'static str, &'static str)]| {
2227 move |key: &str| {
2228 extra.iter().find(|(k, _)| *k == key).map(|(_, v)| v.to_string()).or_else(|| match key {
2229 SESSION_ID_ENV => Some("web-session-1".to_string()),
2230 STORE_ROOT_ENV => Some("/tmp/m4a-root".to_string()),
2231 "M4A_PRODUCT_PASSWORD" | "M4A_PRODUCT_TOKEN" | "M4A_DEVICE_TOKEN" => Some("must-not-be-read".to_string()),
2233 _ => None,
2234 })
2235 }
2236 };
2237 let config = SessionConfig::from_lookup(get(&[(HOMESERVER_URL_ENV, "http://127.0.0.1:9")]), None).expect("config");
2238 assert_eq!(config.store_dir(), session_store_dir(Path::new("/tmp/m4a-root"), "web-session-1"));
2239 assert!(!format!("{config:?}").contains("must-not-be-read"));
2240 assert_eq!(config.identity.tier, m4a_agent::BackendKind::Server);
2241 let matrix = SessionConfig::from_lookup(get(&[(PRODUCT_URL_ENV, "http://127.0.0.1:8"), (TIER_ENV, "matrix")]), None).expect("matrix");
2242 assert_eq!(matrix.identity.tier, m4a_agent::BackendKind::Matrix);
2243 assert!(SessionConfig::from_lookup(get(&[(TIER_ENV, "other"), (PRODUCT_URL_ENV, "http://127.0.0.1:8")]), None).is_err());
2244 let from_toml = SessionConfig::from_lookup(get(&[]), Some("homeserver_url = \"http://127.0.0.1:9\"\nother = \"ignored\"\n")).expect("toml url");
2245 assert_eq!(from_toml.homeserver_url, "http://127.0.0.1:9");
2246 assert!(SessionConfig::from_lookup(get(&[]), None).is_err(), "no server given");
2247 }
2248 use std::io::{Read, Write};
2249 use std::net::TcpListener;
2250 use std::sync::atomic::{AtomicBool, Ordering};
2251 use std::sync::{Arc, Mutex};
2252 use std::thread;
2253 use std::time::Instant;
2254
2255 fn plain(base: &str, token: &str) -> Arc<dyn Backend> {
2256 Arc::new(m4a_agent::backend::attached::AttachedBackend::with_prefix(m4a_agent::BackendKind::Server, base, token, false).expect("backend"))
2257 }
2258
2259 struct TempDir(PathBuf);
2260
2261 impl Drop for TempDir {
2262 fn drop(&mut self) {
2263 let _ = std::fs::remove_dir_all(&self.0);
2264 }
2265 }
2266
2267 fn temp_dir(name: &str) -> TempDir {
2268 let dir = TempDir(std::env::temp_dir().join(format!(
2269 "mail4agent-messenger-shell-{}-{name}",
2270 std::process::id()
2271 )));
2272 let _ = std::fs::remove_dir_all(&dir.0);
2273 dir
2274 }
2275
2276 fn open_alice(dir: &Path, session: &str) -> OpenedStore {
2277 let device = DeviceId::parse("DEVICE1").expect("device id");
2278 OpenedStore::open(
2279 dir,
2280 session,
2281 device,
2282 "@alice:localhost",
2283 "localhost",
2284 plain("http://127.0.0.1:9", "fake-token"),
2285 "fake-token",
2286 )
2287 .expect("open")
2288 }
2289
2290 #[test]
2291 fn same_session_opens_and_a_different_session_fails_the_seal() {
2292 let dir = temp_dir("seal");
2293 open_alice(&dir.0, "session-a");
2294 open_alice(&dir.0, "session-a");
2295
2296 let device = DeviceId::parse("DEVICE1").expect("device id");
2297 match OpenedStore::open(
2298 &dir.0,
2299 "session-b",
2300 device,
2301 "@alice:localhost",
2302 "localhost",
2303 plain("http://127.0.0.1:9", "fake-token"),
2304 "fake-token",
2305 ) {
2306 Err(ShellError::Store(StoreError::CodecOpen { .. })) => {}
2307 Ok(_) => panic!("different session opened the sealed store"),
2308 Err(err) => panic!("expected seal auth failure, got {err}"),
2309 }
2310 }
2311
2312 #[test]
2313 fn two_sessions_under_one_root_seal_and_reopen_only_with_their_own_id() {
2314 let root = temp_dir("isolate");
2315 let dir_a = session_store_dir(&root.0, "session-a");
2316 let dir_b = session_store_dir(&root.0, "session-b");
2317 assert_ne!(dir_a, dir_b);
2318 assert_eq!(dir_a.parent(), Some(root.0.as_path()));
2319 assert_eq!(dir_b.parent(), Some(root.0.as_path()));
2320 assert_eq!(session_store_dir(&root.0, "session-a"), dir_a);
2321 let slipped = session_store_dir(&root.0, "../session-a");
2322 assert_eq!(slipped.parent(), Some(root.0.as_path()));
2323 let name = slipped.file_name().expect("name").to_string_lossy();
2324 assert_eq!(name.len(), 64);
2325 assert!(name.chars().all(|ch| ch.is_ascii_hexdigit()));
2326
2327 let bearer = "isolation-bearer-7c2e";
2328 let device = DeviceId::parse("DEVICE1").expect("device id");
2329 let open_with = |dir: &Path, session: &str| {
2330 OpenedStore::open(
2331 dir,
2332 session,
2333 device.clone(),
2334 "@alice:localhost",
2335 "localhost",
2336 plain("http://127.0.0.1:9", bearer),
2337 bearer,
2338 )
2339 };
2340 open_with(&dir_a, "session-a").expect("seal a");
2341 open_with(&dir_b, "session-b").expect("seal b");
2342 open_with(&dir_a, "session-a").expect("reopen a");
2343 open_with(&dir_b, "session-b").expect("reopen b");
2344
2345 for (dir, session) in [(&dir_a, "session-b"), (&dir_b, "session-a")] {
2346 match open_with(dir, session) {
2347 Err(ShellError::Store(StoreError::CodecOpen { .. })) => {}
2348 Ok(_) => panic!("{session} opened the other sealed store"),
2349 Err(err) => panic!("expected seal auth failure, got {err}"),
2350 }
2351 }
2352
2353 fn contains_bearer(dir: &Path, needle: &str) -> bool {
2354 if !dir.exists() {
2355 return false;
2356 }
2357 for entry in std::fs::read_dir(dir).expect("read") {
2358 let entry = entry.expect("entry");
2359 let path = entry.path();
2360 if path.to_string_lossy().contains(needle) {
2361 return true;
2362 }
2363 if path.is_dir() {
2364 if contains_bearer(&path, needle) {
2365 return true;
2366 }
2367 } else if std::fs::read(&path)
2368 .expect("file")
2369 .windows(needle.len())
2370 .any(|window| window == needle.as_bytes())
2371 {
2372 return true;
2373 }
2374 }
2375 false
2376 }
2377 assert!(
2378 !contains_bearer(&root.0, bearer),
2379 "raw bearer was written under the store root"
2380 );
2381 }
2382
2383
2384
2385 #[test]
2386 fn a_base_url_carries_no_fragment_credentials_or_query() {
2387 assert!(parse_base_url("http://127.0.0.1:9").is_ok());
2388 assert!(parse_base_url("http://127.0.0.1:9/#other").is_err());
2389 assert!(parse_base_url("http://u:p@127.0.0.1:9").is_err());
2390 assert!(parse_base_url("ftp://127.0.0.1:9").is_err());
2391 }
2392
2393 #[test]
2394 fn send_message_releases_a_matrix_room_send() {
2395 let dir = temp_dir("send");
2396 let mut store = open_alice(&dir.0, "session-a");
2397 let released = store
2398 .send_room_message("!room:localhost", "hello room", 0)
2399 .expect("release");
2400 let send = released
2401 .iter()
2402 .find(|request| request.kind == OutgoingRequestKind::RoomSend)
2403 .unwrap_or_else(|| {
2404 panic!(
2405 "engine did not release a RoomSend: {:?}",
2406 released
2407 .iter()
2408 .map(|request| request.kind)
2409 .collect::<Vec<_>>()
2410 )
2411 });
2412 assert!(
2413 send.path.starts_with("/_matrix/client/v3/rooms/")
2414 && send.path.contains("/send/m.room.message/"),
2415 "not a matrix room send: {}",
2416 send.path
2417 );
2418 assert!(!send.path.contains("/mail/send"), "{}", send.path);
2419 assert!(!send.path.contains("/admin/listener"), "{}", send.path);
2420 let body = send.body.as_ref().expect("room send body");
2421 assert_eq!(body["msgtype"], "m.text");
2422 assert_eq!(body["body"], "hello room");
2423 }
2424
2425 #[test]
2426 fn drive_performs_the_released_request_with_bearer_and_no_matrix_prefix() {
2427 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2428 let addr = listener.local_addr().expect("addr");
2429 let seen = Arc::new(Mutex::new(Vec::<String>::new()));
2430 let seen_worker = Arc::clone(&seen);
2431 listener.set_nonblocking(true).expect("nonblocking");
2432 let server = thread::spawn(move || {
2433 let deadline = std::time::Instant::now() + Duration::from_secs(3);
2434 loop {
2435 if std::time::Instant::now() > deadline {
2436 break;
2437 }
2438 let sock = match listener.accept() {
2439 Ok((sock, _)) => sock,
2440 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
2441 thread::sleep(Duration::from_millis(20));
2442 continue;
2443 }
2444 Err(_) => break,
2445 };
2446 let mut sock = sock;
2447 let _ = sock.set_nonblocking(false);
2448 let _ = sock.set_read_timeout(Some(Duration::from_secs(2)));
2449 let mut buf = Vec::new();
2450 let mut tmp = [0u8; 2048];
2451 loop {
2452 let n = sock.read(&mut tmp).unwrap_or(0);
2453 if n == 0 {
2454 break;
2455 }
2456 buf.extend_from_slice(&tmp[..n]);
2457 if let Some(header_end) =
2458 buf.windows(4).position(|window| window == b"\r\n\r\n")
2459 {
2460 let headers = String::from_utf8_lossy(&buf[..header_end]).to_string();
2461 let length = headers
2462 .lines()
2463 .find_map(|line| {
2464 let (name, value) = line.split_once(':')?;
2465 if name.eq_ignore_ascii_case("content-length") {
2466 value.trim().parse::<usize>().ok()
2467 } else {
2468 None
2469 }
2470 })
2471 .unwrap_or(0);
2472 if buf.len() >= header_end + 4 + length {
2473 let body = buf[header_end + 4..header_end + 4 + length].to_vec();
2474 let first = headers.lines().next().unwrap_or("").to_string();
2475 let bearer_ok = headers.lines().any(|line| {
2476 let (name, value) = line.split_once(':').unwrap_or(("", ""));
2477 name.eq_ignore_ascii_case("authorization")
2478 && value.trim() == "Bearer fake-token"
2479 });
2480 let note = format!(
2481 "{first} bearer_ok={bearer_ok} body={}",
2482 String::from_utf8_lossy(&body)
2483 );
2484 seen_worker.lock().expect("seen").push(note);
2485 let request = first;
2486 let response_body = if request.contains("/send/") {
2487 br#"{"event_id":"$e:localhost"}"#.to_vec()
2488 } else if request.contains("/keys/") {
2489 br#"{"one_time_key_counts":{"signed_curve25519":50}}"#.to_vec()
2490 } else {
2491 br#"{"next_batch":"s1","device_one_time_keys_count":{"signed_curve25519":50},"device_unused_fallback_key_types":["signed_curve25519"]}"#.to_vec()
2492 };
2493 let head = format!(
2494 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2495 response_body.len()
2496 );
2497 let _ = sock.write_all(head.as_bytes());
2498 let _ = sock.write_all(&response_body);
2499 break;
2500 }
2501 }
2502 }
2503 }
2504 });
2505
2506 let dir = temp_dir("drive");
2507 let device = DeviceId::parse("DEVICE1").expect("device id");
2508 let mut store = OpenedStore::open(
2509 &dir.0,
2510 "session-a",
2511 device,
2512 "@alice:localhost",
2513 "localhost",
2514 plain(&format!("http://{addr}"), "fake-token"),
2515 "fake-token",
2516 )
2517 .expect("open");
2518 store
2519 .dispatch(
2520 MessengerCommand::SendMessage {
2521 room_id: RoomId::parse("!room:localhost").expect("room"),
2522 message: OutgoingMessage {
2523 kind: MessageKind::Text,
2524 body: "hello room".to_string(),
2525 reply_to: None,
2526 edit_of: None,
2527 },
2528 txn_id: None,
2529 },
2530 0,
2531 )
2532 .expect("dispatch");
2533 store.drive(0, false).expect("drive");
2534 thread::sleep(Duration::from_millis(200));
2535 drop(store);
2536 let _ = server.join();
2537 let seen = seen.lock().expect("seen");
2538 assert!(
2539 seen.iter().any(|line| {
2540 line.contains("PUT /client/v3/rooms/")
2541 && line.contains("/send/m.room.message/")
2542 && line.contains("bearer_ok=true")
2543 && line.contains("hello room")
2544 && !line.contains("/_matrix")
2545 }),
2546 "released room send was not performed: {seen:?}"
2547 );
2548 assert!(
2549 seen.iter().all(|line| !line.contains("/_matrix")),
2550 "prefix was not stripped: {seen:?}"
2551 );
2552 }
2553
2554 #[allow(dead_code)]
2555 struct Hit {
2556 headers: String,
2557 body: Vec<u8>,
2558 }
2559
2560 fn read_http(sock: &mut std::net::TcpStream) -> Option<(String, Vec<u8>)> {
2561 let _ = sock.set_read_timeout(Some(Duration::from_secs(2)));
2562 let mut buf = Vec::new();
2563 let mut tmp = [0u8; 2048];
2564 loop {
2565 let n = sock.read(&mut tmp).unwrap_or(0);
2566 if n == 0 {
2567 break;
2568 }
2569 buf.extend_from_slice(&tmp[..n]);
2570 if let Some(header_end) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
2571 let headers = String::from_utf8_lossy(&buf[..header_end]).to_string();
2572 let length = headers
2573 .lines()
2574 .find_map(|line| {
2575 let (name, value) = line.split_once(':')?;
2576 if name.eq_ignore_ascii_case("content-length") {
2577 value.trim().parse::<usize>().ok()
2578 } else {
2579 None
2580 }
2581 })
2582 .unwrap_or(0);
2583 if buf.len() >= header_end + 4 + length {
2584 let body = buf[header_end + 4..header_end + 4 + length].to_vec();
2585 return Some((headers, body));
2586 }
2587 }
2588 }
2589 None
2590 }
2591
2592 fn write_http(sock: &mut std::net::TcpStream, status: &str, body: &[u8]) {
2593 let head = format!(
2594 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2595 body.len()
2596 );
2597 let _ = sock.write_all(head.as_bytes());
2598 let _ = sock.write_all(body);
2599 }
2600
2601 fn sync_with_text_nick(body: &str, nick: Option<&str>) -> Vec<u8> {
2602 let mut state = Vec::new();
2603 if let Some(nick) = nick {
2604 state.push(serde_json::json!({
2605 "event_id": "$mem:localhost",
2606 "type": "m.room.member",
2607 "state_key": "@bob:localhost",
2608 "sender": "@bob:localhost",
2609 "origin_server_ts": 1,
2610 "content": { "membership": "join", "displayname": nick }
2611 }));
2612 }
2613 serde_json::json!({
2614 "next_batch": "s1",
2615 "rooms": {
2616 "join": {
2617 "!r:localhost": {
2618 "state": { "events": state },
2619 "timeline": {
2620 "events": [{
2621 "event_id": "$m1:localhost",
2622 "type": "m.room.message",
2623 "sender": "@bob:localhost",
2624 "origin_server_ts": 10,
2625 "content": { "msgtype": "m.text", "body": body }
2626 }]
2627 }
2628 }
2629 }
2630 },
2631 "device_one_time_keys_count": { "signed_curve25519": 50 },
2632 "device_unused_fallback_key_types": ["signed_curve25519"]
2633 })
2634 .to_string()
2635 .into_bytes()
2636 }
2637
2638 fn sync_empty() -> Vec<u8> {
2639 serde_json::json!({
2640 "next_batch": "s2",
2641 "device_one_time_keys_count": { "signed_curve25519": 50 },
2642 "device_unused_fallback_key_types": ["signed_curve25519"]
2643 })
2644 .to_string()
2645 .into_bytes()
2646 }
2647
2648 fn spawn_homeserver(text: &'static str) -> (String, Arc<AtomicBool>, thread::JoinHandle<()>) {
2649 spawn_homeserver_nick(text, None)
2650 }
2651
2652 fn spawn_homeserver_nick(
2653 text: &'static str,
2654 nick: Option<&'static str>,
2655 ) -> (String, Arc<AtomicBool>, thread::JoinHandle<()>) {
2656 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2657 let addr = listener.local_addr().expect("addr");
2658 listener.set_nonblocking(true).expect("nonblocking");
2659 let done = Arc::new(AtomicBool::new(false));
2660 let flag = Arc::clone(&done);
2661 let handle = thread::spawn(move || {
2662 let deadline = Instant::now() + Duration::from_secs(8);
2663 while !flag.load(Ordering::Relaxed) && Instant::now() < deadline {
2664 match listener.accept() {
2665 Ok((mut sock, _)) => {
2666 let _ = sock.set_nonblocking(false);
2667 let Some((headers, _)) = read_http(&mut sock) else {
2668 continue;
2669 };
2670 let line = headers.lines().next().unwrap_or("");
2671 let resp = if line.contains("/sync") && line.contains("since=") {
2672 sync_empty()
2673 } else if line.contains("/sync") {
2674 sync_with_text_nick(text, nick)
2675 } else if line.contains("/keys/") {
2676 br#"{"one_time_key_counts":{"signed_curve25519":50}}"#.to_vec()
2677 } else {
2678 b"{}".to_vec()
2679 };
2680 write_http(&mut sock, "200 OK", &resp);
2681 }
2682 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
2683 thread::sleep(Duration::from_millis(15));
2684 }
2685 Err(_) => break,
2686 }
2687 }
2688 });
2689 (format!("http://{addr}"), done, handle)
2690 }
2691
2692 fn spawn_routine() -> (
2693 String,
2694 Arc<Mutex<Vec<Hit>>>,
2695 Arc<AtomicBool>,
2696 thread::JoinHandle<()>,
2697 ) {
2698 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2699 let addr = listener.local_addr().expect("addr");
2700 listener.set_nonblocking(true).expect("nonblocking");
2701 let hits = Arc::new(Mutex::new(Vec::new()));
2702 let recorded = Arc::clone(&hits);
2703 let done = Arc::new(AtomicBool::new(false));
2704 let flag = Arc::clone(&done);
2705 let handle = thread::spawn(move || {
2706 let deadline = Instant::now() + Duration::from_secs(8);
2707 while !flag.load(Ordering::Relaxed) && Instant::now() < deadline {
2708 match listener.accept() {
2709 Ok((mut sock, _)) => {
2710 let _ = sock.set_nonblocking(false);
2711 if let Some((headers, body)) = read_http(&mut sock) {
2712 recorded.lock().expect("hits").push(Hit { headers, body });
2713 let _ = sock.write_all(
2714 b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
2715 );
2716 }
2717 }
2718 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
2719 thread::sleep(Duration::from_millis(15));
2720 }
2721 Err(_) => break,
2722 }
2723 }
2724 });
2725 (format!("http://{addr}/routine"), hits, done, handle)
2726 }
2727
2728 fn stop(flag: &AtomicBool, handle: thread::JoinHandle<()>) {
2729 flag.store(true, Ordering::Relaxed);
2730 let _ = handle.join();
2731 }
2732
2733 fn open_against(base: &str, dir: &Path, session: &str) -> OpenedStore {
2734 let device = DeviceId::parse("DEVICE1").expect("device id");
2735 OpenedStore::open(
2736 dir,
2737 session,
2738 device,
2739 "@alice:localhost",
2740 "localhost",
2741 plain(base, "fake-token"),
2742 "fake-token",
2743 )
2744 .expect("open")
2745 }
2746
2747 #[test]
2748 fn inbound_room_text_goes_through_the_provider_chain_once() {
2749 let (base, home_done, home) = spawn_homeserver("wake-chain");
2750 let dir = temp_dir("wake-chain");
2751 let inbox_dir = dir.0.join("inbox");
2752 let mut store = open_against(&base, &dir.0, "session-c");
2753 let session = provider::ProviderSession {
2754 kind: provider::SessionKind::local(provider::ProviderKind::Codex),
2755 session_id: "thread-c".into(),
2756 nick: "builder".into(),
2757 cwd: None,
2758 headless: false,
2759 };
2760 let chain = provider::letter_chain(session.kind, Some(inbox_dir.clone()), &provider::AdapterConfig::from_env(Some(inbox_dir.clone())));
2761 store.set_wake_chain(session, chain);
2762 assert_eq!(store.wake_chain_ids(), vec!["deliver", "inbox-queue"], "g4a deliver, then the inbox");
2763 store.drive(1_000, false).expect("drive");
2764 store.drive(3_000, false).expect("drive again");
2765 let route = store.take_wake_route();
2766 let note = store.wake_note().unwrap_or("").to_string();
2767 drop(store);
2768 stop(&home_done, home);
2769 assert_eq!(route.as_deref(), Some("inbox-queue queued"), "note={note}");
2771 let letters = provider::inbox::drain(&inbox_dir);
2772 assert_eq!(letters.len(), 1);
2773 assert!(letters[0].letter.prompt.ends_with("wake-chain"));
2774 assert!(letters[0].letter.prompt.contains("--as builder"));
2775 }
2776
2777
2778
2779
2780 #[test]
2781 fn the_owners_policy_silences_a_wake_and_the_silenced_message_is_never_replayed() {
2782 let (base, home_done, home) = spawn_homeserver("wake-plain");
2783 let (routine, hits, routine_done, routine_thread) = spawn_routine();
2784 let dir = temp_dir("wake-policy");
2785 let mut store = open_against(&base, &dir.0, "session-a");
2786 store.set_wake(SessionWake { routine_url: Some(routine), ..SessionWake::default() });
2787 let off = wake_policy::WakePolicy { enabled: false, mode: wake_policy::WakeMode::Mention };
2788 off.save(store.store_dir()).expect("policy");
2789 store.drive(1_000, false).expect("drive");
2790 assert!(store.texts().iter().any(|t| t.body == "wake-plain"), "the text still arrives and can be read");
2791 assert!(hits.lock().expect("hits").is_empty(), "a disabled wake posted");
2792 wake_policy::WakePolicy::default().save(store.store_dir()).expect("policy");
2794 store.drive(3_000, false).expect("drive again");
2795 let hits_now = hits.lock().expect("hits").len();
2796 drop(store);
2797 stop(&routine_done, routine_thread);
2798 stop(&home_done, home);
2799 assert_eq!(hits_now, 0, "the silenced message was replayed");
2800 }
2801
2802 #[test]
2803 fn a_web_client_with_a_routine_url_wakes_it_exactly_once_through_g4a() {
2804 let (base, home_done, home) = spawn_homeserver("wake-plain");
2805 let (routine, hits, routine_done, routine_thread) = spawn_routine();
2806 let dir = temp_dir("wake-routine-once");
2807 let mut store = open_against(&base, &dir.0, "session-a");
2808 store.set_wake(SessionWake { routine_url: Some(routine), routine_bearer: Some("k-test".into()), ..SessionWake::default() });
2809 store.drive(1_000, false).expect("drive");
2810 store.drive(3_000, false).expect("drive again");
2811 let route = store.take_wake_route();
2812 let note = store.wake_note().unwrap_or("").to_string();
2813 drop(store);
2814 stop(&routine_done, routine_thread);
2815 stop(&home_done, home);
2816 assert_eq!(hits.lock().expect("hits").len(), 1, "one letter, one routine wake; note={note}");
2817 assert_eq!(route.as_deref(), Some("deliver delivered"), "note={note}");
2818 let inbox = provider::registry::inbox_dir(dir.0.parent().expect("parent"), "session-a");
2819 assert!(provider::inbox::drain(&inbox).is_empty(), "a delivered letter is not also queued");
2820 }
2821
2822 fn spawn_homeserver_events(events: serde_json::Value) -> (String, Arc<AtomicBool>, thread::JoinHandle<()>) {
2823 let first = serde_json::json!({
2824 "next_batch": "s1",
2825 "rooms": { "join": { "!r:localhost": { "state": { "events": [] }, "timeline": { "events": events } } } },
2826 "device_one_time_keys_count": { "signed_curve25519": 50 },
2827 "device_unused_fallback_key_types": ["signed_curve25519"]
2828 }).to_string().into_bytes();
2829 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2830 let addr = listener.local_addr().expect("addr");
2831 listener.set_nonblocking(true).expect("nonblocking");
2832 let done = Arc::new(AtomicBool::new(false));
2833 let flag = Arc::clone(&done);
2834 let handle = thread::spawn(move || {
2835 let deadline = Instant::now() + Duration::from_secs(8);
2836 while !flag.load(Ordering::Relaxed) && Instant::now() < deadline {
2837 match listener.accept() {
2838 Ok((mut sock, _)) => {
2839 let _ = sock.set_nonblocking(false);
2840 let Some((headers, _)) = read_http(&mut sock) else { continue };
2841 let line = headers.lines().next().unwrap_or("");
2842 let resp = if line.contains("/sync") && line.contains("since=") {
2843 sync_empty()
2844 } else if line.contains("/sync") {
2845 first.clone()
2846 } else if line.contains("/keys/") {
2847 br#"{"one_time_key_counts":{"signed_curve25519":50}}"#.to_vec()
2848 } else {
2849 b"{}".to_vec()
2850 };
2851 write_http(&mut sock, "200 OK", &resp);
2852 }
2853 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => thread::sleep(Duration::from_millis(15)),
2854 Err(_) => break,
2855 }
2856 }
2857 });
2858 (format!("http://{addr}"), done, handle)
2859 }
2860
2861 fn text_ev(id: &str, sender: &str, ts: i64, body: &str) -> serde_json::Value {
2862 serde_json::json!({ "event_id": id, "type": "m.room.message", "sender": sender, "origin_server_ts": ts, "content": { "msgtype": "m.text", "body": body } })
2863 }
2864 fn redaction_ev(id: &str, sender: &str, ts: i64, target: &str) -> serde_json::Value {
2865 serde_json::json!({ "event_id": id, "type": "m.room.redaction", "sender": sender, "origin_server_ts": ts, "redacts": target, "content": { "redacts": target } })
2866 }
2867 fn later(ms: i64) -> i64 {
2869 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0) + 3_600_000 + ms
2870 }
2871 fn reaction_ev(id: &str, sender: &str, ts: i64, target: &str, key: &str) -> serde_json::Value {
2872 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 } } })
2873 }
2874
2875 fn wakes_for(name: &str, events: serde_json::Value, restart: bool) -> Vec<serde_json::Value> {
2877 let (base, home_done, home) = spawn_homeserver_events(events);
2878 let (routine, hits, routine_done, routine_thread) = spawn_routine();
2879 let dir = temp_dir(name);
2880 let mut store = open_against(&base, &dir.0, "session-a");
2881 store.set_wake(SessionWake { routine_url: Some(routine.clone()), ..SessionWake::default() });
2882 store.drive(1_000, false).expect("drive");
2883 store.drive(3_000, false).expect("drive again");
2884 drop(store);
2885 if restart {
2886 let mut store = open_against(&base, &dir.0, "session-a");
2887 store.set_wake(SessionWake { routine_url: Some(routine), ..SessionWake::default() });
2888 store.drive(5_000, false).expect("drive after restart");
2889 store.drive(7_000, false).expect("drive after restart again");
2890 }
2891 stop(&routine_done, routine_thread);
2892 stop(&home_done, home);
2893 let hits = hits.lock().expect("hits");
2894 hits.iter().map(|h| serde_json::from_slice(&h.body).unwrap_or(serde_json::Value::Null)).collect()
2895 }
2896
2897 #[test]
2898 fn a_message_sent_and_redacted_while_the_agent_was_offline_wakes_nothing() {
2899 let w = wakes_for("coalesce-redact", serde_json::json!([
2900 text_ev("$m1:localhost", "@bob:localhost", 10, "oops wrong chat"),
2901 redaction_ev("$x1:localhost", "@bob:localhost", 11, "$m1:localhost"),
2902 ]), false);
2903 assert!(w.is_empty(), "redacted-before-delivery must produce no wake: {w:?}");
2904 }
2905
2906 #[test]
2907 fn an_edit_before_the_wake_delivers_only_the_final_version() {
2908 let w = wakes_for("coalesce-edit", serde_json::json!([
2909 text_ev("$m1:localhost", "@bob:localhost", 10, "draft text"),
2910 { "event_id": "$e1:localhost", "type": "m.room.message", "sender": "@bob:localhost", "origin_server_ts": 11,
2911 "content": { "msgtype": "m.text", "body": "* final text", "m.new_content": { "msgtype": "m.text", "body": "final text" },
2912 "m.relates_to": { "rel_type": "m.replace", "event_id": "$m1:localhost" } } },
2913 ]), false);
2914 assert_eq!(w.len(), 1, "one wake for the edited message: {w:?}");
2915 assert_eq!(w[0]["body"], "final text");
2916 assert_eq!(w[0]["event_id"], "$m1:localhost");
2917 }
2918
2919 #[test]
2920 fn a_reaction_to_my_message_wakes_once_low_priority_and_mine_never() {
2921 let w = wakes_for("reaction-wake", serde_json::json!([
2922 text_ev("$a1:localhost", "@alice:localhost", 10, "deploy finished"),
2923 reaction_ev("$r1:localhost", "@bob:localhost", later(0), "$a1:localhost", "👍"),
2924 reaction_ev("$r2:localhost", "@alice:localhost", later(1), "$a1:localhost", "🎉"),
2925 ]), true);
2926 assert_eq!(w.len(), 1, "exactly one wake (bob's reaction), none for my own, no replay after restart: {w:?}");
2927 assert_eq!(w[0]["kind"], "reaction");
2928 assert_eq!(w[0]["reaction"], "👍");
2929 assert_eq!(w[0]["target_event_id"], "$a1:localhost");
2930 assert_eq!(w[0]["event_id"], "$r1:localhost");
2931 assert_eq!(w[0]["reply_needed"], false);
2932 assert_eq!(w[0]["priority"], "low");
2933 assert!(w[0].get("reply").is_none());
2934 assert!(w[0]["body"].as_str().unwrap_or("").contains("deploy finished"));
2935 }
2936
2937 #[test]
2938 fn reactions_from_before_the_first_run_never_wake() {
2939 let w = wakes_for("reaction-history", serde_json::json!([
2940 text_ev("$a1:localhost", "@alice:localhost", 10, "old"),
2941 reaction_ev("$r1:localhost", "@bob:localhost", 11, "$a1:localhost", "👍"),
2942 ]), false);
2943 assert!(w.is_empty(), "history replayed: {w:?}");
2944 }
2945
2946 #[test]
2947 fn a_reaction_added_and_removed_before_the_wake_produces_nothing() {
2948 let w = wakes_for("reaction-undo", serde_json::json!([
2949 text_ev("$a1:localhost", "@alice:localhost", 10, "hello"),
2950 reaction_ev("$r1:localhost", "@bob:localhost", later(0), "$a1:localhost", "👍"),
2951 redaction_ev("$x1:localhost", "@bob:localhost", later(1), "$r1:localhost"),
2952 ]), false);
2953 assert!(w.is_empty(), "{w:?}");
2954 }
2955
2956 #[test]
2957 fn no_routine_url_posts_nothing() {
2958 let (base, home_done, home) = spawn_homeserver("wake-plain");
2959 let (_routine, hits, routine_done, routine_thread) = spawn_routine();
2960 let dir = temp_dir("wake-none");
2961 let mut store = open_against(&base, &dir.0, "session-a");
2962 store.set_wake(SessionWake::default());
2963 store.drive(1_000, false).expect("drive");
2964 let saw_text = store.texts().iter().any(|text| text.body == "wake-plain");
2965 let trace = store.http_trace();
2966 drop(store);
2967 stop(&routine_done, routine_thread);
2968 stop(&home_done, home);
2969 assert!(
2970 saw_text,
2971 "missing routine url dropped the inbound text; http={trace:?}"
2972 );
2973 let hits = hits.lock().expect("hits");
2974 assert!(hits.is_empty(), "a post happened with no routine url");
2975 }
2976
2977
2978
2979}
2980
2981fn server_of(user_id: &str) -> &str {
2983 user_id.split_once(':').map(|(_, server)| server).unwrap_or("")
2984}
2985
2986#[derive(Debug, Clone, PartialEq, Eq)]
2991pub struct AutoJoinPolicy {
2992 pub dms: bool,
2993 pub rooms: bool,
2994 pub room_inviters: Vec<String>,
2996}
2997
2998impl Default for AutoJoinPolicy {
2999 fn default() -> Self {
3000 Self { dms: true, rooms: true, room_inviters: Vec::new() }
3001 }
3002}
3003
3004impl AutoJoinPolicy {
3005 pub fn from_env() -> Self {
3006 let flag = |k: &str| std::env::var(k).map(|v| !matches!(v.trim(), "0" | "false" | "off" | "no")).unwrap_or(true);
3007 let from = std::env::var("M4A_AUTO_JOIN_FROM").unwrap_or_default();
3008 Self {
3009 dms: flag("M4A_AUTO_JOIN_DMS"),
3010 rooms: flag("M4A_AUTO_JOIN_ROOMS"),
3011 room_inviters: from.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
3012 }
3013 }
3014
3015 pub fn accepts<'a>(&self, home: &str, me: &str, is_direct: bool, inviter: Option<&str>, members: impl Iterator<Item = &'a str>) -> bool {
3017 if home.is_empty() {
3018 return false;
3019 }
3020 if is_direct {
3021 let others: Vec<&str> = members.filter(|u| *u != me).collect();
3022 let peers_home = !others.is_empty() && others.iter().all(|u| server_of(u) == home);
3023 return self.dms && peers_home && inviter.is_none_or(|i| server_of(i) == home);
3024 }
3025 let Some(inviter) = inviter else { return false };
3026 if !self.rooms || server_of(inviter) != home || inviter == me {
3027 return false;
3028 }
3029 self.room_inviters.is_empty()
3030 || 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("")))
3031 }
3032}
3033
3034#[cfg(test)]
3035mod auto_join_tests {
3036 use super::AutoJoinPolicy;
3037
3038 const HOME: &str = "example.org";
3039 const ME: &str = "@agent-a:example.org";
3040
3041 fn ok(p: &AutoJoinPolicy, direct: bool, inviter: Option<&str>, members: &[&str]) -> bool {
3042 p.accepts(HOME, ME, direct, inviter, members.iter().copied())
3043 }
3044
3045 #[test]
3046 fn group_room_invite_from_our_server_is_joined() {
3047 let p = AutoJoinPolicy::default();
3048 assert!(ok(&p, false, Some("@operator:example.org"), &[ME, "@operator:example.org", "@x:other.org"]));
3049 }
3050
3051 #[test]
3052 fn group_room_invite_from_another_server_or_unknown_inviter_is_left() {
3053 let p = AutoJoinPolicy::default();
3054 assert!(!ok(&p, false, Some("@evil:other.org"), &[ME, "@evil:other.org"]));
3055 assert!(!ok(&p, false, None, &[ME, "@operator:example.org"]));
3056 }
3057
3058 #[test]
3059 fn dm_policy_is_unchanged() {
3060 let p = AutoJoinPolicy::default();
3061 assert!(ok(&p, true, Some("@agent-b:example.org"), &[ME, "@agent-b:example.org"]));
3062 assert!(ok(&p, true, None, &[ME, "@agent-b:example.org"]));
3063 assert!(!ok(&p, true, Some("@x:other.org"), &[ME, "@x:other.org"]));
3064 }
3065
3066 #[test]
3067 fn switches_and_allow_list() {
3068 let off = AutoJoinPolicy { rooms: false, ..AutoJoinPolicy::default() };
3069 assert!(!ok(&off, false, Some("@operator:example.org"), &[ME]));
3070 let nodm = AutoJoinPolicy { dms: false, ..AutoJoinPolicy::default() };
3071 assert!(!ok(&nodm, true, Some("@agent-b:example.org"), &[ME, "@agent-b:example.org"]));
3072 let only_op = AutoJoinPolicy { room_inviters: vec!["operator".into()], ..AutoJoinPolicy::default() };
3073 assert!(ok(&only_op, false, Some("@operator:example.org"), &[ME]));
3074 assert!(!ok(&only_op, false, Some("@agent-b:example.org"), &[ME]));
3075 }
3076}