1use 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
31fn 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
36fn said_ms(s: &Session, ms: i64) -> String {
38 crate::date(s, Some(ms.div_euclid(1000)))
39}
40
41struct 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
63fn 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
69fn 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 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 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 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 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
183pub const TEXTS: [&str; 3] = ["texts", "search_texts", "draft_text"];
187
188fn 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
207struct 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
247fn 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
252fn 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 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 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}