Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions crates/signal-bot/src/bot_identity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
//! Track the bot's Signal phone(s) and UUID so we never relay our own messages.

use signal_client::BotMessage;
use std::collections::HashSet;
use std::sync::{Arc, RwLock};

/// In-memory bot identity (phones from `receiving_account`, UUID learned if seen).
#[derive(Debug, Default)]
pub struct BotIdentity {
phones: RwLock<HashSet<String>>,
uuids: RwLock<HashSet<String>>,
}

impl BotIdentity {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}

pub fn remember_phone(&self, phone: &str) {
if phone.is_empty() {
return;
}
self.phones.write().unwrap().insert(phone.to_string());
}

pub fn learn_uuid(&self, uuid: &str) {
if uuid.is_empty() || uuid.starts_with('+') {
return;
}
self.uuids.write().unwrap().insert(uuid.to_string());
}

/// Call on every inbound message: remember account phone; learn UUID if self-sourced.
pub fn note_inbound(&self, message: &BotMessage) {
self.remember_phone(&message.receiving_account);

let phones = self.phones.read().unwrap();
let is_self = phones.contains(&message.source)
|| message
.source_number
.as_ref()
.is_some_and(|n| phones.contains(n));
drop(phones);

if is_self && !message.source.starts_with('+') {
self.learn_uuid(&message.source);
}
}

pub fn is_bot_message(&self, message: &BotMessage) -> bool {
let phones = self.phones.read().unwrap();
let uuids = self.uuids.read().unwrap();

if phones.contains(&message.source) || uuids.contains(&message.source) {
return true;
}
if let Some(n) = &message.source_number {
if phones.contains(n) {
return true;
}
}
false
}
}

