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