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