#[cfg(test)]
mod tests {
use super::*;

fn msg(source: &str, source_number: Option<&str>, account: &str) -> BotMessage {
BotMessage {
source: source.into(),
source_number: source_number.map(str::to_string),
source_name: None,
text: "hi".into(),
timestamp: 1,
message_timestamp: 1,
is_group: true,
group_id: Some("g".into()),
group_name: None,
receiving_account: account.into(),
attachments: vec![],
quote: None,
}
}

#[test]
fn skips_bot_phone_as_source() {
let id = BotIdentity::new();
let m = msg("+15550001111", Some("+15550001111"), "+15550001111");
id.note_inbound(&m);
assert!(id.is_bot_message(&m));
}

#[test]
fn skips_bot_uuid_after_learn() {
let id = BotIdentity::new();
id.remember_phone("+15550001111");
let self_msg = msg(
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
Some("+15550001111"),
"+15550001111",
);
id.note_inbound(&self_msg);
assert!(id.is_bot_message(&self_msg));

let again = msg(
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
None,
"+15550001111",
);
assert!(id.is_bot_message(&again));
}

#[test]
fn human_not_bot() {
let id = BotIdentity::new();
id.remember_phone("+15550001111");
let m = msg("+15550002222", Some("+15550002222"), "+15550001111");
assert!(!id.is_bot_message(&m));
}
}
3 changes: 3 additions & 0 deletions crates/signal-bot/src/commands/ask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,14 @@ mod tests {
fn sample_message(text: &str) -> BotMessage {
BotMessage {
source: "+1234567890".into(),
source_number: None,
source_name: None,
text: text.into(),
timestamp: 0,
message_timestamp: 0,
is_group: true,
group_id: Some("group.test".into()),
group_name: None,
receiving_account: "+0987654321".into(),
attachments: vec![],
quote: None,
Expand Down
3 changes: 3 additions & 0 deletions crates/signal-bot/src/commands/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,8 @@ mod tests {
fn sample_message(text: &str, is_group: bool) -> BotMessage {
BotMessage {
source: "+1234567890".into(),
source_number: None,
source_name: None,
text: text.into(),
timestamp: 0,
message_timestamp: 0,
Expand All @@ -449,6 +451,7 @@ mod tests {
} else {
None
},
group_name: None,
receiving_account: "+0987654321".into(),
attachments: vec![],
quote: None,
Expand Down
3 changes: 3 additions & 0 deletions crates/signal-bot/src/commands/manual_transcribe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,11 +242,14 @@ mod tests {
);
let mut msg = BotMessage {
source: "+1".into(),
source_number: None,
source_name: None,
text: "!transcribe".into(),
timestamp: 0,
message_timestamp: 0,
is_group: false,
group_id: None,
group_name: None,
receiving_account: "+2".into(),
attachments: vec![],
quote: None,
Expand Down
71 changes: 41 additions & 30 deletions crates/signal-bot/src/commands/menu_locale.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,56 +29,44 @@ pub fn privacy_menu(language: MenuLanguage) -> &'static str {
}
}

const HELP_EN: &str = r#"**Bread Coop AI** (Private & Verifiable)
const HELP_EN: &str = r#"Translation Threads:

**Voice:**
- !transcribe — Quote-reply per msg
- !transcribe-on
- !transcribe-off
Join a per-language Signal group bridged to this main chat.

**Translation:**
- !list-langs — Supported languages
- !translate <lang> — Quote-reply per msg
- !translate-on <lang1> <lang2>
- !translate-off
- !translate-me on <lang> — Translate only my msgs
- !translate-me off
**Commands:**
- !translate-me-on <lang>
- !translate-me-off
- !list-langs

**AI chat:**
- !ask <question> — Chat w/ AI
example:
!translate-me-on es

**Default Language**
- !set-es — español
- !set-en — english

**Command Menus**
- !privacy — Show privacy menu
- !privacy — Privacy & TEE
- !help — Show this menu"#;

const HELP_ES: &str = r#"**Bread Coop AI** (Privado y verificable)
const HELP_ES: &str = r#"Hilos de traducción:

**Voz:**
- !transcribe — Responder citando un mensaje de voz
- !transcribe-on — Transcripción automática
- !transcribe-off — Desactivar transcripción automática
Únete a un grupo Signal por idioma, conectado a este chat principal.

**Traducción:**
- !list-langs — Idiomas disponibles
- !translate <lang> — Responder citando un mensaje
- !translate-on <lang1> <lang2> — Traducción automática
- !translate-off — Desactivar traducción automática
- !translate-me on <lang> — Traducir solo mis mensajes
- !translate-me off — Desactivar
**Commands:**
- !translate-me-on <lang>
- !translate-me-off
- !list-langs

**Chat con IA:**
- !ask <pregunta> — Pregunta a la IA
ejemplo:
!translate-me-on es

**Idioma predeterminado**
- !set-es — español
- !set-en — english

**Menús de comandos**
- !privacy — Menú de privacidad y seguridad
- !privacy — Privacidad y TEE
- !help — Mostrar este menú"#;

const PRIVACY_EN: &str = r#"**Bread Coop AI** (Private & Verifiable)
Expand Down Expand Up @@ -122,3 +110,26 @@ Tus mensajes están cifrados de extremo a extremo con Signal, se procesan en un
La transcripción de voz corre localmente en el TEE (Whisper). La traducción usa NEAR AI solo con texto.

Ni el operador del bot ni NEAR AI pueden leer tus mensajes."#;

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn help_en_covers_sidecars_omits_legacy() {
let h = help_menu(MenuLanguage::En);
assert!(h.contains("!translate-me-on"));
assert!(h.contains("!list-langs"));
assert!(!h.contains("!ask"));
assert!(!h.contains("!transcribe"));
assert!(!h.contains("!translate-on"));
assert!(!h.contains("list-langs-common"));
}

#[test]
fn help_es_covers_sidecars() {
let h = help_menu(MenuLanguage::Es);
assert!(h.contains("!translate-me-on"));
assert!(!h.contains("!ask"));
}
}
6 changes: 6 additions & 0 deletions crates/signal-bot/src/commands/set_language.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,14 @@ mod tests {
let handler = test_handler();
let mut msg = BotMessage {
source: "+1".into(),
source_number: None,
source_name: None,
text: "!set-es".into(),
timestamp: 0,
message_timestamp: 0,
is_group: true,
group_id: Some("gid".into()),
group_name: None,
receiving_account: "+2".into(),
attachments: vec![],
quote: None,
Expand All @@ -87,11 +90,14 @@ mod tests {
let handler = test_handler();
let msg = BotMessage {
source: "+1".into(),
source_number: None,
source_name: None,
text: "!set-es".into(),
timestamp: 0,
message_timestamp: 0,
is_group: true,
group_id: Some("gid".into()),
group_name: None,
receiving_account: "+2".into(),
attachments: vec![],
quote: None,
Expand Down
3 changes: 3 additions & 0 deletions crates/signal-bot/src/commands/translate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,11 +261,14 @@ mod tests {

let mut msg = BotMessage {
source: "+1".into(),
source_number: None,
source_name: None,
text: "!translate-on es en".into(),
timestamp: 0,
message_timestamp: 0,
is_group: true,
group_id: None,
group_name: None,
receiving_account: "+2".into(),
attachments: vec![],
quote: None,
Expand Down
3 changes: 3 additions & 0 deletions crates/signal-bot/src/commands/translate_all.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,11 +291,14 @@ mod tests {
let handler = test_handler();
let mut msg = BotMessage {
source: "+1".into(),
source_number: None,
source_name: None,
text: "Hola".into(),
timestamp: 0,
message_timestamp: 0,
is_group: true,
group_id: Some("gid".into()),
group_name: None,
receiving_account: "+2".into(),
attachments: vec![],
quote: None,
Expand Down
37 changes: 36 additions & 1 deletion crates/signal-bot/src/commands/translate_langs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,49 @@ impl CommandHandler for TranslateLangsHandler {
Some("!list-langs")
}

fn matches(&self, message: &BotMessage) -> bool {
let text = message.text.trim();
text == "!list-langs"
|| text
.strip_prefix("!list-langs")
.is_some_and(|rest| rest.starts_with(' ') || rest.starts_with('\n'))
}

fn label(&self) -> &'static str {
"translate_langs"
}

async fn execute(&self, _message: &BotMessage) -> AppResult<String> {
Ok(format!(
"**Supported languages** (use code with !translate):\n\n{}",
"**Supported languages** (use code with !translate-me-on):\n\n{}",
format_language_list(ALL_LANGUAGES)
))
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn list_langs_exact_not_common_suffix() {
let h = TranslateLangsHandler::new();
let mut msg = BotMessage {
source: "+1".into(),
source_number: None,
source_name: None,
text: "!list-langs".into(),
timestamp: 0,
message_timestamp: 0,
is_group: true,
group_id: Some("g".into()),
group_name: None,
receiving_account: "+2".into(),
attachments: vec![],
quote: None,
};
assert!(h.matches(&msg));
msg.text = "!list-langs-common".into();
assert!(!h.matches(&msg));
}
}
Loading