hatchery_server/
m4a_gateway.rs1use serde_json::Value;
14use std::path::PathBuf;
15use std::process::{Command, Stdio};
16use std::time::Duration;
17
18pub const CLI_ENV: &str = "HATCHERY_CORE_M4A_CLI";
19pub const AS_ENV: &str = "HATCHERY_CORE_M4A_AS";
20
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct Letter {
23 pub event_id: String,
24 pub from: String,
25 pub body: String,
26}
27
28pub trait M4a: Send + Sync {
29 fn send(&self, to: &str, text: &str) -> Result<(), String>;
30 fn read_dm(&self, nick: &str, n: u32) -> Result<Vec<Letter>, String>;
32 fn me(&self) -> &str;
33}
34
35pub struct CliM4a {
36 bin: PathBuf,
37 as_nick: String,
38}
39
40impl CliM4a {
41 pub fn from_env() -> Option<Self> {
42 let bin = std::env::var(CLI_ENV).ok().filter(|v| !v.is_empty())?;
43 let as_nick = std::env::var(AS_ENV).ok().filter(|v| !v.is_empty())?;
44 Some(Self { bin: bin.into(), as_nick })
45 }
46
47 fn run(&self, args: &[&str], stdin: Option<&str>) -> Result<String, String> {
48 use std::io::Write;
49 let mut child = Command::new(&self.bin)
50 .args(["--as", &self.as_nick])
51 .args(args)
52 .stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
53 .stdout(Stdio::piped())
54 .stderr(Stdio::piped())
55 .spawn()
56 .map_err(|e| format!("m4a: {e}"))?;
57 if let (Some(text), Some(mut pipe)) = (stdin, child.stdin.take()) {
58 pipe.write_all(text.as_bytes()).map_err(|e| format!("m4a: {e}"))?;
59 }
60 let deadline = std::time::Instant::now() + Duration::from_secs(20);
61 loop {
62 if let Some(status) = child.try_wait().map_err(|e| e.to_string())? {
63 let out = child.wait_with_output().map_err(|e| e.to_string())?;
64 return if status.success() {
65 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
66 } else {
67 Err(format!("m4a exited {}: {}", status.code().unwrap_or(-1), String::from_utf8_lossy(&out.stderr).chars().take(200).collect::<String>()))
69 };
70 }
71 if std::time::Instant::now() > deadline {
72 let _ = child.kill();
73 return Err("m4a timed out".into());
74 }
75 std::thread::sleep(Duration::from_millis(50));
76 }
77 }
78}
79
80pub fn parse_rows(out: &str) -> Vec<Letter> {
81 let v: Value = serde_json::from_str(out.trim()).unwrap_or(Value::Null);
82 let rows = v.as_array().cloned().or_else(|| v["messages"].as_array().cloned()).or_else(|| v["rows"].as_array().cloned()).unwrap_or_default();
83 let mut letters: Vec<(i64, Letter)> = rows
84 .iter()
85 .filter(|r| r["kind"].as_str() != Some("undecryptable"))
86 .filter_map(|r| {
87 Some((
88 r["ts"].as_i64().unwrap_or(0),
89 Letter { event_id: r["event_id"].as_str()?.to_owned(), from: r["from"].as_str()?.to_owned(), body: r["body"].as_str().unwrap_or("").to_owned() },
90 ))
91 })
92 .collect();
93 letters.sort_by_key(|(ts, _)| *ts);
94 letters.into_iter().map(|(_, l)| l).collect()
95}
96
97impl M4a for CliM4a {
98 fn send(&self, to: &str, text: &str) -> Result<(), String> {
99 self.run(&["send", to, "-"], Some(text)).map(|_| ())
100 }
101 fn read_dm(&self, nick: &str, n: u32) -> Result<Vec<Letter>, String> {
102 self.run(&["--json", "read", nick, "-n", &n.to_string()], None).map(|o| parse_rows(&o))
103 }
104 fn me(&self) -> &str {
105 &self.as_nick
106 }
107}
108
109#[derive(Debug, PartialEq, Eq)]
111pub struct CloseCmd {
112 pub run_id: i64,
113 pub status: String,
114 pub branch: Option<String>,
115 pub commit: Option<String>,
116}
117
118pub fn parse_close(body: &str) -> Option<CloseCmd> {
119 let line = body.lines().next()?.trim();
120 let mut w = line.split_whitespace();
121 if w.next()? != "run.close" {
122 return None;
123 }
124 let run_id = w.next()?.trim_start_matches('#').parse().ok()?;
125 let status = w.next()?.to_owned();
126 let clean = |s: &str| Some(s.to_owned()).filter(|s| s.len() <= 200 && !s.chars().any(char::is_control));
127 Some(CloseCmd { run_id, status, branch: w.next().and_then(clean), commit: w.next().and_then(clean) })
128}
129
130pub fn bare_nick(n: &str) -> &str {
132 let n = n.trim_start_matches('@');
133 n.split(':').next().unwrap_or(n)
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 #[test]
141 fn parses_close_letters() {
142 assert_eq!(parse_close("run.close 7 done feat/x abc123\nthanks"), Some(CloseCmd { run_id: 7, status: "done".into(), branch: Some("feat/x".into()), commit: Some("abc123".into()) }));
143 assert_eq!(parse_close("run.close #3 review").unwrap().run_id, 3);
144 assert!(parse_close("hello run.close 1 done").is_none());
145 assert!(parse_close("run.close x done").is_none());
146 }
147
148 #[test]
149 fn parses_rows_oldest_first_and_skips_undecryptable() {
150 let out = r#"[{"event_id":"$b","from":"@s1:h","body":"two","ts":2},{"event_id":"$a","from":"@s1:h","body":"one","ts":1},{"event_id":"$c","from":"x","kind":"undecryptable","ts":3}]"#;
151 let l = parse_rows(out);
152 assert_eq!(l.iter().map(|x| x.body.as_str()).collect::<Vec<_>>(), ["one", "two"]);
153 assert_eq!(bare_nick("@s1:h"), "s1");
154 assert!(parse_rows("garbage").is_empty());
155 }
156}