Skip to main content

sioul_core/
shield.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The shield of a public address. A contact address brings work, and also
5//! insults; its mail is read first, here, before you see any of it:
6//!
7//! - **its tone**: calm, rude (swearing, contempt), or hostile (insults aimed
8//!   at you, harassment, threats). Hostile mail is set aside without its
9//!   sender's words being shown: not the subject, not a line of the text;
10//! - **its topic**: work (a job, a mission, a quote), a question about your
11//!   software, the press, thanks, or other; work comes first.
12//!
13//! The reading is made of word lists (`words::ShieldWords`: the languages in
14//! use, French and English built in, and your changes), matched on whole
15//! words with case and accents ignored; an insult near "you" weighs more,
16//! and shouting adds to it. When you allow it, an AI reads the message too
17//! (`ai_prompt`), more finely; its answer is kept and takes precedence.
18
19use crate::text::{find_word, fold};
20use crate::words::ShieldWords;
21use serde::{Deserialize, Serialize};
22
23/// How a message speaks.
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum Tone {
27    #[default]
28    Calm,
29    /// Swearing or contempt, not aimed at you.
30    Rude,
31    /// Insults aimed at you, harassment, threats.
32    Hostile,
33}
34
35/// What a message is about.
36#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum Topic {
39    /// A job, a mission, a quote, a collaboration.
40    Work,
41    /// A question about your software or your work.
42    Support,
43    Press,
44    Thanks,
45    /// Money given: a donation, sponsoring.
46    Donation,
47    #[default]
48    Other,
49}
50
51/// What the shield made of a message.
52#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
53pub struct Assessment {
54    pub tone: Tone,
55    pub topic: Topic,
56    /// The words that weighed, to say why, on request.
57    #[serde(default)]
58    pub words: Vec<String>,
59    /// One neutral line on what it asks, when an AI read it.
60    #[serde(default)]
61    pub summary: String,
62    #[serde(default)]
63    pub by_ai: bool,
64}
65
66/// Where `phrase` appears as whole words in `folded`, every time.
67fn positions(folded: &[char], phrase: &str) -> Vec<usize> {
68    let mut out = Vec::new();
69    let mut from = 0;
70    while let Some(at) = find_word(folded, phrase, from) {
71        out.push(at);
72        from = at + 1;
73    }
74    out
75}
76
77/// Whether a word saying "you" (`you`) stands within a few words of `at`;
78/// an elided one ("t'") anywhere it is written, the word after it joined.
79fn near_you(you: &[String], folded: &[char], at: usize) -> bool {
80    let start = at.saturating_sub(40);
81    let end = (at + 40).min(folded.len());
82    let window = &folded[start..end];
83    you.iter().any(|w| {
84        let elided: Vec<char> = fold(w.trim());
85        find_word(window, w, 0).is_some() || (w.trim().ends_with('\'') && !elided.is_empty() && window.windows(elided.len()).any(|p| p == elided.as_slice()))
86    })
87}
88
89/// Shouting: most letters in capitals, over a real length of text.
90fn shouts(text: &str) -> bool {
91    let letters: Vec<char> = text.chars().filter(|c| c.is_alphabetic()).collect();
92    letters.len() >= 40 && letters.iter().filter(|c| c.is_uppercase()).count() * 10 >= letters.len() * 6
93}
94
95/// Reads a message's subject and text: its tone and its topic, with the
96/// words in use (`words::current`).
97pub fn assess(subject: &str, text: &str) -> Assessment {
98    assess_with(&crate::words::current().shield, subject, text)
99}
100
101/// `assess` with these words. Each word counts once, however many lists
102/// (languages) hold it.
103pub fn assess_with(lists: &ShieldWords, subject: &str, text: &str) -> Assessment {
104    let whole = format!("{subject}\n{text}");
105    let folded = fold(&whole);
106    let mut score = 0.0f32;
107    let mut words = Vec::new();
108    for threat in &lists.threats {
109        if !positions(&folded, threat).is_empty() {
110            score += 3.0;
111            words.push(threat.to_string());
112        }
113    }
114    for insult in &lists.insults {
115        for at in positions(&folded, insult) {
116            score += if near_you(&lists.you, &folded, at) { 3.0 } else { 1.5 };
117            if !words.iter().any(|w| w == insult) {
118                words.push(insult.to_string());
119            }
120        }
121    }
122    let mut rude = 0.0f32;
123    for word in &lists.rude {
124        let found = positions(&folded, word).len();
125        if found > 0 {
126            rude += found as f32;
127            if !words.iter().any(|w| w == word) {
128                words.push(word.to_string());
129            }
130        }
131    }
132    if shouts(text) {
133        rude += 1.0;
134    }
135    if whole.matches("!!").count() >= 3 {
136        rude += 0.5;
137    }
138    let tone = if score >= 3.0 || score + rude >= 4.0 {
139        Tone::Hostile
140    } else if score > 0.0 || rude >= 1.0 {
141        Tone::Rude
142    } else {
143        Tone::Calm
144    };
145    // The topic most spoken of; work wins a tie, as it matters most.
146    let t = &lists.topics;
147    let topic = [(Topic::Work, &t.work), (Topic::Support, &t.support), (Topic::Press, &t.press), (Topic::Thanks, &t.thanks), (Topic::Donation, &t.donation)]
148        .into_iter()
149        .map(|(topic, keys)| (keys.iter().map(|k| positions(&folded, k).len()).sum::<usize>(), topic))
150        .filter(|(n, _)| *n > 0)
151        .max_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)))
152        .map_or(Topic::Other, |(_, t)| t);
153    Assessment { tone, topic, words, summary: String::new(), by_ai: false }
154}
155
156/// Where an AI's answer about a message is kept: its Message-ID, which its
157/// sender writes, and a digest of who sent it and what it says (FNV-1a), so
158/// that a message borrowing the ID of a calm one is read again, never taken for it.
159pub fn ai_key(card: &crate::card::Card) -> Option<String> {
160    let id = crate::mailindex::bare_id(card.message_id.as_deref()?);
161    let mut digest: u64 = 0xcbf2_9ce4_8422_2325;
162    for byte in [card.from_address.as_deref().unwrap_or(""), card.subject.as_str(), card.excerpt.as_str()].iter().flat_map(|part| part.bytes().chain(std::iter::once(0))) {
163        digest ^= u64::from(byte);
164        digest = digest.wrapping_mul(0x0100_0000_01b3);
165    }
166    Some(format!("{id}#{digest:016x}"))
167}
168
169/// How a message to a shielded address reads: the AI's answer about this
170/// very message when it read it (found under `ai_key`, where
171/// `sioul_sync::shield_ai` keeps it), else the word lists'. Every reader goes
172/// through here (the Porch, `sioul shield`, the MCP server), so that none
173/// looks an answer up another way, misses it, and passes on mail the AI found hostile.
174pub fn reading(card: &crate::card::Card, answers: Option<&std::collections::BTreeMap<String, Assessment>>) -> Assessment {
175    answers.and_then(|answers| answers.get(&ai_key(card)?)).cloned().unwrap_or_else(|| assess(&card.subject, &card.excerpt))
176}
177
178/// What an AI is asked about a message, to answer in one line of JSON.
179pub fn ai_prompt(subject: &str, text: &str) -> String {
180    let excerpt: String = text.chars().take(4000).collect();
181    format!(
182        "You screen mail sent to a public contact address, so that its owner, who is vulnerable to harassment, never reads hostile text. \
183         Answer with one line of JSON and nothing else: {{\"tone\": \"calm\" | \"rude\" | \"hostile\", \"topic\": \"work\" | \"support\" | \"press\" | \"thanks\" | \"donation\" | \"other\", \"summary\": \"…\"}}. \
184         tone: hostile for insults aimed at the reader, harassment, threats, intimidation; rude for swearing or contempt not aimed at the reader; calm otherwise. \
185         topic: work for a job, a mission, a quote, a collaboration; support for a question about software. \
186         summary: at most 15 neutral words saying what the message asks, in the message's language, without quoting any insult.\n\nSubject: {subject}\n\n{excerpt}"
187    )
188}
189
190/// An AI's answer, read; None when it is not the JSON asked for.
191pub fn read_ai_answer(answer: &str) -> Option<Assessment> {
192    #[derive(Deserialize)]
193    struct Answer {
194        tone: Tone,
195        topic: Topic,
196        #[serde(default)]
197        summary: String,
198    }
199    let start = answer.find('{')?;
200    let end = answer.rfind('}')?;
201    // "} … {": no object, and no panic on a range turned around.
202    let parsed: Answer = serde_json::from_str(answer.get(start..=end)?).ok()?;
203    Some(Assessment { tone: parsed.tone, topic: parsed.topic, words: Vec::new(), summary: parsed.summary.chars().take(200).collect(), by_ai: true })
204}
205
206/// What an AI made of shielded mail, one file per account
207/// (`$XDG_STATE_HOME/sioul/shield/<account>.toml`): asked once per message,
208/// kept under `ai_key`, read with `reading`.
209#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
210pub struct AiCache {
211    #[serde(default)]
212    pub messages: std::collections::BTreeMap<String, Assessment>,
213    /// The messages whose answer came back not in the form asked, under
214    /// `ai_key`, with how many times: asked once more at most (`wants`), so
215    /// that their subject and text are not sent again round after round.
216    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
217    pub malformed: std::collections::BTreeMap<String, u32>,
218}
219
220impl AiCache {
221    /// How many times a message is sent at most: once, and once more when
222    /// the answer did not come in the form asked.
223    pub const TIMES: u32 = 2;
224
225    /// Whether the message under `id` (`ai_key`) is to be sent: no answer
226    /// kept for it, and not sent `TIMES` already for answers not in the form asked.
227    pub fn wants(&self, id: &str) -> bool {
228        !self.messages.contains_key(id) && self.malformed.get(id).copied().unwrap_or(0) < AiCache::TIMES
229    }
230
231    pub fn folder() -> std::path::PathBuf {
232        crate::config::state_dir().join("shield")
233    }
234
235    pub fn path(account: &str) -> std::path::PathBuf {
236        AiCache::folder().join(format!("{account}.toml"))
237    }
238
239    pub fn load(account: &str) -> AiCache {
240        std::fs::read_to_string(AiCache::path(account)).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
241    }
242
243    /// Every account's answers together, under `ai_key`.
244    pub fn load_all() -> std::collections::BTreeMap<String, Assessment> {
245        let Ok(entries) = std::fs::read_dir(AiCache::folder()) else { return Default::default() };
246        entries
247            .filter_map(Result::ok)
248            .filter_map(|e| std::fs::read_to_string(e.path()).ok())
249            .filter_map(|t| toml::from_str::<AiCache>(&t).ok())
250            .flat_map(|c| c.messages)
251            .collect()
252    }
253
254    pub fn save(&self, account: &str) -> Result<(), String> {
255        let path = AiCache::path(account);
256        let fail = |e: std::io::Error| format!("{}: {e}", path.display());
257        if let Some(parent) = path.parent() {
258            std::fs::create_dir_all(parent).map_err(fail)?;
259        }
260        let temporary = path.with_extension("toml.new");
261        std::fs::write(&temporary, toml::to_string(self).map_err(|e| e.to_string())?).map_err(fail)?;
262        std::fs::rename(&temporary, &path).map_err(fail)
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn tone_and_topic() {
272        let job = assess("Mission de conseil", "Bonjour, nous recrutons un consultant en traitement d'image pour une mission de trois mois. Quel serait votre tarif ?");
273        assert_eq!((job.tone, job.topic), (Tone::Calm, Topic::Work));
274        let support = assess("The app crashes on export", "Hi, since the last update the app crashes when I export a file. How to fix this bug?");
275        assert_eq!((support.tone, support.topic), (Tone::Calm, Topic::Support));
276        let rude = assess("Export", "This export module is garbage, what the fuck.");
277        assert_eq!(rude.tone, Tone::Rude);
278        let insult = assess("Ton logiciel", "T'es qu'un connard prétentieux, ton logiciel est nul.");
279        assert_eq!(insult.tone, Tone::Hostile);
280        assert!(insult.words.contains(&"connard".to_string()));
281        let threat = assess("hey", "I know where you live.");
282        assert_eq!(threat.tone, Tone::Hostile);
283        // Whole words only: "connard" is not in "connaissance", "con" never counts.
284        assert_eq!(assess("Merci", "Merci pour la connaissance partagée, c'est un contact précieux.").tone, Tone::Calm);
285        assert_eq!(assess("Merci", "Merci pour vos articles, quelle reconnaissance !").topic, Topic::Thanks);
286    }
287
288    /// Your words: a German insult and a topic of your own work, once added.
289    #[test]
290    fn your_words_are_read() {
291        let config: crate::config::Config = toml::from_str("[words]\nlanguages = [\"fr\", \"en\"]\ncountries = [\"FR\"]\n[words.shield.insults]\nadd = [\"Mistkerl\"]\n[words.shield.you]\nadd = [\"du\"]\n[words.shield.topics.support]\nadd = [\"pixelmill\"]\n").unwrap();
292        let yours = crate::words::Words::of(&config);
293        assert_eq!(assess_with(&yours.shield, "Hallo", "Du bist ein Mistkerl.").tone, Tone::Hostile);
294        assert_eq!(assess("Hallo", "Du bist ein Mistkerl.").tone, Tone::Calm, "not in the packs");
295        assert_eq!(assess_with(&yours.shield, "pixelmill", "Une question sur pixelmill.").topic, Topic::Support);
296        // "t'" said before a verb, as the packs read it.
297        assert_eq!(assess("Toi", "T'es qu'un connard.").tone, Tone::Hostile);
298    }
299
300    #[test]
301    fn an_ai_answer_is_read() {
302        let read = read_ai_answer("Here: {\"tone\": \"hostile\", \"topic\": \"other\", \"summary\": \"Complains about the export: \\\"rude\\\"\"}").unwrap();
303        assert_eq!((read.tone, read.topic, read.by_ai), (Tone::Hostile, Topic::Other, true));
304        assert!(read.summary.starts_with("Complains"));
305        assert!(read_ai_answer("no json").is_none());
306        assert!(read_ai_answer("} said the model, then {").is_none());
307        assert!(ai_prompt("s", "t").contains("Subject: s"));
308    }
309
310    /// The AI's answer is kept under `ai_key` and read back under it, by
311    /// every reader: never under the bare Message-ID, which its sender writes.
312    #[test]
313    fn the_ai_answer_is_found_where_it_is_kept() {
314        let card = |text: &str| crate::card::Card::from_bytes(format!("From: Someone <someone@hostile.example>\r\nSubject: Words for you\r\nMessage-ID: <h-1@hostile.example>\r\n\r\n{text}\r\n").as_bytes()).unwrap();
315        let calm_words = card("Some unkind sentences.");
316        assert_eq!(assess(&calm_words.subject, &calm_words.excerpt).tone, Tone::Calm, "the word lists find nothing");
317        let hostile = Assessment { tone: Tone::Hostile, by_ai: true, ..Assessment::default() };
318        let answers: std::collections::BTreeMap<String, Assessment> = [(ai_key(&calm_words).unwrap(), hostile.clone())].into();
319        assert_eq!(reading(&calm_words, Some(&answers)), hostile);
320        // Under the bare Message-ID, nothing is kept: an answer kept there would be read for any message borrowing it.
321        assert!(!answers.contains_key("h-1@hostile.example"));
322        // Another text under the same Message-ID is read again, by the word lists here.
323        assert_eq!(reading(&card("Thanks for the export module."), Some(&answers)).tone, Tone::Calm);
324        assert!(!reading(&card("Thanks for the export module."), Some(&answers)).by_ai);
325        // No answers at all: the word lists.
326        assert_eq!(reading(&calm_words, None).tone, Tone::Calm);
327    }
328}