Skip to main content

mail4agent_messenger_shell/
letter_hook.rs

1//! The letter event: what m4a emits when a letter for a session arrives, for the harness
2//! (g4a / hatchery node) to act on. m4a does not wake or spawn; it hands the letter over.
3//!
4//! `M4A_LETTER_HOOK=<program>`: for every letter, `<program>` runs once with one JSON object
5//! on stdin -- `{"nick","session_id","provider","from","event_id","room","body"}` -- and no
6//! secrets in its environment (`M4A_SESSION_CAP`, `M4A_PRODUCT_INVITE` removed). Exit 0 within
7//! 10 s means the harness took it (`delivered`). Anything else falls through to the rest of the
8//! chain (legacy adapters, then the durable inbox), so a down harness loses nothing.
9
10use std::io::Write;
11use std::path::PathBuf;
12use std::process::{Command, Stdio};
13use std::time::{Duration, Instant};
14
15use crate::provider::{ProviderSession, WakeAdapter, WakeChain, WakeError, WakeLetter, WakeOutcome};
16
17pub const LETTER_HOOK_ENV: &str = "M4A_LETTER_HOOK";
18const HOOK_TIMEOUT: Duration = Duration::from_secs(10);
19
20/// The letter as the hook sees it.
21pub fn letter_event(session: &ProviderSession, letter: &WakeLetter<'_>) -> serde_json::Value {
22    serde_json::json!({
23        "nick": session.nick,
24        "session_id": session.session_id,
25        "provider": session.kind.provider.id(),
26        "from": letter.from_nick,
27        "event_id": letter.event_id,
28        "room": letter.room,
29        "body": letter.body,
30        "kind": if letter.reaction.is_some() { "reaction" } else { "message" },
31        "reaction": letter.reaction.map(|r| r.key),
32        "target_event_id": letter.reaction.map(|r| r.target),
33        "reply_needed": letter.reaction.is_none(),
34    })
35}
36
37pub struct LetterHookAdapter {
38    pub program: PathBuf,
39}
40
41impl WakeAdapter for LetterHookAdapter {
42    fn kind(&self) -> crate::provider::SessionKind {
43        crate::provider::SessionKind::local(crate::provider::ProviderKind::Grok)
44    }
45
46    fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
47        if self.program.as_os_str().is_empty() {
48            return Err(WakeError::Unavailable("letter hook unset".into()));
49        }
50        Ok(())
51    }
52
53    fn wake(&mut self, session: &ProviderSession, letter: &WakeLetter<'_>) -> Result<WakeOutcome, WakeError> {
54        let bytes = serde_json::to_vec(&letter_event(session, letter)).map_err(|e| WakeError::Transport(e.to_string()))?;
55        let mut child = Command::new(&self.program)
56            .env_remove("M4A_SESSION_CAP")
57            .env_remove("M4A_PRODUCT_INVITE")
58            .stdin(Stdio::piped())
59            .stdout(Stdio::null())
60            .stderr(Stdio::null())
61            .spawn()
62            .map_err(|e| WakeError::Unavailable(format!("letter hook: {}", e.kind())))?;
63        if let Some(mut stdin) = child.stdin.take() {
64            let _ = stdin.write_all(&bytes);
65        }
66        let deadline = Instant::now() + HOOK_TIMEOUT;
67        loop {
68            match child.try_wait() {
69                Ok(Some(status)) if status.success() => return Ok(WakeOutcome::Delivered),
70                Ok(Some(status)) => return Err(WakeError::Transport(format!("letter hook exited {status}"))),
71                Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)),
72                Ok(None) => {
73                    let _ = child.kill();
74                    let _ = child.wait();
75                    return Err(WakeError::Transport("letter hook timed out".into()));
76                }
77                Err(e) => return Err(WakeError::Transport(e.to_string())),
78            }
79        }
80    }
81}
82
83/// Puts the hook first in `chain` when `M4A_LETTER_HOOK` is set.
84pub fn prepend_from_env(chain: &mut WakeChain) {
85    if let Some(program) = std::env::var_os(LETTER_HOOK_ENV).filter(|v| !v.is_empty()) {
86        chain.prepend("letter-hook", Box::new(LetterHookAdapter { program: program.into() }));
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::provider::{ProviderKind, SessionKind};
94
95    fn session() -> ProviderSession {
96        ProviderSession { kind: SessionKind::local(ProviderKind::Codex), session_id: "s-1".into(), nick: "codex-1".into(), cwd: None, headless: false }
97    }
98    fn letter() -> WakeLetter<'static> {
99        WakeLetter { body: "hello", from_nick: "operator", event_id: "$e", room: Some("!r"), reaction: None }
100    }
101
102    #[cfg(unix)]
103    #[test]
104    fn hook_gets_the_letter_without_secrets_and_a_failing_hook_falls_through() {
105        use std::os::unix::fs::PermissionsExt;
106        let dir = std::env::temp_dir().join(format!("m4a-letter-hook-{}", std::process::id()));
107        std::fs::create_dir_all(&dir).unwrap();
108        let out = dir.join("got.json");
109        let hook = dir.join("hook.sh");
110        std::fs::write(&hook, format!("#!/bin/sh\ncat > {}\nenv | grep -c M4A_SESSION_CAP >> {}.cap\nexit 0\n", out.display(), out.display())).unwrap();
111        std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o700)).unwrap();
112        let mut a = LetterHookAdapter { program: hook.clone() };
113        assert_eq!(a.wake(&session(), &letter()).unwrap(), WakeOutcome::Delivered);
114        let got: serde_json::Value = serde_json::from_slice(&std::fs::read(&out).unwrap()).unwrap();
115        assert_eq!(got["nick"], "codex-1");
116        assert_eq!(got["session_id"], "s-1");
117        assert_eq!(got["body"], "hello");
118        assert_eq!(std::fs::read_to_string(format!("{}.cap", out.display())).unwrap().trim(), "0");
119
120        let bad = dir.join("bad.sh");
121        std::fs::write(&bad, "#!/bin/sh\nexit 3\n").unwrap();
122        std::fs::set_permissions(&bad, std::fs::Permissions::from_mode(0o700)).unwrap();
123        let mut chain = WakeChain::new();
124        chain.push("inbox-queue", Box::new(crate::provider::hook::InboxQueueAdapter::new(SessionKind::local(ProviderKind::Codex), Some(dir.join("inbox")))));
125        chain.prepend("letter-hook", Box::new(LetterHookAdapter { program: bad }));
126        assert_eq!(chain.ids(), vec!["letter-hook", "inbox-queue"]);
127        let (id, _) = chain.wake(&session(), &letter()).unwrap();
128        assert_eq!(id, "inbox-queue", "a failing hook must not lose the letter");
129        let _ = std::fs::remove_dir_all(&dir);
130    }
131}