use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use crate::provider::{ProviderSession, WakeAdapter, WakeChain, WakeError, WakeLetter, WakeOutcome};
pub const LETTER_HOOK_ENV: &str = "M4A_LETTER_HOOK";
const HOOK_TIMEOUT: Duration = Duration::from_secs(10);
pub fn letter_event(session: &ProviderSession, letter: &WakeLetter<'_>) -> serde_json::Value {
serde_json::json!({
"nick": session.nick,
"session_id": session.session_id,
"provider": session.kind.provider.id(),
"from": letter.from_nick,
"event_id": letter.event_id,
"room": letter.room,
"body": letter.body,
})
}
pub struct LetterHookAdapter {
pub program: PathBuf,
}
impl WakeAdapter for LetterHookAdapter {
fn kind(&self) -> crate::provider::SessionKind {
crate::provider::SessionKind::local(crate::provider::ProviderKind::Grok)
}
fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
if self.program.as_os_str().is_empty() {
return Err(WakeError::Unavailable("letter hook unset".into()));
}
Ok(())
}
fn wake(&mut self, session: &ProviderSession, letter: &WakeLetter<'_>) -> Result<WakeOutcome, WakeError> {
let bytes = serde_json::to_vec(&letter_event(session, letter)).map_err(|e| WakeError::Transport(e.to_string()))?;
let mut child = Command::new(&self.program)
.env_remove("M4A_SESSION_CAP")
.env_remove("M4A_PRODUCT_INVITE")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| WakeError::Unavailable(format!("letter hook: {}", e.kind())))?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(&bytes);
}
let deadline = Instant::now() + HOOK_TIMEOUT;
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => return Ok(WakeOutcome::Delivered),
Ok(Some(status)) => return Err(WakeError::Transport(format!("letter hook exited {status}"))),
Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)),
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return Err(WakeError::Transport("letter hook timed out".into()));
}
Err(e) => return Err(WakeError::Transport(e.to_string())),
}
}
}
}
pub fn prepend_from_env(chain: &mut WakeChain) {
if let Some(program) = std::env::var_os(LETTER_HOOK_ENV).filter(|v| !v.is_empty()) {
chain.prepend("letter-hook", Box::new(LetterHookAdapter { program: program.into() }));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::{ProviderKind, SessionKind};
fn session() -> ProviderSession {
ProviderSession { kind: SessionKind::local(ProviderKind::Codex), session_id: "s-1".into(), nick: "codex-1".into(), cwd: None, headless: false }
}
fn letter() -> WakeLetter<'static> {
WakeLetter { body: "hello", from_nick: "operator", event_id: "$e", room: Some("!r") }
}
#[cfg(unix)]
#[test]
fn hook_gets_the_letter_without_secrets_and_a_failing_hook_falls_through() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("m4a-letter-hook-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("got.json");
let hook = dir.join("hook.sh");
std::fs::write(&hook, format!("#!/bin/sh\ncat > {}\nenv | grep -c M4A_SESSION_CAP >> {}.cap\nexit 0\n", out.display(), out.display())).unwrap();
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o700)).unwrap();
let mut a = LetterHookAdapter { program: hook.clone() };
assert_eq!(a.wake(&session(), &letter()).unwrap(), WakeOutcome::Delivered);
let got: serde_json::Value = serde_json::from_slice(&std::fs::read(&out).unwrap()).unwrap();
assert_eq!(got["nick"], "codex-1");
assert_eq!(got["session_id"], "s-1");
assert_eq!(got["body"], "hello");
assert_eq!(std::fs::read_to_string(format!("{}.cap", out.display())).unwrap().trim(), "0");
let bad = dir.join("bad.sh");
std::fs::write(&bad, "#!/bin/sh\nexit 3\n").unwrap();
std::fs::set_permissions(&bad, std::fs::Permissions::from_mode(0o700)).unwrap();
let mut chain = WakeChain::new();
chain.push("inbox-queue", Box::new(crate::provider::hook::InboxQueueAdapter::new(SessionKind::local(ProviderKind::Codex), Some(dir.join("inbox")))));
chain.prepend("letter-hook", Box::new(LetterHookAdapter { program: bad }));
assert_eq!(chain.ids(), vec!["letter-hook", "inbox-queue"]);
let (id, _) = chain.wake(&session(), &letter()).unwrap();
assert_eq!(id, "inbox-queue", "a failing hook must not lose the letter");
let _ = std::fs::remove_dir_all(&dir);
}
}