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