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