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