Skip to main content

mail4agent_messenger_shell/cli/
node_client.rs

1//! Local Grok ACP node client: one session per machine, woken on
2//! `M4A_LEADER_SOCK`, never by webhook.
3//!
4//! Open path: [`crate::NodeClient::from_env`].
5//! That refuses `M4A_ROUTINE_URL` / `M4A_ROUTINE_BEARER` before register,
6//! requires an existing ACP leader socket, opens the homeserver push
7//! link, and runs a drive/push loop. Replies go through the local send
8//! socket (`m4a-send`, default `node-client.sock` under the store root).
9//!
10//! Homeserver URL, bot display name, session id, and store root come from
11//! the environment / `node-client.env` (see the project documentation
12//! `docs/mail4agent/local-acp-client.md`).
13//! Nothing here embeds a host, URL, key, or bearer. This process does not
14//! start `grok` and does not deploy to a VPS.
15
16use crate::{
17    load_env_file_named, NodeClient, ShellError, LEADER_SOCK_ENV,
18};
19use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
20
21fn env_secs(key: &str, default: u64) -> u64 {
22    std::env::var(key)
23        .ok()
24        .and_then(|value| value.parse().ok())
25        .unwrap_or(default)
26}
27
28fn now_ms() -> i64 {
29    SystemTime::now()
30        .duration_since(UNIX_EPOCH)
31        .map(|elapsed| elapsed.as_millis() as i64)
32        .unwrap_or(0)
33}
34
35pub fn run(_args: Vec<String>) {
36    load_env_file_named("node-client.env");
37
38    let mut client = match NodeClient::from_env() {
39        Ok(client) => client,
40        Err(err) => fail(&err),
41    };
42
43    match client.listen_for_sends_from_env() {
44        Ok(path) => println!("send socket {}", path.display()),
45        Err(err) => eprintln!("send socket not opened: {err}"),
46    }
47
48    let nick = client.nick().unwrap_or("(unknown)");
49    println!("node open ok nick={nick}");
50    println!("wake=acp sock_set=1");
51    println!("push+drive loop; replies via m4a-send on the send socket");
52
53    let drive_secs = env_secs("M4A_DRIVE_SECS", 15);
54    let run_secs = env_secs("M4A_RUN_SECS", 0);
55    let started = Instant::now();
56    let mut seen_wakes = 0usize;
57
58    loop {
59        let report = client.tick(now_ms(), drive_secs);
60        for event_id in &report.pushed {
61            println!("push event={event_id}");
62        }
63        for room in &report.joined {
64            println!("joined room={room}");
65        }
66        for (from, to, reply) in &report.sent {
67            match (&reply.event_id, &reply.error) {
68                (_, Some(err)) => println!("send {from} -> {to} failed: {err}"),
69                (Some(event_id), None) => println!("send {from} -> {to} event={event_id}"),
70                (None, None) => println!("send {from} -> {to} ok"),
71            }
72        }
73        for err in &report.errors {
74            eprintln!("drive: {err}");
75        }
76        if let Some(note) = &report.wake_note {
77            // Public clipped note only (no bearer / body).
78            if seen_wakes == 0 || client.wake_note() != Some(note.as_str()) {
79                eprintln!("wake note: {note}");
80            }
81            seen_wakes = seen_wakes.saturating_add(1);
82        }
83        if run_secs > 0 && started.elapsed().as_secs() >= run_secs {
84            println!("run time reached; exiting");
85            break;
86        }
87        std::thread::sleep(Duration::from_millis(250));
88    }
89}
90
91fn fail(err: &ShellError) -> ! {
92    // ShellError never includes bearers, routine URLs, or keys.
93    eprintln!("node open failed: {err}");
94    if matches!(err, ShellError::NodeRoutine) {
95        eprintln!(
96            "node client refuses M4A_ROUTINE_URL / M4A_ROUTINE_BEARER;              webhooks are for m4a-web-client only"
97        );
98    }
99    let _ = LEADER_SOCK_ENV;
100    std::process::exit(1);
101}