From f4c6c22efb5bbf86eae9d61922bb3ab941914e15 Mon Sep 17 00:00:00 2001 From: daopunk Date: Tue, 21 Jul 2026 23:18:52 -0500 Subject: [PATCH 1/3] feat: language sidecar bridge for mutual-aid groups (!translate-me-on) --- crates/signal-bot/src/bot_identity.rs | 122 ++++ crates/signal-bot/src/commands/ask.rs | 3 + crates/signal-bot/src/commands/chat.rs | 3 + .../src/commands/manual_transcribe.rs | 3 + crates/signal-bot/src/commands/menu_locale.rs | 69 +- .../signal-bot/src/commands/set_language.rs | 6 + crates/signal-bot/src/commands/translate.rs | 3 + .../signal-bot/src/commands/translate_all.rs | 3 + .../src/commands/translate_langs.rs | 37 +- .../signal-bot/src/commands/translate_me.rs | 686 ++++++++++++------ .../signal-bot/src/group_preferences_store.rs | 216 +++++- crates/signal-bot/src/lib.rs | 1 + crates/signal-bot/src/main.rs | 175 +---- crates/signal-bot/tests/e2e_test.rs | 6 + crates/signal-client/src/client.rs | 136 ++++ crates/signal-client/src/lib.rs | 142 +++- crates/signal-client/src/types.rs | 66 +- docs/spikes/2026-07-21-sidecar-groups.md | 35 + 18 files changed, 1256 insertions(+), 456 deletions(-) create mode 100644 crates/signal-bot/src/bot_identity.rs create mode 100644 docs/spikes/2026-07-21-sidecar-groups.md diff --git a/crates/signal-bot/src/bot_identity.rs b/crates/signal-bot/src/bot_identity.rs new file mode 100644 index 0000000..100925f --- /dev/null +++ b/crates/signal-bot/src/bot_identity.rs @@ -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>, + uuids: RwLock>, +} + +impl BotIdentity { + pub fn new() -> Arc { + 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)); + } +} diff --git a/crates/signal-bot/src/commands/ask.rs b/crates/signal-bot/src/commands/ask.rs index 33c234a..d3d81e2 100644 --- a/crates/signal-bot/src/commands/ask.rs +++ b/crates/signal-bot/src/commands/ask.rs @@ -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, diff --git a/crates/signal-bot/src/commands/chat.rs b/crates/signal-bot/src/commands/chat.rs index d0ddc3d..1ce2610 100644 --- a/crates/signal-bot/src/commands/chat.rs +++ b/crates/signal-bot/src/commands/chat.rs @@ -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, @@ -449,6 +451,7 @@ mod tests { } else { None }, + group_name: None, receiving_account: "+0987654321".into(), attachments: vec![], quote: None, diff --git a/crates/signal-bot/src/commands/manual_transcribe.rs b/crates/signal-bot/src/commands/manual_transcribe.rs index f6d5424..c8167d4 100644 --- a/crates/signal-bot/src/commands/manual_transcribe.rs +++ b/crates/signal-bot/src/commands/manual_transcribe.rs @@ -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, diff --git a/crates/signal-bot/src/commands/menu_locale.rs b/crates/signal-bot/src/commands/menu_locale.rs index f9711e8..1757f6c 100644 --- a/crates/signal-bot/src/commands/menu_locale.rs +++ b/crates/signal-bot/src/commands/menu_locale.rs @@ -29,56 +29,38 @@ pub fn privacy_menu(language: MenuLanguage) -> &'static str { } } -const HELP_EN: &str = r#"**Bread Coop AI** (Private & Verifiable) +const HELP_EN: &str = r#"**BAM** (Bread Coop AI) -**Voice:** -- !transcribe — Quote-reply per msg -- !transcribe-on -- !transcribe-off +Language sidecars: join a per-language Signal group bridged to this main chat. -**Translation:** -- !list-langs — Supported languages -- !translate — Quote-reply per msg -- !translate-on -- !translate-off -- !translate-me on — Translate only my msgs -- !translate-me off - -**AI chat:** -- !ask — Chat w/ AI +**Sidecars:** +- !translate-me-on — Join BAM {language} sidecar +- !translate-me-off — Leave your sidecar +- !list-langs — Supported language codes **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) - -**Voz:** -- !transcribe — Responder citando un mensaje de voz -- !transcribe-on — Transcripción automática -- !transcribe-off — Desactivar transcripción automática +const HELP_ES: &str = r#"**BAM** (Bread Coop AI) -**Traducción:** -- !list-langs — Idiomas disponibles -- !translate — Responder citando un mensaje -- !translate-on — Traducción automática -- !translate-off — Desactivar traducción automática -- !translate-me on — Traducir solo mis mensajes -- !translate-me off — Desactivar +Sidecars de idioma: únete a un grupo Signal por idioma, conectado a este chat principal. -**Chat con IA:** -- !ask — Pregunta a la IA +**Sidecars:** +- !translate-me-on — Unirte al sidecar BAM {idioma} +- !translate-me-off — Salir del sidecar +- !list-langs — Códigos de idioma disponibles **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) @@ -122,3 +104,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")); + } +} diff --git a/crates/signal-bot/src/commands/set_language.rs b/crates/signal-bot/src/commands/set_language.rs index 6e266e8..7979a91 100644 --- a/crates/signal-bot/src/commands/set_language.rs +++ b/crates/signal-bot/src/commands/set_language.rs @@ -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, @@ -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, diff --git a/crates/signal-bot/src/commands/translate.rs b/crates/signal-bot/src/commands/translate.rs index 103d912..716bb75 100644 --- a/crates/signal-bot/src/commands/translate.rs +++ b/crates/signal-bot/src/commands/translate.rs @@ -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, diff --git a/crates/signal-bot/src/commands/translate_all.rs b/crates/signal-bot/src/commands/translate_all.rs index e41b8c9..ed94159 100644 --- a/crates/signal-bot/src/commands/translate_all.rs +++ b/crates/signal-bot/src/commands/translate_all.rs @@ -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, diff --git a/crates/signal-bot/src/commands/translate_langs.rs b/crates/signal-bot/src/commands/translate_langs.rs index ca74ee1..338402b 100644 --- a/crates/signal-bot/src/commands/translate_langs.rs +++ b/crates/signal-bot/src/commands/translate_langs.rs @@ -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 { 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)); + } +} diff --git a/crates/signal-bot/src/commands/translate_me.rs b/crates/signal-bot/src/commands/translate_me.rs index 91f4b14..c61575d 100644 --- a/crates/signal-bot/src/commands/translate_me.rs +++ b/crates/signal-bot/src/commands/translate_me.rs @@ -1,32 +1,36 @@ -//! `!translate-me on ` / `!translate-me off` — per-user opt-in translation. +//! Language sidecar bridge: `!translate-me-on` / `!translate-me-off` + relay engine. //! -//! Unlike group-wide `!translate-on`, this only translates the messages of the -//! individual user who opted in. Each opted-in user picks a single target -//! language; their own outgoing group messages are translated into it and -//! posted back to the group. Everyone else's messages are left untouched. +//! Main group stays bilingual. Each subscribed language gets a `BAM {Language}` +//! Signal sidecar. Messages fan out: main→sidecars (relay/translate), +//! sidecar→main (relay) + other sidecars (translate). Bot never relays itself. +use crate::bot_identity::BotIdentity; use crate::commands::translate_lang::resolve_language; -use crate::commands::translate_service::{ - detect_text_language, format_text_auto_translation, near_ai_translate, -}; +use crate::commands::translate_service::{detect_text_language, near_ai_translate}; use crate::commands::CommandHandler; use crate::error::AppResult; use crate::group_preferences_store::GroupPreferencesStore; use async_trait::async_trait; use near_ai_client::NearAiClient; use signal_client::{BotMessage, SignalClient}; +use std::collections::HashMap; use std::sync::Arc; use tracing::{debug, info, instrument, warn}; -const COMMAND_PREFIXES: &[&str] = &["!translate-me", "!translation-me"]; - -const GROUP_ONLY_MSG: &str = "!translate-me is only available in group chats"; -const USAGE_MSG: &str = "Usage: !translate-me on (e.g. !translate-me on en), or !translate-me off"; +const GROUP_ONLY_MSG: &str = + "!translate-me-on is only available in the main mutual-aid group (not DMs)."; +const SIDECAR_ON_MSG: &str = + "Subscribe from the main group with !translate-me-on . Use !translate-me-off here to leave."; +const USAGE_MSG: &str = + "Usage: !translate-me-on (e.g. !translate-me-on es), or !translate-me-off"; +const NO_ADDRESS_MSG: &str = "Could not invite you: Signal did not include your phone number. \ +Message this bot in a 1:1 chat once, then retry !translate-me-on ."; pub struct TranslateMeHandler { store: Arc, near_ai: Arc, signal: Arc, + bot_identity: Arc, } impl TranslateMeHandler { @@ -34,192 +38,460 @@ impl TranslateMeHandler { store: Arc, near_ai: Arc, signal: Arc, + bot_identity: Arc, ) -> Self { Self { store, near_ai, signal, + bot_identity, } } + fn is_on_command(text: &str) -> bool { + let t = text.trim(); + starts_with_word(t, "!translate-me-on") + || starts_with_word(t, "!translation-me-on") + || is_translate_me_with_rest(t, "on") + } + + fn is_off_command(text: &str) -> bool { + let t = text.trim(); + starts_with_word(t, "!translate-me-off") + || starts_with_word(t, "!translation-me-off") + || is_translate_me_with_rest(t, "off") + || t == "!translate-me off" + || t == "!translation-me off" + } + fn is_command(text: &str) -> bool { - let text = text.trim(); - COMMAND_PREFIXES.iter().any(|prefix| { - text == *prefix - || text - .strip_prefix(prefix) - .map_or(false, |rest| rest.starts_with(' ')) - }) + let t = text.trim(); + Self::is_on_command(t) + || Self::is_off_command(t) + || t == "!translate-me" + || t == "!translation-me" + || starts_with_word(t, "!translate-me ") + || starts_with_word(t, "!translation-me ") } - fn is_text_intercept(message: &BotMessage) -> bool { - let text = message.text.trim(); - message.group_id.is_some() - && !message.is_voice_note() - && !text.is_empty() - && !text.starts_with('!') + fn on_lang_arg(text: &str) -> Option<&str> { + let t = text.trim(); + for prefix in ["!translate-me-on", "!translation-me-on"] { + if let Some(rest) = strip_word_prefix(t, prefix) { + return rest.split_whitespace().next(); + } + } + for prefix in ["!translate-me", "!translation-me"] { + if let Some(rest) = strip_word_prefix(t, prefix) { + let mut parts = rest.split_whitespace(); + match parts.next() { + Some("on") => return parts.next(), + Some(token) if resolve_language(token).is_some() => return Some(token), + _ => return None, + } + } + } + None } - /// The arguments following the command prefix (e.g. `"on en"`). - fn command_args(text: &str) -> &str { - let text = text.trim(); - COMMAND_PREFIXES - .iter() - .find_map(|prefix| text.strip_prefix(prefix)) - .map(str::trim) - .unwrap_or("") + fn is_relay_candidate(&self, message: &BotMessage) -> bool { + let text = message.text.trim(); + if message.group_id.is_none() || message.is_voice_note() || text.is_empty() || text.starts_with('!') + { + return false; + } + let Some(gid) = message.group_id.as_deref() else { + return false; + }; + self.store.get_bridge(gid).is_some() || self.store.lookup_sidecar(gid).is_some() } async fn handle_command(&self, message: &BotMessage) -> AppResult { - let group_id = match message.group_id.as_deref() { - Some(id) => id, - None => return Ok(GROUP_ONLY_MSG.into()), - }; + let text = message.text.trim(); - let args = Self::command_args(&message.text); - let mut parts = args.split_whitespace(); + if Self::is_off_command(text) { + return self.handle_off(message).await; + } - match parts.next().map(str::to_lowercase).as_deref() { - Some("off") => { - if self.store.clear_user_translate(group_id, &message.source) { - info!(group_id, source = %message.source, "translate-me disabled"); - Ok("Translation off: your messages will no longer be translated.".into()) - } else { - Ok("Translation was not active for you in this chat.".into()) - } - } - // `!translate-me on ` or the convenience form `!translate-me `. - Some("on") => self.enable(group_id, message, parts.next()), - Some(token) if resolve_language(token).is_some() => { - self.enable(group_id, message, Some(token)) + if Self::is_on_command(text) || starts_with_word(text, "!translate-me ") { + let Some(gid) = message.group_id.as_deref() else { + return Ok(GROUP_ONLY_MSG.into()); + }; + + if self.store.lookup_sidecar(gid).is_some() { + return Ok(SIDECAR_ON_MSG.into()); } - _ => Ok(USAGE_MSG.into()), + + let Some(lang_token) = Self::on_lang_arg(text) else { + return Ok(USAGE_MSG.into()); + }; + return self.handle_on(message, gid, lang_token).await; } + + Ok(USAGE_MSG.into()) } - fn enable( + async fn handle_on( &self, - group_id: &str, message: &BotMessage, - lang_token: Option<&str>, + main_id: &str, + lang_token: &str, ) -> AppResult { - let token = match lang_token { - Some(t) if !t.is_empty() => t, - _ => return Ok(USAGE_MSG.into()), + let Some(lang) = resolve_language(lang_token) else { + return Ok(format!( + "Unknown language `{lang_token}`. Try !list-langs for supported codes." + )); + }; + + let Some(address) = message.invite_address() else { + return Ok(NO_ADDRESS_MSG.into()); }; - let lang = match resolve_language(token) { - Some(l) => l, - None => { + let user_key = message.source.clone(); + let bot = &message.receiving_account; + + if let Some(existing) = self.store.member_lang(main_id, &user_key) { + if existing == lang.code { return Ok(format!( - "Unknown language: {token}. Use !list-langs for supported codes." + "You are already in the {} sidecar. Accept the Signal invite if it is still pending.", + lang.name )); } - }; + // Language switch: remove from old sidecar first. + if let Some(bridge) = self.store.get_bridge(main_id) { + if let Some(old_send) = bridge.sidecar_send_id(&existing) { + if let Err(e) = self + .signal + .remove_members(bot, old_send, vec![address.clone()]) + .await + { + warn!(error = %e, "Failed to remove member from old sidecar"); + } + } + } + } + + let bridge = self.store.get_bridge(main_id); + let sidecar_exists = bridge + .as_ref() + .and_then(|b| b.sidecar_send_id(lang.code)) + .is_some(); + + if sidecar_exists { + let send_id = bridge + .as_ref() + .and_then(|b| b.sidecar_send_id(lang.code)) + .unwrap() + .to_string(); + if let Err(e) = self + .signal + .add_members(bot, &send_id, vec![address.clone()]) + .await + { + return Ok(format!( + "Could not add you to the {} sidecar: {e}. Try again shortly.", + lang.name + )); + } + } else { + let name = format!("BAM {}", lang.name); + let description = format!( + "{} sidecar bridged to the main mutual-aid group.", + lang.name + ); + match self + .signal + .create_group( + bot, + &name, + vec![address.clone()], + Some(&description), + ) + .await + { + Ok(group) => { + self.store.set_sidecar( + main_id, + lang.code, + group.id.clone(), + group.internal_id.clone(), + ); + let welcome = format!( + "Welcome to BAM {}. Messages here are bridged with the main group.", + lang.name + ); + if let Err(e) = self.signal.send(bot, &group.id, &welcome).await { + warn!(error = %e, "Failed to send sidecar welcome"); + } + } + Err(e) => { + return Ok(format!( + "Could not create the {} sidecar: {e}. Try again shortly.", + lang.name + )); + } + } + } + + self.store.set_bridge_member( + main_id, + &user_key, + lang.code, + Some(address), + ); - self.store - .set_user_translate(group_id, &message.source, lang.code.to_string()); info!( - group_id, - source = %message.source, - target = lang.code, - "translate-me enabled" + main_id, + lang = lang.code, + user = %user_key, + "translate-me-on: subscribed to sidecar" ); + Ok(format!( - "Translation on: your messages will be translated to {} {}.", - lang.flag, lang.name + "Joined the {} sidecar (BAM {}). Accept the Signal group invite if prompted. \ +Use !translate-me-off to leave.", + lang.name, lang.name )) } - async fn handle_text_intercept(&self, message: &BotMessage) -> AppResult<()> { - let group_id = match message.group_id.as_deref() { - Some(id) => id, - None => return Ok(()), + async fn handle_off(&self, message: &BotMessage) -> AppResult { + let Some(gid) = message.group_id.as_deref() else { + return Ok("!translate-me-off is only available in group chats.".into()); }; - let target_code = match self.store.get_user_translate(group_id, &message.source) { - Some(code) => code, - None => return Ok(()), + let (main_id, _) = if let Some(pair) = self.store.lookup_sidecar(gid) { + pair + } else if self.store.get_bridge(gid).is_some() + || self.store.member_lang(gid, &message.source).is_some() + { + (gid.to_string(), String::new()) + } else { + return Ok("You are not subscribed to a language sidecar in this chat.".into()); }; - let target = match resolve_language(&target_code) { - Some(lang) => lang, - None => return Ok(()), + + let user_key = message.source.as_str(); + let Some((lang, stored_addr)) = self.store.clear_bridge_member(&main_id, user_key) else { + return Ok("You are not subscribed to a language sidecar.".into()); }; - let text = message.text.trim(); + let address = stored_addr + .or_else(|| message.invite_address()) + .unwrap_or_else(|| message.source.clone()); + + if let Some(bridge) = self.store.get_bridge(&main_id) { + if let Some(send_id) = bridge.sidecar_send_id(&lang) { + if let Err(e) = self + .signal + .remove_members(&message.receiving_account, send_id, vec![address]) + .await + { + warn!(error = %e, "Failed to remove member from sidecar on off"); + } + } + } - // Skip if the message already looks like the target language. - if let Some(detected) = detect_text_language(text) { - if detected == target.code { - debug!( - group_id, - target = target.code, - "translate-me skipped (message already in target language)" - ); + let lang_name = resolve_language(&lang) + .map(|l| l.name) + .unwrap_or(lang.as_str()); + Ok(format!("Left the {lang_name} sidecar.")) + } + + #[instrument(skip(self, message))] + async fn handle_relay(&self, message: &BotMessage) -> AppResult<()> { + if self.bot_identity.is_bot_message(message) { + debug!("Skipping bot-authored message for relay"); + return Ok(()); + } + + let Some(gid) = message.group_id.as_deref() else { + return Ok(()); + }; + + if let Some((main_id, lang)) = self.store.lookup_sidecar(gid) { + if !self.store.allow_message(&main_id) { + warn!(main_id, "Rate limit: skipping sidecar fan-out"); return Ok(()); } + return self.handle_sidecar_in(message, &main_id, &lang).await; } - if !self.store.allow_message(group_id) { - warn!(group_id, "translate-me rate limited — skipping text message"); - return Ok(()); + if let Some(bridge) = self.store.get_bridge(gid) { + if bridge.sidecars.is_empty() { + return Ok(()); + } + if !self.store.allow_message(gid) { + warn!(main_id = gid, "Rate limit: skipping main fan-out"); + return Ok(()); + } + return self.handle_main_out(message, &bridge).await; + } + + Ok(()) + } + + async fn handle_main_out( + &self, + message: &BotMessage, + bridge: &crate::group_preferences_store::LanguageBridge, + ) -> AppResult<()> { + let detected = detect_text_language(&message.text); + let display = message.display_name(); + let bot = &message.receiving_account; + let mut translation_cache: HashMap = HashMap::new(); + + for (lang, send_id) in &bridge.sidecars { + let Some(target_lang) = resolve_language(lang) else { + warn!(lang, "Unknown sidecar language code; skipping"); + continue; + }; + let body = if detected.as_deref() == Some(lang.as_str()) { + message.text.clone() + } else if let Some(cached) = translation_cache.get(lang) { + cached.clone() + } else { + match near_ai_translate(&self.near_ai, &message.text, target_lang).await { + Ok(t) => { + translation_cache.insert(lang.clone(), t.clone()); + t + } + Err(e) => { + warn!(error = %e, target = %lang, "Main→sidecar translate failed"); + continue; + } + } + }; + let formatted = format_attribution(&display, &body); + if let Err(e) = self.signal.send(bot, send_id, &formatted).await { + warn!(error = %e, send_id, "Failed to send main→sidecar"); + } } + Ok(()) + } + + async fn handle_sidecar_in( + &self, + message: &BotMessage, + main_id: &str, + source_lang: &str, + ) -> AppResult<()> { + let Some(bridge) = self.store.get_bridge(main_id) else { + return Ok(()); + }; - let translation = match near_ai_translate(&self.near_ai, text, target).await { - Ok(t) => t, + let display = message.display_name(); + let bot = &message.receiving_account; + let to_main = format_attribution(&display, &message.text); + + // Resolve main send id (incoming group_id is internal). + let main_recipient = match self + .signal + .resolve_group_send_id_for_account(bot, main_id) + .await + { + Ok(id) => id, Err(e) => { - warn!("translate-me translation failed: {}", e); - self.signal - .reply_quoted(message, "Could not translate. Try again later.", None) - .await?; + warn!(error = %e, main_id, "Could not resolve main send id"); return Ok(()); } }; - let body = format_text_auto_translation(target, &translation); - self.signal.reply_quoted(message, &body, None).await?; - info!( - group_id, - source = %message.source, - target = target.code, - translation_chars = translation.len(), - "translate-me text translated" - ); + if let Err(e) = self.signal.send(bot, &main_recipient, &to_main).await { + warn!(error = %e, "Failed to relay sidecar→main"); + } + + let mut translation_cache: HashMap = HashMap::new(); + for (lang, send_id) in &bridge.sidecars { + if lang == source_lang { + continue; + } + let Some(target_lang) = resolve_language(lang) else { + warn!(lang, "Unknown sidecar language code; skipping"); + continue; + }; + let body = if let Some(cached) = translation_cache.get(lang) { + cached.clone() + } else { + match near_ai_translate(&self.near_ai, &message.text, target_lang).await { + Ok(t) => { + translation_cache.insert(lang.clone(), t.clone()); + t + } + Err(e) => { + warn!(error = %e, target = %lang, "Sidecar→sidecar translate failed"); + continue; + } + } + }; + let formatted = format_attribution(&display, &body); + if let Err(e) = self.signal.send(bot, send_id, &formatted).await { + warn!(error = %e, send_id, "Failed to send sidecar→sidecar"); + } + } Ok(()) } } -#[async_trait] -impl CommandHandler for TranslateMeHandler { - fn label(&self) -> &'static str { - "translate_me" +fn format_attribution(display_name: &str, body: &str) -> String { + format!("{display_name}:\n{body}") +} + +fn starts_with_word(text: &str, prefix: &str) -> bool { + text == prefix + || text + .strip_prefix(prefix) + .is_some_and(|rest| rest.is_empty() || rest.starts_with(' ')) +} + +fn strip_word_prefix<'a>(text: &'a str, prefix: &str) -> Option<&'a str> { + if text == prefix { + return Some(""); } + text.strip_prefix(prefix) + .filter(|rest| rest.is_empty() || rest.starts_with(' ')) + .map(str::trim) +} - fn matches(&self, message: &BotMessage) -> bool { - if Self::is_command(&message.text) { - return true; - } - if Self::is_text_intercept(message) { - if let Some(gid) = &message.group_id { - return self.store.is_user_translate_active(gid, &message.source); +fn is_translate_me_with_rest(text: &str, rest_first: &str) -> bool { + for prefix in ["!translate-me", "!translation-me"] { + if let Some(rest) = strip_word_prefix(text, prefix) { + let mut parts = rest.split_whitespace(); + if parts.next() == Some(rest_first) { + return true; } } - false + } + false +} + +#[async_trait] +impl CommandHandler for TranslateMeHandler { + fn matches(&self, message: &BotMessage) -> bool { + if self.bot_identity.is_bot_message(message) { + return false; + } + Self::is_command(&message.text) || self.is_relay_candidate(message) } fn handles_own_reply(&self) -> bool { true } - #[instrument(skip(self, message), fields(source = %message.source, is_group = message.is_group))] async fn execute(&self, message: &BotMessage) -> AppResult { if Self::is_command(&message.text) { - let response = self.handle_command(message).await?; - self.signal.reply(message, &response).await?; + let reply = self.handle_command(message).await?; + if !reply.is_empty() { + if let Err(e) = self + .signal + .reply(message, &reply) + .await + { + warn!(error = %e, "Failed to send translate-me command reply"); + } + } return Ok(String::new()); } - self.handle_text_intercept(message).await?; + self.handle_relay(message).await?; Ok(String::new()) } } @@ -228,140 +500,90 @@ impl CommandHandler for TranslateMeHandler { mod tests { use super::*; - fn test_handler() -> TranslateMeHandler { - TranslateMeHandler::new( - GroupPreferencesStore::new_in_memory(30), - Arc::new( - NearAiClient::new( - "key", - "http://localhost", - "model", - std::time::Duration::from_secs(5), - ) - .unwrap(), - ), - Arc::new(SignalClient::new("http://localhost").unwrap()), - ) - } - fn group_msg(source: &str, text: &str) -> BotMessage { BotMessage { source: source.into(), + source_number: Some(source.into()), + source_name: Some("Maria".into()), text: text.into(), - timestamp: 0, - message_timestamp: 0, + timestamp: 1, + message_timestamp: 1, is_group: true, - group_id: Some("gid".into()), - receiving_account: "+bot".into(), + group_id: Some("main-internal".into()), + group_name: None, + receiving_account: "+15550001111".into(), attachments: vec![], quote: None, } } #[test] - fn recognizes_commands() { - assert!(TranslateMeHandler::is_command("!translate-me on en")); - assert!(TranslateMeHandler::is_command("!translate-me off")); - assert!(TranslateMeHandler::is_command("!translate-me")); - assert!(TranslateMeHandler::is_command("!translation-me on es")); - // Must not swallow the group-wide command or bare !translate. + fn matches_on_off_commands() { + assert!(TranslateMeHandler::is_on_command("!translate-me-on es")); + assert!(TranslateMeHandler::is_on_command("!translate-me on es")); + assert!(TranslateMeHandler::is_off_command("!translate-me-off")); + assert!(TranslateMeHandler::is_off_command("!translate-me off")); assert!(!TranslateMeHandler::is_command("!translate-on es en")); assert!(!TranslateMeHandler::is_command("!translate es")); } #[test] - fn command_args_extracted() { - assert_eq!(TranslateMeHandler::command_args("!translate-me on en"), "on en"); - assert_eq!(TranslateMeHandler::command_args("!translate-me off"), "off"); - assert_eq!(TranslateMeHandler::command_args("!translate-me"), ""); - } - - #[test] - fn intercept_only_matches_opted_in_user() { - let handler = test_handler(); - - // Not opted in yet — no intercept. - assert!(!handler.matches(&group_msg("+alice", "Hola a todos"))); - - handler - .store - .set_user_translate("gid", "+alice", "en".into()); - - // Alice opted in — her plain text is intercepted. - assert!(handler.matches(&group_msg("+alice", "Hola a todos"))); - // Bob did not opt in — his messages are left alone. - assert!(!handler.matches(&group_msg("+bob", "Hola a todos"))); - // Commands are always claimed regardless of opt-in state. - assert!(handler.matches(&group_msg("+bob", "!translate-me on es"))); - // But a bare `!` message from Alice is not treated as translatable text. - assert!(!handler.matches(&group_msg("+alice", "!help"))); - } - - #[tokio::test] - async fn enable_and_disable_roundtrip() { - let handler = test_handler(); - - let on = handler - .handle_command(&group_msg("+alice", "!translate-me on en")) - .await - .unwrap(); - assert!(on.contains("English")); - assert!(handler.store.is_user_translate_active("gid", "+alice")); + fn parses_lang_arg() { assert_eq!( - handler.store.get_user_translate("gid", "+alice").as_deref(), + TranslateMeHandler::on_lang_arg("!translate-me-on es"), + Some("es") + ); + assert_eq!( + TranslateMeHandler::on_lang_arg("!translate-me on en"), Some("en") ); - - let off = handler - .handle_command(&group_msg("+alice", "!translate-me off")) - .await - .unwrap(); - assert!(off.to_lowercase().contains("off")); - assert!(!handler.store.is_user_translate_active("gid", "+alice")); - } - - #[tokio::test] - async fn convenience_form_without_on_keyword() { - let handler = test_handler(); - let reply = handler - .handle_command(&group_msg("+alice", "!translate-me es")) - .await - .unwrap(); - assert!(reply.contains("Spanish")); assert_eq!( - handler.store.get_user_translate("gid", "+alice").as_deref(), + TranslateMeHandler::on_lang_arg("!translate-me es"), Some("es") ); + assert_eq!(TranslateMeHandler::on_lang_arg("!translate-me-on"), None); } - #[tokio::test] - async fn unknown_language_reports_error() { - let handler = test_handler(); - let reply = handler - .handle_command(&group_msg("+alice", "!translate-me on klingon")) - .await - .unwrap(); - assert!(reply.contains("Unknown language")); - assert!(!handler.store.is_user_translate_active("gid", "+alice")); + #[test] + fn attribution_format() { + assert_eq!(format_attribution("Maria", "Hola"), "Maria:\nHola"); } - #[tokio::test] - async fn bare_command_shows_usage() { - let handler = test_handler(); - let reply = handler - .handle_command(&group_msg("+alice", "!translate-me")) - .await - .unwrap(); - assert!(reply.contains("Usage")); + #[test] + fn display_name_prefers_source_name() { + let m = group_msg("+15550002222", "hi"); + assert_eq!(m.display_name(), "Maria"); } - #[tokio::test] - async fn group_only() { - let handler = test_handler(); - let mut msg = group_msg("+alice", "!translate-me on en"); - msg.group_id = None; - msg.is_group = false; - let reply = handler.handle_command(&msg).await.unwrap(); - assert_eq!(reply, GROUP_ONLY_MSG); + #[test] + fn bot_messages_do_not_match() { + let store = GroupPreferencesStore::new_in_memory(0); + store.set_sidecar( + "main-internal", + "es", + "group.es".into(), + "es-internal".into(), + ); + let identity = BotIdentity::new(); + identity.remember_phone("+15550001111"); + + // Handler constructed without live clients — only matches() needs identity+store. + // Use NearAi/Signal stubs via wiremock in integration tests; here test identity gate + // with a minimal fake by checking is_bot_message path directly. + let bot_msg = BotMessage { + source: "+15550001111".into(), + source_number: Some("+15550001111".into()), + source_name: None, + text: "relayed".into(), + timestamp: 1, + message_timestamp: 1, + is_group: true, + group_id: Some("main-internal".into()), + group_name: None, + receiving_account: "+15550001111".into(), + attachments: vec![], + quote: None, + }; + assert!(identity.is_bot_message(&bot_msg)); } } diff --git a/crates/signal-bot/src/group_preferences_store.rs b/crates/signal-bot/src/group_preferences_store.rs index 2071fd0..f39bc90 100644 --- a/crates/signal-bot/src/group_preferences_store.rs +++ b/crates/signal-bot/src/group_preferences_store.rs @@ -65,6 +65,40 @@ impl GroupTranslateMode { } } +/// Language sidecar bridge keyed under the **main** group `internal_id`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct LanguageBridge { + /// lang code → sidecar send id (`group.…`) + #[serde(default)] + pub sidecars: HashMap, + /// lang code → sidecar internal_id (inbound match) + #[serde(default)] + pub sidecar_internal: HashMap, + /// user key (UUID or phone) → lang code + #[serde(default)] + pub members: HashMap, + /// user key → invite address used for Signal members[] + #[serde(default)] + pub member_addresses: HashMap, +} + +impl LanguageBridge { + pub fn is_empty(&self) -> bool { + self.sidecars.is_empty() + && self.sidecar_internal.is_empty() + && self.members.is_empty() + && self.member_addresses.is_empty() + } + + pub fn sidecar_send_id(&self, lang: &str) -> Option<&str> { + self.sidecars.get(lang).map(String::as_str) + } + + pub fn member_lang(&self, user: &str) -> Option<&str> { + self.members.get(user).map(String::as_str) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct GroupPreference { #[serde(default = "default_true")] @@ -73,9 +107,9 @@ struct GroupPreference { translate: Option, #[serde(default)] menu_language: MenuLanguage, - /// Per-user opt-in translation: sender id -> target language code. + /// Mutual-aid language sidecar bridge (replaces legacy per-user translate map). #[serde(default)] - user_translate: HashMap, + language_bridge: Option, } impl Default for GroupPreference { @@ -84,7 +118,7 @@ impl Default for GroupPreference { transcribe_enabled: true, translate: None, menu_language: MenuLanguage::En, - user_translate: HashMap::new(), + language_bridge: None, } } } @@ -94,7 +128,7 @@ impl GroupPreference { self.transcribe_enabled && self.translate.is_none() && self.menu_language == MenuLanguage::En - && self.user_translate.is_empty() + && self.language_bridge.as_ref().is_none_or(LanguageBridge::is_empty) } } @@ -107,6 +141,8 @@ struct GroupPreferencesSnapshot { /// In-memory group preferences with optional TEE-encrypted persistence. pub struct GroupPreferencesStore { groups: RwLock>, + /// sidecar internal_id → (main internal_id, lang code); rebuilt on load/mutate. + sidecar_index: RwLock>, rate_limits: RwLock>>, max_per_minute: u32, dstack: Option>, @@ -120,6 +156,7 @@ impl GroupPreferencesStore { pub fn new_in_memory(max_per_minute: u32) -> Arc { Arc::new(Self { groups: RwLock::new(HashMap::new()), + sidecar_index: RwLock::new(HashMap::new()), rate_limits: RwLock::new(HashMap::new()), max_per_minute, dstack: None, @@ -138,6 +175,7 @@ impl GroupPreferencesStore { ) -> Arc { let store = Arc::new(Self { groups: RwLock::new(HashMap::new()), + sidecar_index: RwLock::new(HashMap::new()), rate_limits: RwLock::new(HashMap::new()), max_per_minute, dstack: if persist { @@ -175,6 +213,7 @@ impl GroupPreferencesStore { ) -> Arc { let store = Arc::new(Self { groups: RwLock::new(HashMap::new()), + sidecar_index: RwLock::new(HashMap::new()), rate_limits: RwLock::new(HashMap::new()), max_per_minute, dstack: Some(Arc::new(dstack)), @@ -186,6 +225,18 @@ impl GroupPreferencesStore { store } + fn rebuild_sidecar_index(&self) { + let mut index = HashMap::new(); + for (main_id, pref) in self.groups.read().unwrap().iter() { + if let Some(bridge) = &pref.language_bridge { + for (lang, internal) in &bridge.sidecar_internal { + index.insert(internal.clone(), (main_id.clone(), lang.clone())); + } + } + } + *self.sidecar_index.write().unwrap() = index; + } + // --- Transcription (per group) --- pub fn is_transcribe_enabled(&self, group_id: &str) -> bool { @@ -276,48 +327,96 @@ impl GroupPreferencesStore { had_translate } - // --- Per-user opt-in translate (per group + sender) --- + // --- Language sidecar bridge (keyed by main group internal_id) --- - /// Target language code the given user opted into for this group, if any. - pub fn get_user_translate(&self, group_id: &str, user: &str) -> Option { + pub fn get_bridge(&self, main_group_id: &str) -> Option { self.groups .read() .unwrap() - .get(group_id) - .and_then(|p| p.user_translate.get(user).cloned()) + .get(main_group_id) + .and_then(|p| p.language_bridge.clone()) + .filter(|b| !b.is_empty()) } - /// Whether this specific user has opted into translation in this group. - pub fn is_user_translate_active(&self, group_id: &str, user: &str) -> bool { - self.groups + /// Resolve sidecar internal_id → (main_id, lang). + pub fn lookup_sidecar(&self, sidecar_internal_id: &str) -> Option<(String, String)> { + self.sidecar_index .read() .unwrap() - .get(group_id) - .is_some_and(|p| p.user_translate.contains_key(user)) + .get(sidecar_internal_id) + .cloned() + } + + pub fn member_lang(&self, main_group_id: &str, user: &str) -> Option { + self.get_bridge(main_group_id) + .and_then(|b| b.members.get(user).cloned()) } - /// Enable per-user translation for `user` in `group_id`, targeting `lang_code`. - pub fn set_user_translate(self: &Arc, group_id: &str, user: &str, lang_code: String) { + pub fn set_sidecar( + self: &Arc, + main_group_id: &str, + lang: &str, + send_id: String, + internal_id: String, + ) { { let mut groups = self.groups.write().unwrap(); - let entry = groups.entry(group_id.to_string()).or_default(); - entry.user_translate.insert(user.to_string(), lang_code); + let entry = groups.entry(main_group_id.to_string()).or_default(); + let bridge = entry.language_bridge.get_or_insert_with(LanguageBridge::default); + bridge.sidecars.insert(lang.to_string(), send_id); + bridge + .sidecar_internal + .insert(lang.to_string(), internal_id); } + self.rebuild_sidecar_index(); self.schedule_persist(); } - /// Disable per-user translation for `user`; returns true if it was active. - pub fn clear_user_translate(self: &Arc, group_id: &str, user: &str) -> bool { + /// Record user membership; returns previous lang if switching. + pub fn set_bridge_member( + self: &Arc, + main_group_id: &str, + user: &str, + lang: &str, + address: Option, + ) -> Option { + let previous = { + let mut groups = self.groups.write().unwrap(); + let entry = groups.entry(main_group_id.to_string()).or_default(); + let bridge = entry.language_bridge.get_or_insert_with(LanguageBridge::default); + let prev = bridge.members.insert(user.to_string(), lang.to_string()); + if let Some(addr) = address { + bridge.member_addresses.insert(user.to_string(), addr); + } + prev + }; + self.schedule_persist(); + previous + } + + /// Remove member; returns (lang, address) if they were subscribed. + pub fn clear_bridge_member( + self: &Arc, + main_group_id: &str, + user: &str, + ) -> Option<(String, Option)> { let removed = { let mut groups = self.groups.write().unwrap(); - let Some(entry) = groups.get_mut(group_id) else { - return false; + let Some(entry) = groups.get_mut(main_group_id) else { + return None; }; - let removed = entry.user_translate.remove(user).is_some(); + let Some(bridge) = entry.language_bridge.as_mut() else { + return None; + }; + let lang = bridge.members.remove(user)?; + let address = bridge.member_addresses.remove(user); + if bridge.is_empty() { + entry.language_bridge = None; + } if entry.is_default() { - groups.remove(group_id); + groups.remove(main_group_id); } - removed + Some((lang, address)) }; self.schedule_persist(); removed @@ -496,6 +595,7 @@ impl GroupPreferencesStore { let count = snapshot.groups.len(); *self.groups.write().unwrap() = snapshot.groups; + self.rebuild_sidecar_index(); Ok(count) } @@ -600,4 +700,70 @@ mod tests { MenuLanguage::Es ); } + + #[test] + fn language_bridge_sidecar_and_members() { + let store = GroupPreferencesStore::new_in_memory(0); + let main = "main-internal"; + store.set_sidecar( + main, + "es", + "group.es-send".into(), + "es-internal".into(), + ); + store.set_sidecar( + main, + "en", + "group.en-send".into(), + "en-internal".into(), + ); + + assert_eq!( + store.lookup_sidecar("es-internal"), + Some((main.into(), "es".into())) + ); + assert_eq!( + store.get_bridge(main).unwrap().sidecar_send_id("en"), + Some("group.en-send") + ); + + assert!(store.set_bridge_member(main, "user-a", "es", Some("+1".into())).is_none()); + assert_eq!(store.member_lang(main, "user-a").as_deref(), Some("es")); + + let prev = store.set_bridge_member(main, "user-a", "en", None); + assert_eq!(prev.as_deref(), Some("es")); + assert_eq!(store.member_lang(main, "user-a").as_deref(), Some("en")); + + let removed = store.clear_bridge_member(main, "user-a").unwrap(); + assert_eq!(removed.0, "en"); + assert!(store.member_lang(main, "user-a").is_none()); + } + + #[tokio::test] + async fn language_bridge_encrypted_round_trip() { + let dir = tempdir().unwrap(); + let path = dir.path().join("bridge.enc"); + let key = [9u8; 32]; + let dstack = DstackClient::new("/nonexistent/dstack.sock"); + + let store = GroupPreferencesStore::with_test_key(dstack, path.clone(), key, 30).await; + store.set_sidecar( + "main-1", + "es", + "group.es".into(), + "es-int".into(), + ); + store.set_bridge_member("main-1", "uuid-1", "es", Some("+1555".into())); + store.persist_now().await.unwrap(); + + let store2 = + GroupPreferencesStore::with_test_key(DstackClient::new("/x"), path, key, 30).await; + let bridge = store2.get_bridge("main-1").unwrap(); + assert_eq!(bridge.sidecar_send_id("es"), Some("group.es")); + assert_eq!(bridge.member_lang("uuid-1"), Some("es")); + assert_eq!( + store2.lookup_sidecar("es-int"), + Some(("main-1".into(), "es".into())) + ); + } } diff --git a/crates/signal-bot/src/lib.rs b/crates/signal-bot/src/lib.rs index 9aa7957..c912c0e 100644 --- a/crates/signal-bot/src/lib.rs +++ b/crates/signal-bot/src/lib.rs @@ -1,3 +1,4 @@ +pub mod bot_identity; pub mod commands; pub mod config; pub mod error; diff --git a/crates/signal-bot/src/main.rs b/crates/signal-bot/src/main.rs index 4d7ded4..e9ee7eb 100644 --- a/crates/signal-bot/src/main.rs +++ b/crates/signal-bot/src/main.rs @@ -4,62 +4,19 @@ use signal_bot::commands::*; use signal_bot::config::Config; use signal_bot::error::AppResult; use signal_bot::group_preferences_store::GroupPreferencesStore; -use signal_bot::transcribe_store::TranscribeStore; -use signal_bot::voice_attachment_cache::VoiceAttachmentCache; use anyhow::Context; use conversation_store::ConversationStore; use dstack_client::DstackClient; use near_ai_client::NearAiClient; use signal_client::{MessageReceiver, SignalClient}; -use whisper_client::WhisperClient; use std::path::PathBuf; use std::sync::Arc; use tokio::signal; use tokio_stream::StreamExt; -use tools::{ToolRegistry, builtin::{CalculatorTool, WeatherTool, WebSearchTool}}; use tracing::{debug, error, info, warn}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; use x402_payments::CreditStore; -/// Create and configure tool registry based on config. -fn create_tool_registry(config: &signal_bot::config::ToolsConfig) -> ToolRegistry { - let mut registry = ToolRegistry::new(); - - if !config.enabled { - info!("Tools system disabled by configuration"); - return registry; - } - - // Calculator - always available (no API key needed) - if config.calculator.enabled { - registry.register(Arc::new(CalculatorTool::new())); - info!("Registered tool: calculate"); - } - - // Weather - always available (no API key needed) - if config.weather.enabled { - registry.register(Arc::new(WeatherTool::new())); - info!("Registered tool: get_weather"); - } - - // Web search - requires API key - if config.web_search.enabled { - if let Some(api_key) = &config.web_search.api_key { - let tool = WebSearchTool::new(api_key.clone()) - .with_max_results(config.web_search.max_results); - registry.register(Arc::new(tool)); - info!("Registered tool: web_search (max_results: {})", config.web_search.max_results); - } else { - warn!("Web search tool enabled but TOOLS__WEB_SEARCH__API_KEY not set - skipping"); - } - } - - let enabled_count = registry.list_enabled().len(); - info!("Tool registry ready with {} enabled tools", enabled_count); - - registry -} - #[tokio::main] async fn main() -> AppResult<()> { // Load configuration @@ -93,9 +50,6 @@ async fn main() -> AppResult<()> { .context("Failed to create Signal client")?, ); - // Create tool registry based on config - let tool_registry = Arc::new(create_tool_registry(&config.tools)); - // Initialize payment system let credit_store = if config.payments.enabled { info!("Initializing payment system..."); @@ -162,60 +116,7 @@ async fn main() -> AppResult<()> { } info!("Signal API healthy"); - let whisper_client = if config.whisper.enabled { - let whisper = Arc::new( - WhisperClient::new( - &config.whisper.service_url, - config.whisper.timeout, - ) - .context("Failed to create Whisper client")?, - ); - - if whisper.health_check().await { - info!( - "Whisper API healthy at {} (model: {})", - config.whisper.service_url, config.whisper.model - ); - } else { - warn!( - "Whisper API not reachable at {} — voice notes will fail until whisper-api is up", - config.whisper.service_url - ); - } - - Some(whisper) - } else { - info!("Whisper transcription disabled"); - None - }; - - // Create command handlers - let chat = if let Some(ref store) = credit_store { - ChatHandler::with_payments( - near_ai.clone(), - conversations.clone(), - signal.clone(), - tool_registry.clone(), - config.bot.system_prompt.clone(), - config.tools.max_tool_calls, - config.bot.signal_username.clone(), - config.bot.github_repo.clone(), - store.clone(), - config.payments.pricing.clone(), - ) - } else { - ChatHandler::new( - near_ai.clone(), - conversations.clone(), - signal.clone(), - tool_registry.clone(), - config.bot.system_prompt.clone(), - config.tools.max_tool_calls, - config.bot.signal_username.clone(), - config.bot.github_repo.clone(), - ) - }; - + // Alpha command surface: sidecars + menus + TEE. Ask/chat/voice/translate-on kept in tree but unregistered. let mut handlers: Vec> = Vec::new(); let group_prefs = GroupPreferencesStore::open( @@ -233,67 +134,20 @@ async fn main() -> AppResult<()> { ); } - let transcribe_store = Arc::new(TranscribeStore::new(Some(group_prefs.clone()))); - let whisper_available = whisper_client.is_some(); - - let voice_attachment_cache = VoiceAttachmentCache::with_default_capacity(); - - if let Some(ref whisper) = whisper_client { - let mut voice = VoiceHandler::new( - whisper.clone(), - signal.clone(), - config.whisper.reply_prefix.clone(), - config.whisper.max_attachment_bytes, - ) - .with_transcribe_store(transcribe_store.clone()); - if config.translate_all.enabled { - voice = voice.with_translate_all(group_prefs.clone(), near_ai.clone()); - } - handlers.push(Box::new(voice)); - handlers.push(Box::new(ManualTranscribeHandler::new( - whisper.clone(), - signal.clone(), - config.whisper.reply_prefix.clone(), - config.whisper.max_attachment_bytes, - voice_attachment_cache.clone(), - ))); - info!("Voice note transcription enabled"); - } + let bot_identity = signal_bot::bot_identity::BotIdentity::new(); - handlers.push(Box::new(TranscribeHandler::new( - transcribe_store, - whisper_available, - ))); - - if config.translate_all.enabled { - handlers.push(Box::new(TranslateAllHandler::new( - group_prefs.clone(), - near_ai.clone(), - signal.clone(), - ))); - // Per-user opt-in translation. Registered after TranslateAllHandler so - // group-wide mode (when active) still takes precedence, and before - // TranslateHandler so `!translate-me` isn't captured by `!translate`. - handlers.push(Box::new(TranslateMeHandler::new( - group_prefs.clone(), - near_ai.clone(), - signal.clone(), - ))); - info!( - "Group auto-translate enabled: !translate-on, !translate-off, !translate-me (max {}/min)", - config.translate_all.max_messages_per_minute - ); - } - - handlers.push(Box::new(TranslateHandler::new( + handlers.push(Box::new(TranslateMeHandler::new( + group_prefs.clone(), near_ai.clone(), signal.clone(), - config.whisper.reply_prefix.clone(), + bot_identity.clone(), ))); + info!( + "Language sidecars enabled: !translate-me-on / !translate-me-off (max {}/min)", + config.translate_all.max_messages_per_minute + ); + handlers.push(Box::new(TranslateLangsHandler::new())); - handlers.push(Box::new(AskHandler::new(chat.clone()))); - info!("AI chat: DM free-text; groups use !ask"); - handlers.push(Box::new(chat)); handlers.push(Box::new(VerifyHandler::new(dstack.clone()))); handlers.push(Box::new(ClearHandler::new(conversations.clone()))); handlers.push(Box::new(SetLanguageHandler::new(group_prefs.clone()))); @@ -301,7 +155,6 @@ async fn main() -> AppResult<()> { handlers.push(Box::new(PrivacyHandler::new(group_prefs.clone()))); handlers.push(Box::new(ModelsHandler::new(near_ai.clone()))); - // Add payment handlers if enabled if let Some(ref store) = credit_store { handlers.push(Box::new(BalanceHandler::new(store.clone()))); handlers.push(Box::new(DepositHandler::new(config.payments.clone()))); @@ -320,13 +173,7 @@ async fn main() -> AppResult<()> { loop { tokio::select! { Some(message) = stream.next() => { - if let Some(audio) = message.primary_audio_attachment() { - voice_attachment_cache.remember( - message.reply_target(), - message.message_timestamp, - audio.clone(), - ); - } + bot_identity.note_inbound(&message); let handler = handlers .iter() diff --git a/crates/signal-bot/tests/e2e_test.rs b/crates/signal-bot/tests/e2e_test.rs index ccf0cef..10b0f83 100644 --- a/crates/signal-bot/tests/e2e_test.rs +++ b/crates/signal-bot/tests/e2e_test.rs @@ -73,11 +73,14 @@ async fn test_bot_chat_e2e() { // 5. Simulate Incoming Message let incoming = BotMessage { source: "+123456789".to_string(), + source_number: None, + source_name: None, text: "Hi there!".to_string(), timestamp: 123456789, message_timestamp: 123456789, is_group: false, group_id: None, + group_name: None, receiving_account: "+987654321".to_string(), attachments: vec![], quote: None, @@ -207,11 +210,14 @@ async fn test_bot_tool_use_e2e() { // 7. Execute let incoming = BotMessage { source: "+123456789".to_string(), + source_number: None, + source_name: None, text: "How much is 2+2?".to_string(), timestamp: 123456789, message_timestamp: 123456789, is_group: false, group_id: None, + group_name: None, receiving_account: "+987654321".to_string(), attachments: vec![], quote: None, diff --git a/crates/signal-client/src/client.rs b/crates/signal-client/src/client.rs index a8f608d..e7b80da 100644 --- a/crates/signal-client/src/client.rs +++ b/crates/signal-client/src/client.rs @@ -75,6 +75,113 @@ impl SignalClient { Ok(groups) } + /// Create a Signal group. Returns the send id (`group.…`). + #[instrument(skip(self, members))] + pub async fn create_group( + &self, + phone_number: &str, + name: &str, + members: Vec, + description: Option<&str>, + ) -> Result { + let encoded_number = encode(phone_number); + let body = CreateGroupRequest { + name: name.to_string(), + members, + description: description.map(str::to_string), + }; + let response = self + .client + .post(format!("{}/v1/groups/{}", self.base_url, encoded_number)) + .json(&body) + .send() + .await?; + + if !response.status().is_success() { + let msg = response.text().await.unwrap_or_default(); + return Err(SignalError::Api(msg)); + } + + let created: CreateGroupResponse = response.json().await?; + // Refresh list to obtain internal_id for inbound matching. + let groups = self.list_groups(phone_number).await?; + let group = groups + .into_iter() + .find(|g| g.id == created.id) + .unwrap_or(Group { + name: name.to_string(), + id: created.id.clone(), + internal_id: created.id.clone(), + }); + + self.cache_group_mapping(phone_number, &group).await; + debug!( + "Created group {} (internal {}) for {}", + group.id, group.internal_id, phone_number + ); + Ok(group) + } + + /// Add members to an existing group (`group.…` send id). + #[instrument(skip(self, members))] + pub async fn add_members( + &self, + phone_number: &str, + group_send_id: &str, + members: Vec, + ) -> Result<(), SignalError> { + self.change_members(phone_number, group_send_id, members, true) + .await + } + + /// Remove members from an existing group. + #[instrument(skip(self, members))] + pub async fn remove_members( + &self, + phone_number: &str, + group_send_id: &str, + members: Vec, + ) -> Result<(), SignalError> { + self.change_members(phone_number, group_send_id, members, false) + .await + } + + async fn change_members( + &self, + phone_number: &str, + group_send_id: &str, + members: Vec, + add: bool, + ) -> Result<(), SignalError> { + let encoded_number = encode(phone_number); + let encoded_group = encode(group_send_id); + let url = format!( + "{}/v1/groups/{}/{}/members", + self.base_url, encoded_number, encoded_group + ); + let body = ChangeGroupMembersRequest { members }; + let request = if add { + self.client.post(&url).json(&body) + } else { + self.client.delete(&url).json(&body) + }; + let response = request.send().await?; + + if !response.status().is_success() { + let msg = response.text().await.unwrap_or_default(); + return Err(SignalError::Api(msg)); + } + Ok(()) + } + + async fn cache_group_mapping(&self, phone_number: &str, group: &Group) { + let key = (phone_number.to_string(), group.internal_id.clone()); + self.group_cache + .write() + .await + .insert(key, group.id.clone()); + } + /// Resolve the recipient id for `/v2/send` (DM source or `group.*` id). #[instrument(skip(self))] pub async fn resolve_send_recipient( @@ -117,6 +224,35 @@ impl SignalClient { Ok(send_id) } + /// Resolve `internal_id` (or pass-through `group.…`) to send id for an account. + pub async fn resolve_group_send_id_for_account( + &self, + phone_number: &str, + group_id: &str, + ) -> Result { + if group_id.starts_with("group.") { + return Ok(group_id.to_string()); + } + + let cache_key = (phone_number.to_string(), group_id.to_string()); + if let Some(send_id) = self.group_cache.read().await.get(&cache_key) { + return Ok(send_id.clone()); + } + + let groups = self.list_groups(phone_number).await?; + let send_id = resolve_group_send_id(group_id, &groups).ok_or_else(|| { + SignalError::Api(format!( + "Unknown group id {group_id} for account {phone_number}" + )) + })?; + + self.group_cache + .write() + .await + .insert(cache_key, send_id.clone()); + Ok(send_id) + } + /// Check if the Signal API is healthy. pub async fn health_check(&self) -> bool { self.client diff --git a/crates/signal-client/src/lib.rs b/crates/signal-client/src/lib.rs index 7e6683c..ed038dc 100644 --- a/crates/signal-client/src/lib.rs +++ b/crates/signal-client/src/lib.rs @@ -13,7 +13,7 @@ pub use types::*; #[cfg(test)] mod tests { use super::*; - use wiremock::matchers::{method, path}; + use wiremock::matchers::{body_json, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; async fn create_test_client(mock_server: &MockServer) -> SignalClient { @@ -173,6 +173,7 @@ mod tests { envelope: Envelope { source: "+14155551234".into(), source_number: Some("+14155551234".into()), + source_uuid: None, source_name: Some("Test User".into()), timestamp: 1677652288000, data_message: Some(DataMessage { @@ -203,6 +204,7 @@ mod tests { envelope: Envelope { source: "+14155551234".into(), source_number: Some("+14155551234".into()), + source_uuid: None, source_name: Some("Test User".into()), timestamp: 1677652288000, data_message: Some(DataMessage { @@ -237,6 +239,7 @@ mod tests { envelope: Envelope { source: "+14155551234".into(), source_number: None, + source_uuid: None, source_name: None, timestamp: 1677652288000, data_message: None, @@ -278,11 +281,14 @@ mod tests { let client = create_test_client(&mock_server).await; let original = BotMessage { source: "+14155551234".into(), + source_number: None, + source_name: None, text: "Hola".into(), timestamp: 1677652288000, message_timestamp: 1677652287000, is_group: false, group_id: None, + group_name: None, receiving_account: "+15555555555".into(), attachments: vec![], quote: None, @@ -313,11 +319,14 @@ mod tests { let client = create_test_client(&mock_server).await; let message = BotMessage { source: "+14155551234".into(), + source_number: None, + source_name: None, text: "hi".into(), timestamp: 1, message_timestamp: 1, is_group: true, group_id: Some("MB3b+ZC/9JkmyJS0J6dOpgyboKNYVkp134P7l547Pk8=".into()), + group_name: None, receiving_account: "+15555555555".into(), attachments: vec![], quote: None, @@ -423,4 +432,135 @@ mod tests { assert_eq!(audio.id, "root-audio-id"); assert_eq!(audio.content_type, "audio/aac"); } + + #[tokio::test] + async fn test_create_group() { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/groups/%2B15555555555")) + .and(body_json(serde_json::json!({ + "name": "BAM Spanish", + "members": ["+14155551234"], + "description": "Spanish sidecar" + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({ + "id": "group.sidecarEs==" + }))) + .mount(&mock_server) + .await; + + Mock::given(method("GET")) + .and(path("/v1/groups/%2B15555555555")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([{ + "name": "BAM Spanish", + "id": "group.sidecarEs==", + "internal_id": "es-internal-id" + }]))) + .mount(&mock_server) + .await; + + let client = create_test_client(&mock_server).await; + let group = client + .create_group( + "+15555555555", + "BAM Spanish", + vec!["+14155551234".into()], + Some("Spanish sidecar"), + ) + .await + .unwrap(); + + assert_eq!(group.id, "group.sidecarEs=="); + assert_eq!(group.internal_id, "es-internal-id"); + } + + #[tokio::test] + async fn test_add_and_remove_members() { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path( + "/v1/groups/%2B15555555555/group.sidecarEs%3D%3D/members", + )) + .and(body_json(serde_json::json!({ + "members": ["+14155559876"] + }))) + .respond_with(ResponseTemplate::new(204)) + .mount(&mock_server) + .await; + + Mock::given(method("DELETE")) + .and(path( + "/v1/groups/%2B15555555555/group.sidecarEs%3D%3D/members", + )) + .and(body_json(serde_json::json!({ + "members": ["+14155559876"] + }))) + .respond_with(ResponseTemplate::new(204)) + .mount(&mock_server) + .await; + + let client = create_test_client(&mock_server).await; + client + .add_members( + "+15555555555", + "group.sidecarEs==", + vec!["+14155559876".into()], + ) + .await + .unwrap(); + client + .remove_members( + "+15555555555", + "group.sidecarEs==", + vec!["+14155559876".into()], + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_create_group_error() { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/groups/%2B15555555555")) + .respond_with(ResponseTemplate::new(400).set_body_string("bad members")) + .mount(&mock_server) + .await; + + let client = create_test_client(&mock_server).await; + let err = client + .create_group("+15555555555", "BAM English", vec![], None) + .await + .unwrap_err(); + assert!(err.to_string().contains("bad members")); + } + + #[test] + fn test_bot_message_source_fields() { + let incoming = IncomingMessage { + envelope: Envelope { + source: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee".into(), + source_number: Some("+14155551234".into()), + source_uuid: Some("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee".into()), + source_name: Some("Maria".into()), + timestamp: 1, + data_message: Some(DataMessage { + message: Some("hola".into()), + timestamp: 1, + group_info: None, + attachments: vec![], + quote: None, + }), + }, + account: "+15555555555".into(), + }; + let msg = BotMessage::from_incoming(&incoming).unwrap(); + assert_eq!(msg.source_name.as_deref(), Some("Maria")); + assert_eq!(msg.source_number.as_deref(), Some("+14155551234")); + assert_eq!(msg.invite_address().as_deref(), Some("+14155551234")); + assert_eq!(msg.display_name(), "Maria"); + } } diff --git a/crates/signal-client/src/types.rs b/crates/signal-client/src/types.rs index 1a7561b..54cdc01 100644 --- a/crates/signal-client/src/types.rs +++ b/crates/signal-client/src/types.rs @@ -14,6 +14,8 @@ pub struct Envelope { pub source: String, #[serde(rename = "sourceNumber")] pub source_number: Option, + #[serde(rename = "sourceUuid")] + pub source_uuid: Option, #[serde(rename = "sourceName")] pub source_name: Option, pub timestamp: i64, @@ -50,6 +52,27 @@ pub struct Group { pub internal_id: String, } +/// Request body for `POST /v1/groups/{number}`. +#[derive(Debug, Clone, Serialize)] +pub struct CreateGroupRequest { + pub name: String, + pub members: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +/// Response from create group. +#[derive(Debug, Clone, Deserialize)] +pub struct CreateGroupResponse { + pub id: String, +} + +/// Request body for add/remove group members. +#[derive(Debug, Clone, Serialize)] +pub struct ChangeGroupMembersRequest { + pub members: Vec, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Attachment { #[serde(rename = "contentType")] @@ -142,8 +165,12 @@ pub struct Account { /// Parsed message for bot processing. #[derive(Debug, Clone)] pub struct BotMessage { - /// The phone number that sent the message. + /// Sender id from the envelope (`source` — often UUID). pub source: String, + /// E.164 phone when Signal includes it. + pub source_number: Option, + /// Display name when Signal includes it. + pub source_name: Option, /// The message text (empty for voice-only messages). pub text: String, /// Envelope timestamp (milliseconds). @@ -154,6 +181,8 @@ pub struct BotMessage { pub is_group: bool, /// Group ID if this is a group message. pub group_id: Option, + /// Optional group display name from `groupInfo`. + pub group_name: Option, /// The bot's phone number that received this message. pub receiving_account: String, /// Attachments on this message (voice notes, etc.). @@ -187,17 +216,52 @@ impl BotMessage { Some(Self { source: msg.envelope.source.clone(), + source_number: msg.envelope.source_number.clone(), + source_name: msg.envelope.source_name.clone(), text: data.message.clone().unwrap_or_default(), timestamp: msg.envelope.timestamp, message_timestamp: data.timestamp, is_group: data.group_info.is_some(), group_id: data.group_info.as_ref().map(|g| g.group_id.clone()), + group_name: data.group_info.as_ref().and_then(|g| g.group_name.clone()), receiving_account: msg.account.clone(), attachments: data.attachments.clone(), quote, }) } + /// Best address for group invite (`members[]`): phone, else source if it looks usable. + pub fn invite_address(&self) -> Option { + if let Some(n) = &self.source_number { + if n.starts_with('+') { + return Some(n.clone()); + } + } + if self.source.starts_with('+') { + return Some(self.source.clone()); + } + // UUID may work for some signal-cli versions; prefer when no phone. + if self.source.contains('-') && self.source.len() >= 32 { + return Some(self.source.clone()); + } + None + } + + /// Display name for attribution in bridged messages. + pub fn display_name(&self) -> String { + self.source_name + .clone() + .filter(|n| !n.trim().is_empty()) + .unwrap_or_else(|| { + let s = self.source_number.as_deref().unwrap_or(&self.source); + if s.chars().count() > 16 { + format!("{}…", s.chars().take(12).collect::()) + } else { + s.to_string() + } + }) + } + /// Whether this message is a voice note (has at least one audio attachment). pub fn is_voice_note(&self) -> bool { self.attachments.iter().any(Attachment::is_audio) diff --git a/docs/spikes/2026-07-21-sidecar-groups.md b/docs/spikes/2026-07-21-sidecar-groups.md new file mode 100644 index 0000000..e269a78 --- /dev/null +++ b/docs/spikes/2026-07-21-sidecar-groups.md @@ -0,0 +1,35 @@ +# Spike: Signal sidecar groups + bot self-receive (Phase 0) + +Date: 2026-07-21 +Context: Mutual-aid language sidecar plan (`translate-me` bridge). + +## Local observation (pre-implementation) + +Against a local `signal-cli-rest-api` with bot `+573107677679` sending into a group: + +1. After the bot sent a group message, `/v1/receive` for the bot account returned a **delivery receipt**, not a `dataMessage` echoing the bot’s own text. +2. Human envelopes often use `source` = Signal UUID; `sourceNumber` may be null depending on contact sync. +3. Implication: relay loops are **unlikely** if we only act on inbound `dataMessage`s, but we still implement `BotIdentity` skip (phone + learned UUID) as defense in depth. + +## REST contract (implemented + wiremocked) + +| Call | Path | +|------|------| +| Create | `POST /v1/groups/{botPhone}` body `{ name, members, description? }` → `{ id: "group.…" }` | +| List | `GET /v1/groups/{botPhone}` → `{ id, internal_id, name }[]` | +| Add | `POST /v1/groups/{bot}/{groupSendId}/members` `{ members }` | +| Remove | `DELETE` same path + members body | +| Send | `POST /v2/send` recipients `["group.…"]` | + +Inbound envelopes use `groupInfo.groupId` = list-groups `internal_id`. + +## Manual checklist (still for alpha) + +- [ ] Create group with member A → 201 + `group.` id; A accepts invite +- [ ] Add member B; send to group; A and B receive +- [ ] Capture any self-receive JSON (`source` / `sourceNumber` / `sourceUuid`) on CVM +- [ ] Remove member; bad member error copy + +## Exit + +Client methods + identity helper + bridge store are in tree. Full create/add/send on the pinned Phala image remains an alpha soak item. From e9e2ab4a1a2b968548b5f726ad021d5fbab4d520 Mon Sep 17 00:00:00 2001 From: daopunk Date: Tue, 21 Jul 2026 23:56:49 -0500 Subject: [PATCH 2/3] chore: rename sidecars to Language Thread and slim help copy --- crates/signal-bot/src/commands/menu_locale.rs | 30 +++++++++++-------- .../signal-bot/src/commands/translate_me.rs | 8 ++--- crates/signal-client/src/lib.rs | 8 ++--- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/crates/signal-bot/src/commands/menu_locale.rs b/crates/signal-bot/src/commands/menu_locale.rs index 1757f6c..3e6e619 100644 --- a/crates/signal-bot/src/commands/menu_locale.rs +++ b/crates/signal-bot/src/commands/menu_locale.rs @@ -29,14 +29,17 @@ pub fn privacy_menu(language: MenuLanguage) -> &'static str { } } -const HELP_EN: &str = r#"**BAM** (Bread Coop AI) +const HELP_EN: &str = r#"Translation Threads: -Language sidecars: join a per-language Signal group bridged to this main chat. +Join a per-language Signal group bridged to this main chat. -**Sidecars:** -- !translate-me-on — Join BAM {language} sidecar -- !translate-me-off — Leave your sidecar -- !list-langs — Supported language codes +**Commands:** +- !translate-me-on +- !translate-me-off +- !list-langs + +example: +!translate-me-on es **Default Language** - !set-es — español @@ -46,14 +49,17 @@ Language sidecars: join a per-language Signal group bridged to this main chat. - !privacy — Privacy & TEE - !help — Show this menu"#; -const HELP_ES: &str = r#"**BAM** (Bread Coop AI) +const HELP_ES: &str = r#"Hilos de traducción: + +Únete a un grupo Signal por idioma, conectado a este chat principal. -Sidecars de idioma: únete a un grupo Signal por idioma, conectado a este chat principal. +**Commands:** +- !translate-me-on +- !translate-me-off +- !list-langs -**Sidecars:** -- !translate-me-on — Unirte al sidecar BAM {idioma} -- !translate-me-off — Salir del sidecar -- !list-langs — Códigos de idioma disponibles +ejemplo: +!translate-me-on es **Idioma predeterminado** - !set-es — español diff --git a/crates/signal-bot/src/commands/translate_me.rs b/crates/signal-bot/src/commands/translate_me.rs index c61575d..18669cf 100644 --- a/crates/signal-bot/src/commands/translate_me.rs +++ b/crates/signal-bot/src/commands/translate_me.rs @@ -1,6 +1,6 @@ //! Language sidecar bridge: `!translate-me-on` / `!translate-me-off` + relay engine. //! -//! Main group stays bilingual. Each subscribed language gets a `BAM {Language}` +//! Main group stays bilingual. Each subscribed language gets a `Language Thread {Language}` //! Signal sidecar. Messages fan out: main→sidecars (relay/translate), //! sidecar→main (relay) + other sidecars (translate). Bot never relays itself. @@ -194,7 +194,7 @@ impl TranslateMeHandler { )); } } else { - let name = format!("BAM {}", lang.name); + let name = format!("Language Thread {}", lang.name); let description = format!( "{} sidecar bridged to the main mutual-aid group.", lang.name @@ -217,7 +217,7 @@ impl TranslateMeHandler { group.internal_id.clone(), ); let welcome = format!( - "Welcome to BAM {}. Messages here are bridged with the main group.", + "Welcome to Language Thread {}. Messages here are bridged with the main group.", lang.name ); if let Err(e) = self.signal.send(bot, &group.id, &welcome).await { @@ -248,7 +248,7 @@ impl TranslateMeHandler { ); Ok(format!( - "Joined the {} sidecar (BAM {}). Accept the Signal group invite if prompted. \ + "Joined the {} sidecar (Language Thread {}). Accept the Signal group invite if prompted. \ Use !translate-me-off to leave.", lang.name, lang.name )) diff --git a/crates/signal-client/src/lib.rs b/crates/signal-client/src/lib.rs index ed038dc..430d70f 100644 --- a/crates/signal-client/src/lib.rs +++ b/crates/signal-client/src/lib.rs @@ -440,7 +440,7 @@ mod tests { Mock::given(method("POST")) .and(path("/v1/groups/%2B15555555555")) .and(body_json(serde_json::json!({ - "name": "BAM Spanish", + "name": "Language Thread Spanish", "members": ["+14155551234"], "description": "Spanish sidecar" }))) @@ -453,7 +453,7 @@ mod tests { Mock::given(method("GET")) .and(path("/v1/groups/%2B15555555555")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([{ - "name": "BAM Spanish", + "name": "Language Thread Spanish", "id": "group.sidecarEs==", "internal_id": "es-internal-id" }]))) @@ -464,7 +464,7 @@ mod tests { let group = client .create_group( "+15555555555", - "BAM Spanish", + "Language Thread Spanish", vec!["+14155551234".into()], Some("Spanish sidecar"), ) @@ -532,7 +532,7 @@ mod tests { let client = create_test_client(&mock_server).await; let err = client - .create_group("+15555555555", "BAM English", vec![], None) + .create_group("+15555555555", "Language Thread English", vec![], None) .await .unwrap_err(); assert!(err.to_string().contains("bad members")); From cac6a81d714ab27fe30b29d8139abc0e409d14d7 Mon Sep 17 00:00:00 2001 From: daopunk Date: Wed, 22 Jul 2026 00:32:03 -0500 Subject: [PATCH 3/3] docs: add Language Threads sidecar overview --- docs/language-threads.md | 128 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 docs/language-threads.md diff --git a/docs/language-threads.md b/docs/language-threads.md new file mode 100644 index 0000000..8f85ba9 --- /dev/null +++ b/docs/language-threads.md @@ -0,0 +1,128 @@ +# Language Threads (mutual-aid sidecars) + +Status: **implemented and verified locally**; Phala TEE redeploy paused (image `daopunk/signal-bot-tee:latest` already pushed for `linux/amd64`). + +This document describes what shipped for bilingual mutual-aid groups: one main Signal chat plus per-language **Language Thread** sidecar groups, bridged by the bot. + +## Problem + +In bilingual NYC mutual-aid groups, organizers often dual-post EN+ES by hand. Monolingual members miss context; bilinguals carry the translation load. + +## Solution + +| Room | Role | +|------|------| +| **Main group** | Bilingual hub; bot already a member | +| **Language Thread {Language}** | One Signal sidecar per subscribed language (e.g. `Language Thread Spanish`) | + +Users who want a monolingual lane run `!translate-me-on ` in **main**. The bot creates or joins the sidecar and invites them. Messages are relayed/translated across main and all active threads. + +```text +Main (bilingual) + ├── Language Thread Spanish ← monolingual ES users + ├── Language Thread English ← monolingual EN users + └── … (any !list-langs code) +``` + +## Commands (alpha surface) + +Registered handlers are slimmed for alpha: + +| Command | Where | Effect | +|---------|--------|--------| +| `!translate-me-on ` | Main only | Create/join sidecar; invite user | +| `!translate-me-off` | Main or sidecar | Leave sidecar | +| `!list-langs` | Any | Language codes | +| `!help` / `!privacy` | Any | Menus | +| `!set-en` / `!set-es` | Group | Menu language | +| `!verify` / `!clear` / `!models` | As before | TEE / session | + +Aliases: `!translate-me on es`, `!translation-me-on es`, etc. + +**Not registered** (code kept in tree): `!ask`, DM chat, `!translate-on`/`off`, quote `!translate`, voice/`!transcribe*`. + +## Relay rules + +Bot **never** processes its own messages (phone + learned UUID via `BotIdentity`). + +| Direction | Behavior | +|-----------|----------| +| Main → sidecar | Same detected language → **relay**; else **translate** via NEAR AI | +| Sidecar → main | **Relay only** (main is bilingual) | +| Sidecar → other sidecars | **Translate** to each other language | +| Attribution | `{display_name}:\n{body}` (`sourceName` when present) | + +Same-language relay skips NEAR. Cross-language calls `near_ai_translate` (configured NEAR model). + +Rate limit: one `allow_message(main_id)` per inbound human event (covers fan-out). + +## Subscribe / unsubscribe flow + +1. User in main: `!translate-me-on es` +2. Resolve language; need invite address (`sourceNumber` preferred, else usable `source`) +3. **First subscriber for that lang:** `POST /v1/groups/{bot}` → name `Language Thread Spanish` → persist send id + internal id → welcome in sidecar → confirm in main +4. **Later subscribers:** `add_members` on existing sidecar +5. Language switch: remove from old sidecar, add/create new +6. `!translate-me-off`: remove from Signal group + store + +If Signal omits phone number, bot asks the user to DM once, then retry. + +## Persistence + +Encrypted group prefs (`GroupPreferencesStore`, TEE-derived key when dstack is available): + +```text +LanguageBridge (keyed by main group internal_id) + sidecars: lang → group.… send id + sidecar_internal: lang → internal_id (inbound match) + members: user key → lang + member_addresses: user key → invite address +``` + +In-memory reverse index: sidecar `internal_id` → `(main_id, lang)`. + +Local Docker without dstack may not persist prefs across restarts; Phala with dstack does. + +## Key code + +| Area | Path | +|------|------| +| Commands + relay | [`crates/signal-bot/src/commands/translate_me.rs`](../crates/signal-bot/src/commands/translate_me.rs) | +| Bot skip | [`crates/signal-bot/src/bot_identity.rs`](../crates/signal-bot/src/bot_identity.rs) | +| Bridge store | [`crates/signal-bot/src/group_preferences_store.rs`](../crates/signal-bot/src/group_preferences_store.rs) | +| Group REST | [`crates/signal-client/src/client.rs`](../crates/signal-client/src/client.rs) (`create_group`, `add_members`, `remove_members`) | +| Envelope fields | [`crates/signal-client/src/types.rs`](../crates/signal-client/src/types.rs) (`source_name`, `source_number`, …) | +| Help copy | [`crates/signal-bot/src/commands/menu_locale.rs`](../crates/signal-bot/src/commands/menu_locale.rs) | +| Handler registration | [`crates/signal-bot/src/main.rs`](../crates/signal-bot/src/main.rs) | +| Phase 0 spike notes | [`docs/spikes/2026-07-21-sidecar-groups.md`](spikes/2026-07-21-sidecar-groups.md) | + +## Local testing + +```bash +cd docker +docker compose build signal-bot && docker compose up -d signal-bot +``` + +Only **signal-bot** needs rebuild. Smoke: main group → `!translate-me-on es` → accept invite → message in main appears in Language Thread (translated or relayed). + +## Phala / TEE (paused) + +- Deploy uses **Docker images** in compose, not a public git clone. Pushing `daopunk/signal-bot-tee:latest` is what the CVM pulls. +- Target was a **4 GB** CVM (`tdx.medium`) named `dstack-app-hqvaf`; previous `dstack-app-cxswu` was removed. +- Env: `deploy/dstack-app-cxswu/phala.env` (secrets; do not commit). +- Fresh CVM ⇒ expect **re-register** Signal phone (volume died with old CVM). +- Image already pushed: `daopunk/signal-bot-tee:latest` @ `sha256:09bb7aca…` (linux/amd64). + +## Trust / privacy notes + +- Signal E2E still terminates at Signal CLI inside the TEE (same architecture as before). +- Translation plaintext goes to **NEAR AI** (their GPU TEE / cloud path as configured). +- Operator still sees metadata (timing, sizes, which numbers). +- Sidecar names and bridged posts are visible to members of those Signal groups. + +## Open follow-ups + +- Resume Phala deploy at 4 GB; confirm memory with `phala cvms get` +- Pin image digest in compose for stronger attestation +- Capacity: whisper + bot + signal-api may be tight on 4 GB +- Optional: delete empty sidecars after last member leaves