use crate::imp::memory::RunContext;
use serde_json::Value;
use std::collections::BTreeSet;
pub fn participant_markers(context: &RunContext) -> Vec<String> {
let mut markers: BTreeSet<String> = BTreeSet::new();
if let Some(user) = context.user_id.as_deref() {
markers.insert(user.to_string());
}
if let Some(channel) = context.channel_id.as_deref() {
markers.insert(channel.to_string());
let history = crate::paths::channel_dir(channel).join("messages.jsonl");
if let Ok(text) = std::fs::read_to_string(history) {
for line in text.lines() {
let Ok(record) = serde_json::from_str::<Value>(line) else {
continue;
};
for key in ["author_id", "author"] {
if let Some(v) = record[key].as_str() {
markers.insert(v.to_string());
}
}
}
}
}
markers.into_iter().filter(|m| m.len() >= 3).collect()
}
pub fn check_task_prompt(context: &RunContext, prompt: &str) -> Result<(), String> {
if !context.tainted() {
return Ok(());
}
match scan(prompt, &participant_markers(context), true) {
None => Ok(()),
Some(hit) => Err(format!(
"the task prompt references a conversation participant (\"{hit}\") — that belongs in memory, not in a work order; rephrase without naming people"
)),
}
}
pub fn scan(text: &str, markers: &[String], match_emails: bool) -> Option<String> {
let lower = text.to_lowercase();
for marker in markers {
if lower.contains(&marker.to_lowercase()) {
return Some(marker.clone());
}
}
if let Some(mention) = find_mention(text) {
return Some(mention);
}
if match_emails {
if let Some(email) = find_email(text) {
return Some(email);
}
}
None
}
fn find_mention(text: &str) -> Option<String> {
let mut rest = text;
while let Some(start) = rest.find("<@") {
let tail = &rest[start + 2..];
if let Some(end) = tail.find('>') {
let inner = &tail[..end];
if !inner.is_empty()
&& inner.len() <= 24
&& inner
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'!' || b == b'&')
{
return Some(format!("<@{inner}>"));
}
rest = &tail[end + 1..];
} else {
break;
}
}
None
}
fn find_email(text: &str) -> Option<String> {
let bytes = text.as_bytes();
for (i, b) in bytes.iter().enumerate() {
if *b != b'@' {
continue;
}
let is_part =
|c: u8| c.is_ascii_alphanumeric() || c == b'.' || c == b'_' || c == b'-' || c == b'+';
let start = (0..i).rev().take_while(|&j| is_part(bytes[j])).last();
let end = (i + 1..bytes.len())
.take_while(|&j| is_part(bytes[j]))
.last();
if let (Some(s), Some(e)) = (start, end) {
let candidate = &text[s..=e];
let domain = &candidate[candidate.find('@').unwrap() + 1..];
if domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.') {
return Some(candidate.to_string());
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn task_prompt_gate_denies_person_references_from_tainted_runs_only() {
let clean = RunContext::default();
let relay = RunContext {
inbound: true,
..Default::default()
};
let session = RunContext {
channel_id: Some("C123".into()),
user_id: Some("U0AB12CD3".into()),
..Default::default()
};
let personal = "Ask lead-person@example.com to review the XDG summary.";
let worldly = "Research the XDG base directory specification and record a summary.";
let err = check_task_prompt(&relay, personal).unwrap_err();
assert!(
err.contains("lead-person@example.com") && err.contains("belongs in memory"),
"{err}"
);
assert!(check_task_prompt(&session, "summarize what U0AB12CD3 asked").is_err());
assert!(check_task_prompt(&session, "reply to <@1521174547326566530>").is_err());
assert!(check_task_prompt(&relay, worldly).is_ok());
assert!(check_task_prompt(&session, worldly).is_ok());
assert!(check_task_prompt(&clean, personal).is_ok());
}
#[test]
fn scan_matches_participants_mentions_and_emails() {
let markers = vec!["U0AB12CD3".to_string(), "manas".to_string()];
assert_eq!(
scan("ask U0AB12CD3 about it", &markers, false),
Some("U0AB12CD3".into())
);
assert_eq!(
scan("per Manas's request", &markers, false),
Some("manas".into())
);
assert_eq!(
scan("ping <@1521174547326566530> later", &[], false),
Some("<@1521174547326566530>".into())
);
assert_eq!(
scan("write to a-lead@example.com", &[], true),
Some("a-lead@example.com".into())
);
assert_eq!(
scan("contact sales@vendor.com for pricing", &[], false),
None
);
assert_eq!(
scan("summarize the RFC and cite sources", &markers, true),
None
);
assert_eq!(scan("anything", &[], false), None);
}
}