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