Skip to main content

mail4agent_messenger_shell/
wake_policy.rs

1//! When a session's wake fires, and what it last did. Two small non-secret files in the session's
2//! store directory, written by `m4a-agent wake` and read by the running client at every wake:
3//!
4//! * `wake.json`: `{"enabled": bool, "mode": "off"|"dm"|"mention"|"all"}`. Default: enabled, `mention`
5//!   (a direct message always wakes; a room message wakes only when it addresses the session).
6//! * `wake-status.json`: `{"state","source","last_status","last_event_ms"}`; never a URL or a key.
7//!
8//! The process-wide switch `M4A_WAKE=off` silences every wake of the process whatever the files say.
9
10use std::path::{Path, PathBuf};
11
12use serde::{Deserialize, Serialize};
13
14pub const WAKE_ENV: &str = "M4A_WAKE";
15const POLICY_FILE: &str = "wake.json";
16const STATUS_FILE: &str = "wake-status.json";
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20pub enum WakeMode {
21    /// Never.
22    Off,
23    /// Direct messages only.
24    Dm,
25    /// Direct messages and room messages that address this session (the default).
26    Mention,
27    /// Every inbound text.
28    All,
29}
30
31impl WakeMode {
32    pub fn parse(s: &str) -> Option<Self> {
33        match s.trim().to_ascii_lowercase().as_str() {
34            "off" => Some(Self::Off),
35            "dm" => Some(Self::Dm),
36            "mention" | "dm+mention" => Some(Self::Mention),
37            "all" => Some(Self::All),
38            _ => None,
39        }
40    }
41    pub fn name(self) -> &'static str {
42        match self {
43            Self::Off => "off",
44            Self::Dm => "dm",
45            Self::Mention => "mention",
46            Self::All => "all",
47        }
48    }
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct WakePolicy {
54    pub enabled: bool,
55    pub mode: WakeMode,
56}
57
58impl Default for WakePolicy {
59    fn default() -> Self {
60        Self { enabled: true, mode: WakeMode::Mention }
61    }
62}
63
64/// `M4A_WAKE=off` (also `0`, `false`, `no`) in this process.
65pub fn globally_off() -> bool {
66    std::env::var(WAKE_ENV).is_ok_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "off" | "0" | "false" | "no"))
67}
68
69impl WakePolicy {
70    /// The stored policy; a missing file is the default, a damaged one is `Off` (a wake that
71    /// cannot be understood must not fire).
72    pub fn load(dir: &Path) -> Self {
73        match std::fs::read_to_string(dir.join(POLICY_FILE)) {
74            Ok(t) => serde_json::from_str(&t).unwrap_or(Self { enabled: false, mode: WakeMode::Off }),
75            Err(_) => Self::default(),
76        }
77    }
78
79    pub fn save(&self, dir: &Path) -> std::io::Result<()> {
80        write_atomic(&dir.join(POLICY_FILE), &serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?)
81    }
82
83    /// Whether a message wakes the session. `direct`: the room is a one-to-one conversation;
84    /// `mentioned`: the text addresses this session.
85    pub fn allows(&self, direct: bool, mentioned: bool) -> bool {
86        if globally_off() || !self.enabled {
87            return false;
88        }
89        match self.mode {
90            WakeMode::Off => false,
91            WakeMode::Dm => direct,
92            WakeMode::Mention => direct || mentioned,
93            WakeMode::All => true,
94        }
95    }
96}
97
98/// Whether `body` addresses the session: `@nick`, the full user id, or `nick:` / `nick,` at the start.
99pub fn addresses(body: &str, nick: Option<&str>, user_id: &str) -> bool {
100    let lower = body.to_lowercase();
101    if lower.contains(&user_id.to_lowercase()) {
102        return true;
103    }
104    let Some(nick) = nick.map(str::to_lowercase).filter(|n| !n.is_empty()) else { return false };
105    let at = format!("@{nick}");
106    let mut from = 0;
107    while let Some(i) = lower[from..].find(&at) {
108        let end = from + i + at.len();
109        // `@nick` but not `@nickname`.
110        if !lower[end..].chars().next().is_some_and(|c| c.is_alphanumeric() || c == '-' || c == '_') {
111            return true;
112        }
113        from = end;
114    }
115    let t = lower.trim_start();
116    t.strip_prefix(&nick).is_some_and(|rest| rest.starts_with(':') || rest.starts_with(','))
117}
118
119#[derive(Clone, Debug, Default, Serialize, Deserialize)]
120#[serde(deny_unknown_fields)]
121pub struct WakeStatusFile {
122    /// `ready` (a wake target is attached), `awaiting` (the routine has no key yet), `failed`, `none`.
123    pub state: String,
124    /// Where the target came from: `gateway`, `file`, `vault`, `config`.
125    pub source: String,
126    #[serde(default)]
127    pub last_status: Option<u16>,
128    #[serde(default)]
129    pub last_event_ms: Option<i64>,
130}
131
132impl WakeStatusFile {
133    pub fn load(dir: &Path) -> Option<Self> {
134        serde_json::from_str(&std::fs::read_to_string(dir.join(STATUS_FILE)).ok()?).ok()
135    }
136    fn save(&self, dir: &Path) {
137        if let Ok(b) = serde_json::to_vec_pretty(self) {
138            let _ = write_atomic(&dir.join(STATUS_FILE), &b);
139        }
140    }
141}
142
143fn now_ms() -> i64 {
144    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
145}
146
147/// Records how the wake target of a session stands. Best effort; carries no secret.
148pub fn note_state(store_dir: &Path, state: &str, source: &str) {
149    let mut s = WakeStatusFile::load(store_dir).unwrap_or_default();
150    s.state = state.to_string();
151    s.source = source.to_string();
152    s.save(store_dir);
153}
154
155/// Records the answer of the last wake attempt (`None`: no HTTP answer).
156pub fn note_attempt(store_dir: &Path, status: Option<u16>) {
157    let mut s = WakeStatusFile::load(store_dir).unwrap_or_default();
158    if s.state.is_empty() {
159        s.state = "ready".into();
160    }
161    s.last_status = status;
162    s.last_event_ms = Some(now_ms());
163    s.save(store_dir);
164}
165
166fn write_atomic(path: &PathBuf, data: &[u8]) -> std::io::Result<()> {
167    if let Some(d) = path.parent() {
168        std::fs::create_dir_all(d)?;
169    }
170    let tmp = path.with_extension("tmp");
171    std::fs::write(&tmp, data)?;
172    std::fs::rename(&tmp, path)
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn the_default_wakes_for_dms_and_mentions_only_and_the_switches_win() {
181        let d = tempfile::tempdir().unwrap();
182        let p = WakePolicy::load(d.path());
183        assert_eq!(p, WakePolicy { enabled: true, mode: WakeMode::Mention });
184        assert!(p.allows(true, false) && p.allows(false, true) && !p.allows(false, false));
185        let mut p = p;
186        p.mode = WakeMode::Dm;
187        assert!(p.allows(true, false) && !p.allows(false, true));
188        p.mode = WakeMode::All;
189        assert!(p.allows(false, false));
190        p.enabled = false;
191        assert!(!p.allows(true, true));
192        p.save(d.path()).unwrap();
193        assert_eq!(WakePolicy::load(d.path()), p);
194        // A damaged file never wakes.
195        std::fs::write(d.path().join(POLICY_FILE), "{nope").unwrap();
196        assert!(!WakePolicy::load(d.path()).allows(true, true));
197        assert_eq!(WakeMode::parse("dm+mention"), Some(WakeMode::Mention));
198        assert_eq!(WakeMode::parse("sometimes"), None);
199    }
200
201    #[test]
202    fn addressing_means_the_nick_or_the_user_id_not_a_longer_name() {
203        let me = "@hatcheryweb:example.org";
204        assert!(addresses("hey @HatcheryWeb, look", Some("hatcheryweb"), me));
205        assert!(addresses("hatcheryweb: do it", Some("hatcheryweb"), me));
206        assert!(addresses("cc @hatcheryweb:example.org", Some("hatcheryweb"), me));
207        assert!(addresses("see @hatcheryweb.", Some("hatcheryweb"), me));
208        assert!(!addresses("@hatcherywebber hi", Some("hatcheryweb"), me));
209        assert!(!addresses("nothing here", Some("hatcheryweb"), me));
210        assert!(!addresses("", None, me));
211    }
212
213    #[test]
214    fn the_status_file_holds_state_and_the_last_answer_and_nothing_secret() {
215        let d = tempfile::tempdir().unwrap();
216        note_state(d.path(), "awaiting", "gateway");
217        note_attempt(d.path(), Some(401));
218        let s = WakeStatusFile::load(d.path()).unwrap();
219        assert_eq!((s.state.as_str(), s.source.as_str(), s.last_status), ("awaiting", "gateway", Some(401)));
220        assert!(s.last_event_ms.is_some());
221    }
222}