diff --git a/.agents/docs/DEVELOPMENT.md b/.agents/docs/DEVELOPMENT.md index 13cff83..17e97a1 100644 --- a/.agents/docs/DEVELOPMENT.md +++ b/.agents/docs/DEVELOPMENT.md @@ -6,8 +6,7 @@ Interoperable Signal products (see [issue #10](https://github.com/BreadchainCoop 1. **Voice transcription** — own CVM (`BOT__ROLE=transcription`) 2. **In-chat group translation** — translation CVM (see [`docs/in-chat-translation.md`](../../docs/in-chat-translation.md)) -3. **Parallel Translation** — translation CVM MVP (see [`docs/parallel-translation.md`](../../docs/parallel-translation.md)) -4. **Language Threads** — translation CVM (see [`docs/language-threads.md`](../../docs/language-threads.md)) +3. **Language Threads** — translation CVM, multilingual main + N sidecars (see [`docs/language-threads.md`](../../docs/language-threads.md)) Architecture overview: [`docs/two-cvm-architecture.md`](../../docs/two-cvm-architecture.md). diff --git a/AGENTS.md b/AGENTS.md index 8d3aa29..0a1e93b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,8 +36,7 @@ docker compose -f docker/compose.translation.yaml --env-file docker/translation. | [`docs/two-cvm-architecture.md`](docs/two-cvm-architecture.md) | Architecture diagram and compose/Phala split | | [`docs/voice-transcription.md`](docs/voice-transcription.md) | Voice transcription product + pairing | | [`docs/in-chat-translation.md`](docs/in-chat-translation.md) | In-chat (group) bilingual auto/manual translate | -| [`docs/parallel-translation.md`](docs/parallel-translation.md) | Parallel Translation product behavior | -| [`docs/language-threads.md`](docs/language-threads.md) | Language Threads product behavior | +| [`docs/language-threads.md`](docs/language-threads.md) | Language Threads (multilingual main + N sidecars) | | [`.agents/skills/`](.agents/skills/) | Vendored skills (Rust, Docker, Stripe) | | [`.agents/rules/`](.agents/rules/) | Agent rules (e.g. commit message subjects) | diff --git a/README.md b/README.md index d68131b..fb62df8 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,10 @@ Not a general AI chat assistant. Conversation history, tool-calling, and x402 cr | Voice transcription | `BOT__ROLE=transcription` | Own CVM / Compose stack (with Whisper) | | In-chat (group) translation | `BOT__ROLE=translation` | Shared translation CVM | | Language Threads | `BOT__ROLE=translation` | Shared translation CVM | -| Parallel Translation | `BOT__ROLE=translation` (MVP) | Shared translation CVM | Pair products by adding **both bots** (two phone numbers) to the same Signal group. Signal is the bus — there is no Docker network between CVMs. -Details: [docs/two-cvm-architecture.md](docs/two-cvm-architecture.md) · [docs/voice-transcription.md](docs/voice-transcription.md) · [docs/in-chat-translation.md](docs/in-chat-translation.md) · [docs/parallel-translation.md](docs/parallel-translation.md) · [docs/language-threads.md](docs/language-threads.md) +Details: [docs/two-cvm-architecture.md](docs/two-cvm-architecture.md) · [docs/voice-transcription.md](docs/voice-transcription.md) · [docs/in-chat-translation.md](docs/in-chat-translation.md) · [docs/language-threads.md](docs/language-threads.md) ## Architecture diff --git a/crates/signal-bot/src/commands/menu_locale.rs b/crates/signal-bot/src/commands/menu_locale.rs index cf8876f..55f9d71 100644 --- a/crates/signal-bot/src/commands/menu_locale.rs +++ b/crates/signal-bot/src/commands/menu_locale.rs @@ -25,26 +25,15 @@ pub fn help_menu(language: MenuLanguage, role: BotRole) -> &'static str { } } -pub fn translation_products_menu(language: MenuLanguage) -> &'static str { - match language { - MenuLanguage::En => TRANSLATION_PRODUCTS_EN, - MenuLanguage::Es => TRANSLATION_PRODUCTS_ES, - } -} - -pub fn in_chat_menu(language: MenuLanguage, translate_all_enabled: bool) -> &'static str { +pub fn translation_products_menu( + language: MenuLanguage, + translate_all_enabled: bool, +) -> &'static str { match (language, translate_all_enabled) { - (MenuLanguage::En, true) => IN_CHAT_EN, - (MenuLanguage::En, false) => IN_CHAT_DISABLED_EN, - (MenuLanguage::Es, true) => IN_CHAT_ES, - (MenuLanguage::Es, false) => IN_CHAT_DISABLED_ES, - } -} - -pub fn parallel_menu(language: MenuLanguage) -> &'static str { - match language { - MenuLanguage::En => PARALLEL_MENU_EN, - MenuLanguage::Es => PARALLEL_MENU_ES, + (MenuLanguage::En, true) => TRANSLATION_MENU_EN, + (MenuLanguage::En, false) => TRANSLATION_MENU_AUTO_DISABLED_EN, + (MenuLanguage::Es, true) => TRANSLATION_MENU_ES, + (MenuLanguage::Es, false) => TRANSLATION_MENU_AUTO_DISABLED_ES, } } @@ -109,7 +98,7 @@ Las notas de voz en este chat se transcriben a texto (Whisper, dentro del TEE). const HELP_HUB_EN: &str = r#"Sigstack -- !translation — Translation products +- !translation — Translation - !transcription — Voice transcription - !privacy — Privacy & TEE @@ -118,110 +107,82 @@ Menu language: !set-en / !set-es const HELP_HUB_ES: &str = r#"Sigstack -- !translation — Productos de traducción +- !translation — Traducción - !transcription — Transcripción de voz - !privacy — Privacidad y TEE Idioma del menú: !set-en / !set-es !help — Mostrar este menú"#; -const TRANSLATION_PRODUCTS_EN: &str = r#"Translation products - -- !in-chat — Translate inside this group (auto or one message) -- !parallel — Parallel chat (this group + one translated lane) -- Language Threads — coming in a later update (main bilingual + many language sidecars) - -Also: !list-langs · !models · !verify -!help — Back to main menu"#; - -const TRANSLATION_PRODUCTS_ES: &str = r#"Productos de traducción +const TRANSLATION_MENU_EN: &str = r#"Translation -- !in-chat — Traducir en este grupo (auto o un mensaje) -- !parallel — Chat paralelo (este grupo + un hilo traducido) -- Language Threads — próximamente (principal bilingüe + varios sidecars) - -También: !list-langs · !models · !verify -!help — Volver al menú principal"#; - -const IN_CHAT_EN: &str = r#"In-chat translation +Language Threads (recommended) +Multilingual main + language sidecars. +- !translate-me-on — join/create a Language Thread (from main) +- !translate-me-off — leave your Language Thread +- !list-langs — language codes -**Auto-translate** (detect language, quote-reply the other side): +In-chat (same group only) +Stay in this thread; auto or quote one message. - !translate-on — e.g. !translate-on es en - !translate-off — stop auto-translate -- !list-langs — language codes - -**Manual** (one message): - Reply to a message with !translate -!translation — Back to products · !help — Main menu"#; +Also: !models · !verify +!help — Main menu"#; -const IN_CHAT_ES: &str = r#"Traducción en el chat +const TRANSLATION_MENU_ES: &str = r#"Traducción -**Auto-traducción** (detecta idioma y responde citando la otra lengua): -- !translate-on — ej. !translate-on es en -- !translate-off — detener auto-traducción +Language Threads (recomendado) +Principal multilingüe + sidecars por idioma. +- !translate-me-on — unirte/crear un Language Thread (desde el principal) +- !translate-me-off — salir de tu Language Thread - !list-langs — códigos de idioma -**Manual** (un mensaje): +En el chat (solo este grupo) +Quédate en este hilo; auto o cita un mensaje. +- !translate-on — ej. !translate-on es en +- !translate-off — detener auto-traducción - Responde a un mensaje con !translate -!translation — Volver a productos · !help — Menú principal"#; +También: !models · !verify +!help — Menú principal"#; -const IN_CHAT_DISABLED_EN: &str = r#"In-chat translation +const TRANSLATION_MENU_AUTO_DISABLED_EN: &str = r#"Translation -Auto-translate is disabled on this bot (!translate-on). +Language Threads (recommended) +Multilingual main + language sidecars. +- !translate-me-on — join/create a Language Thread (from main) +- !translate-me-off — leave your Language Thread +- !list-langs — language codes -**Manual** (one message): +In-chat (same group only) +Auto-translate is disabled on this bot (!translate-on). - Reply to a message with !translate -!translation — Back to products · !help — Main menu"#; +Also: !models · !verify +!help — Main menu"#; -const IN_CHAT_DISABLED_ES: &str = r#"Traducción en el chat +const TRANSLATION_MENU_AUTO_DISABLED_ES: &str = r#"Traducción -La auto-traducción está desactivada en este bot (!translate-on). +Language Threads (recomendado) +Principal multilingüe + sidecars por idioma. +- !translate-me-on — unirte/crear un Language Thread (desde el principal) +- !translate-me-off — salir de tu Language Thread +- !list-langs — códigos de idioma -**Manual** (un mensaje): +En el chat (solo este grupo) +La auto-traducción está desactivada en este bot (!translate-on). - Responde a un mensaje con !translate -!translation — Volver a productos · !help — Menú principal"#; - -const PARALLEL_MENU_EN: &str = r#"Parallel Translation - -This chat stays in one language. The bot creates a parallel Signal group in the other language and relays both ways. - -**Setup** (in this group): -- !parallel-on - lang1 = THIS chat; lang2 = the parallel group the bot creates - Example: !parallel-on en es - -**Join** (each person in this group): -- !parallel-join — add yourself to the parallel group -- !parallel-leave — leave the parallel group -- !parallel-off — stop Parallel for this group (in main) - -!list-langs · !translation — Back · !help — Main menu"#; - -const PARALLEL_MENU_ES: &str = r#"Traducción paralela - -Este chat queda en un idioma. El bot crea un grupo Signal paralelo en el otro idioma y retransmite en ambos sentidos. - -**Configurar** (en este grupo): -- !parallel-on - lang1 = ESTE chat; lang2 = el grupo paralelo que crea el bot - Ejemplo: !parallel-on en es - -**Unirse** (cada persona en este grupo): -- !parallel-join — añadirte al grupo paralelo -- !parallel-leave — salir del grupo paralelo -- !parallel-off — desactivar Parallel en este grupo (en el principal) - -!list-langs · !translation — Volver · !help — Menú principal"#; +También: !models · !verify +!help — Menú principal"#; const TRANSCRIPTION_UNAVAILABLE_EN: &str = r#"Voice transcription is currently unavailable. The transcription bot is not paired with this group yet. Meanwhile, try translation: -- !translation — Translation products (in-chat, parallel, …) +- !translation — Translation !help — Main menu"#; @@ -229,7 +190,7 @@ const TRANSCRIPTION_UNAVAILABLE_ES: &str = r#"La transcripción de voz no está El bot de transcripción aún no está emparejado con este grupo. Mientras tanto, prueba la traducción: -- !translation — Productos de traducción (en el chat, paralelo, …) +- !translation — Traducción !help — Menú principal"#; @@ -332,17 +293,29 @@ mod tests { } #[test] - fn translation_products_lists_modes() { - let h = translation_products_menu(MenuLanguage::En); - assert!(h.contains("!in-chat")); - assert!(h.contains("!parallel")); - assert!(h.contains("Language Threads")); + fn translation_menu_leads_with_language_threads() { + let h = translation_products_menu(MenuLanguage::En, true); + assert!(h.contains("Language Threads (recommended)")); + assert!(h.contains("!translate-me-on")); + assert!(h.contains("!translate-me-off")); + assert!(h.contains("!translate-on")); + assert!(h.contains("!translate ")); + assert!(!h.contains("!parallel")); + assert!(!h.contains("!in-chat")); + let lt = h.find("Language Threads").expect("lt"); + let in_chat = h.find("In-chat").expect("in-chat section"); + assert!( + lt < in_chat, + "Language Threads should appear before In-chat" + ); } #[test] - fn in_chat_menu_covers_auto_and_manual() { - let h = in_chat_menu(MenuLanguage::En, true); - assert!(h.contains("!translate-on")); + fn translation_menu_auto_disabled_hides_translate_on() { + let h = translation_products_menu(MenuLanguage::En, false); + assert!(h.contains("!translate-me-on")); + assert!(h.contains("Auto-translate is disabled")); + assert!(!h.contains("!translate-on ")); assert!(h.contains("!translate ")); } @@ -350,8 +323,8 @@ mod tests { fn exact_command_does_not_match_prefixed() { assert!(is_exact_command("!translation", "!translation")); assert!(!is_exact_command("!translation-on es en", "!translation")); - assert!(is_exact_command("!parallel", "!parallel")); - assert!(!is_exact_command("!parallel-on en es", "!parallel")); + assert!(is_exact_command("!in-chat", "!in-chat")); + assert!(!is_exact_command("!in-chat-extra", "!in-chat")); } #[test] diff --git a/crates/signal-bot/src/commands/mod.rs b/crates/signal-bot/src/commands/mod.rs index f891ae9..0630dc1 100644 --- a/crates/signal-bot/src/commands/mod.rs +++ b/crates/signal-bot/src/commands/mod.rs @@ -11,7 +11,6 @@ mod translate_all; pub mod translate_lang; mod translate_langs; mod translate_me; -mod translate_parallel; mod translate_service; mod verify; @@ -19,7 +18,7 @@ pub use help::HelpHandler; pub use models::ModelsHandler; pub use privacy::PrivacyHandler; pub use product_menus::{ - InChatMenuHandler, ParallelMenuHandler, TranscriptionMenuHandler, TranscriptionPairingHandler, + InChatMenuHandler, TranscriptionMenuHandler, TranscriptionPairingHandler, TranslationMenuHandler, }; pub use set_language::SetLanguageHandler; @@ -28,5 +27,4 @@ pub use translate::TranslateHandler; pub use translate_all::TranslateAllHandler; pub use translate_langs::TranslateLangsHandler; pub use translate_me::TranslateMeHandler; -pub use translate_parallel::TranslateParallelHandler; pub use verify::VerifyHandler; diff --git a/crates/signal-bot/src/commands/product_menus.rs b/crates/signal-bot/src/commands/product_menus.rs index 21234c5..afd3288 100644 --- a/crates/signal-bot/src/commands/product_menus.rs +++ b/crates/signal-bot/src/commands/product_menus.rs @@ -1,9 +1,8 @@ -//! Nested product menus: `!translation`, `!transcription`, `!in-chat`, `!parallel`. +//! Product menus: flat `!translation`, `!transcription`, `!in-chat` (redirects to translation). use crate::commands::menu_locale::{ - help_menu, in_chat_menu, is_exact_command, menu_language_for_message, parallel_menu, - transcription_group_only, transcription_invited, transcription_unavailable, - translation_products_menu, + help_menu, is_exact_command, menu_language_for_message, transcription_group_only, + transcription_invited, transcription_unavailable, translation_products_menu, }; use crate::commands::CommandHandler; use crate::config::BotRole; @@ -16,11 +15,15 @@ use tracing::warn; pub struct TranslationMenuHandler { group_prefs: Arc, + translate_all_enabled: bool, } impl TranslationMenuHandler { - pub fn new(group_prefs: Arc) -> Self { - Self { group_prefs } + pub fn new(group_prefs: Arc, translate_all_enabled: bool) -> Self { + Self { + group_prefs, + translate_all_enabled, + } } } @@ -36,7 +39,7 @@ impl CommandHandler for TranslationMenuHandler { async fn execute(&self, message: &BotMessage) -> AppResult { let language = menu_language_for_message(message, &self.group_prefs); - Ok(translation_products_menu(language).into()) + Ok(translation_products_menu(language, self.translate_all_enabled).into()) } } @@ -226,34 +229,9 @@ impl CommandHandler for InChatMenuHandler { } async fn execute(&self, message: &BotMessage) -> AppResult { + // Muscle-memory alias: same flat Translation menu as !translation. let language = menu_language_for_message(message, &self.group_prefs); - Ok(in_chat_menu(language, self.translate_all_enabled).into()) - } -} - -pub struct ParallelMenuHandler { - group_prefs: Arc, -} - -impl ParallelMenuHandler { - pub fn new(group_prefs: Arc) -> Self { - Self { group_prefs } - } -} - -#[async_trait] -impl CommandHandler for ParallelMenuHandler { - fn matches(&self, message: &BotMessage) -> bool { - is_exact_command(&message.text, "!parallel") - } - - fn label(&self) -> &'static str { - "parallel_menu" - } - - async fn execute(&self, message: &BotMessage) -> AppResult { - let language = menu_language_for_message(message, &self.group_prefs); - Ok(parallel_menu(language).into()) + Ok(translation_products_menu(language, self.translate_all_enabled).into()) } } @@ -284,14 +262,10 @@ mod tests { #[test] fn menus_match_exact_only() { let store = GroupPreferencesStore::new_in_memory(0); - let t = TranslationMenuHandler::new(store.clone()); + let t = TranslationMenuHandler::new(store.clone(), true); assert!(t.matches(&msg("!translation"))); assert!(!t.matches(&msg("!translation-on es en"))); - let p = ParallelMenuHandler::new(store.clone()); - assert!(p.matches(&msg("!parallel"))); - assert!(!p.matches(&msg("!parallel-on en es"))); - let i = InChatMenuHandler::new(store.clone(), true); assert!(i.matches(&msg("!in-chat"))); @@ -306,6 +280,18 @@ mod tests { assert!(m.matches(&msg("!transcription"))); } + #[tokio::test] + async fn in_chat_redirects_to_flat_translation_menu() { + let store = GroupPreferencesStore::new_in_memory(0); + let translation = TranslationMenuHandler::new(store.clone(), true); + let in_chat = InChatMenuHandler::new(store, true); + let via_translation = translation.execute(&msg("!translation")).await.unwrap(); + let via_in_chat = in_chat.execute(&msg("!in-chat")).await.unwrap(); + assert_eq!(via_translation, via_in_chat); + assert!(via_translation.contains("Language Threads (recommended)")); + assert!(via_translation.contains("!translate-me-on")); + } + #[tokio::test] async fn pairing_without_peer_reports_unavailable() { let signal_mock = MockServer::start().await; diff --git a/crates/signal-bot/src/commands/translate_all.rs b/crates/signal-bot/src/commands/translate_all.rs index 1351b2c..87c9241 100644 --- a/crates/signal-bot/src/commands/translate_all.rs +++ b/crates/signal-bot/src/commands/translate_all.rs @@ -124,13 +124,6 @@ impl TranslateAllHandler { return Ok("Choose two different languages. Example: !translate-on es en".into()); } - if self.store.has_parallel(group_id) { - return Ok( - "Parallel Translation is active here. Run !parallel-off before enabling in-chat auto-translate." - .into(), - ); - } - let mode = GroupTranslateMode::new(lang_a, lang_b); let pair_label = mode.display_pair(); self.store.set(group_id.to_string(), mode); @@ -329,63 +322,6 @@ mod tests { assert!(!handler.matches(&msg)); } - #[tokio::test] - async fn setup_rejects_when_parallel_active() { - use crate::group_preferences_store::ParallelBridge; - use serde_json::json; - use wiremock::matchers::{method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - let signal = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v2/send")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) - .mount(&signal) - .await; - - let store = GroupPreferencesStore::new_in_memory(30); - store.set_parallel( - "group.main", - ParallelBridge { - main_lang: "en".into(), - parallel_lang: "es".into(), - parallel_send_id: "group.p".into(), - parallel_internal_id: "p-int".into(), - members: Default::default(), - }, - ); - let handler = TranslateAllHandler::new( - store, - Arc::new( - NearAiClient::new( - "key", - "http://127.0.0.1:9", - "model", - std::time::Duration::from_secs(2), - ) - .unwrap(), - ), - Arc::new(SignalClient::new(signal.uri()).unwrap()), - ); - - let msg = BotMessage { - source: "+1".into(), - source_number: Some("+1".into()), - source_name: None, - text: "!translate-on en es".into(), - timestamp: 1, - message_timestamp: 1, - is_group: true, - group_id: Some("group.main".into()), - group_name: None, - receiving_account: "+2".into(), - attachments: vec![], - quote: None, - }; - assert!(handler.execute(&msg).await.unwrap().is_empty()); - assert!(!handler.store.is_active("group.main")); - } - #[tokio::test] async fn execute_setup_commands_send_replies() { use serde_json::json; diff --git a/crates/signal-bot/src/commands/translate_me.rs b/crates/signal-bot/src/commands/translate_me.rs index 6902adc..e927b36 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. +//! Language Threads: `!translate-me-on` / `!translate-me-off` + relay engine. //! -//! Main group stays bilingual. Each subscribed language gets a `Language Thread {Language}` +//! Main group stays multilingual. 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. @@ -470,6 +470,10 @@ impl CommandHandler for TranslateMeHandler { true } + fn label(&self) -> &'static str { + "translate_me" + } + async fn execute(&self, message: &BotMessage) -> AppResult { if Self::is_command(&message.text) { let reply = self.handle_command(message).await?; @@ -749,28 +753,10 @@ mod tests { #[tokio::test] async fn relay_main_to_sidecar_and_sidecar_to_main() { - use serde_json::json; - use wiremock::matchers::{method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - let signal = MockServer::start().await; - let near = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v2/send")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) - .mount(&signal) - .await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "1", - "choices": [{"index": 0, "message": {"role": "assistant", "content": "Hola"}, "finish_reason": "stop"}], - "created": 1, - "model": "m", - "object": "chat.completion" - }))) - .mount(&near) - .await; + let signal = wiremock::MockServer::start().await; + let near = wiremock::MockServer::start().await; + mount_relay_signal(&signal).await; + mount_near(&near).await; let store = GroupPreferencesStore::new_in_memory(0); store.set_sidecar( @@ -788,18 +774,225 @@ mod tests { let handler = handler_pair(store, signal.uri(), near.uri()); - // Main → sidecars (English text translates into ES/FR). let main_msg = group_msg("+15550002222", "Hello friends from the mutual aid group"); assert!(handler.matches(&main_msg)); assert!(handler.execute(&main_msg).await.unwrap().is_empty()); - // Sidecar → main + other sidecars. - let mut side = group_msg("+15550002222", "Bonjour"); + let mut side = group_msg("+15550002222", "Bonjour amis du groupe d entraide"); side.group_id = Some("fr-internal".into()); assert!(handler.matches(&side)); assert!(handler.execute(&side).await.unwrap().is_empty()); } + #[tokio::test] + async fn relay_main_to_three_sidecars() { + let signal = wiremock::MockServer::start().await; + let near = wiremock::MockServer::start().await; + mount_relay_signal(&signal).await; + mount_near(&near).await; + + let store = GroupPreferencesStore::new_in_memory(0); + for (lang, send, internal) in [ + ("es", "group.es", "es-internal"), + ("fr", "group.fr", "fr-internal"), + ("hi", "group.hi", "hi-internal"), + ] { + store.set_sidecar("main-internal", lang, send.into(), internal.into()); + } + + let handler = handler_pair(store, signal.uri(), near.uri()); + let main_msg = group_msg( + "+15550002222", + "Hello friends from the mutual aid group tonight", + ); + assert!(handler.execute(&main_msg).await.unwrap().is_empty()); + + let recipients = send_recipients(&signal).await; + assert_eq!(recipients.len(), 3); + assert!(recipients.contains(&"group.es".to_string())); + assert!(recipients.contains(&"group.fr".to_string())); + assert!(recipients.contains(&"group.hi".to_string())); + } + + #[tokio::test] + async fn relay_sidecar_skips_source_and_fans_out() { + let signal = wiremock::MockServer::start().await; + let near = wiremock::MockServer::start().await; + mount_relay_signal(&signal).await; + mount_near(&near).await; + + let store = GroupPreferencesStore::new_in_memory(0); + for (lang, send, internal) in [ + ("es", "group.es", "es-internal"), + ("fr", "group.fr", "fr-internal"), + ("hi", "group.hi", "hi-internal"), + ] { + store.set_sidecar("main-internal", lang, send.into(), internal.into()); + } + + let handler = handler_pair(store, signal.uri(), near.uri()); + let mut side = group_msg("+15550002222", "Bonjour amis du groupe d entraide ensemble"); + side.group_id = Some("fr-internal".into()); + assert!(handler.execute(&side).await.unwrap().is_empty()); + + let recipients = send_recipients(&signal).await; + assert!(recipients.contains(&"group.main".to_string())); + assert!(recipients.contains(&"group.es".to_string())); + assert!(recipients.contains(&"group.hi".to_string())); + assert!(!recipients.contains(&"group.fr".to_string())); + assert_eq!(recipients.len(), 3); + } + + #[tokio::test] + async fn relay_bot_authored_zero_sends() { + let signal = wiremock::MockServer::start().await; + mount_relay_signal(&signal).await; + + 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"); + let handler = TranslateMeHandler::new( + store, + Arc::new( + NearAiClient::new( + "key", + "http://127.0.0.1:9", + "m", + std::time::Duration::from_secs(2), + ) + .unwrap(), + ), + Arc::new(SignalClient::new(signal.uri()).unwrap()), + identity, + ); + + let bot_msg = group_msg("+15550001111", "Maria:\nHola"); + assert!(!handler.matches(&bot_msg)); + assert!(handler.execute(&bot_msg).await.unwrap().is_empty()); + assert!(send_recipients(&signal).await.is_empty()); + } + + #[tokio::test] + async fn relay_n1_then_second_sidecar() { + let signal = wiremock::MockServer::start().await; + let near = wiremock::MockServer::start().await; + mount_relay_signal(&signal).await; + mount_near(&near).await; + + let store = GroupPreferencesStore::new_in_memory(0); + store.set_sidecar( + "main-internal", + "es", + "group.es".into(), + "es-internal".into(), + ); + let handler = handler_pair(store.clone(), signal.uri(), near.uri()); + + let main_msg = group_msg("+15550002222", "Hello friends from the mutual aid group"); + assert!(handler.execute(&main_msg).await.unwrap().is_empty()); + let first = send_recipients(&signal).await; + assert_eq!(first, vec!["group.es".to_string()]); + + store.set_sidecar( + "main-internal", + "fr", + "group.fr".into(), + "fr-internal".into(), + ); + // Reset received requests by starting a fresh mock is hard; count delta instead. + let before = send_recipients(&signal).await.len(); + assert!(handler.execute(&main_msg).await.unwrap().is_empty()); + let after = send_recipients(&signal).await; + let new_sends = &after[before..]; + assert_eq!(new_sends.len(), 2); + assert!(new_sends.contains(&"group.es".to_string())); + assert!(new_sends.contains(&"group.fr".to_string())); + } + + async fn mount_relay_signal(signal: &wiremock::MockServer) { + use serde_json::json; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, ResponseTemplate}; + + Mock::given(method("POST")) + .and(path("/v2/send")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .mount(signal) + .await; + Mock::given(method("GET")) + .and(path("/v1/groups/%2B15550001111")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + { + "name": "Main", + "id": "group.main", + "internal_id": "main-internal" + }, + { + "name": "ES", + "id": "group.es", + "internal_id": "es-internal" + }, + { + "name": "FR", + "id": "group.fr", + "internal_id": "fr-internal" + }, + { + "name": "HI", + "id": "group.hi", + "internal_id": "hi-internal" + } + ]))) + .mount(signal) + .await; + } + + async fn mount_near(near: &wiremock::MockServer) { + use serde_json::json; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, ResponseTemplate}; + + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "1", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "Translated"}, "finish_reason": "stop"}], + "created": 1, + "model": "m", + "object": "chat.completion" + }))) + .mount(near) + .await; + } + + async fn send_recipients(signal: &wiremock::MockServer) -> Vec { + let mut out = Vec::new(); + let Some(requests) = signal.received_requests().await else { + return out; + }; + for req in requests { + if req.url.path() != "/v2/send" { + continue; + } + let body: serde_json::Value = + serde_json::from_slice(&req.body).unwrap_or(serde_json::json!({})); + if let Some(arr) = body["recipients"].as_array() { + for r in arr { + if let Some(s) = r.as_str() { + out.push(s.to_string()); + } + } + } + } + out + } + #[tokio::test] async fn on_rejects_unknown_lang_and_missing_address() { use serde_json::json; diff --git a/crates/signal-bot/src/commands/translate_parallel.rs b/crates/signal-bot/src/commands/translate_parallel.rs deleted file mode 100644 index 4dd979c..0000000 --- a/crates/signal-bot/src/commands/translate_parallel.rs +++ /dev/null @@ -1,786 +0,0 @@ -//! Parallel Translation: monolingual main + one parallel Signal group, bidirectional relay. - -use crate::bot_identity::BotIdentity; -use crate::commands::translate_lang::resolve_language; -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, ParallelBridge}; -use async_trait::async_trait; -use near_ai_client::NearAiClient; -use signal_client::{BotMessage, SignalClient}; -use std::sync::Arc; -use tracing::{debug, info, instrument, warn}; - -const GROUP_ONLY: &str = "Parallel commands are only available in group chats."; -const USAGE: &str = "Usage: !parallel-on \n\ -Example: !parallel-on en es\n\ -lang1 = THIS chat; the bot creates a parallel group for lang2.\n\ -Then each person runs !parallel-join to be added."; - -pub struct TranslateParallelHandler { - store: Arc, - near_ai: Arc, - signal: Arc, - bot_identity: Arc, -} - -impl TranslateParallelHandler { - pub fn new( - store: Arc, - near_ai: Arc, - signal: Arc, - bot_identity: Arc, - ) -> Self { - Self { - store, - near_ai, - signal, - bot_identity, - } - } - - fn starts_with_word(text: &str, prefix: &str) -> bool { - text.strip_prefix(prefix) - .is_some_and(|rest| rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\n')) - } - - fn is_on(text: &str) -> bool { - Self::starts_with_word(text, "!parallel-on") - } - - fn is_off(text: &str) -> bool { - text == "!parallel-off" - } - - fn is_join(text: &str) -> bool { - text == "!parallel-join" - } - - fn is_leave(text: &str) -> bool { - text == "!parallel-leave" - } - - fn parse_lang_pair(text: &str) -> Option<(&str, &str)> { - let rest = text.trim().strip_prefix("!parallel-on")?.trim(); - let mut parts = rest.split_whitespace(); - let a = parts.next()?; - let b = parts.next()?; - if parts.next().is_some() { - return None; - } - Some((a, b)) - } - - 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_parallel(gid).is_some() || self.store.lookup_parallel(gid).is_some() - } - - async fn handle_on(&self, message: &BotMessage) -> AppResult { - let Some(main_id) = message.group_id.as_deref() else { - return Ok(GROUP_ONLY.into()); - }; - - if self.store.lookup_parallel(main_id).is_some() { - return Ok("Run !parallel-on in the main group (not inside the parallel chat).".into()); - } - - if self.store.is_active(main_id) { - return Ok( - "In-chat auto-translate is active here. Run !translate-off before enabling Parallel." - .into(), - ); - } - - if self.store.has_parallel(main_id) { - let bridge = self.store.get_parallel(main_id).unwrap(); - return Ok(format!( - "Parallel is already on: this chat = {}, parallel = {}. \ -Use !parallel-join to join the parallel group, or !parallel-off to stop.", - bridge.main_lang, bridge.parallel_lang - )); - } - - let Some((token_a, token_b)) = Self::parse_lang_pair(message.text.trim()) else { - return Ok(USAGE.into()); - }; - - let Some(lang_main) = resolve_language(token_a) else { - return Ok(format!( - "Unknown language `{token_a}`. Try !list-langs for supported codes." - )); - }; - let Some(lang_parallel) = resolve_language(token_b) else { - return Ok(format!( - "Unknown language `{token_b}`. Try !list-langs for supported codes." - )); - }; - - if lang_main.code == lang_parallel.code { - return Ok("Choose two different languages. Example: !parallel-on en es".into()); - } - - let Some(address) = message.invite_address() else { - return Ok( - "Could not determine your Signal address. Message the bot in a DM once, then retry." - .into(), - ); - }; - - let bot = &message.receiving_account; - let name = format!("Parallel {}", lang_parallel.name); - let description = format!( - "Parallel {} lane bridged to the main group ({}).", - lang_parallel.name, lang_main.name - ); - - let group = match self - .signal - .create_group(bot, &name, vec![address.clone()], Some(&description)) - .await - { - Ok(g) => g, - Err(e) => { - return Ok(format!( - "Could not create the parallel group: {e}. Try again shortly." - )); - } - }; - - let bridge = ParallelBridge { - main_lang: lang_main.code.to_string(), - parallel_lang: lang_parallel.code.to_string(), - parallel_send_id: group.id.clone(), - parallel_internal_id: group.internal_id.clone(), - members: [(message.source.clone(), address)].into_iter().collect(), - }; - self.store.set_parallel(main_id, bridge); - - let welcome = format!( - "Welcome to Parallel {}. Messages here are translated to/from the main group ({}).", - lang_parallel.name, lang_main.name - ); - if let Err(e) = self.signal.send(bot, &group.id, &welcome).await { - warn!(error = %e, "Failed to send parallel welcome"); - } - - info!( - main_id, - main_lang = lang_main.code, - parallel_lang = lang_parallel.code, - "parallel-on: created parallel bridge" - ); - - Ok(format!( - "Parallel enabled: this chat = {} {}, parallel = {} {}.\n\ -Accept the Signal invite to \"{}\" if prompted.\n\ -Others in this group: run !parallel-join to be added to the parallel chat.\n\ -!parallel-off stops Parallel for the group.", - lang_main.flag, lang_main.name, lang_parallel.flag, lang_parallel.name, name - )) - } - - async fn handle_join(&self, message: &BotMessage) -> AppResult { - let Some(gid) = message.group_id.as_deref() else { - return Ok(GROUP_ONLY.into()); - }; - - let main_id = if self.store.lookup_parallel(gid).is_some() { - return Ok( - "You are already in the parallel chat. Use !parallel-leave to leave, \ -or run !parallel-join from the main group to re-invite." - .into(), - ); - } else if self.store.has_parallel(gid) { - gid.to_string() - } else { - return Ok( - "Parallel is not set up here. An organizer should run !parallel-on first." - .into(), - ); - }; - - let Some(bridge) = self.store.get_parallel(&main_id) else { - return Ok("Parallel is not active.".into()); - }; - - if bridge.is_member(&message.source) { - return Ok( - "You are already on the parallel group roster. Accept the Signal invite if pending." - .into(), - ); - } - - let Some(address) = message.invite_address() else { - return Ok( - "Could not determine your Signal address. Message the bot in a DM once, then retry." - .into(), - ); - }; - - if let Err(e) = self - .signal - .add_members( - &message.receiving_account, - &bridge.parallel_send_id, - vec![address.clone()], - ) - .await - { - return Ok(format!("Could not add you to the parallel group: {e}")); - } - - self.store - .add_parallel_member(&main_id, &message.source, address); - - let lang_name = resolve_language(&bridge.parallel_lang) - .map(|l| l.name) - .unwrap_or(bridge.parallel_lang.as_str()); - Ok(format!( - "Joined Parallel {lang_name}. Accept the Signal group invite if prompted." - )) - } - - async fn handle_leave(&self, message: &BotMessage) -> AppResult { - let Some(gid) = message.group_id.as_deref() else { - return Ok(GROUP_ONLY.into()); - }; - - let main_id = if let Some(main) = self.store.lookup_parallel(gid) { - main - } else if self.store.has_parallel(gid) { - gid.to_string() - } else { - return Ok("Parallel is not active in this chat.".into()); - }; - - let Some(bridge) = self.store.get_parallel(&main_id) else { - return Ok("Parallel is not active.".into()); - }; - - let address = self - .store - .remove_parallel_member(&main_id, &message.source) - .or_else(|| message.invite_address()) - .unwrap_or_else(|| message.source.clone()); - - if let Err(e) = self - .signal - .remove_members( - &message.receiving_account, - &bridge.parallel_send_id, - vec![address], - ) - .await - { - warn!(error = %e, "Failed to remove member from parallel group"); - } - - Ok("Left the parallel group.".into()) - } - - async fn handle_off(&self, message: &BotMessage) -> AppResult { - let Some(gid) = message.group_id.as_deref() else { - return Ok(GROUP_ONLY.into()); - }; - - if self.store.lookup_parallel(gid).is_some() { - return Ok("Run !parallel-off in the main group to stop Parallel.".into()); - } - - if self.store.clear_parallel(gid) { - info!(main_id = gid, "parallel-off: cleared bridge"); - Ok("Parallel disabled for this group. Existing parallel Signal group is left as-is; you can leave it manually.".into()) - } else { - Ok("Parallel was not active in this chat.".into()) - } - } - - #[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 parallel relay"); - return Ok(()); - } - - let Some(gid) = message.group_id.as_deref() else { - return Ok(()); - }; - - if let Some(main_id) = self.store.lookup_parallel(gid) { - if !self.store.allow_message(&main_id) { - warn!(main_id, "Rate limit: skipping parallel→main"); - return Ok(()); - } - return self.relay_parallel_to_main(message, &main_id).await; - } - - if let Some(bridge) = self.store.get_parallel(gid) { - if !self.store.allow_message(gid) { - warn!(main_id = gid, "Rate limit: skipping main→parallel"); - return Ok(()); - } - return self.relay_main_to_parallel(message, &bridge).await; - } - - Ok(()) - } - - async fn relay_main_to_parallel( - &self, - message: &BotMessage, - bridge: &ParallelBridge, - ) -> AppResult<()> { - let Some(target) = resolve_language(&bridge.parallel_lang) else { - return Ok(()); - }; - let detected = detect_text_language(&message.text); - let body = if detected.as_deref() == Some(bridge.parallel_lang.as_str()) { - message.text.clone() - } else { - match near_ai_translate(&self.near_ai, &message.text, target).await { - Ok(t) => t, - Err(e) => { - warn!("parallel main→lane translation failed: {e}"); - return Ok(()); - } - } - }; - let attributed = format_attribution(&message.display_name(), &body); - if let Err(e) = self - .signal - .send( - &message.receiving_account, - &bridge.parallel_send_id, - &attributed, - ) - .await - { - warn!(error = %e, "Failed to post to parallel group"); - } - Ok(()) - } - - async fn relay_parallel_to_main(&self, message: &BotMessage, main_id: &str) -> AppResult<()> { - let Some(bridge) = self.store.get_parallel(main_id) else { - return Ok(()); - }; - let Some(target) = resolve_language(&bridge.main_lang) else { - return Ok(()); - }; - let detected = detect_text_language(&message.text); - let body = if detected.as_deref() == Some(bridge.main_lang.as_str()) { - message.text.clone() - } else { - match near_ai_translate(&self.near_ai, &message.text, target).await { - Ok(t) => t, - Err(e) => { - warn!("parallel lane→main translation failed: {e}"); - return Ok(()); - } - } - }; - let attributed = format_attribution(&message.display_name(), &body); - let bot = &message.receiving_account; - let main_recipient = match self - .signal - .resolve_group_send_id_for_account(bot, main_id) - .await - { - Ok(id) => id, - Err(e) => { - warn!(error = %e, main_id, "Could not resolve main send id for parallel relay"); - return Ok(()); - } - }; - if let Err(e) = self.signal.send(bot, &main_recipient, &attributed).await { - warn!(error = %e, "Failed to post to main group from parallel"); - } - Ok(()) - } - - async fn handle_command(&self, message: &BotMessage) -> AppResult { - let text = message.text.trim(); - if Self::is_off(text) { - self.handle_off(message).await - } else if Self::is_join(text) { - self.handle_join(message).await - } else if Self::is_leave(text) { - self.handle_leave(message).await - } else if Self::is_on(text) { - self.handle_on(message).await - } else { - Ok(USAGE.into()) - } - } -} - -fn format_attribution(display_name: &str, body: &str) -> String { - format!("{display_name}:\n{body}") -} - -#[async_trait] -impl CommandHandler for TranslateParallelHandler { - fn label(&self) -> &'static str { - "translate_parallel" - } - - fn matches(&self, message: &BotMessage) -> bool { - let text = message.text.trim(); - Self::is_on(text) - || Self::is_off(text) - || Self::is_join(text) - || Self::is_leave(text) - || self.is_relay_candidate(message) - } - - fn handles_own_reply(&self) -> bool { - true - } - - #[instrument(skip(self, message), fields(source = %message.source))] - async fn execute(&self, message: &BotMessage) -> AppResult { - let text = message.text.trim(); - if text.starts_with('!') { - let reply = self.handle_command(message).await?; - self.signal.reply(message, &reply).await?; - Ok(String::new()) - } else { - self.handle_relay(message).await?; - Ok(String::new()) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::commands::translate_lang::resolve_language; - use crate::group_preferences_store::GroupTranslateMode; - use serde_json::json; - use wiremock::matchers::{method, path, path_regex}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - fn group_msg(source: &str, text: &str) -> BotMessage { - BotMessage { - source: source.into(), - source_number: Some(source.into()), - source_name: Some("Ada".into()), - text: text.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, - } - } - - fn handler_pair( - store: Arc, - signal_uri: String, - near_uri: String, - ) -> TranslateParallelHandler { - TranslateParallelHandler::new( - store, - Arc::new( - NearAiClient::new("key", near_uri, "m", std::time::Duration::from_secs(5)).unwrap(), - ), - Arc::new(SignalClient::new(signal_uri).unwrap()), - BotIdentity::new(), - ) - } - - fn sample_bridge() -> ParallelBridge { - ParallelBridge { - main_lang: "en".into(), - parallel_lang: "es".into(), - parallel_send_id: "group.es".into(), - parallel_internal_id: "es-internal".into(), - members: [("+15550002222".into(), "+15550002222".into())] - .into_iter() - .collect(), - } - } - - async fn mount_signal_basics(signal: &MockServer) { - Mock::given(method("POST")) - .and(path("/v2/send")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) - .mount(signal) - .await; - Mock::given(method("POST")) - .and(path("/v1/groups/%2B15550001111")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": "group.es"}))) - .mount(signal) - .await; - Mock::given(method("GET")) - .and(path("/v1/groups/%2B15550001111")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([ - { - "name": "Main", - "id": "group.main", - "internal_id": "main-internal" - }, - { - "name": "Parallel Spanish", - "id": "group.es", - "internal_id": "es-internal" - } - ]))) - .mount(signal) - .await; - Mock::given(method("POST")) - .and(path_regex(r"^/v1/groups/%2B15550001111/.+/members$")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) - .mount(signal) - .await; - Mock::given(method("DELETE")) - .and(path_regex(r"^/v1/groups/%2B15550001111/.+/members$")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) - .mount(signal) - .await; - } - - #[test] - fn parse_lang_pair_ok() { - assert_eq!( - TranslateParallelHandler::parse_lang_pair("!parallel-on en es"), - Some(("en", "es")) - ); - assert!(TranslateParallelHandler::parse_lang_pair("!parallel-on en").is_none()); - assert!(TranslateParallelHandler::parse_lang_pair("!parallel-on en es fr").is_none()); - } - - #[test] - fn command_matchers() { - assert!(TranslateParallelHandler::is_on("!parallel-on en es")); - assert!(!TranslateParallelHandler::is_on("!parallel")); - assert!(TranslateParallelHandler::is_join("!parallel-join")); - assert!(TranslateParallelHandler::is_leave("!parallel-leave")); - assert!(TranslateParallelHandler::is_off("!parallel-off")); - } - - #[test] - fn attribution_format() { - assert_eq!(format_attribution("Ada", "hola"), "Ada:\nhola"); - } - - #[test] - fn matches_commands_and_relay_candidates() { - let store = GroupPreferencesStore::new_in_memory(0); - store.set_parallel("main-internal", sample_bridge()); - let handler = handler_pair( - store, - "http://127.0.0.1:9".into(), - "http://127.0.0.1:9".into(), - ); - - assert!(handler.matches(&group_msg("+1", "!parallel-on en es"))); - assert!(handler.matches(&group_msg("+1", "!parallel-join"))); - assert!(handler.matches(&group_msg("+1", "!parallel-leave"))); - assert!(handler.matches(&group_msg("+1", "!parallel-off"))); - - let relay = group_msg("+15550002222", "Hello from the main chat"); - assert!(handler.matches(&relay)); - - let mut cmd = relay.clone(); - cmd.text = "!help".into(); - assert!(!handler.matches(&cmd)); - - let mut dm = relay.clone(); - dm.is_group = false; - dm.group_id = None; - dm.text = "hello".into(); - assert!(!handler.matches(&dm)); - } - - #[tokio::test] - async fn setup_join_leave_off_happy_path() { - let signal = MockServer::start().await; - mount_signal_basics(&signal).await; - - let store = GroupPreferencesStore::new_in_memory(0); - let handler = handler_pair(store.clone(), signal.uri(), "http://127.0.0.1:9".into()); - - let on = group_msg("+15550002222", "!parallel-on en es"); - assert!(handler.execute(&on).await.unwrap().is_empty()); - assert!(store.has_parallel("main-internal")); - - // Already on. - assert!(handler.execute(&on).await.unwrap().is_empty()); - - let join = group_msg("+15550003333", "!parallel-join"); - assert!(handler.execute(&join).await.unwrap().is_empty()); - assert!(store - .get_parallel("main-internal") - .unwrap() - .is_member("+15550003333")); - - // Already a member. - assert!(handler.execute(&join).await.unwrap().is_empty()); - - let leave = group_msg("+15550003333", "!parallel-leave"); - assert!(handler.execute(&leave).await.unwrap().is_empty()); - - let off = group_msg("+15550002222", "!parallel-off"); - assert!(handler.execute(&off).await.unwrap().is_empty()); - assert!(!store.has_parallel("main-internal")); - } - - #[tokio::test] - async fn setup_rejects_invalid_and_conflicting_states() { - let signal = MockServer::start().await; - mount_signal_basics(&signal).await; - - let store = GroupPreferencesStore::new_in_memory(0); - let handler = handler_pair(store.clone(), signal.uri(), "http://127.0.0.1:9".into()); - - let dm = BotMessage { - source: "+15550002222".into(), - source_number: Some("+15550002222".into()), - source_name: None, - text: "!parallel-on en es".into(), - timestamp: 1, - message_timestamp: 1, - is_group: false, - group_id: None, - group_name: None, - receiving_account: "+15550001111".into(), - attachments: vec![], - quote: None, - }; - assert!(handler.execute(&dm).await.unwrap().is_empty()); - - let usage = group_msg("+15550002222", "!parallel-on"); - assert!(handler.execute(&usage).await.unwrap().is_empty()); - - let unknown = group_msg("+15550002222", "!parallel-on en zz"); - assert!(handler.execute(&unknown).await.unwrap().is_empty()); - - let same = group_msg("+15550002222", "!parallel-on en english"); - assert!(handler.execute(&same).await.unwrap().is_empty()); - - store.set( - "main-internal".into(), - GroupTranslateMode::new( - resolve_language("en").unwrap(), - resolve_language("es").unwrap(), - ), - ); - let blocked = group_msg("+15550002222", "!parallel-on en es"); - assert!(handler.execute(&blocked).await.unwrap().is_empty()); - store.clear("main-internal"); - - store.set_parallel("main-internal", sample_bridge()); - let mut from_parallel = group_msg("+15550002222", "!parallel-on en fr"); - from_parallel.group_id = Some("es-internal".into()); - assert!(handler.execute(&from_parallel).await.unwrap().is_empty()); - - from_parallel.text = "!parallel-join".into(); - assert!(handler.execute(&from_parallel).await.unwrap().is_empty()); - - from_parallel.text = "!parallel-off".into(); - assert!(handler.execute(&from_parallel).await.unwrap().is_empty()); - assert!(store.has_parallel("main-internal")); - } - - #[tokio::test] - async fn join_without_setup_and_leave_from_parallel() { - let signal = MockServer::start().await; - mount_signal_basics(&signal).await; - - let store = GroupPreferencesStore::new_in_memory(0); - let handler = handler_pair(store.clone(), signal.uri(), "http://127.0.0.1:9".into()); - - let join = group_msg("+15550003333", "!parallel-join"); - assert!(handler.execute(&join).await.unwrap().is_empty()); - - store.set_parallel("main-internal", sample_bridge()); - let mut leave = group_msg("+15550002222", "!parallel-leave"); - leave.group_id = Some("es-internal".into()); - assert!(handler.execute(&leave).await.unwrap().is_empty()); - assert!(!store - .get_parallel("main-internal") - .unwrap() - .is_member("+15550002222")); - } - - #[tokio::test] - async fn relay_main_to_parallel_and_back() { - let signal = MockServer::start().await; - let near = MockServer::start().await; - mount_signal_basics(&signal).await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "1", - "choices": [{"index": 0, "message": {"role": "assistant", "content": "Hola"}, "finish_reason": "stop"}], - "created": 1, - "model": "m", - "object": "chat.completion" - }))) - .mount(&near) - .await; - - let store = GroupPreferencesStore::new_in_memory(0); - store.set_parallel("main-internal", sample_bridge()); - let handler = handler_pair(store, signal.uri(), near.uri()); - - let main_msg = group_msg( - "+15550002222", - "Hello friends from the mutual aid meetup tonight", - ); - assert!(handler.matches(&main_msg)); - assert!(handler.execute(&main_msg).await.unwrap().is_empty()); - - let mut side = group_msg( - "+15550002222", - "Necesitamos más voluntarios para el evento de mañana", - ); - side.group_id = Some("es-internal".into()); - assert!(handler.matches(&side)); - assert!(handler.execute(&side).await.unwrap().is_empty()); - } - - #[tokio::test] - async fn relay_skips_bot_authored_messages() { - let signal = MockServer::start().await; - mount_signal_basics(&signal).await; - - let store = GroupPreferencesStore::new_in_memory(0); - store.set_parallel("main-internal", sample_bridge()); - let identity = BotIdentity::new(); - identity.remember_phone("+15550001111"); - let handler = TranslateParallelHandler::new( - store, - Arc::new( - NearAiClient::new( - "key", - "http://127.0.0.1:9", - "m", - std::time::Duration::from_secs(2), - ) - .unwrap(), - ), - Arc::new(SignalClient::new(signal.uri()).unwrap()), - identity, - ); - - let bot_msg = group_msg("+15550001111", "Ada:\nHola"); - assert!(handler.execute(&bot_msg).await.unwrap().is_empty()); - } -} diff --git a/crates/signal-bot/src/group_preferences_store.rs b/crates/signal-bot/src/group_preferences_store.rs index b5eb7ea..0472451 100644 --- a/crates/signal-bot/src/group_preferences_store.rs +++ b/crates/signal-bot/src/group_preferences_store.rs @@ -99,24 +99,6 @@ impl LanguageBridge { } } -/// 1↔1 Parallel Translation bridge (monolingual main + one parallel group). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ParallelBridge { - pub main_lang: String, - pub parallel_lang: String, - pub parallel_send_id: String, - pub parallel_internal_id: String, - /// user key → invite address - #[serde(default)] - pub members: HashMap, -} - -impl ParallelBridge { - pub fn is_member(&self, user: &str) -> bool { - self.members.contains_key(user) - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] struct GroupPreference { #[serde(default = "default_true")] @@ -128,8 +110,6 @@ struct GroupPreference { /// Mutual-aid language sidecar bridge (replaces legacy per-user translate map). #[serde(default)] language_bridge: Option, - #[serde(default)] - parallel_bridge: Option, } impl Default for GroupPreference { @@ -139,7 +119,6 @@ impl Default for GroupPreference { translate: None, menu_language: MenuLanguage::En, language_bridge: None, - parallel_bridge: None, } } } @@ -153,7 +132,6 @@ impl GroupPreference { .language_bridge .as_ref() .is_none_or(LanguageBridge::is_empty) - && self.parallel_bridge.is_none() } } @@ -168,8 +146,6 @@ pub struct GroupPreferencesStore { groups: RwLock>, /// sidecar internal_id → (main internal_id, lang code); rebuilt on load/mutate. sidecar_index: RwLock>, - /// parallel internal_id → main internal_id - parallel_index: RwLock>, rate_limits: RwLock>>, max_per_minute: u32, dstack: Option>, @@ -184,7 +160,6 @@ impl GroupPreferencesStore { Arc::new(Self { groups: RwLock::new(HashMap::new()), sidecar_index: RwLock::new(HashMap::new()), - parallel_index: RwLock::new(HashMap::new()), rate_limits: RwLock::new(HashMap::new()), max_per_minute, dstack: None, @@ -204,7 +179,6 @@ impl GroupPreferencesStore { let store = Arc::new(Self { groups: RwLock::new(HashMap::new()), sidecar_index: RwLock::new(HashMap::new()), - parallel_index: RwLock::new(HashMap::new()), rate_limits: RwLock::new(HashMap::new()), max_per_minute, dstack: if persist { Some(dstack) } else { None }, @@ -233,7 +207,6 @@ impl GroupPreferencesStore { let store = Arc::new(Self { groups: RwLock::new(HashMap::new()), sidecar_index: RwLock::new(HashMap::new()), - parallel_index: RwLock::new(HashMap::new()), rate_limits: RwLock::new(HashMap::new()), max_per_minute, dstack: Some(Arc::new(dstack)), @@ -257,16 +230,6 @@ impl GroupPreferencesStore { *self.sidecar_index.write().unwrap() = index; } - fn rebuild_parallel_index(&self) { - let mut index = HashMap::new(); - for (main_id, pref) in self.groups.read().unwrap().iter() { - if let Some(bridge) = &pref.parallel_bridge { - index.insert(bridge.parallel_internal_id.clone(), main_id.clone()); - } - } - *self.parallel_index.write().unwrap() = index; - } - // --- Transcription (per group) --- pub fn is_transcribe_enabled(&self, group_id: &str) -> bool { @@ -452,96 +415,6 @@ impl GroupPreferencesStore { removed } - // --- Parallel Translation (keyed by main group internal_id) --- - - pub fn get_parallel(&self, main_group_id: &str) -> Option { - self.groups - .read() - .unwrap() - .get(main_group_id) - .and_then(|p| p.parallel_bridge.clone()) - } - - pub fn has_parallel(&self, main_group_id: &str) -> bool { - self.get_parallel(main_group_id).is_some() - } - - /// Resolve parallel internal_id → main_id. - pub fn lookup_parallel(&self, parallel_internal_id: &str) -> Option { - self.parallel_index - .read() - .unwrap() - .get(parallel_internal_id) - .cloned() - } - - pub fn set_parallel(self: &Arc, main_group_id: &str, bridge: ParallelBridge) { - { - let mut groups = self.groups.write().unwrap(); - let entry = groups.entry(main_group_id.to_string()).or_default(); - entry.parallel_bridge = Some(bridge); - } - self.rebuild_parallel_index(); - self.schedule_persist(); - } - - pub fn clear_parallel(self: &Arc, main_group_id: &str) -> bool { - let had = { - let mut groups = self.groups.write().unwrap(); - let Some(entry) = groups.get_mut(main_group_id) else { - return false; - }; - let had = entry.parallel_bridge.is_some(); - entry.parallel_bridge = None; - if entry.is_default() { - groups.remove(main_group_id); - } - had - }; - self.rebuild_parallel_index(); - self.schedule_persist(); - had - } - - pub fn add_parallel_member( - self: &Arc, - main_group_id: &str, - user: &str, - address: String, - ) -> bool { - let ok = { - let mut groups = self.groups.write().unwrap(); - let Some(entry) = groups.get_mut(main_group_id) else { - return false; - }; - let Some(bridge) = entry.parallel_bridge.as_mut() else { - return false; - }; - bridge.members.insert(user.to_string(), address); - true - }; - if ok { - self.schedule_persist(); - } - ok - } - - /// Returns invite address if the user was a member. - pub fn remove_parallel_member( - self: &Arc, - main_group_id: &str, - user: &str, - ) -> Option { - let removed = { - let mut groups = self.groups.write().unwrap(); - let entry = groups.get_mut(main_group_id)?; - let bridge = entry.parallel_bridge.as_mut()?; - bridge.members.remove(user) - }; - self.schedule_persist(); - removed - } - /// Returns false when the group exceeded `max_per_minute` in the rolling window. pub fn allow_message(&self, group_id: &str) -> bool { if self.max_per_minute == 0 { @@ -719,7 +592,6 @@ impl GroupPreferencesStore { let count = snapshot.groups.len(); *self.groups.write().unwrap() = snapshot.groups; self.rebuild_sidecar_index(); - self.rebuild_parallel_index(); Ok(count) } @@ -849,33 +721,6 @@ mod tests { assert!(store.member_lang(main, "user-a").is_none()); } - #[test] - fn parallel_bridge_members_and_index() { - let store = GroupPreferencesStore::new_in_memory(0); - let main = "main-p"; - store.set_parallel( - main, - ParallelBridge { - main_lang: "en".into(), - parallel_lang: "es".into(), - parallel_send_id: "group.par".into(), - parallel_internal_id: "par-int".into(), - members: HashMap::new(), - }, - ); - assert!(store.has_parallel(main)); - assert_eq!(store.lookup_parallel("par-int").as_deref(), Some(main)); - assert!(store.add_parallel_member(main, "u1", "+1555".into())); - assert!(store.get_parallel(main).unwrap().is_member("u1")); - assert_eq!( - store.remove_parallel_member(main, "u1").as_deref(), - Some("+1555") - ); - assert!(store.clear_parallel(main)); - assert!(!store.has_parallel(main)); - assert!(store.lookup_parallel("par-int").is_none()); - } - #[tokio::test] async fn language_bridge_encrypted_round_trip() { let dir = tempdir().unwrap(); diff --git a/crates/signal-bot/src/handlers_setup.rs b/crates/signal-bot/src/handlers_setup.rs index 1e8fcc0..63ee154 100644 --- a/crates/signal-bot/src/handlers_setup.rs +++ b/crates/signal-bot/src/handlers_setup.rs @@ -130,14 +130,6 @@ pub async fn build_translation_handlers( let mut handlers: Vec> = Vec::new(); - handlers.push(Box::new(TranslateParallelHandler::new( - group_prefs.clone(), - near_ai.clone(), - signal.clone(), - bot_identity.clone(), - ))); - info!("Parallel Translation enabled: !parallel-on / !parallel-join"); - handlers.push(Box::new(TranslateMeHandler::new( group_prefs.clone(), near_ai.clone(), @@ -158,7 +150,10 @@ pub async fn build_translation_handlers( info!("In-chat translation enabled: !translate-on / !translate-off"); } - handlers.push(Box::new(TranslationMenuHandler::new(group_prefs.clone()))); + handlers.push(Box::new(TranslationMenuHandler::new( + group_prefs.clone(), + config.translate_all.enabled, + ))); handlers.push(Box::new(TranscriptionPairingHandler::new( group_prefs.clone(), signal.clone(), @@ -168,7 +163,6 @@ pub async fn build_translation_handlers( group_prefs.clone(), config.translate_all.enabled, ))); - handlers.push(Box::new(ParallelMenuHandler::new(group_prefs.clone()))); handlers.push(Box::new(TranslateHandler::new( near_ai.clone(), @@ -188,7 +182,7 @@ pub async fn build_translation_handlers( ))); handlers.push(Box::new(ModelsHandler::new(near_ai))); - info!("Translation role: hub menus + Parallel + in-chat + Language Threads"); + info!("Translation role: hub menus + in-chat + Language Threads"); Ok(handlers) } @@ -297,14 +291,15 @@ mod tests { .await .expect("translation handlers"); - assert_eq!(handlers.len(), 14); + assert_eq!(handlers.len(), 12); let got = labels(&handlers); - assert!(got.contains(&"translate_parallel")); + assert!(got.contains(&"translate_me")); assert!(got.contains(&"translate_all")); assert!(got.contains(&"translation_menu")); assert!(got.contains(&"transcription_pairing")); assert!(got.contains(&"in_chat_menu")); - assert!(got.contains(&"parallel_menu")); + assert!(!got.contains(&"translate_parallel")); + assert!(!got.contains(&"parallel_menu")); assert!(got.contains(&"translate")); assert!(got.contains(&"translate_langs")); assert!(got.contains(&"set_language")); @@ -338,9 +333,9 @@ mod tests { .await .expect("translation handlers"); - assert_eq!(handlers.len(), 13); + assert_eq!(handlers.len(), 11); assert!(!labels(&handlers).contains(&"translate_all")); - assert!(labels(&handlers).contains(&"translate_parallel")); + assert!(labels(&handlers).contains(&"translate_me")); assert!(labels(&handlers).contains(&"in_chat_menu")); } diff --git a/docker/phala.translation.yaml b/docker/phala.translation.yaml index 7b9e52b..a0ac229 100644 --- a/docker/phala.translation.yaml +++ b/docker/phala.translation.yaml @@ -1,5 +1,5 @@ # Phala Cloud TEE — translation CVM (4 GB / tdx.medium) -# Hosts in-chat translation + Parallel Translation + Language Threads. +# Hosts in-chat translation + Language Threads. # # Build & push (linux/amd64): # docker buildx build --platform linux/amd64 -t YOUR_DOCKERHUB/signal-bot-tee:latest -f docker/Dockerfile --push . diff --git a/docs/in-chat-translation.md b/docs/in-chat-translation.md index 4c21fd2..15cf43b 100644 --- a/docs/in-chat-translation.md +++ b/docs/in-chat-translation.md @@ -4,7 +4,7 @@ Status: **MVP implemented** on the translation bot. One **bilingual** Signal group (e.g. English + Spanish). The bot detects which side of the pair a message is on and quote-replies with the other language in the **same** main thread. -Distinct from [Parallel Translation](parallel-translation.md) (monolingual main + one parallel group) and [Language Threads](language-threads.md) (bilingual main + N sidecars). +Distinct from [Language Threads](language-threads.md) (multilingual main + N sidecars). In-chat stays in one Signal group; Language Threads creates sidecar groups. ## Setup @@ -23,7 +23,7 @@ Stop: !translate-off ``` -Menus: `!help` → `!translation` → `!in-chat` +Menus: `!help` → `!translation` (in-chat commands listed under Language Threads on the same screen). `!in-chat` still works as a redirect to that flat menu. ## Behavior @@ -38,10 +38,6 @@ Skip when language is undetected or not in the pair. Bot messages are never proc Voice notes: the **transcription** bot posts a transcript in-group; with auto-translate on, the **translation** bot then intercepts that text like any other message. -## Mutual exclusion - -Cannot enable in-chat auto-translate while Parallel (`!parallel-on`) is active, and vice versa. - ## Key code | Area | Path | @@ -50,4 +46,4 @@ Cannot enable in-chat auto-translate while Parallel (`!parallel-on`) is active, | Quote `!translate` | [`crates/signal-bot/src/commands/translate.rs`](../crates/signal-bot/src/commands/translate.rs) | | Detect / format helpers | [`crates/signal-bot/src/commands/translate_service.rs`](../crates/signal-bot/src/commands/translate_service.rs) | | Prefs (`GroupTranslateMode`) | [`crates/signal-bot/src/group_preferences_store.rs`](../crates/signal-bot/src/group_preferences_store.rs) | -| Menus | [`crates/signal-bot/src/commands/menu_locale.rs`](../crates/signal-bot/src/commands/menu_locale.rs) (`!in-chat`) | +| Menus | [`crates/signal-bot/src/commands/menu_locale.rs`](../crates/signal-bot/src/commands/menu_locale.rs) (flat `!translation`) | diff --git a/docs/language-threads.md b/docs/language-threads.md index 70e0902..5d0339d 100644 --- a/docs/language-threads.md +++ b/docs/language-threads.md @@ -1,30 +1,32 @@ -# Language Threads (mutual-aid sidecars) +# Language Threads 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. +The sole **cross-group** bridging product on the translation bot: one **multilingual main** Signal chat plus per-language **Language Thread** sidecar groups. Parallel Translation was retired — use this for N=1 or N sidecars with the same rules (no mode switch). ## Problem -In bilingual NYC mutual-aid groups, organizers often dual-post EN+ES by hand. Monolingual members miss context; bilinguals carry the translation load. +In multilingual mutual-aid groups, organizers often dual-post by hand. Monolingual members miss context; bilinguals carry the translation load. ## Solution | Room | Role | |------|------| -| **Main group** | Bilingual hub; bot already a member | +| **Main group** | Multilingual 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. +Users who want a monolingual lane run `!translate-me-on ` in **main**. The bot creates or joins the sidecar and invites them. Messages fan out across main and all active threads. ```text -Main (bilingual) +Main (multilingual hub) ├── Language Thread Spanish ← monolingual ES users ├── Language Thread English ← monolingual EN users └── … (any !list-langs code) ``` -## Commands (alpha surface) +N=1 (one sidecar) uses the same relay rules as N=3 — add another language later with no reconfiguration. + +## Commands | Command | Where | Effect | |---------|--------|--------| @@ -37,25 +39,29 @@ Main (bilingual) Aliases: `!translate-me on es`, `!translation-me-on es`, etc. -**Also on the translation bot** (separate products): [in-chat translation](in-chat-translation.md) (`!translate-on` / quote `!translate` via `!in-chat`); [Parallel Translation](parallel-translation.md) (`!parallel` / `!parallel-on`). Language Threads refactor is deferred — see product hub `!translation`. +**Also on the translation bot:** [in-chat translation](in-chat-translation.md) (`!translate-on` / quote `!translate`) — same-group only, not a sidecar bridge. Commands appear on the flat `!translation` menu (secondary to Language Threads). **Not registered:** `!ask`, DM chat, voice/`!transcribe*` (transcription CVM). -## Relay rules +Menus: `!help` → `!translation` (Language Threads commands first; in-chat below). `!in-chat` redirects to the same menu. + +## Relay rules (fan-out + BotIdentity) -Bot **never** processes its own messages (phone + learned UUID via `BotIdentity`). +Loop safety is **one-shot fan-out** from each human inbound (do not rely on main re-processing bot posts) plus **never relay the bot** (`BotIdentity`: phone + learned UUID). | 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 | +| Main → sidecars | Same detected language → **relay**; else **translate** via NEAR AI | +| Sidecar → main | **Relay only** (main stays multilingual; originals kept) | +| Sidecar → other sidecars | **Translate** to each other language (skip source lane; no-op when N=1) | | 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). +**Ops note:** Legacy Parallel Translation Signal groups (if any) are unmanaged after that product’s retirement. Leave them manually and use `!translate-me-on ` instead. + ## Subscribe / unsubscribe flow 1. User in main: `!translate-me-on es` @@ -83,6 +89,8 @@ In-memory reverse index: sidecar `internal_id` → `(main_id, lang)`. Local Docker without dstack may not persist prefs across restarts; Phala with dstack does. +Legacy encrypted prefs that still contain a `parallel_bridge` key are ignored on load and dropped on the next persist. + ## Key code | Area | Path | @@ -93,7 +101,7 @@ Local Docker without dstack may not persist prefs across restarts; Phala with ds | 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) | +| Handler registration | [`crates/signal-bot/src/handlers_setup.rs`](../crates/signal-bot/src/handlers_setup.rs) | | Phase 0 spike notes | [`docs/spikes/2026-07-21-sidecar-groups.md`](spikes/2026-07-21-sidecar-groups.md) | ## Local testing @@ -106,10 +114,22 @@ docker compose -f docker/compose.translation.yaml --env-file docker/translation. docker compose -f docker/compose.translation.yaml --env-file docker/translation.env up -d ``` -Only **signal-bot** on the translation stack needs rebuild for Language Threads changes. Smoke: main group → `!translate-me-on es` → accept invite → message in main appears in Language Thread (translated or relayed). +Only **signal-bot** on the translation stack needs rebuild for Language Threads changes. + +### Smoke checklist + +1. Main group → `!translate-me-on es` → accept invite → message in main appears in Language Thread (translated or relayed). +2. Add a second lang (`!translate-me-on en` or `fr`) from main — no reconfiguration; same bridge. +3. Message in a sidecar → appears raw on main + translated in other sidecars; **no echo** back into the source sidecar. +4. Bot-attributed posts are not re-relayed (no ping-pong). Whisper / voice live on the **transcription** stack — see [voice-transcription.md](voice-transcription.md) and [two-cvm-architecture.md](two-cvm-architecture.md). +## Interoperability + +- **Transcription** (other CVM) composes with Language Threads or in-chat in the same Signal groups (pairing via `!transcription` on the translation bot). +- **In-chat** translates inside one group thread; **Language Threads** bridges a multilingual main to N monolingual sidecars. + ## Phala / TEE (paused) - Deploy uses **Docker images** in compose, not a public git clone. diff --git a/docs/parallel-translation.md b/docs/parallel-translation.md deleted file mode 100644 index eb5a74e..0000000 --- a/docs/parallel-translation.md +++ /dev/null @@ -1,48 +0,0 @@ -# Parallel Translation - -Status: **MVP implemented** on the translation bot. - -One **monolingual** main Signal group plus one **parallel** Signal group. The bot translates both directions so each lane stays in its language. - -Distinct from [In-chat translation](in-chat-translation.md) (bilingual main, quote-reply in-thread) and [Language Threads](language-threads.md) (bilingual main + N sidecars). - -## Setup - -In the main group: - -```text -!parallel-on en es -``` - -- `lang1` = **this** chat (e.g. English) -- `lang2` = parallel group language (e.g. Spanish) -- Bot creates `Parallel Spanish` and adds the organizer - -Each other member in the main group: - -```text -!parallel-join -``` - -Leave / stop: - -```text -!parallel-leave # leave the parallel group -!parallel-off # in main: clear Parallel for the group -``` - -Menus: `!help` → `!translation` → `!parallel` - -## Relay - -| Direction | Behavior | -|-----------|----------| -| Main → parallel | Translate to parallel lang (skip NEAR if already that lang) | -| Parallel → main | Translate to main lang | -| Attribution | `{display_name}:\n{body}` | - -Bot messages are never relayed (`BotIdentity`). - -## Mutual exclusion - -Cannot enable Parallel while in-chat auto-translate (`!translate-on`) is active, and vice versa. diff --git a/docs/two-cvm-architecture.md b/docs/two-cvm-architecture.md index e9b584f..8ad0c36 100644 --- a/docs/two-cvm-architecture.md +++ b/docs/two-cvm-architecture.md @@ -69,9 +69,14 @@ Deploy each compose to its **own** CVM. Do not co-locate Whisper with the transl |---------|-----|-----| | Voice transcription | Transcription | [voice-transcription.md](voice-transcription.md) | | In-chat (group) translation | Translation | [in-chat-translation.md](in-chat-translation.md) | -| Parallel Translation (MVP) | Translation | [parallel-translation.md](parallel-translation.md) | | Language Threads | Translation | [language-threads.md](language-threads.md) | +### Interoperability + +- **Transcription** composes with either translation product in the same Signal group (pair via `!transcription` on the translation bot). +- **In-chat** translates inside one group thread (quote-reply). +- **Language Threads** bridges a multilingual main to N monolingual sidecars (`!translate-me-on`). + ## Why split Transcription (Whisper) is latency- and memory-heavy. Keeping it on a separate CVM prevents long voice jobs from queuing behind translation traffic for users who only subscribe to translation. diff --git a/docs/voice-transcription.md b/docs/voice-transcription.md index 2f95e13..699a31e 100644 --- a/docs/voice-transcription.md +++ b/docs/voice-transcription.md @@ -19,7 +19,7 @@ Both bots are members of the same Signal group (two phone numbers). 1. Human sends a voice note. 2. Transcription bot downloads the attachment, calls Whisper, quote-replies with text prefixed by `📝 Transcript:` (configurable via `WHISPER__REPLY_PREFIX`). -3. Translation bot treats that text like any other group message (in-chat auto-translate, Parallel, Language Threads). +3. Translation bot treats that text like any other group message (in-chat auto-translate, Language Threads). ## Pairing (translation leads)