Skip to main content

sioul/mcp/
phone.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The tools that read what the person's phone shares with their computers,
5//! read-only: the phone's messages (`phonemsgs`: who wrote, in which app and
6//! conversation, when, and the words where the phone sent them) and the
7//! calls it screened (`calls`: who called, when, whether it rang or went to
8//! voicemail). Read from the logs the sharing carries to this computer; none
9//! when the parts "Messages from your phone" and "Calls" are off.
10//!
11//! A code or an approval never left the phone: its line says that one came,
12//! and so does this answer, never a code; what the words still hold of one
13//! is masked as in mail. A message the phone held (do-not-disturb, the
14//! matrix of what reaches you) is not given before the phone lets it
15//! through. Others' words come as data, framed. What concerns a contact the
16//! person keeps from agents (a closed project's client), and all of it while
17//! things outside projects are closed, is left out and counted (`access`).
18
19use super::access;
20use super::mask;
21use super::tools::{Answer, Args};
22use crate::{Session, one_line};
23use jiff::tz::TimeZone;
24use jiff::{Timestamp, Zoned};
25use serde_json::json;
26use sioul_core::consent::Consent;
27use sioul_core::links::{self, Loaded};
28use sioul_core::{calls, phonemsgs, phones};
29use std::collections::BTreeSet;
30
31/// A moment in milliseconds, as the logs keep them, in the person's time zone.
32fn instant_ms(ms: i64) -> String {
33    Timestamp::from_millisecond(ms).map(|t| t.to_zoned(TimeZone::system()).strftime("%Y-%m-%dT%H:%M:%S%:z").to_string()).unwrap_or_default()
34}
35
36/// A moment in milliseconds, as a line says it.
37fn said_ms(s: &Session, ms: i64) -> String {
38    crate::date(s, Some(ms.div_euclid(1000)))
39}
40
41/// The people kept from agents: their names and their numbers' keys.
42struct Kept {
43    names: BTreeSet<String>,
44    keys: BTreeSet<String>,
45}
46
47impl Kept {
48    fn of(s: &Session, loaded: &Loaded, consent: &Consent) -> Kept {
49        let region = sioul_core::reach::region(&s.config);
50        let mut kept = Kept { names: BTreeSet::new(), keys: BTreeSet::new() };
51        for contact in loaded.contacts.iter().filter(|c| !consent.allows(&links::contact_uri(&c.uid))) {
52            kept.names.insert(contact.name.to_lowercase());
53            kept.keys.extend(contact.phones.iter().map(|p| phones::key(&p.value, region)).filter(|k| !k.is_empty()));
54        }
55        kept
56    }
57
58    fn holds(&self, name: &str, key: &str) -> bool {
59        (!key.is_empty() && self.keys.contains(key)) || (!name.trim().is_empty() && self.names.contains(&name.trim().to_lowercase()))
60    }
61}
62
63/// The contact's name for a number's key, as the person's address books know it.
64fn known_name(s: &Session, loaded: &Loaded, key: &str) -> Option<String> {
65    let region = sioul_core::reach::region(&s.config);
66    (!key.is_empty()).then(|| loaded.contacts.iter().find(|c| c.phones.iter().any(|p| phones::key(&p.value, region) == key)).map(|c| c.name.clone())).flatten()
67}
68
69/// The words of a query, folded: case and accents aside.
70fn folded(text: &str) -> String {
71    sioul_core::text::fold(text).into_iter().collect()
72}
73
74pub fn phone_messages(s: &Session, args: &Args) -> Result<Answer, String> {
75    let query = args.text("query")?.unwrap_or_default();
76    let since = args.date("since")?;
77    let limit = args.number("limit", 50, 1, 500)? as usize;
78    let now_ms = Zoned::now().timestamp().as_millisecond();
79    let from_ms = since.and_then(|d| d.to_zoned(TimeZone::system()).ok()).map_or(i64::MIN, |z| z.timestamp().as_millisecond());
80    let loaded = Loaded::read(&s.config);
81    let consent = access::of(s, &loaded);
82    let kept = Kept::of(s, &loaded, &consent);
83    let all = phonemsgs::read_logs(&phonemsgs::folder(), now_ms);
84    let in_scope: Vec<&phonemsgs::Line> = all.iter().filter(|l| l.at >= from_ms).collect();
85    // Held by the phone until later: not given before the person may see it.
86    let waiting = in_scope.iter().filter(|l| l.shows > now_ms).count();
87    let ready: Vec<&phonemsgs::Line> = in_scope.into_iter().filter(|l| l.shows <= now_ms).collect();
88    // Kept from agents: everything while things outside projects are closed; a kept contact's.
89    let (open, closed): (Vec<&phonemsgs::Line>, Vec<&phonemsgs::Line>) = ready.into_iter().partition(|l| consent.outside && !kept.holds(&l.name, &l.key));
90    let closed = closed.len();
91    let looked = sioul_core::words::Words::of(&s.config);
92    let words: Vec<String> = query.split_whitespace().map(folded).collect();
93    // The words as the agent is shown them, masked: a code is not found by its digits.
94    let shown_text = |l: &phonemsgs::Line| if l.code || l.withheld { String::new() } else { mask::message(&looked, "", &l.text).1 };
95    let mut found: Vec<&phonemsgs::Line> = open
96        .into_iter()
97        .filter(|l| {
98            let haystack = folded(&format!("{} {} {} {}", l.name, l.title, if l.label.is_empty() { &l.app } else { &l.label }, shown_text(l)));
99            words.iter().all(|w| haystack.contains(w.as_str()))
100        })
101        .collect();
102    found.sort_by_key(|l| std::cmp::Reverse(l.at));
103    let total = found.len();
104    let mut lines = Vec::new();
105    let mut out = Vec::new();
106    for l in found.into_iter().take(limit) {
107        let app = if l.label.is_empty() { l.app.clone() } else { l.label.clone() };
108        let who = if !l.name.is_empty() {
109            l.name.clone()
110        } else {
111            known_name(s, &loaded, &l.key).unwrap_or_else(|| calls::shown_number(&l.key, sioul_core::reach::region(&s.config)))
112        };
113        let place = if l.group && !l.title.is_empty() { format!(" in “{}”", l.title) } else { String::new() };
114        let words = if l.code {
115            "(A code or an approval came: it stays on the phone, never given.)".to_string()
116        } else if l.withheld {
117            "(Its words stayed on the phone: its app sends who and when only.)".to_string()
118        } else {
119            let text = shown_text(l);
120            let picture = if l.picture { " [a picture, kept on the phone]" } else { "" };
121            format!("{}{picture}", text.split_whitespace().collect::<Vec<_>>().join(" "))
122        };
123        lines.push(format!("{} · {app} · {who}{place}", said_ms(s, l.at)));
124        lines.push(format!("    {words}"));
125        out.push(json!({
126            "at": instant_ms(l.at), "app": app, "kind": l.kind, "from": who, "conversation": l.title, "group": l.group, "text": if l.code || l.withheld { None } else { Some(shown_text(l)) },
127            "picture": l.picture, "code_came": l.code, "withheld": l.withheld, "phone": l.device,
128        }));
129    }
130    // Their words, each on one line, inside a frame made for this answer: whatever they say, they cannot end it.
131    let lines: Vec<String> = lines.iter().map(|l| if l.starts_with("    ") { format!("    {}", one_line(l.trim_start())) } else { one_line(l) }).collect();
132    let mut text = if lines.is_empty() { Vec::new() } else { super::read::framed("The phone's messages, as their senders wrote them,", &lines.join("\n")) };
133    if out.is_empty() {
134        text.push(if all.is_empty() { "No message from a phone here: the phone shares them once its part “Messages from your phone” is on.".into() } else { "No message from a phone matches.".into() });
135    } else if total > limit {
136        text.push(format!("… {} more", total - limit));
137    }
138    if waiting > 0 {
139        text.push(format!("({waiting} more came while the phone held them; they are given once the phone lets them through, as the person sees them.)"));
140    }
141    text.extend(access::left_out(closed));
142    let text = text.join("\n");
143    Ok(Answer { text, data: json!({ "messages": out, "total": total, "waiting": waiting, "kept_from_agents": closed }) })
144}
145
146pub fn calls(s: &Session, args: &Args) -> Result<Answer, String> {
147    let since = args.date("since")?;
148    let limit = args.number("limit", 50, 1, 500)? as usize;
149    let now_ms = Zoned::now().timestamp().as_millisecond();
150    let from_ms = since.and_then(|d| d.to_zoned(TimeZone::system()).ok()).map_or(i64::MIN, |z| z.timestamp().as_millisecond());
151    let loaded = Loaded::read(&s.config);
152    let consent = access::of(s, &loaded);
153    let kept = Kept::of(s, &loaded, &consent);
154    let region = sioul_core::reach::region(&s.config);
155    let all = calls::read_logs(&calls::folder(), now_ms);
156    let in_scope: Vec<&calls::Held> = all.iter().filter(|h| h.at >= from_ms).collect();
157    let (open, closed): (Vec<&calls::Held>, Vec<&calls::Held>) = in_scope.into_iter().partition(|h| consent.outside && !kept.holds(&h.name, &h.key));
158    let closed = closed.len();
159    let mut lines = Vec::new();
160    let mut out = Vec::new();
161    for h in open.iter().rev().take(limit) {
162        let who = if h.hidden {
163            "a hidden number".to_string()
164        } else {
165            known_name(s, &loaded, &h.key).or_else(|| Some(h.name.clone()).filter(|n| !n.is_empty())).unwrap_or_else(|| calls::shown_number(&h.key, region))
166        };
167        let number = if h.hidden { String::new() } else { calls::shown_number(&h.key, region) };
168        let what = if h.rang { "rang" } else { "declined, sent to voicemail" };
169        let shown = if number.is_empty() || number == who { who.clone() } else { format!("{who} ({number})") };
170        lines.push(format!("{} · {shown} · {what}", said_ms(s, h.at)));
171        out.push(json!({ "at": instant_ms(h.at), "from": who, "number": number, "hidden": h.hidden, "rang": h.rang, "row": h.who, "time": h.column, "verified": h.verified, "phone": h.device }));
172    }
173    if out.is_empty() {
174        lines.push(if all.is_empty() { "No call here: a phone of the person's lists the calls it screens once its part “Calls” is on.".into() } else { "No call then.".into() });
175    } else if open.len() > limit {
176        lines.push(format!("… {} more", open.len() - limit));
177    }
178    lines.extend(access::left_out(closed));
179    let text = lines.iter().map(|l| one_line(l)).collect::<Vec<_>>().join("\n");
180    Ok(Answer { text, data: json!({ "calls": out, "total": open.len(), "kept_from_agents": closed }) })
181}
182
183// Texts (docs/texts.md): read, searched, and drafted, behind `[mcp] texts`.
184
185/// The tools that read or draft texts, which `[mcp] texts` keeps from agents unless it is true.
186pub const TEXTS: [&str; 3] = ["texts", "search_texts", "draft_text"];
187
188/// What opens the texts sealed at rest on this computer: the sharing key,
189/// read from the keyring now, while `[mcp] texts` is true, and only for this.
190/// It never reaches the agent. Tests use a key of their own, never a keyring.
191fn sealer() -> Result<sioul_sync::textseal::TextSeal, String> {
192    #[cfg(test)]
193    return Ok(sioul_sync::textseal::TextSeal::new(&super::tests::TEXTS_KEY));
194    #[cfg(not(test))]
195    {
196        let unhex = |text: &str| -> Option<[u8; 32]> {
197            let bytes: Option<Vec<u8>> = (0..text.len()).step_by(2).map(|i| text.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok())).collect();
198            <[u8; 32]>::try_from(bytes?).ok()
199        };
200        sioul_sync::secret::named(sioul_sync::share::KEY_NAME)
201            .and_then(|key| unhex(&key))
202            .map(|key| sioul_sync::textseal::TextSeal::new(&key))
203            .ok_or_else(|| "Sioul cannot open the texts here: this computer holds no sharing key. The person shares texts from their phone first (Settings ▸ Your folder and sharing).".to_string())
204    }
205}
206
207/// The texts as an agent may read them: each once, a text found in two
208/// phones' logs (a restore onto a new phone) given once (`texts::one_each`);
209/// each one's words with its codes masked (as in mail: never given, never
210/// found by their digits), those of a conversation kept from agents left
211/// out, and how many were.
212struct Shelf {
213    texts: Vec<sioul_core::texts::Text>,
214    requests: Vec<sioul_core::texts::Request>,
215    outcomes: Vec<sioul_core::texts::Outcome>,
216    kept: usize,
217    any: bool,
218    loaded: Loaded,
219}
220
221impl Shelf {
222    fn read(s: &Session, seal: &dyn sioul_core::texts::Sealer) -> Shelf {
223        use sioul_core::texts;
224        let root = texts::folder();
225        let loaded = Loaded::read(&s.config);
226        let consent = access::of(s, &loaded);
227        let kept_people = Kept::of(s, &loaded, &consent);
228        let looked = sioul_core::words::Words::of(&s.config);
229        let masked = |body: &str| if body.is_empty() { String::new() } else { mask::message(&looked, "", body).1 };
230        let region = sioul_core::reach::region(&s.config);
231        let all = texts::one_each(texts::read_logs(&root, seal), region);
232        let any = !all.is_empty();
233        let open = |with: &[String]| consent.outside && !with.iter().any(|k| kept_people.holds("", k));
234        let (open_texts, closed): (Vec<texts::Text>, Vec<texts::Text>) = all.into_iter().partition(|t| open(&t.with));
235        let texts: Vec<texts::Text> = open_texts
236            .into_iter()
237            .map(|t| {
238                let parts = t.parts.iter().map(|p| texts::Part { text: masked(&p.text), ..p.clone() }).collect();
239                texts::Text { body: masked(&t.body), parts, fields: Default::default(), addrs: Vec::new(), ..t }
240            })
241            .collect();
242        let requests = texts::read_requests(&root, seal).into_iter().filter(|r| open(&[phones::key(&r.to, region)])).map(|r| texts::Request { body: masked(&r.body), ..r }).collect();
243        Shelf { texts, requests, outcomes: texts::read_outcomes(&root, seal), kept: closed.len(), any, loaded }
244    }
245}
246
247/// What a conversation's page needs besides the lines: names from the address books, no media opened.
248fn viewer<'a>(s: &'a Session, now: &'a Zoned, name_of: &'a dyn Fn(&str) -> Option<String>, media_here: &'a dyn Fn(&str) -> bool) -> sioul_core::texts::Viewer<'a> {
249    sioul_core::texts::Viewer { now, tr: &s.tr, region: sioul_core::reach::region(&s.config), name_of, phone_shared: None, media_here }
250}
251
252/// A message on its lines: when, who, its words, its parts said, its state.
253fn message_lines(m: &sioul_core::texts::Message, title: &str) -> Vec<String> {
254    let who = if m.direction == "out" { "You".to_string() } else if m.from.is_empty() { title.to_string() } else { m.from.clone() };
255    let mut head = format!("{} · {who}", m.when);
256    if m.deleted {
257        head.push_str(" · (deleted on the phone, kept here)");
258    }
259    if let Some(said) = &m.said {
260        head.push_str(&format!(" · {}", said.text));
261    }
262    let mut lines = vec![one_line(&head)];
263    let words = m.body.split_whitespace().collect::<Vec<_>>().join(" ");
264    if !words.is_empty() {
265        lines.push(format!("    {}", one_line(&words)));
266    }
267    for part in &m.parts {
268        lines.push(format!("    [{}]", one_line(&part.said)));
269    }
270    lines
271}
272
273pub fn texts(s: &Session, args: &Args) -> Result<Answer, String> {
274    use sioul_core::texts;
275    let wanted = args.text("conversation")?;
276    let limit = args.number("limit", 30, 1, 500)? as usize;
277    let seal = sealer()?;
278    let shelf = Shelf::read(s, &seal);
279    let now = Zoned::now();
280    let region = sioul_core::reach::region(&s.config);
281    let name_of = |key: &str| known_name(s, &shelf.loaded, key);
282    let media_here = |_: &str| false;
283    let v = viewer(s, &now, &name_of, &media_here);
284    let conversations = texts::conversations(&shelf.texts, &shelf.requests, &v);
285    let mut lines = Vec::new();
286    let data;
287    match wanted {
288        None => {
289            for c in conversations.iter().take(limit) {
290                lines.push(format!("{} · {}{}  [{}]", c.when, c.title, if c.group { " (a group)" } else { "" }, c.id));
291                let last = c.last.split_whitespace().collect::<Vec<_>>().join(" ");
292                if !last.is_empty() {
293                    lines.push(format!("    {last}"));
294                }
295            }
296            data = json!({ "conversations": conversations.iter().take(limit).collect::<Vec<_>>(), "total": conversations.len(), "kept_from_agents": shelf.kept });
297            if conversations.len() > limit {
298                lines.push(format!("… {} more", conversations.len() - limit));
299            }
300        }
301        Some(asked) => {
302            // By its id ("+33199001234", several joined by ","), or a number of it.
303            let id = if conversations.iter().any(|c| c.id == asked) { asked.clone() } else { texts::conversation_id(&asked.split(',').map(|n| phones::key(n, region)).collect::<Vec<_>>()) };
304            let Some(talk) = conversations.iter().find(|c| c.id == id) else {
305                return Err(if shelf.kept > 0 { access::kept("This conversation, if it is one,") } else { format!("No conversation “{}”: `texts` lists them, with their ids.", one_line(&asked)) });
306            };
307            let all = texts::messages(&id, &shelf.texts, &shelf.requests, &shelf.outcomes, &v);
308            let shown: Vec<&texts::Message> = all.iter().rev().take(limit).rev().collect();
309            for m in &shown {
310                lines.extend(message_lines(m, &talk.title));
311            }
312            if all.len() > limit {
313                lines.insert(0, format!("(the {limit} newest of {})", all.len()));
314            }
315            let drafts: Vec<sioul_core::textdraft::TextDraft> = sioul_core::textdraft::all(&seal).into_iter().filter(|d| d.conversation == id).collect();
316            data = json!({ "conversation": talk, "messages": shown, "drafts_waiting": drafts.len(), "kept_from_agents": shelf.kept });
317            if !drafts.is_empty() {
318                lines.push(format!("({} draft{} written by an agent wait{} in the Texts page for the person.)", drafts.len(), if drafts.len() == 1 { "" } else { "s" }, if drafts.len() == 1 { "s" } else { "" }));
319            }
320        }
321    }
322    let mut text = if lines.is_empty() { Vec::new() } else { super::read::framed("The texts, as their people wrote them,", &lines.join("\n")) };
323    if lines.is_empty() {
324        text.push(if shelf.any { "No texts to give here.".into() } else { "No texts here: this computer keeps them once the phone shares the part “Texts”.".into() });
325    }
326    text.extend(access::left_out(shelf.kept));
327    Ok(Answer { text: text.join("\n"), data })
328}
329
330pub fn search_texts(s: &Session, args: &Args) -> Result<Answer, String> {
331    use sioul_core::texts;
332    let query = args.needed("query")?;
333    let limit = args.number("limit", 20, 1, 200)? as usize;
334    let seal = sealer()?;
335    let shelf = Shelf::read(s, &seal);
336    let now = Zoned::now();
337    let name_of = |key: &str| known_name(s, &shelf.loaded, key);
338    let media_here = |_: &str| false;
339    let v = viewer(s, &now, &name_of, &media_here);
340    // In the words as the agent is given them, masked: a code is never found by its digits.
341    let found = texts::search(&query, &shelf.texts, &shelf.requests, &shelf.outcomes, &v, limit);
342    let mut lines = Vec::new();
343    for f in &found {
344        lines.push(format!("{}  [{}]", one_line(&f.title), f.conversation));
345        lines.extend(message_lines(&f.message, &f.title).into_iter().map(|l| format!("  {l}")));
346    }
347    let mut text = if lines.is_empty() { vec![format!("No text holds “{}”.", one_line(&query))] } else { super::read::framed("The texts found, as their people wrote them,", &lines.join("\n")) };
348    text.extend(access::left_out(shelf.kept));
349    Ok(Answer { text: text.join("\n"), data: json!({ "found": found, "kept_from_agents": shelf.kept }) })
350}
351
352pub fn draft_text(s: &Session, args: &Args) -> Result<Answer, String> {
353    use sioul_core::texts;
354    let to = args.needed_line("to")?;
355    let body = args.body("body")?;
356    let region = sioul_core::reach::region(&s.config);
357    if to.contains([',', ';']) {
358        return Err("A draft goes to one person: Sioul does not send texts to a group from a computer.".into());
359    }
360    if let Some(why) = texts::refusal(&to, &body, region) {
361        return Err(match why {
362            "empty" => "“body” is needed: the words of the text.".to_string(),
363            "too-long" => format!("A text drafted here holds at most {} characters.", texts::WRITE_MAX),
364            "short-number" => format!("“{}” is no number a text can go to from a computer (a short number is refused).", one_line(&to)),
365            other => format!("This text cannot be drafted: {other}."),
366        });
367    }
368    let key = phones::key(&to, region);
369    let loaded = Loaded::read(&s.config);
370    let consent = access::of(s, &loaded);
371    if !consent.outside || Kept::of(s, &loaded, &consent).holds("", &key) {
372        return Err(access::kept("This conversation"));
373    }
374    let seal = sealer()?;
375    let draft = sioul_core::textdraft::TextDraft { id: texts::new_key(), to: key.clone(), conversation: texts::conversation_id(std::slice::from_ref(&key)), body, written: Zoned::now().timestamp().as_millisecond(), by: String::new() };
376    sioul_core::textdraft::add(&draft, &seal)?;
377    let who = known_name(s, &loaded, &key).unwrap_or_else(|| calls::shown_number(&key, region));
378    let text = format!(
379        "Saved as a draft in the Texts page, in the conversation with {}  [{}]\n(Not sent: the person reads it there, then uses it and sends it themselves, or discards it.)",
380        one_line(&who),
381        draft.conversation
382    );
383    Ok(Answer { text, data: json!({ "id": draft.id, "conversation": draft.conversation, "to": who, "sent": false }) })
384}