Skip to main content

mail4agent_messenger_shell/
send.rs

1//! Reply path for a woken bot: `m4a-send --as <nick> --to <nick> <text>`.
2//!
3//! The running web client ([`crate::MachineClient`]) holds every bot's
4//! sealed store, so `m4a-send` does not open that store a second time
5//! while it runs: it writes one JSON line to the client's local socket
6//! ([`SEND_SOCK_ENV`], default [`DEFAULT_SOCK_NAME`] under the store root;
7//! a mode-0600 domain socket on Unix, a loopback TCP address file on
8//! Windows) and the client sends the encrypted DM from the `--as`
9//! session, through the configured homeserver. With no client running,
10//! `m4a-send` opens that one session itself (the store lock keeps the two
11//! from overlapping). No URL, key, or bearer is passed in arguments or
12//! printed.
13
14use serde::{Deserialize, Serialize};
15use std::ffi::OsStr;
16use std::io::{BufRead, BufReader, Read, Write};
17use std::path::{Path, PathBuf};
18use std::time::Duration;
19
20use crate::ipc::SendStream;
21
22/// Path of the web client's local send socket.
23pub const SEND_SOCK_ENV: &str = "M4A_SEND_SOCK";
24
25/// Socket file name under the store root when [`SEND_SOCK_ENV`] is unset.
26pub const DEFAULT_SOCK_NAME: &str = "web-client.sock";
27
28/// Env file with the machine's client settings (`KEY=VALUE` lines), read by
29/// `m4a-web-client` and `m4a-send` for every variable the process
30/// environment leaves unset. Default `$HOME/.config/mail4agent/web-client.env`.
31/// Settings only: homeserver URL, store root, keychain dir, skip list,
32/// session-id aliases. Never secrets.
33pub const ENV_FILE_ENV: &str = "M4A_ENV_FILE";
34
35/// Longest text one send carries.
36pub const MAX_SEND_BYTES: usize = 16 * 1024;
37
38/// One request on the send socket.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct SendRequest {
41    /// Sending session's nick (the bot running the command).
42    #[serde(rename = "as")]
43    pub as_nick: String,
44    /// Recipient nick.
45    pub to: String,
46    /// Plaintext; encrypted by the sending session.
47    pub text: String,
48    /// The sending session's capability ([`crate::session_account::SESSION_CAP_ENV`]); the
49    /// daemon refuses a session account's nick without its own cap. Never logged.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub cap: Option<String>,
52}
53
54/// The caller's session capability from its own environment, if the environment set one.
55pub fn session_cap_from_env() -> Option<String> {
56    std::env::var(crate::session_account::SESSION_CAP_ENV).ok().filter(|c| !c.trim().is_empty())
57}
58
59/// The answer to one [`SendRequest`].
60#[derive(Debug, Clone, Default, Serialize, Deserialize)]
61pub struct SendReply {
62    /// Whether the text was accepted by the homeserver.
63    pub ok: bool,
64    /// DM room id.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub room: Option<String>,
67    /// Matrix event id of the sent text.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub event_id: Option<String>,
70    /// Why it failed. No secrets.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub error: Option<String>,
73}
74
75impl SendReply {
76    /// A failed reply with `error`.
77    pub fn failed(error: impl Into<String>) -> Self {
78        Self {
79            error: Some(error.into()),
80            ..Self::default()
81        }
82    }
83}
84
85/// Socket path: [`SEND_SOCK_ENV`] when set, else [`DEFAULT_SOCK_NAME`]
86/// under `store_root`.
87pub fn send_sock_path(get: impl FnMut(&str) -> Option<String>, store_root: &Path) -> PathBuf {
88    send_sock_path_named(get, store_root, DEFAULT_SOCK_NAME)
89}
90
91/// Like [`send_sock_path`], but uses `default_name` under `store_root` when
92/// [`SEND_SOCK_ENV`] is unset (node client uses `node-client.sock`).
93pub fn send_sock_path_named(
94    mut get: impl FnMut(&str) -> Option<String>,
95    store_root: &Path,
96    default_name: &str,
97) -> PathBuf {
98    get(SEND_SOCK_ENV)
99        .map(PathBuf::from)
100        .unwrap_or_else(|| store_root.join(default_name))
101}
102
103/// Sets each `KEY=VALUE` from the env file ([`ENV_FILE_ENV`] or the
104/// default web-client path) that the environment does not already have.
105/// Blank lines and `#` comments are skipped; surrounding quotes are
106/// dropped. Call at the start of `main`, before any thread starts.
107/// Returns the file read.
108pub fn load_env_file() -> Option<PathBuf> {
109    load_env_file_named("web-client.env")
110}
111
112/// Like [`load_env_file`], but the default under
113/// `<home>/.config/mail4agent/` is `default_name` (node uses
114/// `node-client.env`). Home is `HOME` when non-empty, otherwise
115/// `USERPROFILE`. [`ENV_FILE_ENV`] still wins when set.
116pub fn load_env_file_named(default_name: &str) -> Option<PathBuf> {
117    let path = std::env::var(ENV_FILE_ENV)
118        .ok()
119        .filter(|value| !value.is_empty())
120        .map(PathBuf::from)
121        .or_else(|| {
122            home_dir_from(
123                std::env::var_os("HOME").as_deref(),
124                std::env::var_os("USERPROFILE").as_deref(),
125            )
126            .map(|home| home.join(".config/mail4agent").join(default_name))
127        })?;
128    let text = std::fs::read_to_string(&path).ok()?;
129    for (key, value) in parse_env_lines(&text) {
130        if std::env::var_os(&key).is_none() {
131            std::env::set_var(key, value);
132        }
133    }
134    Some(path)
135}
136
137/// `HOME` when it is non-empty, otherwise `USERPROFILE` when that is
138/// non-empty. Both missing or empty is `None`. Does not read the process
139/// environment.
140fn home_dir_from(home: Option<&OsStr>, userprofile: Option<&OsStr>) -> Option<PathBuf> {
141    let chosen = home
142        .filter(|value| !value.is_empty())
143        .or_else(|| userprofile.filter(|value| !value.is_empty()))?;
144    Some(PathBuf::from(chosen))
145}
146
147fn parse_env_lines(text: &str) -> Vec<(String, String)> {
148    text.lines()
149        .filter_map(|line| {
150            let line = line.trim();
151            if line.is_empty() || line.starts_with('#') {
152                return None;
153            }
154            let line = line.strip_prefix("export ").unwrap_or(line);
155            let (key, value) = line.split_once('=')?;
156            let key = key.trim();
157            if key.is_empty() || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
158                return None;
159            }
160            let value = value.trim();
161            let value = value
162                .strip_prefix('"')
163                .and_then(|v| v.strip_suffix('"'))
164                .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
165                .unwrap_or(value);
166            Some((key.to_string(), value.to_string()))
167        })
168        .collect()
169}
170
171/// Sends `request` over the client's socket and waits up to `wait` for
172/// the answer. `Err` means no client answered on that socket (missing,
173/// refused, or closed early), so the caller may fall back to opening the
174/// session itself.
175pub fn send_via_socket(
176    sock: &Path,
177    request: &SendRequest,
178    wait: Duration,
179) -> std::io::Result<SendReply> {
180    let mut stream = SendStream::connect(sock)?;
181    stream.set_read_timeout(Some(wait))?;
182    stream.set_write_timeout(Some(Duration::from_secs(5)))?;
183    let mut line = serde_json::to_vec(request)?;
184    line.push(b'\n');
185    stream.write_all(&line)?;
186    stream.flush()?;
187    let mut reader = BufReader::new(stream);
188    let mut answer = String::new();
189    reader.read_line(&mut answer)?;
190    if answer.trim().is_empty() {
191        return Err(std::io::Error::new(
192            std::io::ErrorKind::UnexpectedEof,
193            "client closed the socket without an answer",
194        ));
195    }
196    serde_json::from_str(answer.trim())
197        .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))
198}
199
200/// What one socket line holds.
201pub(crate) enum Incoming {
202    Send(SendRequest),
203    Cmd(crate::CmdRequest),
204}
205
206/// Like [`read_request`], but also accepts a rich command line (`"cmd"`).
207pub(crate) fn read_incoming(stream: &mut SendStream) -> Result<Incoming, String> {
208    stream.set_nonblocking(false).map_err(|err| err.to_string())?;
209    stream.set_read_timeout(Some(Duration::from_secs(3))).map_err(|err| err.to_string())?;
210    let mut buf = Vec::new();
211    let mut limited = stream.take((MAX_SEND_BYTES * 2 + 1024) as u64);
212    let mut reader = BufReader::new(&mut limited);
213    reader.read_until(b'\n', &mut buf).map_err(|err| err.to_string())?;
214    let value: serde_json::Value =
215        serde_json::from_slice(&buf).map_err(|_| "request is not a JSON line".to_string())?;
216    if value.get("cmd").is_some() {
217        let cmd: crate::CmdRequest =
218            serde_json::from_value(value).map_err(|err| format!("bad command: {err}"))?;
219        return Ok(Incoming::Cmd(cmd));
220    }
221    let request: SendRequest =
222        serde_json::from_value(value).map_err(|_| "request is not a send JSON line".to_string())?;
223    if request.text.trim().is_empty() {
224        return Err("text is empty".to_string());
225    }
226    if request.text.len() > MAX_SEND_BYTES {
227        return Err(format!("text is longer than {MAX_SEND_BYTES} bytes"));
228    }
229    Ok(Incoming::Send(request))
230}
231
232/// Sends one rich command over the client's socket and waits for the answer.
233pub fn send_cmd_via_socket(
234    sock: &Path,
235    request: &crate::CmdRequest,
236    wait: Duration,
237) -> std::io::Result<crate::CmdReply> {
238    let mut stream = SendStream::connect(sock)?;
239    stream.set_read_timeout(Some(wait))?;
240    stream.set_write_timeout(Some(Duration::from_secs(5)))?;
241    let mut line = serde_json::to_vec(request)?;
242    line.push(b'\n');
243    stream.write_all(&line)?;
244    stream.flush()?;
245    let mut reader = BufReader::new(stream);
246    let mut answer = String::new();
247    reader.read_line(&mut answer)?;
248    if answer.trim().is_empty() {
249        return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "client closed the socket without an answer"));
250    }
251    serde_json::from_str(answer.trim()).map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))
252}
253
254/// Writes one command answer line.
255pub(crate) fn write_cmd_reply(stream: &mut SendStream, reply: &crate::CmdReply) {
256    if let Ok(mut line) = serde_json::to_vec(reply) {
257        line.push(b'\n');
258        let _ = stream.set_write_timeout(Some(Duration::from_secs(3)));
259        let _ = stream.write_all(&line);
260        let _ = stream.flush();
261    }
262}
263
264/// Reads one request line from an accepted socket connection.
265pub(crate) fn read_request(stream: &mut SendStream) -> Result<SendRequest, String> {
266    stream
267        .set_nonblocking(false)
268        .map_err(|err| err.to_string())?;
269    stream
270        .set_read_timeout(Some(Duration::from_secs(3)))
271        .map_err(|err| err.to_string())?;
272    let mut buf = Vec::new();
273    let mut limited = stream.take((MAX_SEND_BYTES * 2 + 1024) as u64);
274    let mut reader = BufReader::new(&mut limited);
275    reader
276        .read_until(b'\n', &mut buf)
277        .map_err(|err| err.to_string())?;
278    let request: SendRequest =
279        serde_json::from_slice(&buf).map_err(|_| "request is not a send JSON line".to_string())?;
280    if request.text.trim().is_empty() {
281        return Err("text is empty".to_string());
282    }
283    if request.text.len() > MAX_SEND_BYTES {
284        return Err(format!("text is longer than {MAX_SEND_BYTES} bytes"));
285    }
286    Ok(request)
287}
288
289/// Writes one answer line. Errors are ignored: the sender may have gone.
290pub(crate) fn write_reply(stream: &mut SendStream, reply: &SendReply) {
291    if let Ok(mut line) = serde_json::to_vec(reply) {
292        line.push(b'\n');
293        let _ = stream.set_write_timeout(Some(Duration::from_secs(3)));
294        let _ = stream.write_all(&line);
295        let _ = stream.flush();
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use std::ffi::OsStr;
302
303    use super::*;
304
305    #[test]
306    fn env_lines_skip_comments_and_strip_quotes() {
307        let parsed = parse_env_lines(
308            "# c\n\nM4A_A=1\nexport M4A_B=\"two words\"\nM4A_C='x'\nbad line\n=v\n",
309        );
310        assert_eq!(
311            parsed,
312            vec![
313                ("M4A_A".to_string(), "1".to_string()),
314                ("M4A_B".to_string(), "two words".to_string()),
315                ("M4A_C".to_string(), "x".to_string()),
316            ]
317        );
318    }
319
320    #[test]
321    fn home_dir_prefers_home_then_userprofile() {
322        assert_eq!(
323            home_dir_from(
324                Some(OsStr::new("/from-home")),
325                Some(OsStr::new("/from-profile"))
326            ),
327            Some(PathBuf::from("/from-home"))
328        );
329        assert_eq!(
330            home_dir_from(None, Some(OsStr::new("/from-profile"))),
331            Some(PathBuf::from("/from-profile"))
332        );
333        assert_eq!(
334            home_dir_from(Some(OsStr::new("")), Some(OsStr::new("/from-profile"))),
335            Some(PathBuf::from("/from-profile"))
336        );
337        assert_eq!(home_dir_from(None, None), None);
338        assert_eq!(
339            home_dir_from(Some(OsStr::new("")), Some(OsStr::new(""))),
340            None
341        );
342    }
343
344    #[test]
345    fn request_uses_as_on_the_wire() {
346        let request = SendRequest {
347            as_nick: "alice".to_string(),
348            to: "privet-mir".to_string(),
349            text: "hi".to_string(),
350            cap: None,
351        };
352        let json = serde_json::to_value(&request).expect("json");
353        assert_eq!(json["as"], "alice");
354        assert_eq!(json["to"], "privet-mir");
355    }
356}