Skip to main content

mail4agent_messenger_shell/
lib.rs

1//! Client shell of the messenger: the store key (random, in the client vault), the one web-bot wake,
2//! and the HTTP a released [`OutgoingRequest`] actually performs.
3//!
4//! (Historical: the store key was SHA-256 of the session id string; it is now random and lives in the vault, see `store_key`.) `store_seal_key` is that old derivation. The client derives
5//! it when it opens the store. It is not a passphrase, it is not stored
6//! beside the records, and nothing here is a KDF or a vault.
7//!
8//! The private Olm account stays in this store. `MessengerCore::open_sealed`
9//! calls `OlmAccountState::load_or_create` and pickles it under the seal.
10//! Clients exchange only public keys with the server, through the existing
11//! keys upload and keys query paths.
12//!
13//! Outbound mail to other agents is `MessengerCommand::SendMessage` and
14//! `/sync`, not `POST /mail/send` and not `POST /admin/listener`.
15//!
16//! Inbound wake is not a second mailbox. After the engine has a readable
17//! room text from someone else, this shell posts a small JSON object to
18//! one routine URL through [`post_decrypted`], or pushes the same text on
19//! the leader socket. The plaintext stays in `body`. `from` is the sender
20//! mxid, `event_id` is the Matrix event id, and `nick` is the sender's
21//! display name only when this shell already has one. Which of those two
22//! triggers is armed depends on the open path, not on a file. Missing the
23//! one this path uses skips that trigger. It does not drop the message
24//! and it is not an error. Routine replay skips texts already loaded.
25//! A leader prompt skips keys already listed in `leader-prompted`. When
26//! that file records nothing, joined rooms with an empty timeline are
27//! paged once from the stored sync token, and only the newest inbound
28//! text in each room is prompted.
29//!
30//! The session bearer stays in memory on [`OpenedStore`]. It comes from the client's own
31//! key-signature login (the identity lives in the vault; see `m4a-agent`), is sent as
32//! `Authorization: Bearer` and is never written next to the sealed records. No password, token or
33//! device bearer is ever read from the environment or handed in by a host.
34//! Paths the engine builds under `/_matrix` are sent without that prefix:
35//! `mail4agent-server-bin` mounts the Client-Server router at `/client/v3`.
36//!
37//! Two open paths, one shell. This is not a second product.
38//!
39//! The web machine client is [`MachineClient::from_env`]. One process
40//! uses the agents directory only when [`AGENTS_DIR_ENV`] names one:
41//! each folder is an agent id (also the mail session id) and
42//! `profile.json` has the display name. The home Grok Bot directory is
43//! not opened by default. Otherwise it reads [`SESSIONS_DIR_ENV`]. The nick is
44//! [`nick_from_display_name`] of that display name. Mail between sessions
45//! this client holds is in process
46//! ([`MachineClient::set_local_delivery`]). A peer that is not in the list
47//! uses the homeserver. Wake is the bot's own webhook routine, named by
48//! its nick, which only the bot can create (`UpdateRoutine`). This process
49//! keeps a disabled local mirror in the same folder so the gateway hands
50//! out that routine's URL and key; a null key is retried, never replaced
51//! by a second routine. [`ensure_agent_webhook_routines`] does that without
52//! opening stores. [`MachineClient::poll_agent_directory`] retries and
53//! rescans for new bots. The URL and key live in memory and in the
54//! session's keychain file under the store root, and are not logged. This
55//! path does not read [`LEADER_SOCK_ENV`].
56//!
57//! A node is [`OpenedStore::connect_node_from_env`]. One CLI session, woken
58//! by ACP on [`LEADER_SOCK_ENV`]. A routine URL or bearer in the environment
59//! is refused before register. This path does not gain a webhook config.
60//! [`OpenedStore::connect`] still registers one session a caller already
61//! built; it is not either of those open paths and it does not read a wake
62//! from the environment.
63
64pub 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};
120// AutoJoinPolicy is defined in this file.
121pub 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
135/// SHA-256 of `session_id`'s UTF-8 bytes. That digest is the check that this
136/// session may open the store. The bytes are not written to disk.
137pub 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
144/// Shared store root on this machine. The caller supplies it. Unset is not
145/// a shared fallback outside tests.
146pub const STORE_ROOT_ENV: &str = "M4A_STORE_ROOT";
147
148/// Homeserver origin for a Grok Bot web session, for example
149/// `http://127.0.0.1:8741`. Wins over [`CONFIG_ENV`] / `mail4agent.toml`.
150/// There is no built-in host.
151pub const HOMESERVER_URL_ENV: &str = "M4A_HOMESERVER_URL";
152
153/// Optional path of a toml file whose `homeserver_url` is used when
154/// [`HOMESERVER_URL_ENV`] is unset. Unset looks at `./mail4agent.toml` and
155/// ignores it when that file is absent.
156pub const CONFIG_ENV: &str = "M4A_CONFIG";
157
158/// Display name of this Grok Bot web session, the name the host already
159/// shows. The nick is derived from it. This is not a nick slug.
160pub const BOT_NAME_ENV: &str = "M4A_BOT_NAME";
161
162/// Session id the web host already assigned. The store directory is
163/// [`session_store_dir`] of this id. The same id reopens the same store.
164pub const SESSION_ID_ENV: &str = "M4A_SESSION_ID";
165
166/// Product-session mode: base URL of the product server. Every Client-Server
167/// call goes through its proxy with the product session token as bearer.
168pub const PRODUCT_URL_ENV: &str = "M4A_PRODUCT_URL";
169/// Identity mode: the operator's one-time invite code (read by the CLIENT process, once; the agent
170/// is never given it). Not needed after the identity is enrolled.
171pub const PRODUCT_INVITE_ENV: &str = "M4A_PRODUCT_INVITE";
172/// Identity mode: `server` (our product API, default) or `matrix` (the Matrix client API).
173pub const TIER_ENV: &str = "M4A_TIER";
174/// Identity mode: the client owns an ed25519 identity and logs in by signature. There is no
175/// password and no token the agent could be handed; `invite` is the operator's one-time code,
176/// needed only until the identity is enrolled.
177#[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    /// The nick to ask for at the first enrollment: the session name as a nick.
183    nick_request: Option<String>,
184}
185
186/// Directory for `session_id` under `root`.
187///
188/// The final component is the lowercase hex of [`store_seal_key`]. Different
189/// session ids get different directories. The same id gets the same directory
190/// again. The component is only hex, so it cannot leave `root`. Callers stop
191/// pointing two processes at one path by hand. The device bearer is not an
192/// input and is not written into the directory.
193pub fn session_store_dir(root: &Path, session_id: &str) -> PathBuf {
194    root.join(hex_encode(&store_seal_key(session_id)))
195}
196
197/// [`STORE_ROOT_ENV`] when set and non-empty. Tests with it unset get one
198/// temp directory for this process. Other builds return [`ShellError::StoreRoot`]
199/// and do not invent a path two agents would share.
200pub 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/// One routine POST: the Matrix event id it carried and the HTTP status
228/// (`None` when no response arrived). `200` means the routine woke.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct WakeAttempt {
231    /// Event id of the room text that was posted.
232    pub event_id: String,
233    /// HTTP status of the POST, if one came back.
234    pub status: Option<u16>,
235}
236
237/// One URL per bot, already created. Neighbors are addressed by nick or mxid
238/// on the server.
239pub struct RoutineWake {
240    /// The bot's own routine URL. Configured once. Not a per-letter token.
241    pub url: String,
242}
243
244/// Routine POST target. Unset or empty: no POST. There is no default URL.
245pub const ROUTINE_URL_ENV: &str = "M4A_ROUTINE_URL";
246
247/// Optional `Authorization: Bearer` for [`ROUTINE_URL_ENV`]. Sent only when
248/// that URL is also set. A host keychain injects this into the process
249/// environment from outside. It is never written to disk and never read
250/// from source.
251pub const ROUTINE_BEARER_ENV: &str = "M4A_ROUTINE_BEARER";
252
253/// Leader socket for a local session (`leader.sock`). Unset: no ACP push.
254pub const LEADER_SOCK_ENV: &str = "M4A_LEADER_SOCK";
255
256/// `session/load` working directory when [`LEADER_SOCK_ENV`] is set.
257/// Unset uses the process current directory.
258pub const LEADER_CWD_ENV: &str = "M4A_LEADER_CWD";
259
260/// Wake targets for one shell. Empty fields mean that trigger is off.
261/// `routine_bearer` is kept only in memory and only attached when
262/// `routine_url` is set. The caller fills it from the environment, never
263/// from a literal in source.
264pub struct SessionWake {
265    /// Routine URL. `None` or empty skips the POST.
266    pub routine_url: Option<String>,
267    /// Bearer for the routine POST. `None` or empty sends no
268    /// `Authorization` header.
269    pub routine_bearer: Option<String>,
270    /// Path of an already-running `leader.sock`. `None` skips ACP.
271    pub leader_sock: Option<PathBuf>,
272    /// Working directory for `session/load`. `None` uses the env or cwd.
273    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    /// Web machine client. The host injected [`ROUTINE_URL_ENV`] and, when
289    /// that URL is set, [`ROUTINE_BEARER_ENV`]. [`LEADER_SOCK_ENV`] is not
290    /// read. Nothing is taken from a file.
291    pub fn web_host() -> Self {
292        Self::web_from_lookup(|key| nonempty_var(key))
293    }
294
295    /// Same as [`Self::web_host`] with an explicit lookup. A leader socket
296    /// returned by `get` is ignored.
297    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    /// Node CLI. [`LEADER_SOCK_ENV`] and [`LEADER_CWD_ENV`] only. A routine
313    /// URL or bearer is refused. The refused value is not copied into the
314    /// error.
315    pub fn node_cli() -> Result<Self, ShellError> {
316        Self::node_from_lookup(|key| nonempty_var(key))
317    }
318
319    /// Same as [`Self::node_cli`] with an explicit lookup.
320    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
343/// One session of the client, ready to log in. Built from the host environment
344/// ([`SessionConfig::from_env`]) or [`SessionConfig::new_identity`]. The nick is the one the
345/// operator assigned with the invite; it is known after the first login.
346pub struct SessionConfig {
347    homeserver_url: String,
348    session_id: String,
349    store_root: PathBuf,
350    identity: IdentityAuth,
351}
352
353impl SessionConfig {
354    /// The session's identity is generated and kept by the client's vault under `store_root`, it
355    /// enrolls with the operator's `invite` once, and logs in by signature afterwards. `tier`
356    /// picks the product API (tier 2) or the Matrix API (tier 3).
357    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    /// Asks the server, at the first enrollment, for the nick of this session's name
379    /// ([`nick_from_display_name`] of `name`; a name that makes no nick asks for nothing). The
380    /// operator's invite still decides, and a nick the invite reserved wins.
381    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    /// A session on `url`: the tier from [`TIER_ENV`] and the operator's invite from the invite
387    /// file (`<store_root>/invites/<hash of the session id>`), if the operator left one.
388    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    /// Host environment. Required: [`PRODUCT_URL_ENV`] (or [`HOMESERVER_URL_ENV`] / `homeserver_url`
398    /// in toml), [`SESSION_ID_ENV`], [`STORE_ROOT_ENV`]. Optional: [`PRODUCT_INVITE_ENV`] (first
399    /// login only), [`TIER_ENV`]. [`ROUTINE_URL_ENV`] and [`LEADER_SOCK_ENV`] are not read here.
400    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        // A nick is asked for only when the host named the session; a bare session id is not a name.
428        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    /// Homeserver (or product) origin this session logs in at.
436    pub fn homeserver_url(&self) -> &str {
437        &self.homeserver_url
438    }
439
440    /// [`session_store_dir`] for this session under the configured root.
441    pub fn store_dir(&self) -> PathBuf {
442        session_store_dir(&self.store_root, &self.session_id)
443    }
444
445    /// Session id the host assigned. Not a secret.
446    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
462/// Where the operator leaves the one-time invite for a session: a file the client reads and
463/// deletes after the identity is enrolled. The agent is never given the code.
464fn 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
519/// A session found by nick. No routine URL and no bearer.
520pub struct FoundSession {
521    /// The nick stored for that session.
522    pub nick: String,
523    /// That session's Matrix user id.
524    pub user_id: String,
525}
526
527/// One decrypted room text the routine should learn about.
528///
529/// Serialized as a JSON object. `body` is the plaintext a routine that
530/// only reads the message still finds. `from` is the sender mxid.
531/// `event_id` is the Matrix event id. `nick` is omitted when this shell
532/// does not already know a display name; it is never invented.
533#[derive(Default)]
534pub struct DecryptedWake<'a> {
535    /// Plaintext body.
536    pub body: &'a str,
537    /// Sender mxid.
538    pub from: &'a str,
539    /// Sender display name, if the room state already has one.
540    pub nick: Option<&'a str>,
541    /// Matrix event id.
542    pub event_id: &'a str,
543    /// Room id the text arrived in (`room`).
544    pub room: Option<&'a str>,
545    /// Sender nick: the localpart of `from` (`from_nick`).
546    pub from_nick: Option<&'a str>,
547    /// Recipient nick: the session this wake is for (`to`).
548    pub to: Option<&'a str>,
549    /// One-line reply command for the recipient (`reply`), e.g.
550    /// `m4a-send --as <to> --to <from_nick> '<text>'`.
551    pub reply: Option<&'a str>,
552}
553
554/// Command a woken bot runs to answer, as shown in the wake's `reply`.
555pub const SEND_COMMAND: &str = "m4a-send";
556
557/// The `reply` hint for a wake from `from_nick` to `to`.
558pub fn reply_hint(to: &str, from_nick: &str) -> String {
559    format!("{SEND_COMMAND} --as {to} --to {from_nick} '<your reply>'")
560}
561
562/// Localpart of a Matrix user id (`@alice:server` -> `alice`).
563pub 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
568/// A sealed messenger core opened for one session, plus the homeserver it
569/// performs released requests against. The Olm account was loaded or created
570/// into it. Private key material stays here. The device bearer stays in
571/// memory and is zeroed when this value is dropped.
572pub struct OpenedStore {
573    dir: PathBuf,
574    driver: Driver<SealedRecordCodec>,
575    device_token: Zeroizing<String>,
576    base_url: String,
577    /// Session id this store was opened with. Also the ACP `sessionId`
578    /// when a leader socket is configured. Not a secret.
579    session_id: String,
580    /// Set by [`OpenedStore::connect`]. Empty when opened with a bearer
581    /// the caller already held.
582    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    /// Inbound event keys whose routine POST already succeeded.
588    routine_sent: HashSet<String>,
589    /// When true, the next [`Self::wake_inbound`] with timeline rows seeds
590    /// older keys into [`ROUTINE_WOKEN_FILE`] (newest still POSTs once) —
591    /// first open after a missing/empty ledger, once tip/history has landed.
592    routine_woken_seed_pending: bool,
593    /// Restart with an existing [`ROUTINE_WOKEN_FILE`]: tip/history may show
594    /// more rows than the ledger. While true, remember every inbound key
595    /// without POSTing. Cleared at the end of the first [`Self::drive`].
596    routine_restart_catchup: bool,
597    /// Reactions at or before this `origin_server_ts` were already in the store when this
598    /// process started; they never wake (no replay on restart). Set on the first drive.
599    reaction_floor: Option<i64>,
600    /// Inbound event keys whose leader prompt already succeeded.
601    leader_sent: HashSet<String>,
602    /// Last wake failure, clipped. No bearer and no message body.
603    wake_note: Option<String>,
604    wake_log: Vec<WakeAttempt>,
605    /// Provider wake chain for a session that has no leader socket
606    /// (Codex, Kimi, Claude, Cursor; Claude web / Codex cloud). Uses the
607    /// same `leader-prompted` ledger as the leader path.
608    wake_chain: Option<(provider::ProviderSession, provider::WakeChain)>,
609    /// `<link id> delivered|queued` for the newest chain wake.
610    wake_route: Option<String>,
611    /// In-process bus for sessions this machine's client already holds.
612    /// `None` on a store opened by itself: every request goes to the homeserver.
613    bus: Option<Arc<machine::LocalBus>>,
614    /// Nick and mxid of the other sessions in that client. Lookup does not
615    /// ask the homeserver.
616    local_peers: Vec<(String, String)>,
617    /// Room texts pushed on the machine socket for this session. Not `/sync`.
618    pushed_room_events: Vec<PushedRoomEvent>,
619    /// Joined rooms already paged once. Timelines are not stored, so a
620    /// resumed process asks `/messages` from the sync token a single time.
621    history_pulled: HashSet<String>,
622    /// Joined rooms whose tip page has already landed in this process.
623    /// A `/sync` token that has moved past an event will not replay it.
624    tip_pulled: HashSet<String>,
625}
626
627struct ZeroJitter;
628
629impl Jitter for ZeroJitter {
630    fn next_unit(&mut self) -> f64 {
631        0.0
632    }
633}
634
635/// One room this store currently holds, from this account's own view.
636pub struct RoomView {
637    /// The room id.
638    pub room_id: String,
639    /// This account's membership: `join`, `invite`, `leave`, `ban`, `knock`,
640    /// `unknown`, or `absent`.
641    pub membership: String,
642    /// Whether `m.room.encryption` is set.
643    pub encrypted: bool,
644}
645
646/// One text-like timeline row. Ciphertext is not included.
647pub struct TextView {
648    /// The room the row is in.
649    pub room_id: String,
650    /// The decrypted or plaintext body.
651    pub body: String,
652    /// `sent`, `sending`, `failed`, or `undecryptable`.
653    pub outcome: String,
654    /// Matrix event id once the server has one. Empty for a local echo
655    /// that has not been accepted yet.
656    pub event_id: Option<String>,
657}
658
659impl OpenedStore {
660    /// Derives the seal key from `session_id`, reads `dir`, and opens the
661    /// core. A different session id cannot open records this session sealed.
662    ///
663    /// The host keychain injects `device_token`. [`STORE_ROOT_ENV`] plus
664    /// `session_id` ([`session_store_dir`]) picks `dir`. This does not read
665    /// a routine URL or a leader socket. The web machine client and the
666    /// node CLI set wake on their own open paths. The device bearer stays
667    /// in memory and is not written under `dir`.
668    pub fn open(
669        dir: &Path,
670        session_id: &str,
671        device_id: DeviceId,
672        user_id: &str,
673        server_name: &str,
674        backend: Arc<dyn Backend>,
675        device_token: &str,
676    ) -> Result<Self, ShellError> {
677        if session_id.is_empty() {
678            return Err(ShellError::EmptySession);
679        }
680        if device_token.is_empty()
681            || device_token
682                .bytes()
683                .any(|byte| byte.is_ascii_whitespace() || !byte.is_ascii())
684        {
685            return Err(ShellError::DeviceToken);
686        }
687        let base_url = backend.server_ref().to_string();
688        fs_create_dir(dir)?;
689        let user_id = UserId::parse(user_id)?;
690        let secrets = CoreSecrets {
691            store_seal_key: Some(store_key::store_key(dir, session_id)?),
692            backup_key: None,
693        };
694        let config = CoreConfig {
695            user_id,
696            device_id,
697            server_name: server_name.to_string(),
698        };
699        let records = read_records(dir)?;
700        let mut core =
701            match MessengerCore::open_sealed(records, secrets, config, 0, Box::new(ZeroJitter)) {
702                Ok(core) => core,
703                Err(MessengerError::Store(err)) => return Err(ShellError::Store(err)),
704                Err(err) => return Err(ShellError::Messenger(err)),
705            };
706        persist(dir, &mut core)?;
707        let mut opened = Self {
708            dir: dir.to_path_buf(),
709            driver: Driver::new(core, backend, {
710                let dir = dir.to_path_buf();
711                Box::new(move |core| persist(&dir, core).map_err(|e| AgentError::Store(clip_public(e.to_string()))))
712            }),
713            base_url,
714            device_token: Zeroizing::new(device_token.to_string()),
715            session_id: session_id.to_string(),
716            nick: None,
717            routine_url: None,
718            routine_bearer: None,
719            leader_sock: None,
720            leader_cwd: None,
721            routine_sent: HashSet::new(),
722            routine_woken_seed_pending: false,
723            routine_restart_catchup: false,
724            reaction_floor: None,
725            leader_sent: HashSet::new(),
726            wake_chain: None,
727            wake_route: None,
728            wake_note: None,
729            wake_log: Vec::new(),
730            bus: None,
731            local_peers: Vec::new(),
732            pushed_room_events: Vec::new(),
733            history_pulled: HashSet::new(),
734            tip_pulled: HashSet::new(),
735        };
736        // Routine replay is suppressed for whatever is already on disk.
737        // A leader prompt is not. Only a recorded successful prompt is.
738        opened.note_already_present();
739        Ok(opened)
740    }
741
742    /// Registers this Grok Bot web session, then opens its sealed store.
743    ///
744    /// `POST /client/v3/register` with the derived nick, the session id, and
745    /// a public id equal to that nick. The bearer from a creating response
746    /// stays in memory. A repeat of the same session id returns no bearer;
747    /// [`DEVICE_TOKEN_ENV`] (already copied into `config`) is used instead.
748    /// One [`Self::drive`] publishes this device's public keys. The private
749    /// Olm account stays in the store.
750    pub fn connect(config: &SessionConfig) -> Result<Self, ShellError> {
751        Self::connect_with_wake(config, SessionWake::default())
752    }
753
754    pub(crate) fn connect_with_wake(config: &SessionConfig, wake: SessionWake) -> Result<Self, ShellError> {
755        let registered = register_session(config)?;
756        let server_name = registered
757            .user_id
758            .split_once(':')
759            .map(|(_, server)| server)
760            .filter(|server| !server.is_empty())
761            .ok_or(ShellError::Register("user id has no server".to_string()))?;
762        let mut opened = Self::open(
763            &config.store_dir(),
764            &config.session_id,
765            registered.device_id,
766            &registered.user_id,
767            server_name,
768            registered.backend,
769            &registered.bearer,
770        )?;
771        opened.nick = Some(registered.nick.clone());
772        opened.set_wake(wake);
773        // The first sync is what publishes the public keys.
774        opened.drive(1_000, false)?;
775        Ok(opened)
776    }
777
778    /// [`SessionConfig::from_env`] then [`Self::connect`]. No routine URL
779    /// and no leader socket. The web machine client is
780    /// [`MachineClient::from_env`]. The node CLI is
781    /// [`Self::connect_node_from_env`].
782    pub fn connect_from_env() -> Result<Self, ShellError> {
783        Self::connect(&SessionConfig::from_env()?)
784    }
785
786    /// Node CLI open path. One session from the host environment, woken by
787    /// ACP on [`LEADER_SOCK_ENV`]. [`ROUTINE_URL_ENV`] or
788    /// [`ROUTINE_BEARER_ENV`] is refused before register, is not stored, and
789    /// is not logged. This does not read a session json.
790    pub fn connect_node_from_env() -> Result<Self, ShellError> {
791        let wake = SessionWake::node_cli()?;
792        Self::connect_with_wake(&SessionConfig::from_env()?, wake)
793    }
794
795    #[cfg(test)]
796    pub(crate) fn connect_node_from_lookup(
797        mut get: impl FnMut(&str) -> Option<String>,
798        toml_text: Option<&str>,
799    ) -> Result<Self, ShellError> {
800        let wake = SessionWake::node_from_lookup(&mut get)?;
801        let config = SessionConfig::from_lookup(&mut get, toml_text)?;
802        Self::connect_with_wake(&config, wake)
803    }
804
805    /// Bearer for the host keychain. Not written to disk and not logged.
806    pub(crate) fn keep_prefix(&self) -> bool {
807        self.driver.backend().keep_prefix()
808    }
809
810    pub fn device_bearer(&self) -> &str {
811        self.device_token.as_str()
812    }
813
814    /// This session's derived nick, when [`Self::connect`] stored one.
815    /// [`Self::open`] leaves this empty.
816    pub fn nick(&self) -> Option<&str> {
817        self.nick.as_deref()
818    }
819
820    /// Session / device id used for the seal and for ACP `sessionId`.
821    pub fn session_id(&self) -> &str {
822        &self.session_id
823    }
824
825    /// Homeserver origin this store performs Client-Server calls against.
826    pub fn homeserver_url(&self) -> &str {
827        self.base_url.trim_end_matches('/')
828    }
829
830    /// Whether an ACP leader socket is configured for wake.
831    pub fn has_leader(&self) -> bool {
832        self.leader_sock.is_some()
833    }
834
835    /// Directory this store seals into. Not shared with another session.
836    pub fn store_dir(&self) -> &Path {
837        &self.dir
838    }
839
840    /// Homeserver calls this store's client has counted. In-process bus
841    /// calls are not included. Zero when this store has no bus.
842    pub fn homeserver_hits(&self) -> u64 {
843        self.bus.as_ref().map(|bus| bus.hits()).unwrap_or(0)
844    }
845
846    /// Room texts this session received on the machine push socket.
847    pub fn pushed_room_events(&self) -> &[PushedRoomEvent] {
848        &self.pushed_room_events
849    }
850
851    pub(crate) fn record_push(&mut self, event: PushedRoomEvent) {
852        self.pushed_room_events.push(event);
853    }
854
855    pub(crate) fn attach_bus(&mut self, bus: Arc<machine::LocalBus>) {
856        let inner = Arc::clone(self.driver.backend());
857        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 }));
858        self.bus = Some(bus);
859    }
860
861    pub(crate) fn set_registered_nick(&mut self, nick: String) {
862        self.nick = Some(nick);
863    }
864
865    pub(crate) fn set_local_peers(&mut self, peers: Vec<(String, String)>) {
866        self.local_peers = peers;
867    }
868
869    /// Drops a `/sync` left on the wire by [`Self::drive`] so the next drive
870    /// is not blocked on that poll. The engine retries the sync later. This
871    /// does not start another poll.
872    pub(crate) fn abandon_inflight_sync(&mut self, now_ms: i64) -> Result<(), ShellError> {
873        if self.driver.sync_inflight() {
874            self.driver.abandon_sync(now_ms);
875            self.persist_core()?;
876        }
877        Ok(())
878    }
879
880    /// This account's Matrix user id.
881    pub fn user_id(&self) -> &str {
882        self.driver.core.user_id().as_str()
883    }
884
885    /// Whether `user_id` is a joined member of `room_id` in this store.
886    pub fn member_joined(&self, room_id: &str, user_id: &str) -> bool {
887        let Ok(room_id) = RoomId::parse(room_id) else {
888            return false;
889        };
890        let Ok(user_id) = UserId::parse(user_id) else {
891            return false;
892        };
893        self.driver.core.room_state(&room_id).is_some_and(|state| {
894            state
895                .members
896                .get(&user_id)
897                .is_some_and(|member| member.membership == Membership::Join)
898        })
899    }
900
901    /// Looks up `name_or_nick` in the user directory. A display name is
902    /// derived first when it is not already a nick. The hit is nick plus
903    /// user id. No routine URL is returned or sent.
904    pub fn find_nick(
905        &mut self,
906        name_or_nick: &str,
907        now_ms: i64,
908    ) -> Result<FoundSession, ShellError> {
909        let needle = nick::lookup_nick(name_or_nick)?;
910        if let Some((nick, user_id)) = self
911            .local_peers
912            .iter()
913            .find(|(nick, _)| nick.eq_ignore_ascii_case(&needle))
914        {
915            return Ok(FoundSession {
916                nick: nick.clone(),
917                user_id: user_id.clone(),
918            });
919        }
920        self.dispatch(
921            MessengerCommand::SearchUsers {
922                term: needle.clone(),
923            },
924            now_ms,
925        )?;
926        self.drive(now_ms, false)?;
927        let mut hits: Vec<FoundSession> = self
928            .driver.core
929            .user_search_result()
930            .iter()
931            .filter_map(|entry| {
932                let nick = entry.display_name.as_deref()?.trim();
933                if !nick.eq_ignore_ascii_case(&needle) {
934                    return None;
935                }
936                Some(FoundSession {
937                    nick: nick.to_string(),
938                    user_id: entry.user_id.as_str().to_string(),
939                })
940            })
941            .collect();
942        hits.sort_by(|left, right| left.user_id.cmp(&right.user_id));
943        hits.dedup_by(|left, right| left.user_id == right.user_id);
944        match hits.len() {
945            1 => Ok(hits.remove(0)),
946            0 => Err(ShellError::UnknownNick),
947            _ => Err(ShellError::UnknownNick),
948        }
949    }
950
951    /// Looks `name_or_nick` up and opens the encrypted DM, creating it when
952    /// this account does not already have one. Does not send. The peer may
953    /// still be invited: [`Self::write_to_nick`] sends only after they join.
954    pub fn ensure_dm(&mut self, name_or_nick: &str, mut now_ms: i64) -> Result<String, ShellError> {
955        let (_peer, room_id) = self.open_dm(name_or_nick, &mut now_ms)?;
956        Ok(room_id)
957    }
958
959    /// Joins invites already visible to this store, by the auto-join policy
960    /// ([`AutoJoinPolicy::from_env`]): a DM invite when every other member is on this account's
961    /// own server, a group-room invite when the inviter is on this account's own server (and,
962    /// when an allow-list is set, is on it). Anything else is left alone. Returns the room ids
963    /// this call asked to join. This does not register the inviter.
964    pub fn accept_direct_invites(&mut self, now_ms: i64) -> Result<Vec<String>, ShellError> {
965        self.accept_invites(now_ms, &AutoJoinPolicy::from_env())
966    }
967
968    /// [`Self::accept_direct_invites`] with an explicit policy.
969    pub fn accept_invites(&mut self, mut now_ms: i64, policy: &AutoJoinPolicy) -> Result<Vec<String>, ShellError> {
970        let me = self.driver.core.user_id().clone();
971        let home = server_of(me.as_str()).to_string();
972        let invited: Vec<RoomId> = self
973            .driver.core
974            .room_ids()
975            .cloned()
976            .filter(|room_id| {
977                let Some(state) = self.driver.core.room_state(room_id) else {
978                    return false;
979                };
980                let Some(mine) = state.members.get(&me).filter(|m| m.membership == Membership::Invite) else {
981                    return false;
982                };
983                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()))
984            })
985            .collect();
986        let mut ids = Vec::new();
987        for room_id in invited {
988            self.dispatch(
989                MessengerCommand::JoinRoom {
990                    room_id: room_id.clone(),
991                },
992                now_ms,
993            )?;
994            ids.push(room_id.as_str().to_string());
995        }
996        if ids.is_empty() {
997            return Ok(ids);
998        }
999        for _ in 0..8 {
1000            now_ms += 1_000;
1001            self.drive(now_ms, false)?;
1002            let still_invited = ids.iter().any(|room_id| {
1003                self.rooms()
1004                    .iter()
1005                    .any(|room| room.room_id == *room_id && room.membership == "invite")
1006            });
1007            if !still_invited {
1008                break;
1009            }
1010        }
1011        Ok(ids)
1012    }
1013
1014    /// Writes `text` to the session named by `name_or_nick`.
1015    ///
1016    /// The caller is already registered. This looks the nick up, reuses the
1017    /// encrypted DM when one exists, otherwise creates one, and sends only
1018    /// after the peer's membership is `join`. A peer who is still invited
1019    /// gets no ciphertext: the caller joins them first
1020    /// ([`Self::accept_direct_invites`]) and calls this again. It does not
1021    /// register the other session and it does not put a routine URL in the room.
1022    pub fn write_to_nick(
1023        &mut self,
1024        name_or_nick: &str,
1025        text: &str,
1026        mut now_ms: i64,
1027    ) -> Result<String, ShellError> {
1028        let (peer, room_id) = self.open_dm(name_or_nick, &mut now_ms)?;
1029        if !self.member_joined(&room_id, peer.as_str()) {
1030            return Err(ShellError::Dm);
1031        }
1032        let encrypted = self
1033            .rooms()
1034            .into_iter()
1035            .any(|room| room.room_id == room_id && room.encrypted);
1036        if !encrypted {
1037            return Err(ShellError::Dm);
1038        }
1039        self.dispatch(
1040            MessengerCommand::SendMessage {
1041                room_id: RoomId::parse(&room_id)?,
1042                message: OutgoingMessage {
1043                    kind: MessageKind::Text,
1044                    body: text.to_string(),
1045                    reply_to: None,
1046                    edit_of: None,
1047                },
1048                txn_id: None,
1049            },
1050            now_ms,
1051        )?;
1052        for _ in 0..20 {
1053            now_ms += 1_000;
1054            self.drive(now_ms, false)?;
1055            if let Some(row) = self
1056                .texts()
1057                .into_iter()
1058                .find(|row| row.room_id == room_id && row.body == text)
1059            {
1060                if row.outcome == "sent" {
1061                    return Ok(room_id);
1062                }
1063                if row.outcome.starts_with("failed") {
1064                    return Err(ShellError::Dm);
1065                }
1066            }
1067        }
1068        Err(ShellError::Dm)
1069    }
1070
1071    fn open_dm(
1072        &mut self,
1073        name_or_nick: &str,
1074        now_ms: &mut i64,
1075    ) -> Result<(UserId, String), ShellError> {
1076        let found = self.find_nick(name_or_nick, *now_ms)?;
1077        let peer = UserId::parse(&found.user_id)?;
1078        if &peer == self.driver.core.user_id() {
1079            return Err(ShellError::UnknownNick);
1080        }
1081        let room_id = if let Some(room_id) = self.dm_room(&peer) {
1082            room_id
1083        } else {
1084            self.dispatch(
1085                MessengerCommand::CreateRoom {
1086                    kind: CreateRoomKind::Dm { peer: peer.clone() },
1087                },
1088                *now_ms,
1089            )?;
1090            self.wait_for_dm(&peer, now_ms)?
1091        };
1092        Ok((peer, room_id))
1093    }
1094
1095    fn wait_for_dm(&mut self, peer: &UserId, now_ms: &mut i64) -> Result<String, ShellError> {
1096        for attempt in 0..6 {
1097            *now_ms += 1_000;
1098            self.drive(*now_ms, attempt == 5)?;
1099            if let Some(room_id) = self.dm_room(peer) {
1100                return Ok(room_id);
1101            }
1102        }
1103        Err(ShellError::Dm)
1104    }
1105
1106    fn dm_room(&self, peer: &UserId) -> Option<String> {
1107        let me = self.driver.core.user_id().clone();
1108        let ids: Vec<RoomId> = self.driver.core.room_ids().cloned().collect();
1109        for room_id in &ids {
1110            let (joined, has_peer) = {
1111                let Some(state) = self.driver.core.room_state(room_id) else {
1112                    continue;
1113                };
1114                let joined = state
1115                    .members
1116                    .get(&me)
1117                    .is_some_and(|member| member.membership == Membership::Join);
1118                let has_peer = state.members.contains_key(peer);
1119                (joined, has_peer)
1120            };
1121            if !joined || !has_peer {
1122                continue;
1123            }
1124            if self.driver.core.room_kind(room_id) == Some(RoomKind::Dm) {
1125                return Some(room_id.as_str().to_string());
1126            }
1127        }
1128        None
1129    }
1130
1131    /// Replaces the wake targets. `None` or empty turns that trigger off.
1132    /// The bearer is stored in [`Zeroizing`] memory and is not written to
1133    /// disk. [`Self::open`] does not read a wake; the web machine client
1134    /// and the node CLI set one on their open paths.
1135    pub fn set_wake(&mut self, wake: SessionWake) {
1136        self.routine_url = wake.routine_url.filter(|url| !url.is_empty());
1137        self.routine_bearer = wake
1138            .routine_bearer
1139            .filter(|token| !token.is_empty())
1140            .map(Zeroizing::new);
1141        self.leader_sock = wake.leader_sock.filter(|path| !path.as_os_str().is_empty());
1142        self.leader_cwd = wake.leader_cwd.filter(|cwd| !cwd.is_empty());
1143    }
1144
1145    /// Installs the provider wake chain ([`provider::plan_chain`]). Used
1146    /// only while no leader socket is set; the Grok leader path and the
1147    /// routine POST are unchanged by it.
1148    pub fn set_wake_chain(
1149        &mut self,
1150        session: provider::ProviderSession,
1151        chain: provider::WakeChain,
1152    ) {
1153        self.wake_chain = Some((session, chain));
1154    }
1155
1156    /// Link ids of the installed chain, in order. Empty when none.
1157    pub fn wake_chain_ids(&self) -> Vec<&'static str> {
1158        self.wake_chain
1159            .as_ref()
1160            .map(|(_, chain)| chain.ids())
1161            .unwrap_or_default()
1162    }
1163
1164    /// `<link id> delivered|queued` for the newest chain wake.
1165    pub fn wake_route(&self) -> Option<&str> {
1166        self.wake_route.as_deref()
1167    }
1168
1169    /// [`Self::wake_route`], cleared, so a listener prints it once.
1170    pub fn take_wake_route(&mut self) -> Option<String> {
1171        self.wake_route.take()
1172    }
1173
1174    /// Whether this store has a routine to wake (URL set).
1175    pub fn has_routine(&self) -> bool {
1176        self.routine_url.is_some()
1177    }
1178
1179    /// Last wake failure that did not drop the room text. `None` if the
1180    /// last attempt worked or nothing has been attempted. The bearer and
1181    /// the plaintext are not included.
1182    pub fn wake_note(&self) -> Option<&str> {
1183        self.wake_note.as_deref()
1184    }
1185
1186    /// Routine POSTs this store attempted since open, oldest first. Holds
1187    /// the event id and the HTTP status only: no URL, key, or body.
1188    pub fn wake_log(&self) -> &[WakeAttempt] {
1189        &self.wake_log
1190    }
1191
1192    /// Queues `command` on the engine. It does not perform HTTP; [`Self::drive`]
1193    /// does that for whatever the engine then releases.
1194    pub fn dispatch(&mut self, command: MessengerCommand, now_ms: i64) -> Result<(), ShellError> {
1195        self.driver.core.dispatch(command, now_ms)?;
1196        self.persist_core()?;
1197        Ok(())
1198    }
1199
1200    /// Queues [`MessengerCommand::SendMessage`] and releases whatever the
1201    /// engine will send next. That is a room send on an unencrypted room,
1202    /// or the key-setup requests the engine emits first when the room is
1203    /// encrypted. Nothing here calls the old mailbox. The returned requests
1204    /// are already in flight; a caller that wants them performed uses
1205    /// [`Self::drive`] instead of this method.
1206    pub fn send_room_message(
1207        &mut self,
1208        room_id: &str,
1209        text: &str,
1210        now_ms: i64,
1211    ) -> Result<Vec<OutgoingRequest>, ShellError> {
1212        let room_id = RoomId::parse(room_id)?;
1213        self.driver.core.dispatch(
1214            MessengerCommand::SendMessage {
1215                room_id,
1216                message: OutgoingMessage {
1217                    kind: MessageKind::Text,
1218                    body: text.to_string(),
1219                    reply_to: None,
1220                    edit_of: None,
1221                },
1222                txn_id: None,
1223            },
1224            now_ms,
1225        )?;
1226        self.persist_core()?;
1227        let mut released = self.driver.core.releasable_requests(now_ms);
1228        self.persist_core()?;
1229        if !released
1230            .iter()
1231            .any(|request| request.kind == OutgoingRequestKind::RoomSend)
1232        {
1233            released.extend(self.driver.core.releasable_requests(now_ms));
1234            self.persist_core()?;
1235        }
1236        Ok(released)
1237    }
1238
1239    /// Performs every released request against `base_url`.
1240    ///
1241    /// `/sync` with `timeout=0` is always waited on. A long-poll `/sync` is
1242    /// started immediately and joined only when `wait_for_sync` is set, and
1243    /// at most once per call, so an idle 30s poll does not run back to back
1244    /// while key-setup requests are still in flight. The long-poll that is
1245    /// left on the wire is the engine's own request, including its timeout.
1246    /// A homeserver that wakes that poll (this server does) delivers the
1247    /// event without the shell polling twice.
1248    pub fn drive(&mut self, now_ms: i64, wait_for_sync: bool) -> Result<(), ShellError> {
1249        self.driver.core.decrypt_loaded_timeline();
1250        if self.reaction_floor.is_none() {
1251            // Reactions older than the first time this store ran with reaction wakes never wake, so a
1252            // deploy does not replay history; later ones (also while offline) are deduped by the ledger.
1253            let path = self.store_dir().join("reaction-wake-floor");
1254            // Wall clock, not `now_ms`: callers pass synthetic times on open (`drive(1_000)`).
1255            let _ = now_ms;
1256            let floor = std::fs::read_to_string(&path).ok().and_then(|t| t.trim().parse::<i64>().ok()).unwrap_or_else(|| {
1257                let wall = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0);
1258                let _ = std::fs::write(&path, wall.to_string());
1259                wall
1260            });
1261            self.reaction_floor = Some(floor);
1262        }
1263        let trace_at = self.driver.http_trace.len();
1264        let history = self.request_missing_history(now_ms)?;
1265        self.pull_room_tips()?;
1266        let mut waited_long_poll = false;
1267        if wait_for_sync && self.driver.sync_inflight() {
1268            // A poll that already finished is a stale catch-up. Do not count it: the caller is
1269            // waiting for whatever is current, which is the next `/sync` this call starts.
1270            waited_long_poll = self.driver.harvest_sync(now_ms, true)?;
1271        } else {
1272            self.driver.harvest_sync(now_ms, false)?;
1273        }
1274        self.wake_inbound();
1275        for _ in 0..24 {
1276            if !self.driver.step(now_ms, wait_for_sync, &mut waited_long_poll)? {
1277                break;
1278            }
1279            self.wake_inbound();
1280        }
1281        self.note_history_pages(&history, trace_at);
1282        self.note_room_tips();
1283        self.routine_restart_catchup = false;
1284        Ok(())
1285    }
1286
1287    /// Newest page of each joined room, once the timeline is still empty.
1288    /// [`MessengerCore::pull_latest_page`] does not use the sync token.
1289    fn pull_room_tips(&mut self) -> Result<(), ShellError> {
1290        let me = self.driver.core.user_id().clone();
1291        let ids: Vec<RoomId> = self.driver.core.room_ids().cloned().collect();
1292        for room_id in ids {
1293            if self.tip_pulled.contains(room_id.as_str()) {
1294                continue;
1295            }
1296            if self
1297                .driver.core
1298                .timeline(&room_id)
1299                .is_some_and(|timeline| !timeline.items().is_empty())
1300            {
1301                self.tip_pulled.insert(room_id.as_str().to_string());
1302                continue;
1303            }
1304            let joined = self.driver.core.room_state(&room_id).is_some_and(|state| {
1305                state
1306                    .members
1307                    .get(&me)
1308                    .is_some_and(|member| matches!(member.membership, Membership::Join))
1309            });
1310            if !joined {
1311                continue;
1312            }
1313            self.driver.core.pull_latest_page(room_id)?;
1314        }
1315        Ok(())
1316    }
1317
1318    fn note_room_tips(&mut self) {
1319        let ids: Vec<RoomId> = self.driver.core.room_ids().cloned().collect();
1320        for room_id in ids {
1321            if self
1322                .driver.core
1323                .timeline(&room_id)
1324                .is_some_and(|timeline| !timeline.items().is_empty())
1325            {
1326                self.tip_pulled.insert(room_id.as_str().to_string());
1327            }
1328        }
1329    }
1330
1331    /// Joined rooms whose timeline is still empty. The engine does not
1332    /// store timeline rows, and `/sync?since=` does not replay them. One
1333    /// backward `/messages` from the stored sync token loads that gap.
1334    fn request_missing_history(&mut self, now_ms: i64) -> Result<Vec<String>, ShellError> {
1335        let me = self.driver.core.user_id().clone();
1336        let ids: Vec<RoomId> = self.driver.core.room_ids().cloned().collect();
1337        let mut requested = Vec::new();
1338        for room_id in ids {
1339            if !self.room_needs_history(&room_id, &me) {
1340                continue;
1341            }
1342            let label = room_id.as_str().to_string();
1343            self.driver.core
1344                .dispatch(MessengerCommand::LoadOlder { room_id }, now_ms)?;
1345            requested.push(label);
1346        }
1347        Ok(requested)
1348    }
1349
1350    fn room_needs_history(&self, room_id: &RoomId, me: &UserId) -> bool {
1351        if self.history_pulled.contains(room_id.as_str()) {
1352            return false;
1353        }
1354        let joined = match self
1355            .driver.core
1356            .room_state(room_id)
1357            .and_then(|state| state.members.get(me))
1358        {
1359            Some(member) => matches!(member.membership, Membership::Join),
1360            None => false,
1361        };
1362        if !joined {
1363            return false;
1364        }
1365        match self.driver.core.timeline(room_id) {
1366            Some(timeline) => timeline.items().is_empty(),
1367            None => true,
1368        }
1369    }
1370
1371    fn note_history_pages(&mut self, requested: &[String], trace_at: usize) {
1372        if requested.is_empty() {
1373            return;
1374        }
1375        let ok = self.driver.http_trace[trace_at..]
1376            .iter()
1377            .filter(|(kind, status)| {
1378                *kind == OutgoingRequestKind::RoomMessages && (200..300).contains(status)
1379            })
1380            .count();
1381        if ok < requested.len() {
1382            return;
1383        }
1384        for room_id in requested {
1385            self.history_pulled.insert(room_id.clone());
1386        }
1387    }
1388
1389    /// Rooms this account has state for.
1390    pub fn rooms(&self) -> Vec<RoomView> {
1391        let me = self.driver.core.user_id().clone();
1392        let mut rooms: Vec<RoomView> = self
1393            .driver.core
1394            .room_ids()
1395            .map(|room_id| {
1396                let state = self.driver.core.room_state(room_id);
1397                let membership = state
1398                    .and_then(|state| state.members.get(&me))
1399                    .map(|member| membership_name(&member.membership).to_string())
1400                    .unwrap_or_else(|| "absent".to_string());
1401                let encrypted = state.and_then(|state| state.encryption.as_ref()).is_some();
1402                RoomView {
1403                    room_id: room_id.as_str().to_string(),
1404                    membership,
1405                    encrypted,
1406                }
1407            })
1408            .collect();
1409        rooms.sort_by(|left, right| left.room_id.cmp(&right.room_id));
1410        rooms
1411    }
1412
1413    /// Text-like timeline rows. Encrypted payloads that have not decrypted
1414    /// are reported as `undecryptable` without their ciphertext.
1415    pub fn texts(&self) -> Vec<TextView> {
1416        let mut out = Vec::new();
1417        for room_id in self.driver.core.room_ids() {
1418            let Some(timeline) = self.driver.core.timeline(room_id) else {
1419                continue;
1420            };
1421            for item in timeline.items() {
1422                match &item.content {
1423                    ItemContent::Text(text)
1424                    | ItemContent::Notice(text)
1425                    | ItemContent::Emote(text) => {
1426                        out.push(TextView {
1427                            room_id: room_id.as_str().to_string(),
1428                            body: text.body.clone(),
1429                            outcome: outcome_name(&item.send_state),
1430                            event_id: item.event_id.as_ref().map(|id| id.as_str().to_string()),
1431                        });
1432                    }
1433                    ItemContent::Undecryptable { reason } => out.push(TextView {
1434                        room_id: room_id.as_str().to_string(),
1435                        body: String::new(),
1436                        outcome: format!("undecryptable:{reason}"),
1437                        event_id: item.event_id.as_ref().map(|id| id.as_str().to_string()),
1438                    }),
1439                    _ => {}
1440                }
1441            }
1442        }
1443        out
1444    }
1445
1446    /// HTTP status codes this store has seen, oldest first. Bodies are not kept.
1447    pub fn sync_inflight(&self) -> bool {
1448        self.driver.sync_inflight()
1449    }
1450
1451    pub fn http_trace(&self) -> Vec<String> {
1452        self.driver.http_trace
1453            .iter()
1454            .map(|(kind, status)| format!("{kind:?} {status}"))
1455            .collect()
1456    }
1457
1458    /// The last response this core failed to ingest, once.
1459    pub fn take_ingest_error(&mut self) -> Option<String> {
1460        self.driver.core.take_ingest_error().map(clip_public)
1461    }
1462
1463    fn persist_core(&mut self) -> Result<(), ShellError> {
1464        self.driver.persist().map_err(ShellError::from)
1465    }
1466
1467    /// Drains peer key-change alerts (M3).
1468    pub fn take_security_alerts(&mut self) -> Vec<String> {
1469        std::mem::take(&mut self.driver.security_alerts)
1470    }
1471
1472    fn inbound_plaintexts(&self) -> Vec<InboundPlaintext> {
1473        let me = self.driver.core.user_id();
1474        let mut out = Vec::new();
1475        for room_id in self.driver.core.room_ids() {
1476            let Some(timeline) = self.driver.core.timeline(room_id) else {
1477                continue;
1478            };
1479            for item in timeline.items() {
1480                if item.redacted || &item.sender == me || item.send_state != SendState::Sent {
1481                    continue;
1482                }
1483                let mut body = match &item.content {
1484                    ItemContent::Text(text)
1485                    | ItemContent::Notice(text)
1486                    | ItemContent::Emote(text) => text.body.clone(),
1487                    _ => continue,
1488                };
1489                // An edit that landed before the wake: deliver only the final version.
1490                if let Some(edit) = &item.relations.latest_edit {
1491                    if let mail4agent_messenger::wire::events::RoomMessageContent::Text(t)
1492                    | mail4agent_messenger::wire::events::RoomMessageContent::Notice(t)
1493                    | mail4agent_messenger::wire::events::RoomMessageContent::Emote(t) = &edit.content
1494                    {
1495                        body = t.body.clone();
1496                    }
1497                }
1498                let Some(event_id) = item.event_id.as_ref() else {
1499                    continue;
1500                };
1501                let key = format!("{}\n{}", room_id.as_str(), event_id.as_str());
1502                out.push(InboundPlaintext {
1503                    room_id: room_id.as_str().to_string(),
1504                    key,
1505                    body,
1506                    from: item.sender.as_str().to_string(),
1507                    nick: self.sender_nick(room_id, &item.sender),
1508                    event_id: event_id.as_str().to_string(),
1509                    reaction: None,
1510                    ts: item.origin_server_ts,
1511                });
1512            }
1513        }
1514        out
1515    }
1516
1517    /// Reactions by others on this account's own messages, or on anything in a DM, newer than
1518    /// `floor`. A reaction that was taken back (redacted) is no longer a relating event, so one
1519    /// added and removed before the wake produces nothing; one on a redacted message neither.
1520    fn reaction_items(&self, floor: i64) -> Vec<InboundPlaintext> {
1521        let me = self.driver.core.user_id();
1522        let mut out = Vec::new();
1523        for room_id in self.driver.core.room_ids() {
1524            let Some(timeline) = self.driver.core.timeline(room_id) else { continue };
1525            let direct = self.room_is_direct(room_id.as_str());
1526            for item in timeline.items() {
1527                let Some(target) = item.event_id.as_ref() else { continue };
1528                if item.redacted || !(direct || &item.sender == me) {
1529                    continue;
1530                }
1531                let snippet = match &item.content {
1532                    ItemContent::Text(t) | ItemContent::Notice(t) | ItemContent::Emote(t) => {
1533                        let one: String = t.body.split_whitespace().collect::<Vec<_>>().join(" ");
1534                        if one.chars().count() > 80 { format!("{}…", one.chars().take(80).collect::<String>()) } else { one }
1535                    }
1536                    _ => String::new(),
1537                };
1538                for r in timeline.relating_events_for(target) {
1539                    let mail4agent_messenger::wire::events::RelatesTo::Annotation { key, .. } = &r.relation else { continue };
1540                    if &r.sender == me || r.origin_server_ts <= floor {
1541                        continue;
1542                    }
1543                    let who = mxid_localpart(r.sender.as_str()).to_string();
1544                    let whose = if &item.sender == me { "your message".to_string() } else { format!("{}'s message", mxid_localpart(item.sender.as_str())) };
1545                    out.push(InboundPlaintext {
1546                        room_id: room_id.as_str().to_string(),
1547                        key: format!("{}\n{}", room_id.as_str(), r.event_id.as_str()),
1548                        body: format!("{who} reacted {key} to {whose}: \"{snippet}\""),
1549                        from: r.sender.as_str().to_string(),
1550                        nick: None,
1551                        event_id: r.event_id.as_str().to_string(),
1552                        reaction: Some((key.clone(), target.as_str().to_string())),
1553                        ts: r.origin_server_ts,
1554                    });
1555                }
1556            }
1557        }
1558        out
1559    }
1560
1561    /// Display name already stored for `sender` in this room. Empty and
1562    /// missing names stay `None`. This does not query the network and
1563    /// does not invent a nick from the mxid.
1564    fn sender_nick(&self, room_id: &RoomId, sender: &UserId) -> Option<String> {
1565        let name = self
1566            .driver.core
1567            .room_state(room_id)?
1568            .members
1569            .get(sender)?
1570            .displayname
1571            .as_deref()?
1572            .trim();
1573        if name.is_empty() {
1574            None
1575        } else {
1576            Some(name.to_string())
1577        }
1578    }
1579
1580    fn note_already_present(&mut self) {
1581        self.load_routine_woken();
1582        self.load_leader_prompted();
1583    }
1584
1585    /// Load [`ROUTINE_WOKEN_FILE`]. A missing file means seed once tip/history
1586    /// has filled the timeline (see [`Self::wake_inbound`]).
1587    fn load_routine_woken(&mut self) {
1588        let path = self.dir.join(ROUTINE_WOKEN_FILE);
1589        match std::fs::read_to_string(&path) {
1590            Ok(text) if !text.trim().is_empty() => {
1591                for line in text.lines() {
1592                    let Some((room, event)) = line.split_once('\t') else {
1593                        continue;
1594                    };
1595                    if room.is_empty() || event.is_empty() {
1596                        continue;
1597                    }
1598                    self.routine_sent.insert(format!("{room}\n{event}"));
1599                }
1600                self.routine_woken_seed_pending = false;
1601                // Tip/history after restart can surface rows the ledger never
1602                // saw; silence them for the first drive instead of re-POSTing.
1603                self.routine_restart_catchup = true;
1604            }
1605            Ok(_) | Err(_) => {
1606                // Missing or empty ledger: seed once tip/history fills the timeline.
1607                self.routine_woken_seed_pending = true;
1608                self.routine_restart_catchup = false;
1609            }
1610        }
1611    }
1612
1613    /// Mark every inbound plaintext except the newest in each room as already
1614    /// woken, and persist. The newest still fires once (then
1615    /// [`Self::remember_routine_wake`] lasting across restarts). Mirrors
1616    /// [`Self::seed_leader_prompted_except_newest`].
1617    fn seed_routine_woken_from_timeline(&mut self) {
1618        let path = self.dir.join(ROUTINE_WOKEN_FILE);
1619        let items = self.inbound_plaintexts();
1620        let mut newest: std::collections::HashMap<String, String> =
1621            std::collections::HashMap::new();
1622        for item in &items {
1623            newest.insert(item.room_id.clone(), item.key.clone());
1624        }
1625        let mut lines = String::new();
1626        for item in &items {
1627            if newest.get(&item.room_id) == Some(&item.key) {
1628                continue;
1629            }
1630            self.routine_sent.insert(item.key.clone());
1631            if let Some((room, event)) = item.key.split_once('\n') {
1632                lines.push_str(room);
1633                lines.push('\t');
1634                lines.push_str(event);
1635                lines.push('\n');
1636            }
1637        }
1638        let _ = std::fs::write(path, lines);
1639        self.routine_woken_seed_pending = false;
1640    }
1641
1642    fn remember_routine_wake(&mut self, key: &str) {
1643        self.routine_sent.insert(key.to_string());
1644        let Some((room, event)) = key.split_once('\n') else {
1645            return;
1646        };
1647        if room.is_empty() || event.is_empty() {
1648            return;
1649        }
1650        let path = self.dir.join(ROUTINE_WOKEN_FILE);
1651        let mut file = match std::fs::OpenOptions::new()
1652            .create(true)
1653            .append(true)
1654            .open(&path)
1655        {
1656            Ok(file) => file,
1657            Err(_) => return,
1658        };
1659        use std::io::Write;
1660        let _ = write!(file, "{room}\t{event}\n");
1661    }
1662
1663    /// Keys already given to `session/prompt`. A missing file is the first
1664    /// run of this trigger: older rows stay quiet, and the newest inbound
1665    /// text in each room is left unmarked so the trigger fires for it.
1666    fn load_leader_prompted(&mut self) {
1667        let path = self.dir.join(LEADER_PROMPTED_FILE);
1668        match std::fs::read_to_string(&path) {
1669            Ok(text) => {
1670                for line in text.lines() {
1671                    let Some((room, event)) = line.split_once('\t') else {
1672                        continue;
1673                    };
1674                    if room.is_empty() || event.is_empty() {
1675                        continue;
1676                    }
1677                    self.leader_sent.insert(format!("{room}\n{event}"));
1678                }
1679            }
1680            Err(_) => self.seed_leader_prompted_except_newest(&path),
1681        }
1682    }
1683
1684    fn seed_leader_prompted_except_newest(&mut self, path: &Path) {
1685        let items = self.inbound_plaintexts();
1686        let mut newest: std::collections::HashMap<String, String> =
1687            std::collections::HashMap::new();
1688        for item in &items {
1689            newest.insert(item.room_id.clone(), item.key.clone());
1690        }
1691        let mut lines = String::new();
1692        for item in &items {
1693            if newest.get(&item.room_id) == Some(&item.key) {
1694                continue;
1695            }
1696            self.leader_sent.insert(item.key.clone());
1697            if let Some((room, event)) = item.key.split_once('\n') {
1698                lines.push_str(room);
1699                lines.push('\t');
1700                lines.push_str(event);
1701                lines.push('\n');
1702            }
1703        }
1704        let _ = std::fs::write(path, lines);
1705    }
1706
1707    fn remember_leader_prompt(&mut self, key: &str) {
1708        self.leader_sent.insert(key.to_string());
1709        let Some((room, event)) = key.split_once('\n') else {
1710            return;
1711        };
1712        if room.is_empty() || event.is_empty() {
1713            return;
1714        }
1715        let path = self.dir.join(LEADER_PROMPTED_FILE);
1716        let mut file = match std::fs::OpenOptions::new()
1717            .create(true)
1718            .append(true)
1719            .open(&path)
1720        {
1721            Ok(file) => file,
1722            Err(_) => return,
1723        };
1724        use std::io::Write;
1725        let _ = write!(file, "{room}\t{event}\n");
1726    }
1727
1728    /// Posts and/or prompts each new inbound plaintext. A missing URL or
1729    /// socket skips that trigger. A failed trigger is remembered on
1730    /// [`Self::wake_note`] and retried on a later [`Self::drive`]. The
1731    /// timeline row stays either way.
1732    /// An empty `leader-prompted` was often written before any timeline
1733    /// row existed. Once history is in memory, keep every inbound except
1734    /// the newest in each room off the leader.
1735    fn arm_unprompted_newest(&mut self) {
1736        if (self.leader_sock.is_none() && self.wake_chain.is_none()) || !self.leader_sent.is_empty() {
1737            return;
1738        }
1739        if self.inbound_plaintexts().is_empty() {
1740            return;
1741        }
1742        let path = self.dir.join(LEADER_PROMPTED_FILE);
1743        self.seed_leader_prompted_except_newest(&path);
1744    }
1745
1746    /// A one-to-one conversation: at most two joined or invited members and no name.
1747    fn room_is_direct(&self, room_id: &str) -> bool {
1748        let Some(id) = self.driver.core.room_ids().find(|r| r.as_str() == room_id) else { return false };
1749        let Some(state) = self.driver.core.room_state(id) else { return false };
1750        let live = state.members.values().filter(|m| matches!(membership_name(&m.membership), "join" | "invite")).count();
1751        state.name.is_none() && live <= 2
1752    }
1753
1754    /// The letter chain for a store with no chain installed: the letter hook
1755    /// (`M4A_LETTER_HOOK`) first, then g4a's deliver adapter (routine webhook for a web
1756    /// client with a routine URL), then the durable inbox.
1757    fn default_letter_chain(&self) -> (provider::ProviderSession, provider::WakeChain) {
1758        let kind = if self.routine_url.is_some() && self.leader_sock.is_none() {
1759            provider::SessionKind::web(provider::ProviderKind::Grok)
1760        } else {
1761            provider::SessionKind::local(provider::ProviderKind::Grok)
1762        };
1763        let session = provider::ProviderSession {
1764            kind,
1765            session_id: self.session_id.clone(),
1766            nick: self.nick.clone().unwrap_or_default(),
1767            cwd: self.leader_cwd.clone().map(PathBuf::from),
1768            headless: false,
1769        };
1770        let root = self.dir.parent().map(Path::to_path_buf);
1771        let inbox = root.map(|r| provider::registry::inbox_dir(&r, &self.session_id));
1772        let mut config = provider::AdapterConfig::from_env(inbox.clone());
1773        config.routine_url = self.routine_url.clone();
1774        config.routine_bearer = self.routine_bearer.as_ref().map(|b| b.as_str().to_owned());
1775        if let Some(sock) = &self.leader_sock {
1776            config.leader_sock = Some(PathBuf::from(sock));
1777        }
1778        (session.clone(), crate::provider::letter_chain(kind, inbox, &config))
1779    }
1780
1781    fn wake_inbound(&mut self) {
1782        if self.routine_url.is_none() && self.leader_sock.is_none() && self.wake_chain.is_none() {
1783            return;
1784        }
1785        if self.routine_restart_catchup && self.routine_url.is_some() {
1786            // Existing ledger + tip/history catch-up: persist every inbound
1787            // key without handing it over again. Cleared when this drive finishes.
1788            for item in self.inbound_plaintexts() {
1789                if !self.routine_sent.contains(&item.key) {
1790                    self.remember_routine_wake(&item.key);
1791                    self.remember_leader_prompt(&item.key);
1792                }
1793            }
1794        } else if self.routine_woken_seed_pending && self.routine_url.is_some() {
1795            if !self.inbound_plaintexts().is_empty() {
1796                self.seed_routine_woken_from_timeline();
1797            }
1798        }
1799        self.arm_unprompted_newest();
1800        let policy = crate::wake_policy::WakePolicy::load(self.store_dir());
1801        let me_id = self.driver.core.user_id().as_str().to_string();
1802        let mut items = self.inbound_plaintexts();
1803        // The owner's policy: a message that does not qualify is marked handled, so turning the
1804        // wake on later does not replay it.
1805        items.retain(|item| {
1806            let direct = self.room_is_direct(&item.room_id);
1807            let mentioned = crate::wake_policy::addresses(&item.body, self.nick.as_deref(), &me_id);
1808            if policy.allows(direct, mentioned) {
1809                return true;
1810            }
1811            self.remember_routine_wake(&item.key);
1812            self.remember_leader_prompt(&item.key);
1813            false
1814        });
1815        // Reactions to this agent (its own messages, or in its DMs): a low-priority letter each.
1816        let floor = self.reaction_floor.unwrap_or(i64::MAX);
1817        let reactions = self.reaction_items(floor);
1818        items.extend(reactions.into_iter().filter(|r| policy.allows(self.room_is_direct(&r.room_id), true)));
1819        for item in items {
1820            if self.leader_sent.contains(&item.key) || self.routine_sent.contains(&item.key) {
1821                continue;
1822            }
1823            let (session, mut chain) = match self.wake_chain.take() {
1824                Some(installed) => installed,
1825                None => self.default_letter_chain(),
1826            };
1827            let from_nick = mxid_localpart(&item.from).to_string();
1828            let letter = provider::WakeLetter {
1829                body: &item.body,
1830                from_nick: &from_nick,
1831                event_id: &item.event_id,
1832                room: Some(&item.room_id),
1833                reaction: item.reaction.as_ref().map(|(key, target)| provider::ReactionNote { key, target }),
1834            };
1835            match chain.wake(&session, &letter) {
1836                Ok((id, outcome)) => {
1837                    self.remember_leader_prompt(&item.key);
1838                    self.remember_routine_wake(&item.key);
1839                    let how = match outcome {
1840                        provider::WakeOutcome::Delivered => "delivered",
1841                        provider::WakeOutcome::Queued(_) => "queued",
1842                    };
1843                    let status = (how == "delivered").then_some(200);
1844                    crate::wake_policy::note_attempt(self.store_dir(), status);
1845                    self.wake_log.push(WakeAttempt { event_id: item.event_id.clone(), status });
1846                    self.wake_route = Some(format!("{id} {how}"));
1847                }
1848                Err(err) => {
1849                    self.wake_note = Some(clip_public(err.to_string()));
1850                }
1851            }
1852            self.wake_chain = Some((session, chain));
1853        }
1854    }
1855}
1856
1857#[allow(dead_code)]
1858struct InboundPlaintext {
1859    room_id: String,
1860    key: String,
1861    body: String,
1862    from: String,
1863    nick: Option<String>,
1864    event_id: String,
1865    /// `(emoji, target event)` for a reaction letter.
1866    reaction: Option<(String, String)>,
1867    ts: i64,
1868}
1869
1870fn membership_name(membership: &Membership) -> &'static str {
1871    match membership {
1872        Membership::Invite => "invite",
1873        Membership::Join => "join",
1874        Membership::Knock => "knock",
1875        Membership::Leave => "leave",
1876        Membership::Ban => "ban",
1877        Membership::Unknown => "unknown",
1878    }
1879}
1880
1881fn outcome_name(state: &SendState) -> String {
1882    match state {
1883        SendState::Sent => "sent".to_string(),
1884        SendState::Sending | SendState::LocalEcho => "sending".to_string(),
1885        SendState::Failed { reason } => format!("failed:{reason}"),
1886    }
1887}
1888
1889fn parse_base_url(raw: &str) -> Result<reqwest::Url, ShellError> {
1890    let url = reqwest::Url::parse(raw).map_err(|_| ShellError::BaseUrl)?;
1891    match url.scheme() {
1892        "http" | "https" => {}
1893        _ => return Err(ShellError::BaseUrl),
1894    }
1895    if url.host_str().is_none()
1896        || url.query().is_some()
1897        || url.fragment().is_some()
1898        || !url.username().is_empty()
1899        || url.password().is_some()
1900    {
1901        return Err(ShellError::BaseUrl);
1902    }
1903    Ok(url)
1904}
1905
1906struct RegisteredSession {
1907    user_id: String,
1908    device_id: DeviceId,
1909    bearer: Zeroizing<String>,
1910    /// Identity mode: the nick the operator assigned.
1911    nick: String,
1912    /// The backend that logged in; it executes this session's requests from now on.
1913    backend: Arc<dyn Backend>,
1914}
1915
1916/// Identity mode: the client's own identity logs in by signature (enrolling first when it has to).
1917#[cfg(any(feature = "tier-server", feature = "tier-matrix"))]
1918fn identity_session(config: &SessionConfig, auth: &IdentityAuth) -> Result<RegisteredSession, ShellError> {
1919    let fail = |e: AgentError| ShellError::Register(clip_public(e.to_string()));
1920    parse_base_url(&config.homeserver_url)?;
1921    let ids = m4a_agent::IdentityStore::new(store_key::vault(&config.store_root)?);
1922    let backend: Arc<dyn Backend> = match auth.tier {
1923        #[cfg(feature = "tier-server")]
1924        m4a_agent::BackendKind::Server => Arc::new(m4a_agent::backend::server::ServerBackend::new(&config.homeserver_url).map_err(fail)?),
1925        // The domain's `.well-known` may name the real server; the spec prefix and sliding sync are
1926        // asked of the server, never assumed.
1927        #[cfg(feature = "tier-matrix")]
1928        m4a_agent::BackendKind::Matrix => Arc::new(m4a_agent::backend::matrix::MatrixBackend::discover(&config.homeserver_url).map_err(fail)?),
1929        #[allow(unreachable_patterns)]
1930        _ => return Err(ShellError::Register("this build does not include that tier".into())),
1931    };
1932    let mut id = ids.resolve(&config.session_id, auth.tier, backend.server_ref()).map_err(fail)?;
1933    if !id.enrolled && id.requested_nick != auth.nick_request {
1934        id.requested_nick = auth.nick_request.clone();
1935    }
1936    let session = backend.ensure_session(&ids, &mut id, auth.invite.as_ref().map(|i| i.as_str())).map_err(fail)?;
1937    // The invite is spent; the file the operator left is not kept.
1938    let _ = std::fs::remove_file(invite_path(&config.store_root, &config.session_id));
1939    ids.adopt_nick(&mut id, &session.nick).map_err(fail)?;
1940    let (user_id, device_raw) = match (&session.user_id, &session.device_id) {
1941        (Some(u), Some(d)) => (u.clone(), d.clone()),
1942        _ => {
1943            backend.whoami().map_err(fail)?
1944        }
1945    };
1946    if user_id.is_empty() || device_raw.is_empty() {
1947        return Err(ShellError::Register("whoami missed an id".into()));
1948    }
1949    Ok(RegisteredSession { user_id, device_id: DeviceId::parse(&device_raw)?, bearer: session.token.clone(), nick: session.nick, backend })
1950}
1951
1952#[cfg(not(any(feature = "tier-server", feature = "tier-matrix")))]
1953fn identity_session(_: &SessionConfig, _: &IdentityAuth) -> Result<RegisteredSession, ShellError> {
1954    Err(ShellError::Register("this build has no tier for identity login".into()))
1955}
1956
1957fn register_session(config: &SessionConfig) -> Result<RegisteredSession, ShellError> {
1958    identity_session(config, &config.identity)
1959}
1960
1961fn clip_public(text: String) -> String {
1962    let mut out = String::new();
1963    for token in text.split_whitespace() {
1964        if token.len() > 80 {
1965            out.push_str("[omitted]");
1966        } else {
1967            out.push_str(token);
1968        }
1969        out.push(' ');
1970        if out.len() > 240 {
1971            break;
1972        }
1973    }
1974    out
1975}
1976
1977fn fs_create_dir(dir: &Path) -> Result<(), ShellError> {
1978    std::fs::create_dir_all(dir)?;
1979    Ok(())
1980}
1981
1982/// Why opening the client store, releasing a send, or posting a routine failed.
1983/// The seal key and the device bearer are never included.
1984#[derive(Debug, thiserror::Error)]
1985pub enum ShellError {
1986    /// No session id, so there is nothing to hash.
1987    #[error("session id is empty")]
1988    EmptySession,
1989    /// [`STORE_ROOT_ENV`] is unset outside tests.
1990    #[error("store root is unset")]
1991    StoreRoot,
1992    /// [`HOMESERVER_URL_ENV`] and toml `homeserver_url` are both unset.
1993    #[error("homeserver url is unset")]
1994    HomeserverUrl,
1995    /// [`BOT_NAME_ENV`] is unset. The display name is required for a web session.
1996    #[error("bot display name is unset")]
1997    BotName,
1998    /// The display name did not yield a nick.
1999    #[error("nick could not be derived from the bot display name")]
2000    Nick,
2001    /// The host's session list was missing or refused. No bearer is included.
2002    #[error("session list: {0}")]
2003    SessionList(String),
2004    /// Homeserver register failed. The text is a status and errcode, not a bearer.
2005    #[error("homeserver register failed: {0}")]
2006    Register(String),
2007    /// No session nick matched.
2008    #[error("no session with that nick")]
2009    UnknownNick,
2010    /// A direct room was not opened or its encrypted send did not finish.
2011    #[error("direct room was not ready")]
2012    Dm,
2013    /// Toml for the homeserver URL could not be read.
2014    #[error("config: {0}")]
2015    Config(String),
2016    /// The bearer is empty or not a single header value.
2017    #[error("device token is empty or not a single header value")]
2018    DeviceToken,
2019    /// `base_url` is not an `http` or `https` origin.
2020    #[error("base url must be http or https")]
2021    BaseUrl,
2022    /// A record key tried to leave the store directory.
2023    #[error("record key escapes the store directory")]
2024    BadRecordKey,
2025    /// The routine URL is not an `http` or `https` URL this shell can post to.
2026    #[error("routine url is not an http or https url")]
2027    RoutineUrl,
2028    /// Node CLI open path saw a routine URL or bearer. The value is not included.
2029    #[error("node cli does not take a routine url")]
2030    NodeRoutine,
2031    /// The local gateway did not yield a routine. No token, URL, or key.
2032    #[error("gateway: {0}")]
2033    Gateway(String),
2034    /// The routine bearer is not a single header value. The value is not included.
2035    #[error("routine bearer is empty or not a single header value")]
2036    RoutineBearer,
2037    /// The routine answered once and was not a success. The body is not included.
2038    #[error("routine status {0}")]
2039    RoutineStatus(u16),
2040    /// The routine socket or TLS handshake failed. The body is not included.
2041    #[error("routine post failed: {0}")]
2042    RoutineTransport(String),
2043    /// A homeserver call failed before a status line. The body is not included.
2044    #[error("homeserver http failed: {0}")]
2045    Http(String),
2046    /// The engine rejected a successful HTTP body. Long blobs are omitted.
2047    #[error("homeserver response was not ingested: {0}")]
2048    Ingest(String),
2049    /// Reading or writing the store directory failed.
2050    #[error("io: {0}")]
2051    Io(#[from] std::io::Error),
2052    /// The seal did not authenticate. A different session id produces this.
2053    #[error(transparent)]
2054    Store(#[from] StoreError),
2055    /// The engine rejected the command or could not load the Olm account.
2056    #[error(transparent)]
2057    Messenger(#[from] MessengerError),
2058}
2059
2060impl From<AgentError> for ShellError {
2061    fn from(err: AgentError) -> Self {
2062        match err {
2063            AgentError::Transport(text) => ShellError::Http(text),
2064            AgentError::Ingest(text) => ShellError::Ingest(text),
2065            other => ShellError::Register(clip_public(other.to_string())),
2066        }
2067    }
2068}
2069
2070fn persist(dir: &Path, core: &mut MessengerCore<SealedRecordCodec>) -> Result<(), ShellError> {
2071    while let Some(batch) = core.take_flush_batch() {
2072        for record in &batch.records {
2073            let path = record_path(dir, &record.key)?;
2074            if let Some(parent) = path.parent() {
2075                std::fs::create_dir_all(parent)?;
2076            }
2077            std::fs::write(&path, &record.bytes)?;
2078            #[cfg(unix)]
2079            {
2080                use std::os::unix::fs::PermissionsExt;
2081                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
2082            }
2083        }
2084        for key in &batch.deletes {
2085            let path = record_path(dir, key)?;
2086            if path.exists() {
2087                std::fs::remove_file(path)?;
2088            }
2089        }
2090        core.ack_flush(batch.id);
2091    }
2092    Ok(())
2093}
2094
2095/// Files in a session directory that are not sealed records.
2096const NOT_RECORDS: &[&str] = &[
2097    machine::WAKE_KEYCHAIN_FILE,
2098    machine::STORE_LOCK_FILE,
2099    LEADER_PROMPTED_FILE,
2100    ROUTINE_WOKEN_FILE,
2101];
2102
2103/// Event ids this process has already handed to the leader. Presence in the
2104/// timeline is not a prompt. One line is `room \t event_id`.
2105const LEADER_PROMPTED_FILE: &str = "leader-prompted";
2106/// Persisted `room\tevent_id` lines: routine webhook already woke this key.
2107/// Without it, restart + tip-page re-POSTs historical plaintext to the routine.
2108const ROUTINE_WOKEN_FILE: &str = "routine-woken";
2109
2110fn read_records(dir: &Path) -> Result<Vec<SealedRecord>, ShellError> {
2111    let mut records = Vec::new();
2112    if dir.exists() {
2113        walk(dir, dir, &mut records)?;
2114    }
2115    Ok(records)
2116}
2117
2118fn walk(dir: &Path, root: &Path, records: &mut Vec<SealedRecord>) -> Result<(), ShellError> {
2119    for entry in std::fs::read_dir(dir)? {
2120        let entry = entry?;
2121        let path = entry.path();
2122        if path.is_dir() {
2123            walk(&path, root, records)?;
2124            continue;
2125        }
2126        // Keychain and lock files share the session directory; they are
2127        // not sealed records.
2128        if dir == root
2129            && path
2130                .file_name()
2131                .and_then(|name| name.to_str())
2132                .is_some_and(|name| NOT_RECORDS.contains(&name))
2133        {
2134            continue;
2135        }
2136        let rel = path
2137            .strip_prefix(root)
2138            .map_err(|_| ShellError::BadRecordKey)?;
2139        let key = rel
2140            .components()
2141            .map(|component| component.as_os_str().to_string_lossy())
2142            .collect::<Vec<_>>()
2143            .join("/");
2144        let bytes = std::fs::read(&path)?;
2145        records.push(SealedRecord {
2146            key: RecordKey::new(key),
2147            bytes,
2148        });
2149    }
2150    Ok(())
2151}
2152
2153fn record_path(dir: &Path, key: &RecordKey) -> Result<PathBuf, ShellError> {
2154    let rel = Path::new(key.as_str());
2155    if rel.is_absolute()
2156        || rel.components().any(|component| {
2157            matches!(
2158                component,
2159                Component::ParentDir | Component::RootDir | Component::Prefix(_)
2160            )
2161        })
2162    {
2163        return Err(ShellError::BadRecordKey);
2164    }
2165    Ok(dir.join(rel))
2166}
2167
2168#[cfg(test)]
2169mod tests {
2170    #[allow(unused_imports)]
2171    use std::time::Duration;
2172    use super::*;
2173
2174    #[test]
2175    fn session_config_is_identity_only_and_reads_no_secret() {
2176        let get = |extra: &'static [(&'static str, &'static str)]| {
2177            move |key: &str| {
2178                extra.iter().find(|(k, _)| *k == key).map(|(_, v)| v.to_string()).or_else(|| match key {
2179                    SESSION_ID_ENV => Some("web-session-1".to_string()),
2180                    STORE_ROOT_ENV => Some("/tmp/m4a-root".to_string()),
2181                    // Retired inputs are never consulted, even when present.
2182                    "M4A_PRODUCT_PASSWORD" | "M4A_PRODUCT_TOKEN" | "M4A_DEVICE_TOKEN" => Some("must-not-be-read".to_string()),
2183                    _ => None,
2184                })
2185            }
2186        };
2187        let config = SessionConfig::from_lookup(get(&[(HOMESERVER_URL_ENV, "http://127.0.0.1:9")]), None).expect("config");
2188        assert_eq!(config.store_dir(), session_store_dir(Path::new("/tmp/m4a-root"), "web-session-1"));
2189        assert!(!format!("{config:?}").contains("must-not-be-read"));
2190        assert_eq!(config.identity.tier, m4a_agent::BackendKind::Server);
2191        let matrix = SessionConfig::from_lookup(get(&[(PRODUCT_URL_ENV, "http://127.0.0.1:8"), (TIER_ENV, "matrix")]), None).expect("matrix");
2192        assert_eq!(matrix.identity.tier, m4a_agent::BackendKind::Matrix);
2193        assert!(SessionConfig::from_lookup(get(&[(TIER_ENV, "other"), (PRODUCT_URL_ENV, "http://127.0.0.1:8")]), None).is_err());
2194        let from_toml = SessionConfig::from_lookup(get(&[]), Some("homeserver_url = \"http://127.0.0.1:9\"\nother = \"ignored\"\n")).expect("toml url");
2195        assert_eq!(from_toml.homeserver_url, "http://127.0.0.1:9");
2196        assert!(SessionConfig::from_lookup(get(&[]), None).is_err(), "no server given");
2197    }
2198    use std::io::{Read, Write};
2199    use std::net::TcpListener;
2200    use std::sync::atomic::{AtomicBool, Ordering};
2201    use std::sync::{Arc, Mutex};
2202    use std::thread;
2203    use std::time::Instant;
2204
2205    fn plain(base: &str, token: &str) -> Arc<dyn Backend> {
2206        Arc::new(m4a_agent::backend::attached::AttachedBackend::with_prefix(m4a_agent::BackendKind::Server, base, token, false).expect("backend"))
2207    }
2208
2209    struct TempDir(PathBuf);
2210
2211    impl Drop for TempDir {
2212        fn drop(&mut self) {
2213            let _ = std::fs::remove_dir_all(&self.0);
2214        }
2215    }
2216
2217    fn temp_dir(name: &str) -> TempDir {
2218        let dir = TempDir(std::env::temp_dir().join(format!(
2219            "mail4agent-messenger-shell-{}-{name}",
2220            std::process::id()
2221        )));
2222        let _ = std::fs::remove_dir_all(&dir.0);
2223        dir
2224    }
2225
2226    fn open_alice(dir: &Path, session: &str) -> OpenedStore {
2227        let device = DeviceId::parse("DEVICE1").expect("device id");
2228        OpenedStore::open(
2229            dir,
2230            session,
2231            device,
2232            "@alice:localhost",
2233            "localhost",
2234            plain("http://127.0.0.1:9", "fake-token"),
2235            "fake-token",
2236        )
2237        .expect("open")
2238    }
2239
2240    #[test]
2241    fn same_session_opens_and_a_different_session_fails_the_seal() {
2242        let dir = temp_dir("seal");
2243        open_alice(&dir.0, "session-a");
2244        open_alice(&dir.0, "session-a");
2245
2246        let device = DeviceId::parse("DEVICE1").expect("device id");
2247        match OpenedStore::open(
2248            &dir.0,
2249            "session-b",
2250            device,
2251            "@alice:localhost",
2252            "localhost",
2253            plain("http://127.0.0.1:9", "fake-token"),
2254            "fake-token",
2255        ) {
2256            Err(ShellError::Store(StoreError::CodecOpen { .. })) => {}
2257            Ok(_) => panic!("different session opened the sealed store"),
2258            Err(err) => panic!("expected seal auth failure, got {err}"),
2259        }
2260    }
2261
2262    #[test]
2263    fn two_sessions_under_one_root_seal_and_reopen_only_with_their_own_id() {
2264        let root = temp_dir("isolate");
2265        let dir_a = session_store_dir(&root.0, "session-a");
2266        let dir_b = session_store_dir(&root.0, "session-b");
2267        assert_ne!(dir_a, dir_b);
2268        assert_eq!(dir_a.parent(), Some(root.0.as_path()));
2269        assert_eq!(dir_b.parent(), Some(root.0.as_path()));
2270        assert_eq!(session_store_dir(&root.0, "session-a"), dir_a);
2271        let slipped = session_store_dir(&root.0, "../session-a");
2272        assert_eq!(slipped.parent(), Some(root.0.as_path()));
2273        let name = slipped.file_name().expect("name").to_string_lossy();
2274        assert_eq!(name.len(), 64);
2275        assert!(name.chars().all(|ch| ch.is_ascii_hexdigit()));
2276
2277        let bearer = "isolation-bearer-7c2e";
2278        let device = DeviceId::parse("DEVICE1").expect("device id");
2279        let open_with = |dir: &Path, session: &str| {
2280            OpenedStore::open(
2281                dir,
2282                session,
2283                device.clone(),
2284                "@alice:localhost",
2285                "localhost",
2286                plain("http://127.0.0.1:9", bearer),
2287                bearer,
2288            )
2289        };
2290        open_with(&dir_a, "session-a").expect("seal a");
2291        open_with(&dir_b, "session-b").expect("seal b");
2292        open_with(&dir_a, "session-a").expect("reopen a");
2293        open_with(&dir_b, "session-b").expect("reopen b");
2294
2295        for (dir, session) in [(&dir_a, "session-b"), (&dir_b, "session-a")] {
2296            match open_with(dir, session) {
2297                Err(ShellError::Store(StoreError::CodecOpen { .. })) => {}
2298                Ok(_) => panic!("{session} opened the other sealed store"),
2299                Err(err) => panic!("expected seal auth failure, got {err}"),
2300            }
2301        }
2302
2303        fn contains_bearer(dir: &Path, needle: &str) -> bool {
2304            if !dir.exists() {
2305                return false;
2306            }
2307            for entry in std::fs::read_dir(dir).expect("read") {
2308                let entry = entry.expect("entry");
2309                let path = entry.path();
2310                if path.to_string_lossy().contains(needle) {
2311                    return true;
2312                }
2313                if path.is_dir() {
2314                    if contains_bearer(&path, needle) {
2315                        return true;
2316                    }
2317                } else if std::fs::read(&path)
2318                    .expect("file")
2319                    .windows(needle.len())
2320                    .any(|window| window == needle.as_bytes())
2321                {
2322                    return true;
2323                }
2324            }
2325            false
2326        }
2327        assert!(
2328            !contains_bearer(&root.0, bearer),
2329            "raw bearer was written under the store root"
2330        );
2331    }
2332
2333
2334
2335    #[test]
2336    fn a_base_url_carries_no_fragment_credentials_or_query() {
2337        assert!(parse_base_url("http://127.0.0.1:9").is_ok());
2338        assert!(parse_base_url("http://127.0.0.1:9/#other").is_err());
2339        assert!(parse_base_url("http://u:p@127.0.0.1:9").is_err());
2340        assert!(parse_base_url("ftp://127.0.0.1:9").is_err());
2341    }
2342
2343    #[test]
2344    fn send_message_releases_a_matrix_room_send() {
2345        let dir = temp_dir("send");
2346        let mut store = open_alice(&dir.0, "session-a");
2347        let released = store
2348            .send_room_message("!room:localhost", "hello room", 0)
2349            .expect("release");
2350        let send = released
2351            .iter()
2352            .find(|request| request.kind == OutgoingRequestKind::RoomSend)
2353            .unwrap_or_else(|| {
2354                panic!(
2355                    "engine did not release a RoomSend: {:?}",
2356                    released
2357                        .iter()
2358                        .map(|request| request.kind)
2359                        .collect::<Vec<_>>()
2360                )
2361            });
2362        assert!(
2363            send.path.starts_with("/_matrix/client/v3/rooms/")
2364                && send.path.contains("/send/m.room.message/"),
2365            "not a matrix room send: {}",
2366            send.path
2367        );
2368        assert!(!send.path.contains("/mail/send"), "{}", send.path);
2369        assert!(!send.path.contains("/admin/listener"), "{}", send.path);
2370        let body = send.body.as_ref().expect("room send body");
2371        assert_eq!(body["msgtype"], "m.text");
2372        assert_eq!(body["body"], "hello room");
2373    }
2374
2375    #[test]
2376    fn drive_performs_the_released_request_with_bearer_and_no_matrix_prefix() {
2377        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2378        let addr = listener.local_addr().expect("addr");
2379        let seen = Arc::new(Mutex::new(Vec::<String>::new()));
2380        let seen_worker = Arc::clone(&seen);
2381        listener.set_nonblocking(true).expect("nonblocking");
2382        let server = thread::spawn(move || {
2383            let deadline = std::time::Instant::now() + Duration::from_secs(3);
2384            loop {
2385                if std::time::Instant::now() > deadline {
2386                    break;
2387                }
2388                let sock = match listener.accept() {
2389                    Ok((sock, _)) => sock,
2390                    Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
2391                        thread::sleep(Duration::from_millis(20));
2392                        continue;
2393                    }
2394                    Err(_) => break,
2395                };
2396                let mut sock = sock;
2397                let _ = sock.set_nonblocking(false);
2398                let _ = sock.set_read_timeout(Some(Duration::from_secs(2)));
2399                let mut buf = Vec::new();
2400                let mut tmp = [0u8; 2048];
2401                loop {
2402                    let n = sock.read(&mut tmp).unwrap_or(0);
2403                    if n == 0 {
2404                        break;
2405                    }
2406                    buf.extend_from_slice(&tmp[..n]);
2407                    if let Some(header_end) =
2408                        buf.windows(4).position(|window| window == b"\r\n\r\n")
2409                    {
2410                        let headers = String::from_utf8_lossy(&buf[..header_end]).to_string();
2411                        let length = headers
2412                            .lines()
2413                            .find_map(|line| {
2414                                let (name, value) = line.split_once(':')?;
2415                                if name.eq_ignore_ascii_case("content-length") {
2416                                    value.trim().parse::<usize>().ok()
2417                                } else {
2418                                    None
2419                                }
2420                            })
2421                            .unwrap_or(0);
2422                        if buf.len() >= header_end + 4 + length {
2423                            let body = buf[header_end + 4..header_end + 4 + length].to_vec();
2424                            let first = headers.lines().next().unwrap_or("").to_string();
2425                            let bearer_ok = headers.lines().any(|line| {
2426                                let (name, value) = line.split_once(':').unwrap_or(("", ""));
2427                                name.eq_ignore_ascii_case("authorization")
2428                                    && value.trim() == "Bearer fake-token"
2429                            });
2430                            let note = format!(
2431                                "{first} bearer_ok={bearer_ok} body={}",
2432                                String::from_utf8_lossy(&body)
2433                            );
2434                            seen_worker.lock().expect("seen").push(note);
2435                            let request = first;
2436                            let response_body = if request.contains("/send/") {
2437                                br#"{"event_id":"$e:localhost"}"#.to_vec()
2438                            } else if request.contains("/keys/") {
2439                                br#"{"one_time_key_counts":{"signed_curve25519":50}}"#.to_vec()
2440                            } else {
2441                                br#"{"next_batch":"s1","device_one_time_keys_count":{"signed_curve25519":50},"device_unused_fallback_key_types":["signed_curve25519"]}"#.to_vec()
2442                            };
2443                            let head = format!(
2444                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2445                                response_body.len()
2446                            );
2447                            let _ = sock.write_all(head.as_bytes());
2448                            let _ = sock.write_all(&response_body);
2449                            break;
2450                        }
2451                    }
2452                }
2453            }
2454        });
2455
2456        let dir = temp_dir("drive");
2457        let device = DeviceId::parse("DEVICE1").expect("device id");
2458        let mut store = OpenedStore::open(
2459            &dir.0,
2460            "session-a",
2461            device,
2462            "@alice:localhost",
2463            "localhost",
2464            plain(&format!("http://{addr}"), "fake-token"),
2465            "fake-token",
2466        )
2467        .expect("open");
2468        store
2469            .dispatch(
2470                MessengerCommand::SendMessage {
2471                    room_id: RoomId::parse("!room:localhost").expect("room"),
2472                    message: OutgoingMessage {
2473                        kind: MessageKind::Text,
2474                        body: "hello room".to_string(),
2475                        reply_to: None,
2476                        edit_of: None,
2477                    },
2478                    txn_id: None,
2479                },
2480                0,
2481            )
2482            .expect("dispatch");
2483        store.drive(0, false).expect("drive");
2484        thread::sleep(Duration::from_millis(200));
2485        drop(store);
2486        let _ = server.join();
2487        let seen = seen.lock().expect("seen");
2488        assert!(
2489            seen.iter().any(|line| {
2490                line.contains("PUT /client/v3/rooms/")
2491                    && line.contains("/send/m.room.message/")
2492                    && line.contains("bearer_ok=true")
2493                    && line.contains("hello room")
2494                    && !line.contains("/_matrix")
2495            }),
2496            "released room send was not performed: {seen:?}"
2497        );
2498        assert!(
2499            seen.iter().all(|line| !line.contains("/_matrix")),
2500            "prefix was not stripped: {seen:?}"
2501        );
2502    }
2503
2504    #[allow(dead_code)]
2505    struct Hit {
2506        headers: String,
2507        body: Vec<u8>,
2508    }
2509
2510    fn read_http(sock: &mut std::net::TcpStream) -> Option<(String, Vec<u8>)> {
2511        let _ = sock.set_read_timeout(Some(Duration::from_secs(2)));
2512        let mut buf = Vec::new();
2513        let mut tmp = [0u8; 2048];
2514        loop {
2515            let n = sock.read(&mut tmp).unwrap_or(0);
2516            if n == 0 {
2517                break;
2518            }
2519            buf.extend_from_slice(&tmp[..n]);
2520            if let Some(header_end) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
2521                let headers = String::from_utf8_lossy(&buf[..header_end]).to_string();
2522                let length = headers
2523                    .lines()
2524                    .find_map(|line| {
2525                        let (name, value) = line.split_once(':')?;
2526                        if name.eq_ignore_ascii_case("content-length") {
2527                            value.trim().parse::<usize>().ok()
2528                        } else {
2529                            None
2530                        }
2531                    })
2532                    .unwrap_or(0);
2533                if buf.len() >= header_end + 4 + length {
2534                    let body = buf[header_end + 4..header_end + 4 + length].to_vec();
2535                    return Some((headers, body));
2536                }
2537            }
2538        }
2539        None
2540    }
2541
2542    fn write_http(sock: &mut std::net::TcpStream, status: &str, body: &[u8]) {
2543        let head = format!(
2544            "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2545            body.len()
2546        );
2547        let _ = sock.write_all(head.as_bytes());
2548        let _ = sock.write_all(body);
2549    }
2550
2551    fn sync_with_text_nick(body: &str, nick: Option<&str>) -> Vec<u8> {
2552        let mut state = Vec::new();
2553        if let Some(nick) = nick {
2554            state.push(serde_json::json!({
2555                "event_id": "$mem:localhost",
2556                "type": "m.room.member",
2557                "state_key": "@bob:localhost",
2558                "sender": "@bob:localhost",
2559                "origin_server_ts": 1,
2560                "content": { "membership": "join", "displayname": nick }
2561            }));
2562        }
2563        serde_json::json!({
2564            "next_batch": "s1",
2565            "rooms": {
2566                "join": {
2567                    "!r:localhost": {
2568                        "state": { "events": state },
2569                        "timeline": {
2570                            "events": [{
2571                                "event_id": "$m1:localhost",
2572                                "type": "m.room.message",
2573                                "sender": "@bob:localhost",
2574                                "origin_server_ts": 10,
2575                                "content": { "msgtype": "m.text", "body": body }
2576                            }]
2577                        }
2578                    }
2579                }
2580            },
2581            "device_one_time_keys_count": { "signed_curve25519": 50 },
2582            "device_unused_fallback_key_types": ["signed_curve25519"]
2583        })
2584        .to_string()
2585        .into_bytes()
2586    }
2587
2588    fn sync_empty() -> Vec<u8> {
2589        serde_json::json!({
2590            "next_batch": "s2",
2591            "device_one_time_keys_count": { "signed_curve25519": 50 },
2592            "device_unused_fallback_key_types": ["signed_curve25519"]
2593        })
2594        .to_string()
2595        .into_bytes()
2596    }
2597
2598    fn spawn_homeserver(text: &'static str) -> (String, Arc<AtomicBool>, thread::JoinHandle<()>) {
2599        spawn_homeserver_nick(text, None)
2600    }
2601
2602    fn spawn_homeserver_nick(
2603        text: &'static str,
2604        nick: Option<&'static str>,
2605    ) -> (String, Arc<AtomicBool>, thread::JoinHandle<()>) {
2606        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2607        let addr = listener.local_addr().expect("addr");
2608        listener.set_nonblocking(true).expect("nonblocking");
2609        let done = Arc::new(AtomicBool::new(false));
2610        let flag = Arc::clone(&done);
2611        let handle = thread::spawn(move || {
2612            let deadline = Instant::now() + Duration::from_secs(8);
2613            while !flag.load(Ordering::Relaxed) && Instant::now() < deadline {
2614                match listener.accept() {
2615                    Ok((mut sock, _)) => {
2616                        let _ = sock.set_nonblocking(false);
2617                        let Some((headers, _)) = read_http(&mut sock) else {
2618                            continue;
2619                        };
2620                        let line = headers.lines().next().unwrap_or("");
2621                        let resp = if line.contains("/sync") && line.contains("since=") {
2622                            sync_empty()
2623                        } else if line.contains("/sync") {
2624                            sync_with_text_nick(text, nick)
2625                        } else if line.contains("/keys/") {
2626                            br#"{"one_time_key_counts":{"signed_curve25519":50}}"#.to_vec()
2627                        } else {
2628                            b"{}".to_vec()
2629                        };
2630                        write_http(&mut sock, "200 OK", &resp);
2631                    }
2632                    Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
2633                        thread::sleep(Duration::from_millis(15));
2634                    }
2635                    Err(_) => break,
2636                }
2637            }
2638        });
2639        (format!("http://{addr}"), done, handle)
2640    }
2641
2642    fn spawn_routine() -> (
2643        String,
2644        Arc<Mutex<Vec<Hit>>>,
2645        Arc<AtomicBool>,
2646        thread::JoinHandle<()>,
2647    ) {
2648        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2649        let addr = listener.local_addr().expect("addr");
2650        listener.set_nonblocking(true).expect("nonblocking");
2651        let hits = Arc::new(Mutex::new(Vec::new()));
2652        let recorded = Arc::clone(&hits);
2653        let done = Arc::new(AtomicBool::new(false));
2654        let flag = Arc::clone(&done);
2655        let handle = thread::spawn(move || {
2656            let deadline = Instant::now() + Duration::from_secs(8);
2657            while !flag.load(Ordering::Relaxed) && Instant::now() < deadline {
2658                match listener.accept() {
2659                    Ok((mut sock, _)) => {
2660                        let _ = sock.set_nonblocking(false);
2661                        if let Some((headers, body)) = read_http(&mut sock) {
2662                            recorded.lock().expect("hits").push(Hit { headers, body });
2663                            let _ = sock.write_all(
2664                                b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
2665                            );
2666                        }
2667                    }
2668                    Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
2669                        thread::sleep(Duration::from_millis(15));
2670                    }
2671                    Err(_) => break,
2672                }
2673            }
2674        });
2675        (format!("http://{addr}/routine"), hits, done, handle)
2676    }
2677
2678    fn stop(flag: &AtomicBool, handle: thread::JoinHandle<()>) {
2679        flag.store(true, Ordering::Relaxed);
2680        let _ = handle.join();
2681    }
2682
2683    fn open_against(base: &str, dir: &Path, session: &str) -> OpenedStore {
2684        let device = DeviceId::parse("DEVICE1").expect("device id");
2685        OpenedStore::open(
2686            dir,
2687            session,
2688            device,
2689            "@alice:localhost",
2690            "localhost",
2691            plain(base, "fake-token"),
2692            "fake-token",
2693        )
2694        .expect("open")
2695    }
2696
2697    #[test]
2698    fn inbound_room_text_goes_through_the_provider_chain_once() {
2699        let (base, home_done, home) = spawn_homeserver("wake-chain");
2700        let dir = temp_dir("wake-chain");
2701        let inbox_dir = dir.0.join("inbox");
2702        let mut store = open_against(&base, &dir.0, "session-c");
2703        let session = provider::ProviderSession {
2704            kind: provider::SessionKind::local(provider::ProviderKind::Codex),
2705            session_id: "thread-c".into(),
2706            nick: "builder".into(),
2707            cwd: None,
2708            headless: false,
2709        };
2710        let chain = provider::letter_chain(session.kind, Some(inbox_dir.clone()), &provider::AdapterConfig::from_env(Some(inbox_dir.clone())));
2711        store.set_wake_chain(session, chain);
2712        assert_eq!(store.wake_chain_ids(), vec!["deliver", "inbox-queue"], "g4a deliver, then the inbox");
2713        store.drive(1_000, false).expect("drive");
2714        store.drive(3_000, false).expect("drive again");
2715        let route = store.take_wake_route();
2716        let note = store.wake_note().unwrap_or("").to_string();
2717        drop(store);
2718        stop(&home_done, home);
2719        // No hook armed and no codex app-server: deliver is unavailable, the durable queue took it, once.
2720        assert_eq!(route.as_deref(), Some("inbox-queue queued"), "note={note}");
2721        let letters = provider::inbox::drain(&inbox_dir);
2722        assert_eq!(letters.len(), 1);
2723        assert!(letters[0].letter.prompt.ends_with("wake-chain"));
2724        assert!(letters[0].letter.prompt.contains("--as builder"));
2725    }
2726
2727
2728
2729
2730    #[test]
2731    fn the_owners_policy_silences_a_wake_and_the_silenced_message_is_never_replayed() {
2732        let (base, home_done, home) = spawn_homeserver("wake-plain");
2733        let (routine, hits, routine_done, routine_thread) = spawn_routine();
2734        let dir = temp_dir("wake-policy");
2735        let mut store = open_against(&base, &dir.0, "session-a");
2736        store.set_wake(SessionWake { routine_url: Some(routine), ..SessionWake::default() });
2737        let off = wake_policy::WakePolicy { enabled: false, mode: wake_policy::WakeMode::Mention };
2738        off.save(store.store_dir()).expect("policy");
2739        store.drive(1_000, false).expect("drive");
2740        assert!(store.texts().iter().any(|t| t.body == "wake-plain"), "the text still arrives and can be read");
2741        assert!(hits.lock().expect("hits").is_empty(), "a disabled wake posted");
2742        // Turning it back on later does not replay what was silenced.
2743        wake_policy::WakePolicy::default().save(store.store_dir()).expect("policy");
2744        store.drive(3_000, false).expect("drive again");
2745        let hits_now = hits.lock().expect("hits").len();
2746        drop(store);
2747        stop(&routine_done, routine_thread);
2748        stop(&home_done, home);
2749        assert_eq!(hits_now, 0, "the silenced message was replayed");
2750    }
2751
2752    #[test]
2753    fn a_web_client_with_a_routine_url_wakes_it_exactly_once_through_g4a() {
2754        let (base, home_done, home) = spawn_homeserver("wake-plain");
2755        let (routine, hits, routine_done, routine_thread) = spawn_routine();
2756        let dir = temp_dir("wake-routine-once");
2757        let mut store = open_against(&base, &dir.0, "session-a");
2758        store.set_wake(SessionWake { routine_url: Some(routine), routine_bearer: Some("k-test".into()), ..SessionWake::default() });
2759        store.drive(1_000, false).expect("drive");
2760        store.drive(3_000, false).expect("drive again");
2761        let route = store.take_wake_route();
2762        let note = store.wake_note().unwrap_or("").to_string();
2763        drop(store);
2764        stop(&routine_done, routine_thread);
2765        stop(&home_done, home);
2766        assert_eq!(hits.lock().expect("hits").len(), 1, "one letter, one routine wake; note={note}");
2767        assert_eq!(route.as_deref(), Some("deliver delivered"), "note={note}");
2768        let inbox = provider::registry::inbox_dir(dir.0.parent().expect("parent"), "session-a");
2769        assert!(provider::inbox::drain(&inbox).is_empty(), "a delivered letter is not also queued");
2770    }
2771
2772    fn spawn_homeserver_events(events: serde_json::Value) -> (String, Arc<AtomicBool>, thread::JoinHandle<()>) {
2773        let first = serde_json::json!({
2774            "next_batch": "s1",
2775            "rooms": { "join": { "!r:localhost": { "state": { "events": [] }, "timeline": { "events": events } } } },
2776            "device_one_time_keys_count": { "signed_curve25519": 50 },
2777            "device_unused_fallback_key_types": ["signed_curve25519"]
2778        }).to_string().into_bytes();
2779        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
2780        let addr = listener.local_addr().expect("addr");
2781        listener.set_nonblocking(true).expect("nonblocking");
2782        let done = Arc::new(AtomicBool::new(false));
2783        let flag = Arc::clone(&done);
2784        let handle = thread::spawn(move || {
2785            let deadline = Instant::now() + Duration::from_secs(8);
2786            while !flag.load(Ordering::Relaxed) && Instant::now() < deadline {
2787                match listener.accept() {
2788                    Ok((mut sock, _)) => {
2789                        let _ = sock.set_nonblocking(false);
2790                        let Some((headers, _)) = read_http(&mut sock) else { continue };
2791                        let line = headers.lines().next().unwrap_or("");
2792                        let resp = if line.contains("/sync") && line.contains("since=") {
2793                            sync_empty()
2794                        } else if line.contains("/sync") {
2795                            first.clone()
2796                        } else if line.contains("/keys/") {
2797                            br#"{"one_time_key_counts":{"signed_curve25519":50}}"#.to_vec()
2798                        } else {
2799                            b"{}".to_vec()
2800                        };
2801                        write_http(&mut sock, "200 OK", &resp);
2802                    }
2803                    Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => thread::sleep(Duration::from_millis(15)),
2804                    Err(_) => break,
2805                }
2806            }
2807        });
2808        (format!("http://{addr}"), done, handle)
2809    }
2810
2811    fn text_ev(id: &str, sender: &str, ts: i64, body: &str) -> serde_json::Value {
2812        serde_json::json!({ "event_id": id, "type": "m.room.message", "sender": sender, "origin_server_ts": ts, "content": { "msgtype": "m.text", "body": body } })
2813    }
2814    fn redaction_ev(id: &str, sender: &str, ts: i64, target: &str) -> serde_json::Value {
2815        serde_json::json!({ "event_id": id, "type": "m.room.redaction", "sender": sender, "origin_server_ts": ts, "redacts": target, "content": { "redacts": target } })
2816    }
2817    /// A server timestamp after this process started (reactions older than the first run never wake).
2818    fn later(ms: i64) -> i64 {
2819        std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0) + 3_600_000 + ms
2820    }
2821    fn reaction_ev(id: &str, sender: &str, ts: i64, target: &str, key: &str) -> serde_json::Value {
2822        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 } } })
2823    }
2824
2825    /// Runs one offline catch-up (agent was down while `events` happened) and returns the routine bodies posted.
2826    fn wakes_for(name: &str, events: serde_json::Value, restart: bool) -> Vec<serde_json::Value> {
2827        let (base, home_done, home) = spawn_homeserver_events(events);
2828        let (routine, hits, routine_done, routine_thread) = spawn_routine();
2829        let dir = temp_dir(name);
2830        let mut store = open_against(&base, &dir.0, "session-a");
2831        store.set_wake(SessionWake { routine_url: Some(routine.clone()), ..SessionWake::default() });
2832        store.drive(1_000, false).expect("drive");
2833        store.drive(3_000, false).expect("drive again");
2834        drop(store);
2835        if restart {
2836            let mut store = open_against(&base, &dir.0, "session-a");
2837            store.set_wake(SessionWake { routine_url: Some(routine), ..SessionWake::default() });
2838            store.drive(5_000, false).expect("drive after restart");
2839            store.drive(7_000, false).expect("drive after restart again");
2840        }
2841        stop(&routine_done, routine_thread);
2842        stop(&home_done, home);
2843        let hits = hits.lock().expect("hits");
2844        hits.iter().map(|h| serde_json::from_slice(&h.body).unwrap_or(serde_json::Value::Null)).collect()
2845    }
2846
2847    #[test]
2848    fn a_message_sent_and_redacted_while_the_agent_was_offline_wakes_nothing() {
2849        let w = wakes_for("coalesce-redact", serde_json::json!([
2850            text_ev("$m1:localhost", "@bob:localhost", 10, "oops wrong chat"),
2851            redaction_ev("$x1:localhost", "@bob:localhost", 11, "$m1:localhost"),
2852        ]), false);
2853        assert!(w.is_empty(), "redacted-before-delivery must produce no wake: {w:?}");
2854    }
2855
2856    #[test]
2857    fn an_edit_before_the_wake_delivers_only_the_final_version() {
2858        let w = wakes_for("coalesce-edit", serde_json::json!([
2859            text_ev("$m1:localhost", "@bob:localhost", 10, "draft text"),
2860            { "event_id": "$e1:localhost", "type": "m.room.message", "sender": "@bob:localhost", "origin_server_ts": 11,
2861              "content": { "msgtype": "m.text", "body": "* final text", "m.new_content": { "msgtype": "m.text", "body": "final text" },
2862                           "m.relates_to": { "rel_type": "m.replace", "event_id": "$m1:localhost" } } },
2863        ]), false);
2864        assert_eq!(w.len(), 1, "one wake for the edited message: {w:?}");
2865        assert_eq!(w[0]["body"], "final text");
2866        assert_eq!(w[0]["event_id"], "$m1:localhost");
2867    }
2868
2869    #[test]
2870    fn a_reaction_to_my_message_wakes_once_low_priority_and_mine_never() {
2871        let w = wakes_for("reaction-wake", serde_json::json!([
2872            text_ev("$a1:localhost", "@alice:localhost", 10, "deploy finished"),
2873            reaction_ev("$r1:localhost", "@bob:localhost", later(0), "$a1:localhost", "👍"),
2874            reaction_ev("$r2:localhost", "@alice:localhost", later(1), "$a1:localhost", "🎉"),
2875        ]), true);
2876        assert_eq!(w.len(), 1, "exactly one wake (bob's reaction), none for my own, no replay after restart: {w:?}");
2877        assert_eq!(w[0]["kind"], "reaction");
2878        assert_eq!(w[0]["reaction"], "👍");
2879        assert_eq!(w[0]["target_event_id"], "$a1:localhost");
2880        assert_eq!(w[0]["event_id"], "$r1:localhost");
2881        assert_eq!(w[0]["reply_needed"], false);
2882        assert_eq!(w[0]["priority"], "low");
2883        assert!(w[0].get("reply").is_none());
2884        assert!(w[0]["body"].as_str().unwrap_or("").contains("deploy finished"));
2885    }
2886
2887    #[test]
2888    fn reactions_from_before_the_first_run_never_wake() {
2889        let w = wakes_for("reaction-history", serde_json::json!([
2890            text_ev("$a1:localhost", "@alice:localhost", 10, "old"),
2891            reaction_ev("$r1:localhost", "@bob:localhost", 11, "$a1:localhost", "👍"),
2892        ]), false);
2893        assert!(w.is_empty(), "history replayed: {w:?}");
2894    }
2895
2896    #[test]
2897    fn a_reaction_added_and_removed_before_the_wake_produces_nothing() {
2898        let w = wakes_for("reaction-undo", serde_json::json!([
2899            text_ev("$a1:localhost", "@alice:localhost", 10, "hello"),
2900            reaction_ev("$r1:localhost", "@bob:localhost", later(0), "$a1:localhost", "👍"),
2901            redaction_ev("$x1:localhost", "@bob:localhost", later(1), "$r1:localhost"),
2902        ]), false);
2903        assert!(w.is_empty(), "{w:?}");
2904    }
2905
2906    #[test]
2907    fn no_routine_url_posts_nothing() {
2908        let (base, home_done, home) = spawn_homeserver("wake-plain");
2909        let (_routine, hits, routine_done, routine_thread) = spawn_routine();
2910        let dir = temp_dir("wake-none");
2911        let mut store = open_against(&base, &dir.0, "session-a");
2912        store.set_wake(SessionWake::default());
2913        store.drive(1_000, false).expect("drive");
2914        let saw_text = store.texts().iter().any(|text| text.body == "wake-plain");
2915        let trace = store.http_trace();
2916        drop(store);
2917        stop(&routine_done, routine_thread);
2918        stop(&home_done, home);
2919        assert!(
2920            saw_text,
2921            "missing routine url dropped the inbound text; http={trace:?}"
2922        );
2923        let hits = hits.lock().expect("hits");
2924        assert!(hits.is_empty(), "a post happened with no routine url");
2925    }
2926
2927
2928
2929}
2930
2931/// The server part of a Matrix user id (`@nick:server` -> `server`).
2932fn server_of(user_id: &str) -> &str {
2933    user_id.split_once(':').map(|(_, server)| server).unwrap_or("")
2934}
2935
2936/// Which invites a session joins on its own. Defaults: DMs and group rooms from members of this
2937/// account's own server. Env: `M4A_AUTO_JOIN_DMS=0` / `M4A_AUTO_JOIN_ROOMS=0` turn either off;
2938/// `M4A_AUTO_JOIN_FROM` (comma-separated nicks or user ids) limits room invites to those
2939/// inviters (e.g. the operator).
2940#[derive(Debug, Clone, PartialEq, Eq)]
2941pub struct AutoJoinPolicy {
2942    pub dms: bool,
2943    pub rooms: bool,
2944    /// Allowed room inviters (nick or full user id); empty = any member of our server.
2945    pub room_inviters: Vec<String>,
2946}
2947
2948impl Default for AutoJoinPolicy {
2949    fn default() -> Self {
2950        Self { dms: true, rooms: true, room_inviters: Vec::new() }
2951    }
2952}
2953
2954impl AutoJoinPolicy {
2955    pub fn from_env() -> Self {
2956        let flag = |k: &str| std::env::var(k).map(|v| !matches!(v.trim(), "0" | "false" | "off" | "no")).unwrap_or(true);
2957        let from = std::env::var("M4A_AUTO_JOIN_FROM").unwrap_or_default();
2958        Self {
2959            dms: flag("M4A_AUTO_JOIN_DMS"),
2960            rooms: flag("M4A_AUTO_JOIN_ROOMS"),
2961            room_inviters: from.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
2962        }
2963    }
2964
2965    /// Whether to join one invite. `members` includes `me`.
2966    pub fn accepts<'a>(&self, home: &str, me: &str, is_direct: bool, inviter: Option<&str>, members: impl Iterator<Item = &'a str>) -> bool {
2967        if home.is_empty() {
2968            return false;
2969        }
2970        if is_direct {
2971            let others: Vec<&str> = members.filter(|u| *u != me).collect();
2972            let peers_home = !others.is_empty() && others.iter().all(|u| server_of(u) == home);
2973            return self.dms && peers_home && inviter.is_none_or(|i| server_of(i) == home);
2974        }
2975        let Some(inviter) = inviter else { return false };
2976        if !self.rooms || server_of(inviter) != home || inviter == me {
2977            return false;
2978        }
2979        self.room_inviters.is_empty()
2980            || 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("")))
2981    }
2982}
2983
2984#[cfg(test)]
2985mod auto_join_tests {
2986    use super::AutoJoinPolicy;
2987
2988    const HOME: &str = "example.org";
2989    const ME: &str = "@agent-a:example.org";
2990
2991    fn ok(p: &AutoJoinPolicy, direct: bool, inviter: Option<&str>, members: &[&str]) -> bool {
2992        p.accepts(HOME, ME, direct, inviter, members.iter().copied())
2993    }
2994
2995    #[test]
2996    fn group_room_invite_from_our_server_is_joined() {
2997        let p = AutoJoinPolicy::default();
2998        assert!(ok(&p, false, Some("@operator:example.org"), &[ME, "@operator:example.org", "@x:other.org"]));
2999    }
3000
3001    #[test]
3002    fn group_room_invite_from_another_server_or_unknown_inviter_is_left() {
3003        let p = AutoJoinPolicy::default();
3004        assert!(!ok(&p, false, Some("@evil:other.org"), &[ME, "@evil:other.org"]));
3005        assert!(!ok(&p, false, None, &[ME, "@operator:example.org"]));
3006    }
3007
3008    #[test]
3009    fn dm_policy_is_unchanged() {
3010        let p = AutoJoinPolicy::default();
3011        assert!(ok(&p, true, Some("@agent-b:example.org"), &[ME, "@agent-b:example.org"]));
3012        assert!(ok(&p, true, None, &[ME, "@agent-b:example.org"]));
3013        assert!(!ok(&p, true, Some("@x:other.org"), &[ME, "@x:other.org"]));
3014    }
3015
3016    #[test]
3017    fn switches_and_allow_list() {
3018        let off = AutoJoinPolicy { rooms: false, ..AutoJoinPolicy::default() };
3019        assert!(!ok(&off, false, Some("@operator:example.org"), &[ME]));
3020        let nodm = AutoJoinPolicy { dms: false, ..AutoJoinPolicy::default() };
3021        assert!(!ok(&nodm, true, Some("@agent-b:example.org"), &[ME, "@agent-b:example.org"]));
3022        let only_op = AutoJoinPolicy { room_inviters: vec!["operator".into()], ..AutoJoinPolicy::default() };
3023        assert!(ok(&only_op, false, Some("@operator:example.org"), &[ME]));
3024        assert!(!ok(&only_op, false, Some("@agent-b:example.org"), &[ME]));
3025    }
3026}