diff --git a/.agents/docs/DEVELOPMENT.md b/.agents/docs/DEVELOPMENT.md index ae2232b..c2dce50 100644 --- a/.agents/docs/DEVELOPMENT.md +++ b/.agents/docs/DEVELOPMENT.md @@ -1,4 +1,4 @@ -# Sigstack Bot — Development Guide +# Bread Bot — Development Guide ## Product direction @@ -26,11 +26,13 @@ Signal E2E encryption terminates at Signal CLI. Each product CVM runs its own `s ### Two CVMs, Signal as bus -- Transcription CVM: Whisper + transcription bot (phone A) -- Translation CVM: translation bot (phone B) + NEAR AI for text +- Transcription CVM: Whisper + transcription bot (phone A) — **worker** (voice only; no hub) +- Translation CVM: translation bot (phone B) + NEAR AI — **hub** (menus, translation products, transcription pairing) - No cross-CVM Docker network. Integration = both bots in the same Signal group. - Whisper HTTP (`http://whisper-api:9000`) is **intra**-transcription-stack only. +Full hierarchy table: [`docs/two-cvm-architecture.md`](../../docs/two-cvm-architecture.md#bot-hierarchy). + ### What attestation proves / does not prove | Property | Verified by | @@ -45,8 +47,8 @@ Does **not** prove Signal CLI image integrity beyond pinning, or hide network me | Role | Handlers | Requires | |------|----------|----------| -| `transcription` | Voice, `!transcribe*`, help, privacy, verify | Whisper sidecar | -| `translation` | Language Threads, `!translate-on`, quote `!translate`, menus, verify | `NEAR_AI__API_KEY` | +| `transcription` | Voice, `!transcribe*`, `!transcription` menu, `!help-transcription`, `!verify` (worker — no hub `!help` / `!info` / `!privacy`) | Whisper sidecar | +| `translation` | Hub (`!help`, `!info`, `!privacy`, product menus), Language Threads, in-chat, quote `!translate`, `!transcription` pairing, `!verify` | `NEAR_AI__API_KEY` | Fail-fast if role is missing/invalid or required deps are missing. @@ -114,7 +116,7 @@ Health (transcription): Whisper `GET /health` on `:9000`, Signal CLI `GET /v1/he | `BOT__ROLE` | `transcription` \| `translation` | | `SIGNAL__SERVICE_URL` | Default `http://signal-api:8080` | | `SIGNAL__PHONE_NUMBER` | Ops phone for this CVM | -| `SIGNAL__PEER_PHONE` | Peer product bot (translation invites transcription) | +| `SIGNAL__PEER_PHONE` | Peer product bot. Translation: invites transcription. Transcription: must be translation phone for auto-join | | `NEAR_AI__*` | Translation role | | `WHISPER__*` | Transcription role | | `TRANSLATE_ALL__*` | In-chat translation | diff --git a/README.md b/README.md index 6701dac..6ff5204 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Sigstack Bot +# Bread Bot TEE-hosted Signal bots for **voice transcription** and **group translation**, designed as an interoperable product suite (see [issue #10](https://github.com/BreadchainCoop/sigstack-bot/issues/10)). @@ -14,8 +14,35 @@ Not a general AI chat assistant. Conversation history, tool-calling, and x402 cr 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. +**Bot hierarchy:** the **translation** bot is the Bread Bot **hub** (`!help`, `!info`, `!privacy`, translation products, transcription pairing). The **transcription** bot is a **specialized worker** (voice → text via `!transcription` / `!transcribe*` only). See [docs/two-cvm-architecture.md](docs/two-cvm-architecture.md#bot-hierarchy). + 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) +## Translation commands + +| Product | Command | Where | Effect | +|---------|---------|-------|--------| +| Hub | `!help` / `!info` | Translation bot only | Bread Bot hub menus | +| Hub | `!privacy` | Translation bot only | Privacy, TEE, and `!verify` (dual quotes in paired groups) | +| Hub | `!translation-threads` | Translation bot | Language Threads menu | +| Hub | `!translation-in-chat` | Translation bot | In-chat translation menu | +| Hub | `!help-threads` | Translation bot | How Language Threads works | +| Hub | `!help-in-chat` | Translation bot | How in-chat translation works | +| Hub | `!help-transcription` | Translation bot | How voice transcription works (guide; worker runs on transcription bot) | +| Transcription | `!transcription` | Transcription bot | Voice product menu | +| Language Threads | `!translate-me-thread ` | Main only | Create/join sidecar | +| Language Threads | `!leave` | Sidecar only | Leave this Language Thread | +| Language Threads | `!commands` | Sidecar only | Compact Language Thread command list | +| Language Threads | `!enable-in-chat` | Main | Tear down Language Threads (switch path to in-chat) | +| In-chat group-wide | `!translate-all-on ` | Group | Auto-translate all messages | +| In-chat group-wide | `!translate-all-off` | Group | Disable group-wide auto | +| In-chat personal | `!translate-me-on ` | Group (not sidecar) | Auto-translate this user’s messages only | +| In-chat personal | `!translate-me-off` | Group (not sidecar) | Clear this user’s personal auto | +| In-chat | `!enable-threads` | Group | Clear all in-chat auto (switch path to Language Threads) | +| In-chat manual | `!translate ` | Group | Quote-reply translate one message | + +Language Threads and in-chat auto are mutually exclusive. Details in the product docs above. + ## Architecture ``` @@ -93,7 +120,7 @@ Format: `type(scope): subject` — e.g. `feat: add whisper timeout`, `fix(docker | `SIGNAL__SERVICE_URL` | Signal CLI REST URL (default `http://signal-api:8080`) | | `NEAR_AI__API_KEY` | Required for translation role | | `WHISPER__ENABLED` / `WHISPER__SERVICE_URL` | Required for transcription role | -| `TRANSLATE_ALL__ENABLED` | In-chat `!translate-on` (translation role) | +| `TRANSLATE_ALL__ENABLED` | In-chat `!translate-all-on` / `!translate-me-on` (translation role) | See `.env.example` and the docker `*.env.example` files. diff --git a/crates/signal-bot-transcription/src/handlers.rs b/crates/signal-bot-transcription/src/handlers.rs index f60165b..7cbac2e 100644 --- a/crates/signal-bot-transcription/src/handlers.rs +++ b/crates/signal-bot-transcription/src/handlers.rs @@ -40,6 +40,7 @@ pub fn build_voice_handlers( reply_prefix, max_attachment_bytes, voice_cache, + transcribe_store.clone(), )), Box::new(TranscribeHandler::new(transcribe_store, true)), ] diff --git a/crates/signal-bot-transcription/src/manual_transcribe.rs b/crates/signal-bot-transcription/src/manual_transcribe.rs index e07c3ef..45240db 100644 --- a/crates/signal-bot-transcription/src/manual_transcribe.rs +++ b/crates/signal-bot-transcription/src/manual_transcribe.rs @@ -1,5 +1,6 @@ //! `!transcribe` — quote-reply manual voice transcription via Whisper. +use crate::transcribe_store::TranscribeStore; use crate::voice::VoiceHandler; use crate::voice_attachment_cache::VoiceAttachmentCache; use async_trait::async_trait; @@ -15,8 +16,11 @@ pub struct ManualTranscribeHandler { reply_prefix: String, max_attachment_bytes: usize, voice_cache: Arc, + transcribe_store: Arc, } +const AUTO_ALREADY_ON_MSG: &str = "Automatic transcription is already on. Voice notes are transcribed as they arrive — no need to !transcribe."; + impl ManualTranscribeHandler { pub fn new( whisper: Arc, @@ -24,6 +28,7 @@ impl ManualTranscribeHandler { reply_prefix: impl Into, max_attachment_bytes: usize, voice_cache: Arc, + transcribe_store: Arc, ) -> Self { Self { whisper, @@ -31,6 +36,7 @@ impl ManualTranscribeHandler { reply_prefix: reply_prefix.into(), max_attachment_bytes, voice_cache, + transcribe_store, } } @@ -130,6 +136,15 @@ impl CommandHandler for ManualTranscribeHandler { #[instrument(skip(self, message), fields(source = %message.source, is_group = message.is_group))] async fn execute(&self, message: &BotMessage) -> AppResult { + if self + .transcribe_store + .is_enabled(message.reply_target(), message.is_group) + { + self.send_reply(message, message.quote.as_ref(), AUTO_ALREADY_ON_MSG) + .await?; + return Ok(String::new()); + } + let quote = match &message.quote { Some(q) => q, None => { @@ -235,6 +250,10 @@ mod tests { assert_eq!(resolved.id, "cached-voice-id"); } + fn empty_store() -> Arc { + Arc::new(TranscribeStore::new(None)) + } + #[test] fn matches_bare_command_only() { let handler = ManualTranscribeHandler::new( @@ -245,6 +264,7 @@ mod tests { "📝 Transcript:", 5_000_000, VoiceAttachmentCache::new(10), + empty_store(), ); let mut msg = BotMessage { source: "+1".into(), @@ -290,6 +310,7 @@ mod tests { "📝 Transcript:", 5_000_000, VoiceAttachmentCache::new(10), + empty_store(), ); let msg = BotMessage { @@ -347,6 +368,59 @@ mod tests { "📝 Transcript:", 5_000_000, VoiceAttachmentCache::new(10), + empty_store(), + ); + + let msg = BotMessage { + source: "+15550002222".into(), + source_number: Some("+15550002222".into()), + source_name: None, + text: "!transcribe".into(), + timestamp: 2, + message_timestamp: 2, + is_group: false, + group_id: None, + group_name: None, + receiving_account: "+15550001111".into(), + attachments: vec![], + quote: Some(QuotedMessage { + id: 100, + author_number: Some("+15550003333".into()), + text: None, + audio_attachment: Some(sample_audio()), + }), + }; + let out = handler.execute(&msg).await.unwrap(); + assert!(out.is_empty()); + } + + #[tokio::test] + async fn execute_when_auto_on_replies_without_whisper() { + use serde_json::json; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let signal_mock = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v2/send")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .expect(1) + .mount(&signal_mock) + .await; + + let store = empty_store(); + store.set_enabled("+15550002222", true, false); + + let handler = ManualTranscribeHandler::new( + Arc::new( + WhisperClient::new("http://127.0.0.1:9", std::time::Duration::from_secs(2)) + .unwrap(), + ), + Arc::new(SignalClient::new(signal_mock.uri()).unwrap()), + "📝 Transcript:", + 5_000_000, + VoiceAttachmentCache::new(10), + store, ); let msg = BotMessage { diff --git a/crates/signal-bot-transcription/src/transcribe.rs b/crates/signal-bot-transcription/src/transcribe.rs index 2016544..af030b9 100644 --- a/crates/signal-bot-transcription/src/transcribe.rs +++ b/crates/signal-bot-transcription/src/transcribe.rs @@ -72,7 +72,7 @@ mod tests { .unwrap() .get(group_id) .copied() - .unwrap_or(true) + .unwrap_or(false) } fn set_transcribe_enabled(&self, group_id: &str, enabled: bool) { @@ -120,12 +120,12 @@ mod tests { assert_eq!( handler - .execute(&msg("!transcribe-off", false)) + .execute(&msg("!transcribe-on", false)) .await .unwrap(), - "Voice transcription disabled." + "Voice transcription enabled." ); - assert!(!store.is_enabled("+15550002222", false)); + assert!(store.is_enabled("+15550002222", false)); assert_eq!( handler.execute(&msg("!transcribe-on", true)).await.unwrap(), @@ -140,6 +140,7 @@ mod tests { .unwrap(), "Voice transcription disabled for this group." ); + assert!(!store.is_enabled("group-1", true)); } #[tokio::test] diff --git a/crates/signal-bot-transcription/src/transcribe_store.rs b/crates/signal-bot-transcription/src/transcribe_store.rs index ffd83e7..8d076a2 100644 --- a/crates/signal-bot-transcription/src/transcribe_store.rs +++ b/crates/signal-bot-transcription/src/transcribe_store.rs @@ -1,21 +1,22 @@ //! Per-chat voice transcription preference (`!transcribe-on` / `!transcribe-off`). //! //! Group preferences go through [`TranscribeGroupPrefs`]; DM toggles are ephemeral. +//! Auto transcription defaults **off** until explicitly enabled. use crate::prefs::SharedTranscribeGroupPrefs; use std::collections::HashSet; use std::sync::RwLock; -/// DM-only in-memory transcription toggle (default: enabled). +/// DM-only in-memory transcription toggle (default: disabled). pub struct TranscribeStore { - dm_disabled: RwLock>, + dm_enabled: RwLock>, group_prefs: Option, } impl TranscribeStore { pub fn new(group_prefs: Option) -> Self { Self { - dm_disabled: RwLock::new(HashSet::new()), + dm_enabled: RwLock::new(HashSet::new()), group_prefs, } } @@ -25,9 +26,9 @@ impl TranscribeStore { self.group_prefs .as_ref() .map(|store| store.is_transcribe_enabled(context_id)) - .unwrap_or(true) + .unwrap_or(false) } else { - !self.dm_disabled.read().unwrap().contains(context_id) + self.dm_enabled.read().unwrap().contains(context_id) } } @@ -39,11 +40,11 @@ impl TranscribeStore { return; } - let mut disabled = self.dm_disabled.write().unwrap(); + let mut enabled_dms = self.dm_enabled.write().unwrap(); if enabled { - disabled.remove(context_id); + enabled_dms.insert(context_id.to_string()); } else { - disabled.insert(context_id.to_string()); + enabled_dms.remove(context_id); } } } @@ -66,7 +67,7 @@ mod tests { .unwrap() .get(group_id) .copied() - .unwrap_or(true) + .unwrap_or(false) } fn set_transcribe_enabled(&self, group_id: &str, enabled: bool) { @@ -78,19 +79,19 @@ mod tests { } #[test] - fn dm_enabled_by_default() { + fn dm_disabled_by_default() { let store = TranscribeStore::new(None); - assert!(store.is_enabled("dm:+1234", false)); + assert!(!store.is_enabled("dm:+1234", false)); } #[test] fn dm_toggle_off_and_on() { let store = TranscribeStore::new(None); let ctx = "dm:+1234"; - store.set_enabled(ctx, false, false); - assert!(!store.is_enabled(ctx, false)); store.set_enabled(ctx, true, false); assert!(store.is_enabled(ctx, false)); + store.set_enabled(ctx, false, false); + assert!(!store.is_enabled(ctx, false)); } #[test] @@ -98,8 +99,9 @@ mod tests { let prefs: SharedTranscribeGroupPrefs = Arc::new(MemoryPrefs { enabled: RwLock::new(HashMap::new()), }); - let store = TranscribeStore::new(Some(prefs)); - store.set_enabled("group.x", false, true); + let store = TranscribeStore::new(Some(prefs.clone())); assert!(!store.is_enabled("group.x", true)); + store.set_enabled("group.x", true, true); + assert!(store.is_enabled("group.x", true)); } } diff --git a/crates/signal-bot-transcription/src/voice.rs b/crates/signal-bot-transcription/src/voice.rs index 0c30b62..d032705 100644 --- a/crates/signal-bot-transcription/src/voice.rs +++ b/crates/signal-bot-transcription/src/voice.rs @@ -87,7 +87,7 @@ impl CommandHandler for VoiceHandler { } self.transcribe_store .as_ref() - .is_none_or(|store| store.is_enabled(message.reply_target(), message.is_group)) + .is_some_and(|store| store.is_enabled(message.reply_target(), message.is_group)) } fn reply_with_quote(&self) -> bool { @@ -166,6 +166,7 @@ impl CommandHandler for VoiceHandler { #[cfg(test)] mod tests { use super::*; + use crate::transcribe_store::TranscribeStore; #[test] fn format_transcript_includes_prefix() { @@ -259,10 +260,13 @@ mod tests { ); let signal = Arc::new(SignalClient::new(signal_mock.uri()).unwrap()); let cache = VoiceAttachmentCache::with_default_capacity(); + let store = Arc::new(TranscribeStore::new(None)); + let msg = dm_voice(Some(16)); + store.set_enabled(msg.reply_target(), true, false); let handler = VoiceHandler::new(whisper, signal, DEFAULT_REPLY_PREFIX, 10_000) - .with_voice_cache(cache.clone()); + .with_voice_cache(cache.clone()) + .with_transcribe_store(store); - let msg = dm_voice(Some(16)); assert!(handler.matches(&msg)); let out = handler.execute(&msg).await.unwrap(); assert_eq!(out, "📝 Transcript:\nHola mundo"); diff --git a/crates/signal-bot/src/commands/help.rs b/crates/signal-bot/src/commands/help.rs index 128097e..9aa8dfa 100644 --- a/crates/signal-bot/src/commands/help.rs +++ b/crates/signal-bot/src/commands/help.rs @@ -1,6 +1,8 @@ -//! Help command - displays feature menu. +//! Help / info / thread-commands menus. -use crate::commands::menu_locale::{help_menu, thread_help_menu}; +use crate::commands::menu_locale::{ + help_menu, info_menu, is_exact_command, thread_help_menu, thread_info_menu, +}; use crate::commands::CommandHandler; use crate::config::BotRole; use crate::error::AppResult; @@ -9,36 +11,98 @@ use async_trait::async_trait; use signal_client::BotMessage; use std::sync::Arc; +const NOT_THREAD_MSG: &str = "!commands is only available in a Language Thread."; + pub struct HelpHandler { - group_prefs: Arc, role: BotRole, } impl HelpHandler { - pub fn new(group_prefs: Arc, role: BotRole) -> Self { - Self { group_prefs, role } + pub fn new(role: BotRole) -> Self { + Self { role } } } #[async_trait] impl CommandHandler for HelpHandler { - fn trigger(&self) -> Option<&str> { - Some("!help") + fn matches(&self, message: &BotMessage) -> bool { + // Exact match so !help-threads / !help-in-chat are not swallowed. + is_exact_command(&message.text, "!help") } fn label(&self) -> &'static str { "help" } + async fn execute(&self, message: &BotMessage) -> AppResult { + let _ = message; + Ok(help_menu(self.role).into()) + } +} + +/// Sidecar-only compact Language Thread command list (`!commands`). +pub struct CommandsHandler { + group_prefs: Arc, +} + +impl CommandsHandler { + pub fn new(group_prefs: Arc) -> Self { + Self { group_prefs } + } +} + +#[async_trait] +impl CommandHandler for CommandsHandler { + fn matches(&self, message: &BotMessage) -> bool { + is_exact_command(&message.text, "!commands") + } + + fn label(&self) -> &'static str { + "commands" + } + + async fn execute(&self, message: &BotMessage) -> AppResult { + let Some(group_id) = message.group_id.as_deref() else { + return Ok(NOT_THREAD_MSG.into()); + }; + if self.group_prefs.lookup_sidecar(group_id).is_none() { + return Ok(NOT_THREAD_MSG.into()); + } + Ok(thread_help_menu().into()) + } +} + +/// Same menus as [`HelpHandler`], with per-command explanations and blank-line breaks. +pub struct InfoHandler { + group_prefs: Arc, + role: BotRole, +} + +impl InfoHandler { + pub fn new(group_prefs: Arc, role: BotRole) -> Self { + Self { group_prefs, role } + } +} + +#[async_trait] +impl CommandHandler for InfoHandler { + fn matches(&self, message: &BotMessage) -> bool { + is_exact_command(&message.text, "!info") + } + + fn label(&self) -> &'static str { + "info" + } + async fn execute(&self, message: &BotMessage) -> AppResult { if self.role == BotRole::Translation { if let Some(group_id) = message.group_id.as_deref() { if self.group_prefs.lookup_sidecar(group_id).is_some() { - return Ok(thread_help_menu().into()); + return Ok(thread_info_menu().into()); } } } - Ok(help_menu(self.role).into()) + Ok(info_menu(self.role).into()) } } @@ -72,44 +136,105 @@ mod tests { #[tokio::test] async fn help_returns_role_specific_menu() { - let store = GroupPreferencesStore::new_in_memory(0); - let transcription = HelpHandler::new(store.clone(), BotRole::Transcription); - let translation = HelpHandler::new(store.clone(), BotRole::Translation); + let transcription = HelpHandler::new(BotRole::Transcription); + let translation = HelpHandler::new(BotRole::Translation); assert!(transcription.matches(&dm("!help"))); + assert!(!transcription.matches(&dm("!help-threads"))); + assert!(!transcription.matches(&dm("!help-in-chat"))); let t = transcription.execute(&dm("!help")).await.unwrap(); assert!(t.contains("!transcribe")); + assert!(t.contains("!help-transcription")); + assert!(!t.contains("!privacy")); assert!(!t.contains("!translate-me-on")); let t = translation.execute(&dm("!help")).await.unwrap(); - assert!(t.contains("!translation")); + assert!(t.contains("!translation-threads")); + assert!(t.contains("!translation-in-chat")); assert!(t.contains("!transcription")); assert!(t.contains("!privacy")); + assert!(t.contains("!info")); assert!(!t.contains("Voice notes in this chat")); assert!(!t.contains("!set-en")); } #[tokio::test] - async fn help_in_sidecar_returns_thread_menu() { + async fn info_returns_described_hub() { + let store = GroupPreferencesStore::new_in_memory(0); + let handler = InfoHandler::new(store, BotRole::Translation); + assert!(handler.matches(&dm("!info"))); + let out = handler.execute(&dm("!info")).await.unwrap(); + assert!(out.contains("!translation-threads\n ")); + assert!(out.contains("\n\n!privacy")); + assert!(out.contains("Compact command list")); + assert!(out.contains("!privacy\n ")); + assert!(out.contains("TEE")); + assert!(!out.contains("!verify ")); + } + + #[tokio::test] + async fn help_in_sidecar_returns_hub_menu() { let store = GroupPreferencesStore::new_in_memory(0); store.set_sidecar("main-1", "it", "group.it".into(), "it-internal".into()); - let handler = HelpHandler::new(store, BotRole::Translation); + let handler = HelpHandler::new(BotRole::Translation); let out = handler .execute(&group("!help", "it-internal")) .await .unwrap(); + assert!(out.contains("!translation-threads")); + assert!(!out.contains("!rename")); + } + + #[tokio::test] + async fn commands_in_sidecar_returns_thread_menu() { + let store = GroupPreferencesStore::new_in_memory(0); + store.set_sidecar("main-1", "it", "group.it".into(), "it-internal".into()); + let handler = CommandsHandler::new(store); + assert!(handler.matches(&group("!commands", "it-internal"))); + assert!(!handler.matches(&group("!help", "it-internal"))); + let out = handler + .execute(&group("!commands", "it-internal")) + .await + .unwrap(); assert!(out.contains("!rename ")); - assert!(out.contains("!translate-me-off")); - assert!(!out.contains("!translation")); + assert!(out.contains("!leave")); + assert!(out.contains("!commands")); + assert!(!out.contains("!translation-threads")); } #[tokio::test] - async fn help_in_main_stays_hub() { + async fn commands_outside_sidecar_refuses() { + let store = GroupPreferencesStore::new_in_memory(0); + store.set_sidecar("main-1", "it", "group.it".into(), "it-internal".into()); + let handler = CommandsHandler::new(store); + let out = handler + .execute(&group("!commands", "main-1")) + .await + .unwrap(); + assert_eq!(out, NOT_THREAD_MSG); + let out = handler.execute(&dm("!commands")).await.unwrap(); + assert_eq!(out, NOT_THREAD_MSG); + } + + #[tokio::test] + async fn info_in_sidecar_returns_thread_info() { let store = GroupPreferencesStore::new_in_memory(0); store.set_sidecar("main-1", "it", "group.it".into(), "it-internal".into()); - let handler = HelpHandler::new(store, BotRole::Translation); + let handler = InfoHandler::new(store, BotRole::Translation); + let out = handler + .execute(&group("!info", "it-internal")) + .await + .unwrap(); + assert!(out.contains("!rename \n ")); + assert!(out.contains("!leave\n ")); + assert!(!out.contains("!translation-threads")); + } + + #[tokio::test] + async fn help_in_main_stays_hub() { + let handler = HelpHandler::new(BotRole::Translation); let out = handler.execute(&group("!help", "main-1")).await.unwrap(); - assert!(out.contains("!translation")); + assert!(out.contains("!translation-threads")); assert!(!out.contains("!rename")); } } diff --git a/crates/signal-bot/src/commands/menu_locale.rs b/crates/signal-bot/src/commands/menu_locale.rs index b00e0af..53f7a6b 100644 --- a/crates/signal-bot/src/commands/menu_locale.rs +++ b/crates/signal-bot/src/commands/menu_locale.rs @@ -13,35 +13,72 @@ pub fn help_menu(role: BotRole) -> &'static str { } } +/// Hub descriptive menu (translation bot only; transcription uses `!transcription`). +pub fn info_menu(role: BotRole) -> &'static str { + match role { + BotRole::Translation => INFO_HUB, + BotRole::Transcription => unreachable!("transcription bot does not register !info"), + } +} + pub fn thread_help_menu() -> &'static str { HELP_THREAD } -pub fn translation_products_menu(translate_all_enabled: bool) -> &'static str { +pub fn thread_info_menu() -> &'static str { + INFO_THREAD +} + +pub fn translation_threads_menu() -> &'static str { + TRANSLATION_THREADS_MENU +} + +pub fn translation_in_chat_menu(translate_all_enabled: bool) -> &'static str { if translate_all_enabled { - TRANSLATION_MENU + TRANSLATION_IN_CHAT_MENU } else { - TRANSLATION_MENU_AUTO_DISABLED + TRANSLATION_IN_CHAT_MENU_AUTO_DISABLED } } +/// How Language Threads works (use case + flow). +pub fn help_threads_guide() -> &'static str { + HELP_THREADS_GUIDE +} + +/// How in-chat translation works (use case + flow). +pub fn help_in_chat_guide() -> &'static str { + HELP_IN_CHAT_GUIDE +} + +/// How voice transcription works (use case + flow). +pub fn help_transcription_guide() -> &'static str { + HELP_TRANSCRIPTION_GUIDE +} + +/// Legacy `!translation` redirect naming the two product menus. +pub fn translation_split_redirect() -> &'static str { + TRANSLATION_SPLIT_REDIRECT +} + pub fn transcription_unavailable() -> &'static str { TRANSCRIPTION_UNAVAILABLE } -pub fn transcription_invited() -> &'static str { - TRANSCRIPTION_INVITED +pub fn transcription_invited() -> String { + format!( + "Invited the transcription bot to this group.\n\n\ + {}", + help_menu(BotRole::Transcription) + ) } pub fn transcription_group_only() -> &'static str { TRANSCRIPTION_GROUP_ONLY } -pub fn privacy_menu(role: BotRole) -> &'static str { - match role { - BotRole::Transcription => PRIVACY_TRANSCRIPTION, - BotRole::Translation => PRIVACY_TRANSLATION, - } +pub fn privacy_menu() -> &'static str { + PRIVACY_MENU } /// Exact command match (avoids `!translation` matching `!translation-on`). @@ -49,148 +86,250 @@ pub fn is_exact_command(text: &str, command: &str) -> bool { text.trim() == command } -const HELP_TRANSCRIPTION: &str = r#"Voice transcription +pub fn is_exact_command_any(text: &str, commands: &[&str]) -> bool { + let t = text.trim(); + commands.contains(&t) +} -Voice notes in this chat are transcribed to text (Whisper, inside the TEE). +const TRANSLATION_THREADS_MENU_COMMANDS: &[&str] = &[ + "!translation-threads", + "!translate-threads", + "!translate-thread", + "!translation-thread", +]; -!transcription - This menu -!transcribe-on / !transcribe-off - Toggle auto transcription +const TRANSLATION_IN_CHAT_MENU_COMMANDS: &[&str] = &["!translation-in-chat", "!translate-in-chat"]; + +/// Product hub menu for Language Threads (canonical + common typos). +pub fn is_translation_threads_menu_command(text: &str) -> bool { + is_exact_command_any(text, TRANSLATION_THREADS_MENU_COMMANDS) +} + +/// Product hub menu for in-chat translation (canonical + common typos). +pub fn is_translation_in_chat_menu_command(text: &str) -> bool { + is_exact_command_any(text, TRANSLATION_IN_CHAT_MENU_COMMANDS) +} + +const HELP_TRANSCRIPTION: &str = r#"Voice Transcription + +AUTO: +!transcribe-on +!transcribe-off + +PER MSG QUOTE REPLY: !transcribe - Quote a voice note to transcribe -!privacy - Privacy & TEE -!help - Show this menu -!verify - TEE attestation"#; -const HELP_HUB: &str = r#"Sigstack +!help-transcription"#; -!translation - Translation +const HELP_HUB: &str = r#"--Bread Bot-- + +MENUS: +!translation-threads +!translation-in-chat !transcription - Voice transcription + +GUIDES: +!help-threads +!help-in-chat +!help-transcription + +OTHER: +!info !privacy - Privacy & TEE -!help - Show this menu"#; +!help"#; const HELP_THREAD: &str = r#"Language Thread !rename Change this group's name -!translate-me-off +!leave Leave this Language Thread -!help - Show this menu"#; +!info +!commands"#; -const TRANSLATION_MENU: &str = r#"Translation +const INFO_HUB: &str = r#"--Bread Bot-- -Language Threads (recommended) -Multilingual main + language sidecars. +!translation-threads + Language Threads — multilingual main chat + language sidecars -!translate-me-on - Join/create a Language Thread (from main) -!translate-me-off - Leave your Language Thread -!list-langs - Language codes +!translation-in-chat + In-chat translation — auto or quote-translate in this group -In-chat (same group only) -Stay in this thread; auto or quote one message. +!transcription + Voice transcription — pair/open the transcription bot -!translate-on - e.g. !translate-on es en -!translate-off - Stop auto-translate -!translate - Reply to a message +!privacy + Privacy, TEE, and !verify attestation + +!help-transcription + How voice transcription works + +!info + This menu (commands with descriptions) -!verify - TEE attestation !help - Main menu"#; + Compact command list"#; -const TRANSLATION_MENU_AUTO_DISABLED: &str = r#"Translation +const INFO_THREAD: &str = r#"Language Thread -Language Threads (recommended) -Multilingual main + language sidecars. +!rename + Change this Language Thread's group name + +!leave + Leave this Language Thread + +!info + This menu (commands with descriptions) + +!commands + Compact command list"#; + +const TRANSLATION_THREADS_MENU: &str = r#"Join/Create Language Thread -!translate-me-on - Join/create a Language Thread (from main) -!translate-me-off - Leave your Language Thread !list-langs - Language codes +!translate-me-thread +!help-threads + +example: + !translate-me-thread es -In-chat (same group only) -Auto-translate is disabled on this bot (!translate-on). +Unlimited threads are supported. Main chat stays multilingual and threads relay messages between them. Once you join a thread, just read/write in from that thread. + +!enable-in-chat (disable threads) +!help"#; + +const TRANSLATION_IN_CHAT_MENU: &str = r#"In-chat Translation + +!list-langs +!translate-all-on +!translate-all-off +!translate-me-on +!translate-me-off +!translate (as reply) +!help-in-chat + +examples: + !translate-all-on fr zh + !translate-me-on ru ar + !translate es + +!enable-threads (disable in-chat) +!help"#; + +const TRANSLATION_IN_CHAT_MENU_AUTO_DISABLED: &str = r#"In-chat translation + +Auto-translate is disabled on this bot (!translate-all-on). !translate Reply to a message -!verify - TEE attestation -!help - Main menu"#; +!help-in-chat +!help"#; -const TRANSCRIPTION_UNAVAILABLE: &str = r#"Voice transcription is currently unavailable. +const HELP_THREADS_GUIDE: &str = r#"Language Threads — how it works -The transcription bot is not paired with this group yet. Meanwhile, try translation: +Use when people need monolingual lanes, but organizers still want one shared main chat. -!translation - Translation +How it works: +- Main group stays multilingual (everyone can post in any language). +- Each language gets a sidecar Signal group ("Language Thread"). +- Messages bridge: main ↔ threads (relay same language, translate otherwise). -!help - Main menu"#; +Typical use: +1. In main, send !list-langs then !translate-me-thread es +2. Accept the sidecar invite +3. Read/write in that thread; the bot bridges with main and other threads +4. !leave from a thread to leave it +5. From main, !enable-in-chat tears down threads if you want in-chat auto instead -const TRANSCRIPTION_INVITED: &str = r#"Invited the transcription bot to this group. +Language Threads and in-chat auto cannot run at the same time. -Accept the Signal invite on that number, then send !transcription again (the transcription bot will answer with its menu). +Commands: !translation-threads +!help"#; -!help - Main menu"#; +const HELP_IN_CHAT_GUIDE: &str = r#"In-chat translation — how it works -const TRANSCRIPTION_GROUP_ONLY: &str = r#"Voice transcription pairing works in a Signal group. +Use when everyone stays in one Signal group and wants bilingual (or quote) translation there. -Add both bots to a group, then send !transcription there. +How it works: +- No sidecar groups — replies stay in this chat as quote-replies. +- Group-wide: !translate-all-on es en auto-translates messages between that pair. +- Personal: !translate-me-on es en auto-translates only your messages. +- One-off: reply to a message with !translate -!help - Main menu"#; +Typical use: +1. Pick two languages (!list-langs) +2. !translate-all-on es en (or !translate-me-on for just you) +3. Chat normally; the bot quote-replies translations +4. !translate-all-off / !translate-me-off to stop +5. From this group, !enable-threads clears in-chat auto if you want Language Threads instead -const PRIVACY_TRANSCRIPTION: &str = r#"**Sigstack transcription** (Private & Verifiable) +In-chat auto and Language Threads cannot run at the same time. -**TEE Commands:** -!verify - Get TEE attestation with your challenge +Commands: !translation-in-chat +!help"#; + +const HELP_TRANSCRIPTION_GUIDE: &str = r#"Voice transcription — how it works + +Use when people send voice notes and you want text in the same Signal chat. + +This bot runs Whisper in its own Phala CVM/TEE. The Bread Bot translation bot is a separate CVM — use !privacy on the translation bot for suite privacy and !verify behavior. + +How it works: +- Auto mode (default off): send !transcribe-on so inbound voice notes become quote-reply transcripts. +- Manual: quote a voice note and send !transcribe +- Toggle with !transcribe-on / !transcribe-off + +Typical use: +1. Add the translation bot to the group (it auto-accepts invites), or invite via Signal +2. !transcription — hub invites the transcription bot; that bot auto-joins when PEER_PHONE is the translation number +3. Send a voice note and quote-reply !transcribe, or !transcribe-on for auto +4. With the translation bot in the group, transcripts can also be auto-translated -**Privacy:** -Voice notes are decrypted by Signal CLI inside this TEE and transcribed with Whisper in the same CVM. Text transcripts are posted back to Signal. +Commands: !transcription +Privacy / TEE: !privacy on the translation bot"#; -Neither the bot operator nor the host can read decrypted audio or text in TEE memory. +const TRANSLATION_SPLIT_REDIRECT: &str = r#"Translation has two menus: -Pair with the translation bot in the same group if you also want translation."#; +!translation-threads +!translation-in-chat -const PRIVACY_TRANSLATION: &str = r#"**Sigstack translation** (Private & Verifiable) +!help"#; + +const TRANSCRIPTION_UNAVAILABLE: &str = r#"Voice transcription is currently unavailable. + +The transcription bot is not paired with this group yet. Meanwhile, try translation: + +!translation-threads +!translation-in-chat + +!help-transcription +!help"#; + +const TRANSCRIPTION_GROUP_ONLY: &str = r#"Voice transcription pairing works in a Signal group. + +Add both bots to a group, then send !transcription there. + +!help"#; + +const PRIVACY_MENU: &str = r#"Privacy & TEE -**TEE Commands:** !verify - Get TEE attestation with your challenge -**Verification:** -`!verify my-random-text` to get cryptographic proof this bot runs in a TEE. Your challenge is embedded in the TDX quote. +example: + !verify "write something unique here" -**Privacy:** -Messages are end-to-end encrypted via Signal, processed in a verified TEE (Intel TDX), and translated via NEAR AI Cloud private inference (NVIDIA GPU TEE). +Bread Bot runs in two separate and isolated TEEs/CVMs: +- translation bot +- transcription bot -Voice transcription is a separate bot/CVM. This bot only acts on text (including transcripts posted by the transcription bot). +Translation: Signal text is processed in the translation TEE and translated via NEAR AI private inference. -Neither the bot operator nor NEAR AI can read your messages in plaintext outside the TEEs. +Transcription: Voice notes are processed in the transcription TEE and transcribed with Whisper in that CVM. -!help - Main menu"#; +Attestation: In a group with both bots, !verify produces two replies — one TDX quote per CVM. Each quote binds your text as Translation: … or Transcription: … so you can tell which bot attested which string. Message one bot directly for a single quote. + +!help"#; #[cfg(test)] mod tests { @@ -199,63 +338,93 @@ mod tests { #[test] fn help_translation_is_hub() { let h = help_menu(BotRole::Translation); - assert!(h.contains("!translation")); + assert!(h.contains("!translation-threads")); + assert!(h.contains("!translation-in-chat")); assert!(h.contains("!transcription")); assert!(h.contains("!privacy")); - assert!(!h.contains("!translate-me-on")); - assert!(!h.contains("!transcribe-on")); - assert!( - h.contains("!translation\n Translation"), - "hub commands should use stacked layout" - ); - assert!(!h.contains("!set-en")); - assert!(!h.contains("!set-es")); - assert!(h.contains("!help\n Show this menu")); - assert!(!h.contains("!translation —")); - assert!(!h.contains("!help —")); + assert!(h.contains("!info")); + assert!(!h.contains("Language Threads\n")); + assert!(!h.contains("In-chat translation\n")); + assert!(!h.contains(" ")); + assert!(!h.contains("!ask")); + assert!(!h.contains("!models")); } #[test] - fn thread_help_covers_rename() { + fn info_hub_has_breaks_and_descriptions() { + let h = info_menu(BotRole::Translation); + assert!(h.contains("!translation-threads\n ")); + assert!(h.contains("!translation-in-chat\n ")); + assert!(h.contains("!transcription\n ")); + assert!(h.contains("!privacy\n ")); + assert!(h.contains("!info\n ")); + assert!(h.contains("!help\n ")); + assert!(h.contains("\n\n!translation-in-chat")); + assert!(h.contains("Privacy, TEE, and !verify attestation")); + assert!(!h.contains("!verify ")); + } + + #[test] + fn thread_menu_has_leave_not_subscribe() { let h = thread_help_menu(); - assert!(h.contains("!rename ")); - assert!(h.contains("!translate-me-off")); - assert!(h.contains("!help\n Show this menu")); - assert!(!h.contains("!set-en")); + assert!(h.contains("!leave")); + assert!(h.contains("!rename")); + assert!(h.contains("!commands")); + assert!(!h.contains("!translate-me-thread")); assert!(!h.contains("!translate-me-on")); + // Hub !help must not appear as the thread menu trigger. + assert!(!h.trim_end().ends_with("!help")); } #[test] - fn translation_menu_leads_with_language_threads() { - let h = translation_products_menu(true); - assert!(h.contains("Language Threads (recommended)")); - assert!(h.contains("!translate-me-on")); - assert!(h.contains("!translate-me-off")); - assert!(h.contains("!translate-on")); + fn threads_menu_lists_thread_commands() { + let h = translation_threads_menu(); + assert!(h.contains("!translate-me-thread ")); + assert!(h.contains("!enable-in-chat")); + assert!(h.contains("!help-threads")); + assert!(h.contains("!list-langs")); + assert!(!h.contains("!leave")); + assert!(!h.contains("!translate-all-on")); + } + + #[test] + fn in_chat_menu_lists_auto_commands() { + let h = translation_in_chat_menu(true); + assert!(h.contains("!translate-all-on ")); + assert!(h.contains("!translate-me-on ")); + assert!(h.contains("!enable-threads")); + assert!(h.contains("!help-in-chat")); 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" - ); - assert!( - h.contains("!translate-me-on \n "), - "translation menu should use stacked layout" - ); - assert!(!h.contains("!translate-me-on —")); + assert!(!h.contains("!translate-me-thread")); + assert!(!h.contains("!models")); + } + + #[test] + fn feature_guides_cover_use_cases() { + let threads = help_threads_guide(); + assert!(threads.contains("Language Threads")); + assert!(threads.contains("!translate-me-thread")); + assert!(threads.contains("sidecar")); + let in_chat = help_in_chat_guide(); + assert!(in_chat.contains("In-chat translation")); + assert!(in_chat.contains("!translate-all-on")); + assert!(in_chat.contains("quote")); + let transcription = help_transcription_guide(); + assert!(transcription.contains("Voice transcription")); + assert!(transcription.contains("Whisper")); + assert!(transcription.contains("!transcribe")); + assert!(transcription.contains("default off")); + assert!(transcription.contains("separate CVM")); + assert!(transcription.contains("!privacy")); + assert!(!transcription.trim_end().ends_with("!help")); } #[test] - fn translation_menu_auto_disabled_hides_translate_on() { - let h = translation_products_menu(false); - assert!(h.contains("!translate-me-on")); + fn in_chat_menu_auto_disabled_hides_on_commands() { + let h = translation_in_chat_menu(false); assert!(h.contains("Auto-translate is disabled")); - assert!(!h.contains("!translate-on ")); + assert!(!h.contains("!translate-all-on ")); assert!(h.contains("!translate ")); - assert!(h.contains("!translate \n ")); } #[test] @@ -264,36 +433,84 @@ mod tests { assert!(!is_exact_command("!translation-on es en", "!translation")); assert!(is_exact_command("!in-chat", "!in-chat")); assert!(!is_exact_command("!in-chat-extra", "!in-chat")); + assert!(is_exact_command( + "!translation-threads", + "!translation-threads" + )); + } + + #[test] + fn translation_threads_menu_command_aliases() { + assert!(is_translation_threads_menu_command("!translation-threads")); + assert!(is_translation_threads_menu_command("!translate-threads")); + assert!(is_translation_threads_menu_command("!translate-thread")); + assert!(is_translation_threads_menu_command("!translation-thread")); + assert!(is_translation_threads_menu_command( + " !translation-threads " + )); + assert!(!is_translation_threads_menu_command( + "!translation-on es en" + )); + assert!(!is_translation_threads_menu_command( + "!translation-threads-extra" + )); + } + + #[test] + fn translation_in_chat_menu_command_aliases() { + assert!(is_translation_in_chat_menu_command("!translation-in-chat")); + assert!(is_translation_in_chat_menu_command("!translate-in-chat")); + assert!(is_translation_in_chat_menu_command( + " !translate-in-chat " + )); + assert!(!is_translation_in_chat_menu_command( + "!translation-on es en" + )); + assert!(!is_translation_in_chat_menu_command( + "!translation-in-chat-extra" + )); } #[test] fn help_transcription_covers_voice() { let h = help_menu(BotRole::Transcription); assert!(h.contains("!transcribe")); + assert!(h.contains("!transcribe-on")); + assert!(h.contains("!transcribe-off")); + assert!(h.contains("!help-transcription")); + assert!(!h.contains("!privacy-transcription")); + assert!(!h.contains("!privacy-translation")); assert!(!h.contains("!ask")); assert!(!h.contains("!translate-me-on")); - assert!(h.contains("!transcribe-on / !transcribe-off\n ")); - assert!(!h.contains("!transcribe-on / !transcribe-off —")); + assert!(!h.contains("!verify")); + assert!(!h.contains("!info")); + assert!(!h.trim_end().ends_with("!help")); + } + + #[test] + fn privacy_menu_covers_both_cvms() { + let m = privacy_menu(); + assert!(m.contains("two separate and isolated TEEs/CVMs")); + assert!(m.contains("Translation:")); + assert!(m.contains("Transcription:")); + assert!(m.contains("!verify ")); + assert!(!m.contains("**")); } #[test] - fn privacy_menus_cover_roles() { - let transcription = privacy_menu(BotRole::Transcription); - assert!(transcription.contains("Sigstack transcription")); - assert!(transcription.contains("!verify \n ")); - assert!(!transcription.contains("!verify -")); - let translation = privacy_menu(BotRole::Translation); - assert!(translation.contains("Sigstack translation")); - assert!(translation.contains("!verify \n ")); - assert!(!translation.contains("!models")); + fn product_menus_omit_verify() { + assert!(!translation_threads_menu().contains("!verify")); + assert!(!translation_in_chat_menu(true).contains("!verify")); + assert!(!translation_in_chat_menu(false).contains("!verify")); + assert!(!help_menu(BotRole::Translation).contains("!verify")); + assert!(!thread_help_menu().contains("!verify")); } #[test] fn transcription_unavailable_offers_translation() { let m = transcription_unavailable(); assert!(m.contains("unavailable")); - assert!(m.contains("!translation")); - assert!(m.contains("!translation\n Translation")); - assert!(!m.contains("!translation —")); + assert!(m.contains("!translation-threads")); + assert!(m.contains("!translation-in-chat")); } } diff --git a/crates/signal-bot/src/commands/mod.rs b/crates/signal-bot/src/commands/mod.rs index ccc91ea..4b1369d 100644 --- a/crates/signal-bot/src/commands/mod.rs +++ b/crates/signal-bot/src/commands/mod.rs @@ -13,11 +13,12 @@ mod translate_me; mod translate_service; mod verify; -pub use help::HelpHandler; +pub use help::{CommandsHandler, HelpHandler, InfoHandler}; pub use privacy::PrivacyHandler; pub use product_menus::{ - InChatMenuHandler, TranscriptionMenuHandler, TranscriptionPairingHandler, - TranslationMenuHandler, + HelpInChatHandler, HelpThreadsHandler, HelpTranscriptionHandler, InChatMenuHandler, + TranscriptionMenuHandler, TranscriptionPairingHandler, TranslationInChatMenuHandler, + TranslationMenuHandler, TranslationThreadsMenuHandler, }; pub use rename::RenameHandler; pub use signal_bot_core::CommandHandler; @@ -25,4 +26,5 @@ pub use translate::TranslateHandler; pub use translate_all::TranslateAllHandler; pub use translate_langs::TranslateLangsHandler; pub use translate_me::TranslateMeHandler; +pub use translate_service::DEFAULT_TRANSCRIPT_PREFIX; pub use verify::VerifyHandler; diff --git a/crates/signal-bot/src/commands/privacy.rs b/crates/signal-bot/src/commands/privacy.rs index 81a1f7b..c76adb5 100644 --- a/crates/signal-bot/src/commands/privacy.rs +++ b/crates/signal-bot/src/commands/privacy.rs @@ -1,26 +1,29 @@ -//! Privacy / TEE explanation menu. +//! Privacy / TEE explanation menu (translation hub only). -use crate::commands::menu_locale::privacy_menu; +use crate::commands::menu_locale::{is_exact_command, privacy_menu}; use crate::commands::CommandHandler; -use crate::config::BotRole; use crate::error::AppResult; use async_trait::async_trait; use signal_client::BotMessage; -pub struct PrivacyHandler { - role: BotRole, -} +pub struct PrivacyHandler; impl PrivacyHandler { - pub fn new(role: BotRole) -> Self { - Self { role } + pub fn new() -> Self { + Self + } +} + +impl Default for PrivacyHandler { + fn default() -> Self { + Self::new() } } #[async_trait] impl CommandHandler for PrivacyHandler { - fn trigger(&self) -> Option<&str> { - Some("!privacy") + fn matches(&self, message: &BotMessage) -> bool { + is_exact_command(&message.text, "!privacy") } fn label(&self) -> &'static str { @@ -28,7 +31,7 @@ impl CommandHandler for PrivacyHandler { } async fn execute(&self, _message: &BotMessage) -> AppResult { - Ok(privacy_menu(self.role).into()) + Ok(privacy_menu().into()) } } @@ -54,10 +57,14 @@ mod tests { } #[tokio::test] - async fn privacy_returns_role_menu() { - let handler = PrivacyHandler::new(BotRole::Translation); + async fn privacy_returns_unified_menu() { + let handler = PrivacyHandler::new(); + assert!(handler.matches(&dm("!privacy"))); + assert!(!handler.matches(&dm("!privacy-translation"))); + assert!(!handler.matches(&dm("!privacy-transcription"))); let out = handler.execute(&dm("!privacy")).await.unwrap(); - assert!(out.contains("Sigstack translation")); + assert!(out.contains("two separate and isolated TEEs/CVMs")); assert!(out.contains("!verify")); + assert!(!out.contains("**")); } } diff --git a/crates/signal-bot/src/commands/product_menus.rs b/crates/signal-bot/src/commands/product_menus.rs index 0ccc626..fcabfbf 100644 --- a/crates/signal-bot/src/commands/product_menus.rs +++ b/crates/signal-bot/src/commands/product_menus.rs @@ -1,8 +1,10 @@ -//! Product menus: flat `!translation`, `!transcription`, `!in-chat` (redirects to translation). +//! Product menus: `!translation-threads`, `!translation-in-chat`, `!transcription`, redirects. use crate::commands::menu_locale::{ - help_menu, is_exact_command, transcription_group_only, transcription_invited, - transcription_unavailable, translation_products_menu, + help_in_chat_guide, help_menu, help_threads_guide, help_transcription_guide, is_exact_command, + is_translation_in_chat_menu_command, is_translation_threads_menu_command, + transcription_group_only, transcription_invited, transcription_unavailable, + translation_in_chat_menu, translation_split_redirect, translation_threads_menu, }; use crate::commands::CommandHandler; use crate::config::BotRole; @@ -12,11 +14,64 @@ use signal_client::{BotMessage, SignalClient}; use std::sync::Arc; use tracing::warn; -pub struct TranslationMenuHandler { +/// Legacy `!translation` → points at the two product menus. +pub struct TranslationMenuHandler; + +impl TranslationMenuHandler { + pub fn new(_translate_all_enabled: bool) -> Self { + Self + } +} + +#[async_trait] +impl CommandHandler for TranslationMenuHandler { + fn matches(&self, message: &BotMessage) -> bool { + is_exact_command(&message.text, "!translation") + } + + fn label(&self) -> &'static str { + "translation_menu" + } + + async fn execute(&self, _message: &BotMessage) -> AppResult { + Ok(translation_split_redirect().into()) + } +} + +pub struct TranslationThreadsMenuHandler; + +impl TranslationThreadsMenuHandler { + pub fn new() -> Self { + Self + } +} + +impl Default for TranslationThreadsMenuHandler { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl CommandHandler for TranslationThreadsMenuHandler { + fn matches(&self, message: &BotMessage) -> bool { + is_translation_threads_menu_command(&message.text) + } + + fn label(&self) -> &'static str { + "translation_threads_menu" + } + + async fn execute(&self, _message: &BotMessage) -> AppResult { + Ok(translation_threads_menu().into()) + } +} + +pub struct TranslationInChatMenuHandler { translate_all_enabled: bool, } -impl TranslationMenuHandler { +impl TranslationInChatMenuHandler { pub fn new(translate_all_enabled: bool) -> Self { Self { translate_all_enabled, @@ -25,17 +80,17 @@ impl TranslationMenuHandler { } #[async_trait] -impl CommandHandler for TranslationMenuHandler { +impl CommandHandler for TranslationInChatMenuHandler { fn matches(&self, message: &BotMessage) -> bool { - is_exact_command(&message.text, "!translation") + is_translation_in_chat_menu_command(&message.text) } fn label(&self) -> &'static str { - "translation_menu" + "translation_in_chat_menu" } async fn execute(&self, _message: &BotMessage) -> AppResult { - Ok(translation_products_menu(self.translate_all_enabled).into()) + Ok(translation_in_chat_menu(self.translate_all_enabled).into()) } } @@ -143,7 +198,7 @@ impl CommandHandler for TranscriptionPairingHandler { .await { Ok(()) => { - self.send(message, transcription_invited()).await?; + self.send(message, &transcription_invited()).await?; } Err(e) => { warn!(error = %e, peer, "Failed to invite transcription bot"); @@ -214,8 +269,97 @@ impl CommandHandler for InChatMenuHandler { } async fn execute(&self, _message: &BotMessage) -> AppResult { - // Muscle-memory alias: same flat Translation menu as !translation. - Ok(translation_products_menu(self.translate_all_enabled).into()) + Ok(translation_in_chat_menu(self.translate_all_enabled).into()) + } +} + +/// Feature guide: how Language Threads works. +pub struct HelpThreadsHandler; + +impl HelpThreadsHandler { + pub fn new() -> Self { + Self + } +} + +impl Default for HelpThreadsHandler { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl CommandHandler for HelpThreadsHandler { + fn matches(&self, message: &BotMessage) -> bool { + is_exact_command(&message.text, "!help-threads") + } + + fn label(&self) -> &'static str { + "help_threads" + } + + async fn execute(&self, _message: &BotMessage) -> AppResult { + Ok(help_threads_guide().into()) + } +} + +/// Feature guide: how in-chat translation works. +pub struct HelpInChatHandler; + +impl HelpInChatHandler { + pub fn new() -> Self { + Self + } +} + +impl Default for HelpInChatHandler { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl CommandHandler for HelpInChatHandler { + fn matches(&self, message: &BotMessage) -> bool { + is_exact_command(&message.text, "!help-in-chat") + } + + fn label(&self) -> &'static str { + "help_in_chat" + } + + async fn execute(&self, _message: &BotMessage) -> AppResult { + Ok(help_in_chat_guide().into()) + } +} + +/// Feature guide: how voice transcription works. +pub struct HelpTranscriptionHandler; + +impl HelpTranscriptionHandler { + pub fn new() -> Self { + Self + } +} + +impl Default for HelpTranscriptionHandler { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl CommandHandler for HelpTranscriptionHandler { + fn matches(&self, message: &BotMessage) -> bool { + is_exact_command(&message.text, "!help-transcription") + } + + fn label(&self) -> &'static str { + "help_transcription" + } + + async fn execute(&self, _message: &BotMessage) -> AppResult { + Ok(help_transcription_guide().into()) } } @@ -249,9 +393,34 @@ mod tests { assert!(t.matches(&msg("!translation"))); assert!(!t.matches(&msg("!translation-on es en"))); + let threads = TranslationThreadsMenuHandler::new(); + assert!(threads.matches(&msg("!translation-threads"))); + assert!(threads.matches(&msg("!translate-threads"))); + assert!(threads.matches(&msg("!translate-thread"))); + assert!(threads.matches(&msg("!translation-thread"))); + assert!(!threads.matches(&msg("!translation-on es en"))); + + let in_chat_prod = TranslationInChatMenuHandler::new(true); + assert!(in_chat_prod.matches(&msg("!translation-in-chat"))); + assert!(in_chat_prod.matches(&msg("!translate-in-chat"))); + assert!(!in_chat_prod.matches(&msg("!translation-on es en"))); + let i = InChatMenuHandler::new(true); assert!(i.matches(&msg("!in-chat"))); + let ht = HelpThreadsHandler::new(); + assert!(ht.matches(&msg("!help-threads"))); + assert!(!ht.matches(&msg("!help"))); + + let hi = HelpInChatHandler::new(); + assert!(hi.matches(&msg("!help-in-chat"))); + assert!(!hi.matches(&msg("!help"))); + + let htr = HelpTranscriptionHandler::new(); + assert!(htr.matches(&msg("!help-transcription"))); + assert!(!htr.matches(&msg("!help"))); + assert!(!htr.matches(&msg("!transcription"))); + let s = TranscriptionPairingHandler::new( Arc::new(SignalClient::new("http://127.0.0.1:9").unwrap()), None, @@ -263,14 +432,74 @@ mod tests { } #[tokio::test] - async fn in_chat_redirects_to_flat_translation_menu() { + async fn in_chat_typo_aliases_match_canonical_menu() { + let canonical = TranslationInChatMenuHandler::new(true); + let expected = canonical + .execute(&msg("!translation-in-chat")) + .await + .unwrap(); + for typo in ["!translate-in-chat"] { + let got = canonical.execute(&msg(typo)).await.unwrap(); + assert_eq!(got, expected); + } + } + + #[tokio::test] + async fn threads_typo_aliases_match_canonical_menu() { + let handler = TranslationThreadsMenuHandler::new(); + let expected = handler.execute(&msg("!translation-threads")).await.unwrap(); + for typo in [ + "!translate-threads", + "!translate-thread", + "!translation-thread", + ] { + let got = handler.execute(&msg(typo)).await.unwrap(); + assert_eq!(got, expected); + } + } + + #[tokio::test] + async fn in_chat_alias_matches_in_chat_menu() { + let product = TranslationInChatMenuHandler::new(true); + let alias = InChatMenuHandler::new(true); + let via_product = product.execute(&msg("!translation-in-chat")).await.unwrap(); + let via_alias = alias.execute(&msg("!in-chat")).await.unwrap(); + assert_eq!(via_product, via_alias); + assert!(via_product.contains("!translate-all-on")); + assert!(via_product.contains("!translate-me-on")); + assert!(!via_product.contains("!translate-me-thread")); + } + + #[tokio::test] + async fn translation_redirect_names_both_menus() { let translation = TranslationMenuHandler::new(true); - let in_chat = InChatMenuHandler::new(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")); + let out = translation.execute(&msg("!translation")).await.unwrap(); + assert!(out.contains("!translation-threads")); + assert!(out.contains("!translation-in-chat")); + } + + #[tokio::test] + async fn feature_guides_return_use_case_copy() { + let threads = HelpThreadsHandler::new() + .execute(&msg("!help-threads")) + .await + .unwrap(); + assert!(threads.contains("sidecar")); + assert!(threads.contains("!translate-me-thread")); + + let in_chat = HelpInChatHandler::new() + .execute(&msg("!help-in-chat")) + .await + .unwrap(); + assert!(in_chat.contains("quote")); + assert!(in_chat.contains("!translate-all-on")); + + let transcription = HelpTranscriptionHandler::new() + .execute(&msg("!help-transcription")) + .await + .unwrap(); + assert!(transcription.contains("Whisper")); + assert!(transcription.contains("!transcribe")); } #[tokio::test] @@ -336,38 +565,10 @@ mod tests { ); let out = handler.execute(&msg("!transcription")).await.unwrap(); assert!(out.is_empty()); - } - - #[tokio::test] - async fn pairing_silent_when_peer_already_member() { - let signal_mock = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/groups/%2B15550001111")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([{ - "name": "Main", - "id": "group.send=", - "internal_id": "g-internal", - "members": ["+15550001111", "+15550009999"], - "pending_invites": [], - "pending_requests": [], - "admins": ["+15550001111"] - }]))) - .mount(&signal_mock) - .await; - - let handler = TranscriptionPairingHandler::new( - Arc::new(SignalClient::new(signal_mock.uri()).unwrap()), - Some("+15550009999".into()), - ); - let out = handler.execute(&msg("!transcription")).await.unwrap(); - assert!(out.is_empty()); - } - #[tokio::test] - async fn transcription_menu_returns_voice_help() { - let handler = TranscriptionMenuHandler::new(); - let out = handler.execute(&msg("!transcription")).await.unwrap(); - assert!(out.contains("!transcribe")); - assert!(out.to_lowercase().contains("voice")); + let invited = transcription_invited(); + assert!(invited.contains("Voice Transcription")); + assert!(invited.contains("!transcribe-on")); + assert!(!invited.contains("send !transcription again")); } } diff --git a/crates/signal-bot/src/commands/translate.rs b/crates/signal-bot/src/commands/translate.rs index 1a10b04..912c4e6 100644 --- a/crates/signal-bot/src/commands/translate.rs +++ b/crates/signal-bot/src/commands/translate.rs @@ -1,7 +1,11 @@ //! `!translate` — quote-reply translation via NEAR AI. +use crate::commands::menu_locale::{ + is_translation_in_chat_menu_command, is_translation_threads_menu_command, +}; use crate::commands::translate_all::is_translate_on_or_off_command; use crate::commands::translate_lang::{resolve_language, Language}; +use crate::commands::translate_service::strip_transcript_prefix; use crate::commands::CommandHandler; use crate::error::AppResult; use async_trait::async_trait; @@ -42,17 +46,11 @@ impl TranslateHandler { if raw.is_empty() { return None; } - - let text = if let Some(rest) = raw.strip_prefix(transcript_prefix) { - rest.trim_start_matches('\n').trim() - } else { - raw - }; - + let text = strip_transcript_prefix(raw, transcript_prefix); if text.is_empty() { None } else { - Some(text.to_string()) + Some(text) } } @@ -130,6 +128,8 @@ impl CommandHandler for TranslateHandler { let text = message.text.trim(); text.starts_with("!translate") && !is_translate_on_or_off_command(text) + && !is_translation_threads_menu_command(text) + && !is_translation_in_chat_menu_command(text) && !text.starts_with("!translate-me") && !text.starts_with("!translation") && !text.starts_with("!transcription") @@ -292,6 +292,15 @@ mod tests { msg.text = "!translate es".into(); assert!(handler.matches(&msg)); + + for typo in [ + "!translate-in-chat", + "!translate-threads", + "!translate-thread", + ] { + msg.text = typo.into(); + assert!(!handler.matches(&msg)); + } } #[tokio::test] diff --git a/crates/signal-bot/src/commands/translate_all.rs b/crates/signal-bot/src/commands/translate_all.rs index 87c9241..6e36160 100644 --- a/crates/signal-bot/src/commands/translate_all.rs +++ b/crates/signal-bot/src/commands/translate_all.rs @@ -1,50 +1,96 @@ -//! `!translate-on` / `!translate-off` — group auto-translate mode. +//! In-chat auto-translate: `!translate-all-on/off`, `!translate-me-on/off`, `!enable-threads`. use crate::commands::translate_lang::resolve_language; +use crate::commands::translate_me::TranslateMeHandler; use crate::commands::translate_service::{ - format_text_auto_translation, near_ai_translate, target_for_message_text, + format_text_auto_translation, near_ai_translate, strip_transcript_prefix, + target_for_message_text, DEFAULT_TRANSCRIPT_PREFIX, }; use crate::commands::CommandHandler; use crate::error::AppResult; -use crate::group_preferences_store::{GroupPreferencesStore, GroupTranslateMode}; +use crate::group_preferences_store::{GroupPreferencesStore, GroupTranslateMode, PendingSwitch}; 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 TRANSLATE_ON_PREFIXES: &[&str] = &["!translate-on", "!translation-on"]; -const TRANSLATE_OFF_COMMANDS: &[&str] = &["!translate-off", "!translation-off"]; - -const BARE_COMMAND_MSG: &str = "Please specify two languages. Example: !translate-on es en"; -const GROUP_ONLY_MSG: &str = "!translate-on is only available in group chats"; - -/// Whether the message is `!translate-on` / `!translation-on` or the off variant. +const ALL_ON_PREFIXES: &[&str] = &[ + "!translate-all-on", + "!translation-all-on", + "!translate-on", + "!translation-on", +]; +const ALL_OFF_COMMANDS: &[&str] = &[ + "!translate-all-off", + "!translation-all-off", + "!translate-off", + "!translation-off", +]; +const ME_ON_PREFIXES: &[&str] = &["!translate-me-on", "!translation-me-on"]; +const ME_OFF_COMMANDS: &[&str] = &["!translate-me-off", "!translation-me-off"]; +/// Tear down in-chat auto so Language Threads can run (`!enable-threads`). +const ENABLE_THREADS: &[&str] = &["!enable-threads", "!translation-enable-threads"]; + +const BARE_ALL_MSG: &str = "Please specify two languages. Example: !translate-all-on es en"; +const BARE_ME_MSG: &str = "Please specify two languages. Example: !translate-me-on es en"; +const GROUP_ONLY_MSG: &str = "In-chat auto-translate is only available in group chats"; +const SIDECAR_REJECT_MSG: &str = + "In-chat auto-translate is only available in the main group (not a Language Thread)."; +const THREADS_BLOCK_MSG: &str = "Language Threads is already on in this group, so in-chat auto-translate can't run alongside it.\n\nTo switch, send:\n!enable-in-chat"; + +/// Whether the message is any in-chat auto on/off/disable command (excludes quote `!translate`). pub(crate) fn is_translate_on_or_off_command(text: &str) -> bool { let text = text.trim(); - TRANSLATE_ON_PREFIXES - .iter() - .any(|prefix| text.starts_with(prefix)) - || TRANSLATE_OFF_COMMANDS.contains(&text) + is_all_on_command(text) + || ALL_OFF_COMMANDS.contains(&text) + || is_me_on_command(text) + || ME_OFF_COMMANDS.contains(&text) + || ENABLE_THREADS.contains(&text) } -fn strip_translate_on_prefix(text: &str) -> Option<&str> { - let text = text.trim(); - TRANSLATE_ON_PREFIXES - .iter() - .find_map(|prefix| text.strip_prefix(prefix)) - .map(str::trim) +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 is_bare_translate_on(text: &str) -> bool { - let text = text.trim(); - TRANSLATE_ON_PREFIXES.contains(&text) +fn is_all_on_command(text: &str) -> bool { + ALL_ON_PREFIXES.iter().any(|p| starts_with_word(text, p)) +} + +fn is_me_on_command(text: &str) -> bool { + ME_ON_PREFIXES.iter().any(|p| starts_with_word(text, p)) +} + +fn strip_prefix_list<'a>(text: &'a str, prefixes: &[&str]) -> Option<&'a str> { + prefixes.iter().find_map(|prefix| { + if text == *prefix { + Some("") + } else { + text.strip_prefix(prefix) + .filter(|rest| rest.is_empty() || rest.starts_with(' ')) + .map(str::trim) + } + }) +} + +fn is_bare_all_on(text: &str) -> bool { + ALL_ON_PREFIXES.contains(&text.trim()) +} + +fn is_bare_me_on(text: &str) -> bool { + ME_ON_PREFIXES.contains(&text.trim()) } pub struct TranslateAllHandler { store: Arc, near_ai: Arc, signal: Arc, + /// Transcription peer E.164 (`SIGNAL__PEER_PHONE`), when paired. + peer_phone: Option, + transcript_prefix: String, } impl TranslateAllHandler { @@ -52,11 +98,30 @@ impl TranslateAllHandler { store: Arc, near_ai: Arc, signal: Arc, + ) -> Self { + Self::with_peer(store, near_ai, signal, None, DEFAULT_TRANSCRIPT_PREFIX) + } + + pub fn with_peer( + store: Arc, + near_ai: Arc, + signal: Arc, + peer_phone: Option, + transcript_prefix: impl Into, ) -> Self { Self { store, near_ai, signal, + peer_phone: peer_phone.and_then(|p| { + let t = p.trim().to_string(); + if t.is_empty() { + None + } else { + Some(t) + } + }), + transcript_prefix: transcript_prefix.into(), } } @@ -64,16 +129,94 @@ impl TranslateAllHandler { is_translate_on_or_off_command(text) } + /// Group text eligible for auto-translate (commands excluded). + /// + /// Allows text even when audio attachments are present so transcription-bot + /// quote-replies that still carry voice metadata are not skipped. 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('!') + message.group_id.is_some() && !text.is_empty() && !text.starts_with('!') + } + + fn is_peer_source(&self, message: &BotMessage) -> bool { + let Some(peer) = self.peer_phone.as_deref() else { + return false; + }; + message.source == peer + || message.source_number.as_deref() == Some(peer) + || message + .source_number + .as_deref() + .is_some_and(|n| n.trim() == peer) + } + + fn looks_like_transcript(&self, text: &str) -> bool { + let prefix = if self.transcript_prefix.is_empty() { + DEFAULT_TRANSCRIPT_PREFIX + } else { + self.transcript_prefix.as_str() + }; + text.trim().starts_with(prefix) + } + + /// Spoken body for detect/translate (strip Whisper label when present). + fn intercept_text(&self, message: &BotMessage) -> String { + strip_transcript_prefix(message.text.trim(), &self.transcript_prefix) + } + + /// Resolve in-chat mode; for peer/transcript posts, try quote author first. + fn resolve_mode_for_message( + &self, + group_id: &str, + message: &BotMessage, + ) -> Option { + let treat_as_transcript = + self.is_peer_source(message) || self.looks_like_transcript(&message.text); + if treat_as_transcript { + if let Some(author) = message + .quote + .as_ref() + .and_then(|q| q.author_number.as_deref()) + { + if let Some(mode) = self.store.resolve_in_chat_mode(group_id, author) { + return Some(mode); + } + } + } + + if let Some(mode) = self.store.resolve_in_chat_mode(group_id, &message.source) { + return Some(mode); + } + if let Some(n) = message.source_number.as_deref() { + if n != message.source.as_str() { + return self.store.resolve_in_chat_mode(group_id, n); + } + } + None } - fn parse_lang_pair(text: &str) -> Option<(&str, &str)> { - let rest = strip_translate_on_prefix(text)?; + fn set_member_prefs(&self, group_id: &str, message: &BotMessage, mode: GroupTranslateMode) { + self.store + .set_member_translate(group_id, &message.source, mode.clone()); + if let Some(n) = message.source_number.as_deref() { + if n != message.source.as_str() { + self.store.set_member_translate(group_id, n, mode); + } + } + } + + fn clear_member_prefs(&self, group_id: &str, message: &BotMessage) -> bool { + let mut cleared = self.store.clear_member_translate(group_id, &message.source); + if let Some(n) = message.source_number.as_deref() { + if n != message.source.as_str() { + cleared = self.store.clear_member_translate(group_id, n) || cleared; + } + } + cleared + } + + fn parse_lang_pair<'a>(text: &'a str, prefixes: &[&str]) -> Option<(&'a str, &'a str)> { + let rest = strip_prefix_list(text.trim(), prefixes)?; let mut parts = rest.split_whitespace(); let a = parts.next()?; let b = parts.next()?; @@ -87,57 +230,125 @@ impl TranslateAllHandler { message.group_id.as_deref().ok_or(GROUP_ONLY_MSG) } - async fn handle_setup(&self, message: &BotMessage) -> AppResult { + fn resolve_pair_tokens( + token_a: &str, + token_b: &str, + example: &str, + ) -> Result { + let lang_a = resolve_language(token_a).ok_or_else(|| { + format!("Unknown language: {token_a}. Use !list-langs for supported codes.") + })?; + let lang_b = resolve_language(token_b).ok_or_else(|| { + format!("Unknown language: {token_b}. Use !list-langs for supported codes.") + })?; + if lang_a.code == lang_b.code { + return Err(format!( + "Choose two different languages. Example: {example}" + )); + } + Ok(GroupTranslateMode::new(lang_a, lang_b)) + } + + async fn refuse_if_threads(&self, group_id: &str, pending: PendingSwitch) -> Option { + if !self.store.threads_active(group_id) { + return None; + } + self.store.set_pending_switch(group_id, pending); + Some(THREADS_BLOCK_MSG.into()) + } + + async fn handle_all_on(&self, message: &BotMessage) -> AppResult { let group_id = match Self::require_group(message) { Ok(id) => id, Err(msg) => return Ok(msg.into()), }; + if self.store.lookup_sidecar(group_id).is_some() { + return Ok(SIDECAR_REJECT_MSG.into()); + } let text = message.text.trim(); - if is_bare_translate_on(text) { - return Ok(BARE_COMMAND_MSG.into()); + if is_bare_all_on(text) { + return Ok(BARE_ALL_MSG.into()); } - - let (token_a, token_b) = match Self::parse_lang_pair(text) { - Some(pair) => pair, - None => return Ok(BARE_COMMAND_MSG.into()), + let Some((token_a, token_b)) = Self::parse_lang_pair(text, ALL_ON_PREFIXES) else { + return Ok(BARE_ALL_MSG.into()); }; - - let lang_a = match resolve_language(token_a) { - Some(l) => l, - None => { - return Ok(format!( - "Unknown language: {token_a}. Use !list-langs for supported codes." - )); - } - }; - let lang_b = match resolve_language(token_b) { - Some(l) => l, - None => { - return Ok(format!( - "Unknown language: {token_b}. Use !list-langs for supported codes." - )); - } + let mode = match Self::resolve_pair_tokens(token_a, token_b, "!translate-all-on es en") { + Ok(m) => m, + Err(e) => return Ok(e), }; - if lang_a.code == lang_b.code { - return Ok("Choose two different languages. Example: !translate-on es en".into()); + if let Some(msg) = self + .refuse_if_threads( + group_id, + PendingSwitch::EnableAllOn { + user: message.source.clone(), + lang_a: mode.lang_a.clone(), + lang_b: mode.lang_b.clone(), + }, + ) + .await + { + return Ok(msg); } - let mode = GroupTranslateMode::new(lang_a, lang_b); let pair_label = mode.display_pair(); self.store.set(group_id.to_string(), mode); - info!(group_id, pair = %pair_label, "translate-all mode enabled"); Ok(format!("Group translate enabled: {pair_label}")) } - async fn handle_off(&self, message: &BotMessage) -> AppResult { + async fn handle_me_on(&self, message: &BotMessage) -> AppResult { let group_id = match Self::require_group(message) { Ok(id) => id, Err(msg) => return Ok(msg.into()), }; + if self.store.lookup_sidecar(group_id).is_some() { + return Ok(SIDECAR_REJECT_MSG.into()); + } + + let text = message.text.trim(); + if is_bare_me_on(text) { + return Ok(BARE_ME_MSG.into()); + } + let Some((token_a, token_b)) = Self::parse_lang_pair(text, ME_ON_PREFIXES) else { + return Ok(BARE_ME_MSG.into()); + }; + let mode = match Self::resolve_pair_tokens(token_a, token_b, "!translate-me-on es en") { + Ok(m) => m, + Err(e) => return Ok(e), + }; + + if let Some(msg) = self + .refuse_if_threads( + group_id, + PendingSwitch::EnableMeOn { + user: message.source.clone(), + lang_a: mode.lang_a.clone(), + lang_b: mode.lang_b.clone(), + }, + ) + .await + { + return Ok(msg); + } + + let pair_label = mode.display_pair(); + self.set_member_prefs(group_id, message, mode); + info!( + group_id, + user = %message.source, + pair = %pair_label, + "translate-me (in-chat) enabled" + ); + Ok(format!("Personal translate enabled: {pair_label}")) + } + async fn handle_all_off(&self, message: &BotMessage) -> AppResult { + let group_id = match Self::require_group(message) { + Ok(id) => id, + Err(msg) => return Ok(msg.into()), + }; if self.store.clear(group_id) { info!(group_id, "translate-all mode disabled"); Ok("Group translate disabled".into()) @@ -146,13 +357,78 @@ impl TranslateAllHandler { } } + async fn handle_me_off(&self, message: &BotMessage) -> AppResult { + let group_id = match Self::require_group(message) { + Ok(id) => id, + Err(msg) => return Ok(msg.into()), + }; + if self.clear_member_prefs(group_id, message) { + info!(group_id, user = %message.source, "translate-me (in-chat) disabled"); + Ok("Personal translate disabled".into()) + } else { + Ok("Personal translate was not active for you in this chat.".into()) + } + } + + async fn handle_enable_threads(&self, message: &BotMessage) -> AppResult { + let group_id = match Self::require_group(message) { + Ok(id) => id, + Err(msg) => return Ok(msg.into()), + }; + + let (had, pending) = self.store.disable_in_chat_and_take_pending(group_id); + + let mut parts = Vec::new(); + if had { + parts.push("In-chat auto-translate disabled.".to_string()); + } else { + parts.push("In-chat auto-translate was not active in this chat.".to_string()); + } + + if let Some(PendingSwitch::EnableThreads { + user, + lang, + address, + }) = pending + { + let applied = TranslateMeHandler::subscribe_user_to_thread( + &self.store, + &self.signal, + message, + group_id, + &lang, + Some(user.as_str()), + address.as_deref(), + ) + .await?; + parts.push(applied); + } else if let Some(other) = pending { + // Restore unexpected pending rather than drop silently. + self.store.set_pending_switch(group_id, other); + parts.push( + "Pending switch was not a Language Threads subscribe; left unchanged. \ + Use !translation-threads for Language Threads." + .into(), + ); + } else if had { + parts.push("You can enable Language Threads with !translate-me-thread .".into()); + } + + Ok(parts.join(" ")) + } + async fn handle_text_intercept(&self, message: &BotMessage) -> AppResult<()> { let group_id = match message.group_id.as_deref() { Some(id) => id, None => return Ok(()), }; - let mode = match self.store.get(group_id) { + // Threads still wins via handler order; skip if somehow both configured. + if self.store.threads_active(group_id) { + return Ok(()); + } + + let mode = match self.resolve_mode_for_message(group_id, message) { Some(m) => m, None => return Ok(()), }; @@ -165,20 +441,24 @@ impl TranslateAllHandler { return Ok(()); } - let (source, target) = match target_for_message_text(&mode, message.text.trim()) { + let spoken = self.intercept_text(message); + if spoken.is_empty() { + return Ok(()); + } + + let (source, target) = match target_for_message_text(&mode, &spoken) { Some(pair) => pair, None => { debug!( group_id, - text_chars = message.text.trim().len(), + text_chars = spoken.len(), "translate-all skipped text (language not in pair or undetected)" ); return Ok(()); } }; - let translation = match near_ai_translate(&self.near_ai, message.text.trim(), target).await - { + let translation = match near_ai_translate(&self.near_ai, &spoken, target).await { Ok(t) => t, Err(e) => { warn!("translate-all text translation failed: {}", e); @@ -204,10 +484,16 @@ impl TranslateAllHandler { #[instrument(skip(self, message), fields(source = %message.source, is_group = message.is_group))] async fn handle_command(&self, message: &BotMessage) -> AppResult { let text = message.text.trim(); - if TRANSLATE_OFF_COMMANDS.contains(&text) { - self.handle_off(message).await + if ENABLE_THREADS.contains(&text) { + self.handle_enable_threads(message).await + } else if ME_OFF_COMMANDS.contains(&text) { + self.handle_me_off(message).await + } else if ALL_OFF_COMMANDS.contains(&text) { + self.handle_all_off(message).await + } else if is_me_on_command(text) { + self.handle_me_on(message).await } else { - self.handle_setup(message).await + self.handle_all_on(message).await } } } @@ -224,7 +510,8 @@ impl CommandHandler for TranslateAllHandler { } if Self::is_text_intercept(message) { if let Some(gid) = &message.group_id { - return self.store.is_active(gid); + return self.resolve_mode_for_message(gid, message).is_some() + && !self.store.threads_active(gid); } } false @@ -249,6 +536,8 @@ impl CommandHandler for TranslateAllHandler { #[cfg(test)] mod tests { use super::*; + use crate::commands::translate_lang::resolve_language; + use crate::group_preferences_store::{GroupTranslateMode, PendingSwitch}; use signal_client::BotMessage; fn test_handler() -> TranslateAllHandler { @@ -270,24 +559,37 @@ mod tests { #[test] fn parse_lang_pair_from_command() { assert_eq!( - TranslateAllHandler::parse_lang_pair("!translate-on es en"), + TranslateAllHandler::parse_lang_pair("!translate-all-on es en", ALL_ON_PREFIXES), Some(("es", "en")) ); assert_eq!( - TranslateAllHandler::parse_lang_pair("!translation-on es en"), + TranslateAllHandler::parse_lang_pair("!translate-on es en", ALL_ON_PREFIXES), Some(("es", "en")) ); - assert!(TranslateAllHandler::parse_lang_pair("!translate-on").is_none()); - assert!(TranslateAllHandler::parse_lang_pair("!translation-on").is_none()); - assert!(TranslateAllHandler::parse_lang_pair("!translate-on es en fr").is_none()); + assert_eq!( + TranslateAllHandler::parse_lang_pair("!translate-me-on es en", ME_ON_PREFIXES), + Some(("es", "en")) + ); + assert!( + TranslateAllHandler::parse_lang_pair("!translate-all-on", ALL_ON_PREFIXES).is_none() + ); + assert!(TranslateAllHandler::parse_lang_pair( + "!translate-all-on es en fr", + ALL_ON_PREFIXES + ) + .is_none()); } #[test] fn is_translate_on_or_off_command_recognizes_aliases() { + assert!(is_translate_on_or_off_command("!translate-all-on es en")); assert!(is_translate_on_or_off_command("!translate-on es en")); - assert!(is_translate_on_or_off_command("!translation-on")); + assert!(is_translate_on_or_off_command("!translate-me-on es en")); + assert!(is_translate_on_or_off_command("!translate-me-off")); + assert!(is_translate_on_or_off_command("!enable-threads")); assert!(is_translate_on_or_off_command("!translation-off")); assert!(!is_translate_on_or_off_command("!translate es")); + assert!(!is_translate_on_or_off_command("!translate-me-thread es")); } #[test] @@ -322,6 +624,132 @@ mod tests { assert!(!handler.matches(&msg)); } + #[test] + fn personal_intercept_only_for_subscriber() { + let handler = test_handler(); + let mode = GroupTranslateMode::new( + resolve_language("es").unwrap(), + resolve_language("en").unwrap(), + ); + handler.store.set_member_translate("gid", "+alice", mode); + + let alice = BotMessage { + source: "+alice".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, + }; + let bob = BotMessage { + source: "+bob".into(), + ..alice.clone() + }; + assert!(handler.matches(&alice)); + assert!(!handler.matches(&bob)); + } + + #[test] + fn group_wide_matches_transcript_even_with_audio_attachment() { + use signal_client::Attachment; + + let handler = test_handler(); + handler.store.set( + "gid".into(), + GroupTranslateMode::new( + resolve_language("es").unwrap(), + resolve_language("en").unwrap(), + ), + ); + + let msg = BotMessage { + source: "+15550009999".into(), + source_number: Some("+15550009999".into()), + source_name: Some("Transcription".into()), + text: "📝 Transcript:\nHola, ¿cómo estás?".into(), + timestamp: 0, + message_timestamp: 0, + is_group: true, + group_id: Some("gid".into()), + group_name: None, + receiving_account: "+15550001111".into(), + attachments: vec![Attachment { + content_type: "audio/aac".into(), + filename: Some("voice.m4a".into()), + id: "att-1".into(), + size: Some(100), + upload_timestamp: None, + }], + quote: None, + }; + assert!(msg.is_voice_note()); + assert!(handler.matches(&msg)); + } + + #[test] + fn personal_matches_peer_transcript_via_quote_author() { + use signal_client::QuotedMessage; + + let mode = GroupTranslateMode::new( + resolve_language("es").unwrap(), + resolve_language("en").unwrap(), + ); + let handler = TranslateAllHandler::with_peer( + 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()), + Some("+15550009999".into()), + DEFAULT_TRANSCRIPT_PREFIX, + ); + handler.store.set_member_translate("gid", "+alice", mode); + + let msg = BotMessage { + source: "+15550009999".into(), + source_number: Some("+15550009999".into()), + source_name: Some("Transcription".into()), + text: "📝 Transcript:\nHola amigos".into(), + timestamp: 0, + message_timestamp: 0, + is_group: true, + group_id: Some("gid".into()), + group_name: None, + receiving_account: "+15550001111".into(), + attachments: vec![], + quote: Some(QuotedMessage { + id: 1, + author_number: Some("+alice".into()), + text: None, + audio_attachment: None, + }), + }; + assert!(handler.matches(&msg)); + + let other = BotMessage { + quote: Some(QuotedMessage { + id: 2, + author_number: Some("+bob".into()), + text: None, + audio_attachment: None, + }), + ..msg.clone() + }; + assert!(!handler.matches(&other)); + } + #[tokio::test] async fn execute_setup_commands_send_replies() { use serde_json::json; @@ -332,7 +760,7 @@ mod tests { Mock::given(method("POST")) .and(path("/v2/send")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) - .expect(5) + .expect(7) .mount(&signal) .await; @@ -355,7 +783,7 @@ mod tests { source: "+15550002222".into(), source_number: Some("+15550002222".into()), source_name: None, - text: "!translate-on".into(), + text: "!translate-all-on".into(), timestamp: 1, message_timestamp: 1, is_group: false, @@ -366,24 +794,169 @@ mod tests { quote: None, }; - // DM → group only assert!(handler.execute(&msg).await.unwrap().is_empty()); msg.is_group = true; msg.group_id = Some("group.main".into()); - // bare on assert!(handler.execute(&msg).await.unwrap().is_empty()); - msg.text = "!translate-on xx yy".into(); + msg.text = "!translate-all-on xx yy".into(); assert!(handler.execute(&msg).await.unwrap().is_empty()); - msg.text = "!translate-on es en".into(); + msg.text = "!translate-all-on es en".into(); assert!(handler.execute(&msg).await.unwrap().is_empty()); assert!(store.is_active("group.main")); - msg.text = "!translate-off".into(); + msg.text = "!translate-me-on fr en".into(); + assert!(handler.execute(&msg).await.unwrap().is_empty()); + assert!(store + .get_member_translate("group.main", "+15550002222") + .is_some()); + + msg.text = "!translate-all-off".into(); + assert!(handler.execute(&msg).await.unwrap().is_empty()); + assert!(!store.is_active("group.main")); + assert!(store.in_chat_auto_active("group.main")); + + msg.text = "!translate-me-off".into(); + assert!(handler.execute(&msg).await.unwrap().is_empty()); + assert!(!store.in_chat_auto_active("group.main")); + } + + #[tokio::test] + async fn refuses_when_threads_active_and_disable_applies_pending() { + 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_sidecar("group.main", "es", "group.es".into(), "es-internal".into()); + let handler = TranslateAllHandler::new( + store.clone(), + 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: "+15550002222".into(), + source_number: Some("+15550002222".into()), + source_name: None, + text: "!translate-all-on es en".into(), + timestamp: 1, + message_timestamp: 1, + is_group: true, + group_id: Some("group.main".into()), + group_name: None, + receiving_account: "+15550001111".into(), + attachments: vec![], + quote: None, + }; assert!(handler.execute(&msg).await.unwrap().is_empty()); assert!(!store.is_active("group.main")); + assert!(matches!( + store.get_pending_switch("group.main"), + Some(PendingSwitch::EnableAllOn { .. }) + )); + } + + #[tokio::test] + async fn enable_threads_applies_pending_subscribe() { + 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; + 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": "Language Thread Spanish", + "id": "group.es", + "internal_id": "es-internal" + } + ]))) + .mount(&signal) + .await; + + let store = GroupPreferencesStore::new_in_memory(0); + let mode = GroupTranslateMode::new( + resolve_language("es").unwrap(), + resolve_language("en").unwrap(), + ); + store.set_member_translate("group.main", "+15550002222", mode); + store.set_pending_switch( + "group.main", + PendingSwitch::EnableThreads { + user: "+15550002222".into(), + lang: "es".into(), + address: Some("+15550002222".into()), + }, + ); + + let handler = TranslateAllHandler::new( + store.clone(), + 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: "+15550002222".into(), + source_number: Some("+15550002222".into()), + source_name: None, + text: "!enable-threads".into(), + timestamp: 1, + message_timestamp: 1, + is_group: true, + group_id: Some("group.main".into()), + group_name: None, + receiving_account: "+15550001111".into(), + attachments: vec![], + quote: None, + }; + assert!(handler.execute(&msg).await.unwrap().is_empty()); + assert!(store.threads_active("group.main")); + assert_eq!( + store.member_lang("group.main", "+15550002222"), + Some("es".into()) + ); + assert_eq!( + store.lookup_sidecar("es-internal"), + Some(("group.main".into(), "es".into())) + ); + assert!(!store.in_chat_auto_active("group.main")); } #[tokio::test] diff --git a/crates/signal-bot/src/commands/translate_langs.rs b/crates/signal-bot/src/commands/translate_langs.rs index 84c2772..25c48a8 100644 --- a/crates/signal-bot/src/commands/translate_langs.rs +++ b/crates/signal-bot/src/commands/translate_langs.rs @@ -40,7 +40,7 @@ impl CommandHandler for TranslateLangsHandler { async fn execute(&self, _message: &BotMessage) -> AppResult { Ok(format!( - "**Supported languages** (use code with !translate-me-on):\n\n{}", + "**Supported languages** (use code with !translate-me-thread or !translate-me-on):\n\n{}", format_language_list(ALL_LANGUAGES) )) } @@ -91,7 +91,8 @@ mod tests { }; let out = h.execute(&msg).await.unwrap(); assert!(out.contains("**Supported languages**")); - assert!(out.contains("!translate-me-on")); + assert!(out.contains("!translate-me-thread")); + assert!(out.contains("!translate-me-on") || out.contains("translate-me-thread")); assert!(out.contains("es")); } } diff --git a/crates/signal-bot/src/commands/translate_me.rs b/crates/signal-bot/src/commands/translate_me.rs index aa136d1..ddcebe4 100644 --- a/crates/signal-bot/src/commands/translate_me.rs +++ b/crates/signal-bot/src/commands/translate_me.rs @@ -1,16 +1,20 @@ -//! Language Threads: `!translate-me-on` / `!translate-me-off` + relay engine. +//! Language Threads: `!translate-me-thread` / `!leave` / `!enable-in-chat` + relay engine. //! //! Main group stays multilingual. Each subscribed language gets a -//! `SigLang {Language} · {disambiguator}` Signal sidecar. Messages fan out: +//! `{Language} · {disambiguator}` 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, Language}; -use crate::commands::translate_service::{detect_text_language, near_ai_translate}; +use crate::commands::translate_service::{ + detect_text_language, near_ai_translate, strip_transcript_prefix, DEFAULT_TRANSCRIPT_PREFIX, +}; use crate::commands::CommandHandler; use crate::error::AppResult; -use crate::group_preferences_store::GroupPreferencesStore; +use crate::group_preferences_store::{ + GroupPreferencesStore, GroupTranslateMode, LanguageBridge, PendingSwitch, +}; use async_trait::async_trait; use near_ai_client::NearAiClient; use signal_client::{BotMessage, SignalClient}; @@ -19,13 +23,19 @@ use std::sync::Arc; use tracing::{debug, info, instrument, warn}; const GROUP_ONLY_MSG: &str = - "!translate-me-on is only available in the main mutual-aid group (not DMs)."; + "!translate-me-thread 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"; + "Subscribe from the main group with !translate-me-thread . Use !leave here to leave."; +const USAGE_MSG: &str = "Usage: !translate-me-thread (e.g. !translate-me-thread es)"; 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 ."; +Message this bot in a 1:1 chat once, then retry !translate-me-thread ."; +const LEAVE_SIDECAR_ONLY_MSG: &str = + "!leave is only available inside a Language Thread. Open that chat and send !leave (or !commands)."; +const IN_CHAT_BLOCK_MSG: &str = "In-chat auto-translate is already on in this group, so Language Threads can't start alongside it.\n\nTo switch, send:\n!enable-threads"; +/// Tear down Language Threads so in-chat can run (`!enable-in-chat`). +const ENABLE_IN_CHAT_CMDS: &[&str] = &["!enable-in-chat", "!translation-enable-in-chat"]; +const THREADS_DISABLED_SIDECAR_MSG: &str = "Language Threads were disabled in the main group (in-chat translation is on).\n\nReturn to the main chat to continue — this thread will no longer relay messages."; +const LEAVE_CMDS: &[&str] = &["!leave"]; pub struct TranslateMeHandler { store: Arc, @@ -51,57 +61,35 @@ impl TranslateMeHandler { 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") + starts_with_word(t, "!translate-me-thread") || starts_with_word(t, "!translation-me-thread") } 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" + LEAVE_CMDS.contains(&text.trim()) + } + + fn is_enable_in_chat(text: &str) -> bool { + ENABLE_IN_CHAT_CMDS.contains(&text.trim()) } fn is_command(text: &str) -> bool { 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 ") + Self::is_on_command(t) || Self::is_off_command(t) || Self::is_enable_in_chat(t) } fn on_lang_arg(text: &str) -> Option<&str> { let t = text.trim(); - for prefix in ["!translate-me-on", "!translation-me-on"] { + for prefix in ["!translate-me-thread", "!translation-me-thread"] { 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 } 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('!') - { + if message.group_id.is_none() || text.is_empty() || text.starts_with('!') { return false; } let Some(gid) = message.group_id.as_deref() else { @@ -113,11 +101,15 @@ impl TranslateMeHandler { async fn handle_command(&self, message: &BotMessage) -> AppResult { let text = message.text.trim(); + if Self::is_enable_in_chat(text) { + return self.handle_enable_in_chat(message).await; + } + if Self::is_off_command(text) { - return self.handle_off(message).await; + return self.handle_leave(message).await; } - if Self::is_on_command(text) || starts_with_word(text, "!translate-me ") { + if Self::is_on_command(text) { let Some(gid) = message.group_id.as_deref() else { return Ok(GROUP_ONLY_MSG.into()); }; @@ -129,17 +121,265 @@ impl TranslateMeHandler { let Some(lang_token) = Self::on_lang_arg(text) else { return Ok(USAGE_MSG.into()); }; - return self.handle_on(message, gid, lang_token).await; + + if self.store.in_chat_auto_active(gid) { + self.store.set_pending_switch( + gid, + PendingSwitch::EnableThreads { + user: message.source.clone(), + lang: lang_token.to_string(), + address: message.invite_address(), + }, + ); + return Ok(IN_CHAT_BLOCK_MSG.into()); + } + + return Self::subscribe_user_to_thread( + &self.store, + &self.signal, + message, + gid, + lang_token, + None, + None, + ) + .await; } Ok(USAGE_MSG.into()) } - async fn handle_on( + async fn handle_leave(&self, message: &BotMessage) -> AppResult { + let Some(gid) = message.group_id.as_deref() else { + return Ok(LEAVE_SIDECAR_ONLY_MSG.into()); + }; + + let Some((main_id, _)) = self.store.lookup_sidecar(gid) else { + return Ok(LEAVE_SIDECAR_ONLY_MSG.into()); + }; + + 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 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 leave"); + } + } + } + + let lang_name = resolve_language(&lang) + .map(|l| l.name) + .unwrap_or(lang.as_str()); + Ok(format!("Left the {lang_name} sidecar.")) + } + + async fn handle_enable_in_chat(&self, message: &BotMessage) -> AppResult { + let Some(gid) = message.group_id.as_deref() else { + return Ok("!enable-in-chat is only available in the main group.".into()); + }; + if self.store.lookup_sidecar(gid).is_some() { + return Ok( + "!enable-in-chat works from the main group. Use !leave to leave this thread." + .into(), + ); + } + + let Some(bridge) = self.store.take_bridge(gid) else { + let pending = self.store.take_pending_switch(gid); + if pending.is_none() { + return Ok( + "Language Threads was not active in this chat. See !translation-threads." + .into(), + ); + } + return Ok(self + .apply_pending_in_chat(gid, pending, "Language Threads was not active.") + .await); + }; + + let bot = message.receiving_account.as_str(); + self.notify_sidecars_threads_disabled(bot, &bridge).await; + + for (user, lang) in &bridge.members { + let address = bridge + .member_addresses + .get(user) + .cloned() + .unwrap_or_else(|| user.clone()); + if let Some(send_id) = bridge.sidecar_send_id(lang) { + if let Err(e) = self + .signal + .remove_members(bot, send_id, vec![address]) + .await + { + warn!(error = %e, user = %user, "Failed to remove member during enable-in-chat"); + } + } + } + + let pending = self.store.take_pending_switch(gid); + Ok(self + .apply_pending_in_chat(gid, pending, "Language Threads disabled.") + .await) + } + + async fn notify_sidecars_threads_disabled(&self, bot: &str, bridge: &LanguageBridge) { + let mut notified = std::collections::HashSet::new(); + for send_id in bridge.sidecars.values() { + if !notified.insert(send_id.as_str()) { + continue; + } + if let Err(e) = self + .signal + .send(bot, send_id, THREADS_DISABLED_SIDECAR_MSG) + .await + { + warn!( + error = %e, + send_id, + "Failed to notify sidecar that Language Threads were disabled" + ); + } else { + info!( + send_id, + "Notified sidecar that Language Threads were disabled" + ); + } + } + } + + async fn apply_pending_in_chat( + &self, + group_id: &str, + pending: Option, + disabled_prefix: &str, + ) -> String { + match pending { + Some(PendingSwitch::EnableAllOn { lang_a, lang_b, .. }) => { + if let (Some(a), Some(b)) = (resolve_language(&lang_a), resolve_language(&lang_b)) { + let mode = GroupTranslateMode::new(a, b); + let pair = mode.display_pair(); + self.store.set(group_id.to_string(), mode); + format!("{disabled_prefix} Group translate enabled: {pair}") + } else { + format!( + "{disabled_prefix} Could not apply pending group translate (unknown language)." + ) + } + } + Some(PendingSwitch::EnableMeOn { + user, + lang_a, + lang_b, + }) => { + if let (Some(a), Some(b)) = (resolve_language(&lang_a), resolve_language(&lang_b)) { + let mode = GroupTranslateMode::new(a, b); + let pair = mode.display_pair(); + self.store.set_member_translate(group_id, &user, mode); + format!("{disabled_prefix} Personal translate enabled: {pair}") + } else { + format!( + "{disabled_prefix} Could not apply pending personal translate (unknown language)." + ) + } + } + Some(other) => { + self.store.set_pending_switch(group_id, other); + format!( + "{disabled_prefix} You can enable in-chat with !translate-all-on or !translate-me-on." + ) + } + None => format!( + "{disabled_prefix} You can enable in-chat with !translate-all-on or !translate-me-on." + ), + } + } + + #[instrument(skip(self, message))] + async fn resolve_sidecar_route( &self, message: &BotMessage, + ) -> AppResult> { + let gid = match message.group_id.as_deref() { + Some(id) => id, + None => return Ok(None), + }; + + if let Some(route) = self.store.lookup_sidecar(gid) { + return Ok(Some(route)); + } + + if gid.starts_with("group.") { + if let Some(route) = self.store.lookup_sidecar_by_send_id(gid) { + return Ok(Some(route)); + } + } + + match self.signal.list_groups(&message.receiving_account).await { + Ok(groups) => Ok(self + .store + .reconcile_sidecar_internal_from_groups(gid, &groups)), + Err(e) => { + warn!(error = %e, "list_groups failed during sidecar reconcile"); + Ok(None) + } + } + } + + #[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.resolve_sidecar_route(message).await? { + 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 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(()) + } + + /// Subscribe `user` (defaults to message.source) to a language sidecar on `main_id`. + pub(crate) async fn subscribe_user_to_thread( + store: &Arc, + signal: &Arc, + message: &BotMessage, main_id: &str, lang_token: &str, + user_override: Option<&str>, + address_override: Option<&str>, ) -> AppResult { let Some(lang) = resolve_language(lang_token) else { return Ok(format!( @@ -147,25 +387,29 @@ impl TranslateMeHandler { )); }; - let Some(address) = message.invite_address() else { - return Ok(NO_ADDRESS_MSG.into()); + let address = match address_override + .map(str::to_string) + .or_else(|| message.invite_address()) + { + Some(a) => a, + None => return Ok(NO_ADDRESS_MSG.into()), }; - let user_key = message.source.clone(); + let user_key = user_override + .map(str::to_string) + .unwrap_or_else(|| message.source.clone()); let bot = &message.receiving_account; - if let Some(existing) = self.store.member_lang(main_id, &user_key) { + if let Some(existing) = store.member_lang(main_id, &user_key) { if existing == lang.code { return Ok(format!( - "You are already in the {} sidecar. Accept the Signal invite if it is still pending.", - lang.name - )); + "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(bridge) = store.get_bridge(main_id) { if let Some(old_send) = bridge.sidecar_send_id(&existing) { - if let Err(e) = self - .signal + if let Err(e) = signal .remove_members(bot, old_send, vec![address.clone()]) .await { @@ -175,7 +419,7 @@ impl TranslateMeHandler { } } - let bridge = self.store.get_bridge(main_id); + let bridge = store.get_bridge(main_id); let sidecar_exists = bridge .as_ref() .and_then(|b| b.sidecar_send_id(lang.code)) @@ -187,8 +431,7 @@ impl TranslateMeHandler { .and_then(|b| b.sidecar_send_id(lang.code)) .unwrap() .to_string(); - if let Err(e) = self - .signal + if let Err(e) = signal .add_members(bot, &send_id, vec![address.clone()]) .await { @@ -198,22 +441,20 @@ impl TranslateMeHandler { )); } } else { - // Default English SigLang title before create+invite. let (name, description, welcome) = sidecar_copy(lang, message.group_name.as_deref(), main_id); - match self - .signal + match signal .create_group(bot, &name, vec![address.clone()], Some(&description)) .await { Ok(group) => { - self.store.set_sidecar( + store.set_sidecar( main_id, lang.code, group.id.clone(), group.internal_id.clone(), ); - if let Err(e) = self.signal.send(bot, &group.id, &welcome).await { + if let Err(e) = signal.send(bot, &group.id, &welcome).await { warn!(error = %e, "Failed to send sidecar welcome"); } } @@ -226,96 +467,21 @@ impl TranslateMeHandler { } } - self.store - .set_bridge_member(main_id, &user_key, lang.code, Some(address)); + store.set_bridge_member(main_id, &user_key, lang.code, Some(address)); info!( main_id, lang = lang.code, user = %user_key, - "translate-me-on: subscribed to sidecar" + "translate-me-thread: subscribed to sidecar" ); - Ok(format!( - "{} joined {} thread", - message.display_name(), - lang.name - )) - } - - 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 (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()) + let who = if user_override.is_some_and(|u| u != message.source.as_str()) { + user_key } else { - return Ok("You are not subscribed to a language sidecar in this chat.".into()); - }; - - 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 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"); - } - } - } - - 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(()); + message.display_name() }; - - 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 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(()) + Ok(format!("{who} joined {} thread", lang.name)) } async fn handle_main_out( @@ -323,7 +489,8 @@ impl TranslateMeHandler { message: &BotMessage, bridge: &crate::group_preferences_store::LanguageBridge, ) -> AppResult<()> { - let detected = detect_text_language(&message.text); + let spoken = strip_transcript_prefix(&message.text, DEFAULT_TRANSCRIPT_PREFIX); + let detected = detect_text_language(&spoken); let display = message.display_name(); let bot = &message.receiving_account; let mut translation_cache: HashMap = HashMap::new(); @@ -334,11 +501,11 @@ impl TranslateMeHandler { continue; }; let body = if detected.as_deref() == Some(lang.as_str()) { - message.text.clone() + spoken.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 { + match near_ai_translate(&self.near_ai, &spoken, target_lang).await { Ok(t) => { translation_cache.insert(lang.clone(), t.clone()); t @@ -367,9 +534,10 @@ impl TranslateMeHandler { return Ok(()); }; + let spoken = strip_transcript_prefix(&message.text, DEFAULT_TRANSCRIPT_PREFIX); let display = message.display_name(); let bot = &message.receiving_account; - let to_main = format_attribution(&display, &message.text); + let to_main = format_attribution(&display, &spoken); // Resolve main send id (incoming group_id is internal). let main_recipient = match self @@ -400,7 +568,7 @@ impl TranslateMeHandler { 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 { + match near_ai_translate(&self.near_ai, &spoken, target_lang).await { Ok(t) => { translation_cache.insert(lang.clone(), t.clone()); t @@ -433,13 +601,18 @@ fn sidecar_copy( main_id: &str, ) -> (String, String, String) { let disambiguator = sidecar_disambiguator(main_group_name, main_id); - let name = format!("SigLang {} · {}", lang.name, disambiguator); + let name = format!("{} · {}", lang.name, disambiguator); let description = format!( "{} Language Thread bridged to the main group ({}).", lang.name, disambiguator ); let welcome = format!( - "Welcome to {name}. Messages here are bridged with the main group. Send !help for thread commands." + "Welcome to {name}. Messages here are bridged with the main group. + +!commands +!rename +!leave +!info" ); (name, description, welcome) } @@ -495,18 +668,6 @@ fn strip_word_prefix<'a>(text: &'a str, prefix: &str) -> Option<&'a str> { .map(str::trim) } -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 -} - #[async_trait] impl CommandHandler for TranslateMeHandler { fn matches(&self, message: &BotMessage) -> bool { @@ -543,6 +704,8 @@ impl CommandHandler for TranslateMeHandler { #[cfg(test)] mod tests { use super::*; + use crate::commands::translate_all::TranslateAllHandler; + use crate::group_preferences_store::GroupTranslateMode; fn group_msg(source: &str, text: &str) -> BotMessage { BotMessage { @@ -565,17 +728,17 @@ mod tests { fn sidecar_copy_uses_main_group_name() { let it = resolve_language("it").unwrap(); let (name, description, welcome) = sidecar_copy(it, Some(" Stacked "), "main-id"); - assert_eq!(name, "SigLang Italian · Stacked"); + assert_eq!(name, "Italian · Stacked"); assert!(description.contains("Stacked")); - assert!(welcome.starts_with("Welcome to SigLang Italian · Stacked")); - assert!(welcome.contains("!help")); + assert!(welcome.starts_with("Welcome to Italian · Stacked")); + assert!(welcome.contains("!commands\n!rename \n!leave\n!info")); } #[test] fn sidecar_copy_falls_back_to_hash_without_group_name() { let es = resolve_language("es").unwrap(); let (name, _, _) = sidecar_copy(es, None, "main-internal-abc"); - assert!(name.starts_with("SigLang Spanish · ")); + assert!(name.starts_with("Spanish · ")); assert!(!name.contains("None")); let suffix = name.rsplit('·').next().unwrap().trim(); assert_eq!(suffix.len(), 4); @@ -596,29 +759,33 @@ mod tests { #[test] 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_on_command("!translate-me-thread es")); + assert!(TranslateMeHandler::is_on_command("!translate-me-thread es")); + assert!(TranslateMeHandler::is_off_command("!leave")); + assert!(TranslateMeHandler::is_off_command("!leave")); assert!(!TranslateMeHandler::is_command("!translate-on es en")); + assert!(!TranslateMeHandler::is_command("!translate-me-on es en")); assert!(!TranslateMeHandler::is_command("!translate es")); } #[test] fn parses_lang_arg() { assert_eq!( - TranslateMeHandler::on_lang_arg("!translate-me-on es"), + TranslateMeHandler::on_lang_arg("!translate-me-thread es"), Some("es") ); assert_eq!( - TranslateMeHandler::on_lang_arg("!translate-me on en"), + TranslateMeHandler::on_lang_arg("!translate-me-thread en"), Some("en") ); assert_eq!( - TranslateMeHandler::on_lang_arg("!translate-me es"), + TranslateMeHandler::on_lang_arg("!translate-me-thread es"), Some("es") ); - assert_eq!(TranslateMeHandler::on_lang_arg("!translate-me-on"), None); + assert_eq!( + TranslateMeHandler::on_lang_arg("!translate-me-thread"), + None + ); } #[test] @@ -699,7 +866,7 @@ mod tests { source: "+15550002222".into(), source_number: Some("+15550002222".into()), source_name: None, - text: "!translate-me-on es".into(), + text: "!translate-me-thread es".into(), timestamp: 1, message_timestamp: 1, is_group: false, @@ -715,10 +882,10 @@ mod tests { let mut group = dm.clone(); group.is_group = true; group.group_id = Some("group.main".into()); - group.text = "!translate-me-on".into(); + group.text = "!translate-me-thread".into(); assert!(handler.execute(&group).await.unwrap().is_empty()); - group.text = "!translate-me-off".into(); + group.text = "!leave".into(); assert!(handler.execute(&group).await.unwrap().is_empty()); } @@ -801,7 +968,7 @@ mod tests { let store = GroupPreferencesStore::new_in_memory(0); let handler = handler_pair(store.clone(), signal.uri(), near.uri()); - let mut msg = group_msg("+15550002222", "!translate-me-on es"); + let mut msg = group_msg("+15550002222", "!translate-me-thread es"); msg.group_id = Some("main-internal".into()); assert!(handler.matches(&msg)); assert!(handler.execute(&msg).await.unwrap().is_empty()); @@ -816,11 +983,11 @@ mod tests { assert!(handler.execute(&msg).await.unwrap().is_empty()); // Existing sidecar: second user joins via add_members. - let mut other = group_msg("+15550003333", "!translate-me-on es"); + let mut other = group_msg("+15550003333", "!translate-me-thread es"); other.group_id = Some("main-internal".into()); assert!(handler.execute(&other).await.unwrap().is_empty()); - msg.text = "!translate-me-on fr".into(); + msg.text = "!translate-me-thread fr".into(); assert!(handler.execute(&msg).await.unwrap().is_empty()); assert_eq!( store @@ -831,7 +998,7 @@ mod tests { // Off from sidecar group. msg.group_id = Some("fr-internal".into()); - msg.text = "!translate-me-off".into(); + msg.text = "!leave".into(); assert!(handler.execute(&msg).await.unwrap().is_empty()); assert!(store.member_lang("main-internal", "+15550002222").is_none()); } @@ -963,6 +1130,122 @@ mod tests { assert!(send_recipients(&signal).await.is_empty()); } + #[tokio::test] + async fn lookup_reconciles_wrong_internal_id() { + 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(), "group.es".into()); + + let handler = handler_pair(store.clone(), signal.uri(), "http://127.0.0.1:9".into()); + + let mut side = group_msg("+15550002222", "Hola desde el thread"); + side.group_id = Some("es-internal".into()); + assert!(handler.execute(&side).await.unwrap().is_empty()); + assert_eq!( + store.lookup_sidecar("es-internal"), + Some(("main-internal".into(), "es".into())) + ); + assert!(send_recipients(&signal) + .await + .contains(&"group.main".to_string())); + + assert!(handler.execute(&side).await.unwrap().is_empty()); + let recipients = send_recipients(&signal).await; + assert!( + recipients + .iter() + .filter(|r| r.as_str() == "group.main") + .count() + >= 2 + ); + } + + #[tokio::test] + async fn relay_after_enable_threads_switch() { + use serde_json::json; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let signal = MockServer::start().await; + let near = MockServer::start().await; + mount_near(&near).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("/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": "Language Thread Spanish", + "id": "group.es", + "internal_id": "es-internal" + } + ]))) + .mount(&signal) + .await; + + let store = GroupPreferencesStore::new_in_memory(0); + let mode = GroupTranslateMode::new( + resolve_language("es").unwrap(), + resolve_language("en").unwrap(), + ); + store.set_member_translate("main-internal", "+15550002222", mode); + + let translate_me = handler_pair(store.clone(), signal.uri(), near.uri()); + let translate_all = TranslateAllHandler::new( + store.clone(), + Arc::new( + NearAiClient::new("key", near.uri(), "m", std::time::Duration::from_secs(5)) + .unwrap(), + ), + Arc::new(SignalClient::new(signal.uri()).unwrap()), + ); + + let mut blocked = group_msg("+15550002222", "!translate-me-thread es"); + blocked.group_id = Some("main-internal".into()); + assert!(translate_me.execute(&blocked).await.unwrap().is_empty()); + assert!(store.get_pending_switch("main-internal").is_some()); + + let mut enable = group_msg("+15550002222", "!enable-threads"); + enable.group_id = Some("main-internal".into()); + assert!(translate_all.execute(&enable).await.unwrap().is_empty()); + assert!(store.threads_active("main-internal")); + assert_eq!( + store.lookup_sidecar("es-internal"), + Some(("main-internal".into(), "es".into())) + ); + + let main_msg = group_msg( + "+15550002222", + "Hello everyone in the main mutual aid group", + ); + assert!(translate_me.execute(&main_msg).await.unwrap().is_empty()); + assert!(send_recipients(&signal) + .await + .contains(&"group.es".to_string())); + + let mut side = group_msg("+15550002222", "Hola desde el thread español"); + side.group_id = Some("es-internal".into()); + assert!(translate_me.execute(&side).await.unwrap().is_empty()); + assert!(send_recipients(&signal) + .await + .contains(&"group.main".to_string())); + } + #[tokio::test] async fn relay_n1_then_second_sidecar() { let signal = wiremock::MockServer::start().await; @@ -1078,6 +1361,89 @@ mod tests { out } + async fn send_messages(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(msg) = body["message"].as_str() { + out.push(msg.to_string()); + } + } + out + } + + #[tokio::test] + async fn enable_in_chat_notifies_each_sidecar_before_removing_members() { + use serde_json::json; + use wiremock::matchers::{method, path, path_regex}; + 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; + Mock::given(method("DELETE")) + .and(path_regex(r"^/v1/groups/%2B15550001111/.+/members$")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .mount(&signal) + .await; + + let store = GroupPreferencesStore::new_in_memory(0); + store.set_sidecar( + "main-internal", + "es", + "group.es".into(), + "es-internal".into(), + ); + store.set_sidecar( + "main-internal", + "fr", + "group.fr".into(), + "fr-internal".into(), + ); + store.set_bridge_member( + "main-internal", + "+15550002222", + "es", + Some("+15550002222".into()), + ); + store.set_bridge_member( + "main-internal", + "+15550003333", + "fr", + Some("+15550003333".into()), + ); + + let handler = handler_pair(store.clone(), signal.uri(), "http://127.0.0.1:9".into()); + let mut msg = group_msg("+15550001111", "!enable-in-chat"); + msg.group_id = Some("main-internal".into()); + msg.source = "+15550001111".into(); + + let reply = handler.execute(&msg).await.unwrap(); + assert!(reply.is_empty()); + assert!(store.get_bridge("main-internal").is_none()); + + let recipients = send_recipients(&signal).await; + assert!(recipients.contains(&"group.es".to_string())); + assert!(recipients.contains(&"group.fr".to_string())); + + let bodies = send_messages(&signal).await; + assert_eq!(bodies.len(), 2); + assert!(bodies + .iter() + .all(|m| m.contains("Language Threads were disabled"))); + assert!(bodies.iter().all(|m| m.contains("Return to the main chat"))); + } + #[tokio::test] async fn on_rejects_unknown_lang_and_missing_address() { use serde_json::json; @@ -1113,15 +1479,15 @@ mod tests { ); let handler = handler_pair(store, signal.uri(), "http://127.0.0.1:9".into()); - let unknown = group_msg("+15550002222", "!translate-me-on zz"); + let unknown = group_msg("+15550002222", "!translate-me-thread zz"); assert!(handler.execute(&unknown).await.unwrap().is_empty()); - let mut no_addr = group_msg("alice", "!translate-me-on es"); + let mut no_addr = group_msg("alice", "!translate-me-thread es"); no_addr.source_number = None; assert!(handler.execute(&no_addr).await.unwrap().is_empty()); // Subscribe from sidecar is rejected. - let mut from_side = group_msg("+15550002222", "!translate-me-on es"); + let mut from_side = group_msg("+15550002222", "!translate-me-thread es"); from_side.group_id = Some("es-internal".into()); assert!(handler.execute(&from_side).await.unwrap().is_empty()); } diff --git a/crates/signal-bot/src/commands/translate_service.rs b/crates/signal-bot/src/commands/translate_service.rs index 5a6b398..b6de39b 100644 --- a/crates/signal-bot/src/commands/translate_service.rs +++ b/crates/signal-bot/src/commands/translate_service.rs @@ -1,4 +1,4 @@ -//! Shared translation helpers for `!translate` and `!translate-on`. +//! Shared translation helpers for `!translate` and in-chat auto-translate. use crate::commands::translate_lang::Language; use crate::group_preferences_store::GroupTranslateMode; @@ -8,6 +8,26 @@ use whatlang::{Detector, Lang}; const MIN_DETECT_CONFIDENCE: f64 = 0.2; +/// Default Whisper transcript reply prefix (matches `WHISPER__REPLY_PREFIX` default). +pub const DEFAULT_TRANSCRIPT_PREFIX: &str = "📝 Transcript:"; + +/// Strip a Whisper transcript label so detect/translate see spoken words only. +/// +/// If `prefix` is empty, falls back to [`DEFAULT_TRANSCRIPT_PREFIX`]. +pub fn strip_transcript_prefix(text: &str, prefix: &str) -> String { + let raw = text.trim(); + let prefix = if prefix.is_empty() { + DEFAULT_TRANSCRIPT_PREFIX + } else { + prefix + }; + if let Some(rest) = raw.strip_prefix(prefix) { + rest.trim_start_matches('\n').trim().to_string() + } else { + raw.to_string() + } +} + /// Map a detected code into one side of the active pair when possible. fn normalize_for_translate_all_pair(mode: &GroupTranslateMode, code: &str) -> Option { let code = code.to_lowercase(); @@ -122,7 +142,7 @@ fn text_language_candidates(mode: &GroupTranslateMode, text: &str) -> Vec Option { let info = whatlang::detect(text)?; if info.confidence() < MIN_DETECT_CONFIDENCE { @@ -245,6 +265,35 @@ mod tests { use super::*; use crate::commands::translate_lang::resolve_language; + #[test] + fn strip_transcript_prefix_removes_label() { + let body = strip_transcript_prefix( + "📝 Transcript:\nHola, ¿cómo estás?", + DEFAULT_TRANSCRIPT_PREFIX, + ); + assert_eq!(body, "Hola, ¿cómo estás?"); + assert_eq!( + strip_transcript_prefix("hola como estas?", DEFAULT_TRANSCRIPT_PREFIX), + "hola como estas?" + ); + } + + #[test] + fn resolve_text_pair_on_stripped_transcript() { + let mode = GroupTranslateMode::new( + resolve_language("es").unwrap(), + resolve_language("en").unwrap(), + ); + let spoken = strip_transcript_prefix( + "📝 Transcript:\nHola, ¿cómo estás ustedes? Hoy es miércoles y tengo tres bananas.", + DEFAULT_TRANSCRIPT_PREFIX, + ); + let pair = resolve_translate_all_text_pair(&mode, &spoken) + .expect("spoken Spanish body should match es in es/en pair"); + assert_eq!(pair.0.code, "es"); + assert_eq!(pair.1.code, "en"); + } + #[test] fn detects_english_text() { assert_eq!( diff --git a/crates/signal-bot/src/commands/verify.rs b/crates/signal-bot/src/commands/verify.rs index bf68bf7..3ebaade 100644 --- a/crates/signal-bot/src/commands/verify.rs +++ b/crates/signal-bot/src/commands/verify.rs @@ -1,6 +1,7 @@ //! Verify command - provides cryptographic attestation proofs. use crate::commands::CommandHandler; +use crate::config::BotRole; use crate::error::AppResult; use async_trait::async_trait; use dstack_client::DstackClient; @@ -30,14 +31,16 @@ impl OperatorAddresses { pub struct VerifyHandler { dstack: Arc, + role: BotRole, /// Optional operator addresses to display. operator_addresses: Option, } impl VerifyHandler { - pub fn new(dstack: Arc) -> Self { + pub fn new(dstack: Arc, role: BotRole) -> Self { Self { dstack, + role, operator_addresses: None, } } @@ -45,14 +48,24 @@ impl VerifyHandler { /// Create handler with operator addresses to display. pub fn with_operator_addresses( dstack: Arc, + role: BotRole, addresses: OperatorAddresses, ) -> Self { Self { dstack, + role, operator_addresses: Some(addresses), } } + pub(crate) fn prefixed_challenge(&self, raw: Option) -> String { + let user_part = raw.unwrap_or_else(|| "no-challenge-provided".into()); + match self.role { + BotRole::Translation => format!("Translation: {user_part}"), + BotRole::Transcription => format!("Transcription: {user_part}"), + } + } + /// Parse the challenge nonce from the message text. /// Expected format: "!verify " or just "!verify" fn parse_challenge(&self, text: &str) -> Option { @@ -278,15 +291,16 @@ impl CommandHandler for VerifyHandler { } async fn execute(&self, message: &BotMessage) -> AppResult { - let challenge = self.parse_challenge(&message.text); + let raw = self.parse_challenge(&message.text); + let prefixed = self.prefixed_challenge(raw); info!( "Attestation requested by {} with challenge: {:?}", message.source, - challenge.as_ref().map(|c| &c[..c.len().min(20)]) + prefixed.chars().take(40).collect::() ); - let result = self.generate_attestation(challenge.as_deref()).await; + let result = self.generate_attestation(Some(&prefixed)).await; Ok(self.format_response(result)) } } @@ -295,16 +309,31 @@ impl CommandHandler for VerifyHandler { mod tests { use super::*; - fn create_test_handler() -> VerifyHandler { - VerifyHandler { - dstack: Arc::new(DstackClient::new("/fake")), - operator_addresses: None, - } + fn create_test_handler(role: BotRole) -> VerifyHandler { + VerifyHandler::new(Arc::new(DstackClient::new("/fake")), role) + } + + #[test] + fn prefixed_challenge_labels_role() { + let tr = create_test_handler(BotRole::Translation); + assert_eq!( + tr.prefixed_challenge(Some("hello".into())), + "Translation: hello" + ); + assert_eq!( + tr.prefixed_challenge(None), + "Translation: no-challenge-provided" + ); + let tx = create_test_handler(BotRole::Transcription); + assert_eq!( + tx.prefixed_challenge(Some("hello".into())), + "Transcription: hello" + ); } #[test] fn test_parse_challenge_with_nonce() { - let handler = create_test_handler(); + let handler = create_test_handler(BotRole::Translation); assert_eq!( handler.parse_challenge("!verify abc123"), @@ -318,7 +347,7 @@ mod tests { #[test] fn test_parse_challenge_without_nonce() { - let handler = create_test_handler(); + let handler = create_test_handler(BotRole::Translation); assert_eq!(handler.parse_challenge("!verify"), None); assert_eq!(handler.parse_challenge("!verify "), None); @@ -326,7 +355,7 @@ mod tests { #[test] fn test_format_response_not_in_tee() { - let handler = create_test_handler(); + let handler = create_test_handler(BotRole::Translation); let result = AttestationResult { in_tee: false, error: Some("Not running in TEE".into()), @@ -346,7 +375,7 @@ mod tests { let expected_hex = hex::encode(challenge.as_bytes()); - let handler = create_test_handler(); + let handler = create_test_handler(BotRole::Translation); let result = AttestationResult { in_tee: true, compose_hash: Some("abc123".into()), @@ -376,7 +405,7 @@ mod tests { let expected_hash = hasher.finalize(); let expected_hex = hex::encode(expected_hash); - let handler = create_test_handler(); + let handler = create_test_handler(BotRole::Translation); let result = AttestationResult { in_tee: true, compose_hash: Some("abc123".into()), @@ -397,7 +426,7 @@ mod tests { #[test] fn test_format_response_with_challenge() { - let handler = create_test_handler(); + let handler = create_test_handler(BotRole::Translation); let challenge = "my-nonce"; let report_data_hex = hex::encode(challenge.as_bytes()); @@ -424,7 +453,7 @@ mod tests { #[test] fn test_format_response_without_challenge() { - let handler = create_test_handler(); + let handler = create_test_handler(BotRole::Translation); let result = AttestationResult { in_tee: true, compose_hash: Some("abc123".into()), @@ -441,7 +470,7 @@ mod tests { #[test] fn test_verification_instructions_present() { - let handler = create_test_handler(); + let handler = create_test_handler(BotRole::Translation); let result = AttestationResult { in_tee: true, compose_hash: Some("abc123".into()), @@ -466,14 +495,15 @@ mod tests { #[test] fn test_operator_addresses_displayed() { - let handler = VerifyHandler { - dstack: Arc::new(DstackClient::new("/fake")), - operator_addresses: Some(OperatorAddresses { + let handler = VerifyHandler::with_operator_addresses( + Arc::new(DstackClient::new("/fake")), + BotRole::Translation, + OperatorAddresses { base: Some("0xABC123".into()), near: Some("operator.near".into()), solana: None, - }), - }; + }, + ); let result = AttestationResult { in_tee: true, @@ -496,7 +526,7 @@ mod tests { #[tokio::test] async fn execute_reports_not_in_tee() { - let handler = create_test_handler(); + let handler = create_test_handler(BotRole::Translation); let msg = BotMessage { source: "+15550002222".into(), source_number: Some("+15550002222".into()), diff --git a/crates/signal-bot/src/config.rs b/crates/signal-bot/src/config.rs index fe9ba93..12300ba 100644 --- a/crates/signal-bot/src/config.rs +++ b/crates/signal-bot/src/config.rs @@ -34,7 +34,7 @@ pub struct Config { #[serde(default)] pub whisper: WhisperConfig, - /// Group auto-translate (`!translate-on`) configuration + /// In-chat auto-translate (`!translate-all-on` / `!translate-me-on`) configuration #[serde(default)] pub translate_all: TranslateAllConfig, diff --git a/crates/signal-bot/src/group_invite_acceptor.rs b/crates/signal-bot/src/group_invite_acceptor.rs new file mode 100644 index 0000000..4f76fc4 --- /dev/null +++ b/crates/signal-bot/src/group_invite_acceptor.rs @@ -0,0 +1,256 @@ +//! Auto-accept pending Signal group invites. +//! +//! - **Translation:** accept any pending invite/request (MVP). +//! - **Transcription:** accept only when the translation peer is already a member/admin. + +use crate::config::BotRole; +use signal_client::{Group, SignalClient}; +use std::sync::Arc; +use std::time::Duration; +use tracing::{debug, info, warn}; + +/// How often to scan `GET /v1/groups` for pending invites. +pub const DEFAULT_INVITE_POLL_INTERVAL: Duration = Duration::from_secs(5); + +/// Policy for which pending invites to accept. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InvitePolicy { + /// Accept every group where this account is pending (translation hub). + AcceptAll, + /// Accept only when `peer` is already a member or admin (transcription worker). + AcceptIfPeerPresent { peer: String }, +} + +impl InvitePolicy { + /// Build policy from bot role and optional peer phone. + /// + /// Transcription without `PEER_PHONE` refuses all invites. + pub fn for_role(role: BotRole, peer_phone: Option<&str>) -> Option { + match role { + BotRole::Translation => Some(Self::AcceptAll), + BotRole::Transcription => { + let peer = peer_phone?.trim(); + if peer.is_empty() { + return None; + } + Some(Self::AcceptIfPeerPresent { + peer: peer.to_string(), + }) + } + } + } +} + +/// Whether this account should `POST .../join` for `group`. +pub fn should_join(group: &Group, self_identity: &str, policy: &InvitePolicy) -> bool { + if !group.is_pending_for(self_identity) { + return false; + } + match policy { + InvitePolicy::AcceptAll => true, + InvitePolicy::AcceptIfPeerPresent { peer } => group.has_member_or_admin(peer), + } +} + +/// One scan: list groups and join those that match policy. +pub async fn accept_pending_invites( + signal: &SignalClient, + phone_number: &str, + policy: &InvitePolicy, +) -> usize { + let groups = match signal.list_groups(phone_number).await { + Ok(g) => g, + Err(e) => { + warn!(error = %e, "Failed to list groups for invite accept"); + return 0; + } + }; + + let mut joined = 0; + for group in groups { + if !should_join(&group, phone_number, policy) { + continue; + } + match signal.join_group(phone_number, &group.id).await { + Ok(()) => { + info!( + group_id = %group.id, + group_name = %group.name, + "Accepted pending group invite" + ); + joined += 1; + } + Err(e) => { + warn!( + error = %e, + group_id = %group.id, + group_name = %group.name, + "Failed to join group from pending invite" + ); + } + } + } + joined +} + +/// Resolve which Signal account to poll (configured phone, else first registered). +pub async fn resolve_account_phone( + signal: &SignalClient, + configured: Option<&str>, +) -> Option { + if let Some(phone) = configured.map(str::trim).filter(|p| !p.is_empty()) { + return Some(phone.to_string()); + } + match signal.list_accounts().await { + Ok(accounts) => accounts.into_iter().next(), + Err(e) => { + warn!(error = %e, "Failed to list accounts for invite accept"); + None + } + } +} + +/// Background loop: periodically accept pending invites per policy. +pub async fn run_invite_acceptor( + signal: Arc, + phone_number: Option, + policy: InvitePolicy, + poll_interval: Duration, +) { + info!( + ?policy, + interval_secs = poll_interval.as_secs(), + "Group invite acceptor started" + ); + loop { + if let Some(account) = resolve_account_phone(&signal, phone_number.as_deref()).await { + let n = accept_pending_invites(&signal, &account, &policy).await; + if n == 0 { + debug!("No pending group invites to accept"); + } + } else { + warn!("No Signal account available for invite accept; retrying"); + } + tokio::time::sleep(poll_interval).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn group_json( + id: &str, + members: &[&str], + pending: &[&str], + admins: &[&str], + ) -> serde_json::Value { + json!({ + "name": "G", + "id": id, + "internal_id": format!("{id}-internal"), + "members": members, + "pending_invites": pending, + "pending_requests": [], + "admins": admins + }) + } + + fn sample_group(members: &[&str], pending: &[&str], admins: &[&str]) -> Group { + serde_json::from_value(group_json("group.abc==", members, pending, admins)).unwrap() + } + + #[test] + fn policy_for_role() { + assert_eq!( + InvitePolicy::for_role(BotRole::Translation, None), + Some(InvitePolicy::AcceptAll) + ); + assert_eq!( + InvitePolicy::for_role(BotRole::Transcription, Some("+15550009999")), + Some(InvitePolicy::AcceptIfPeerPresent { + peer: "+15550009999".into() + }) + ); + assert!(InvitePolicy::for_role(BotRole::Transcription, None).is_none()); + assert!(InvitePolicy::for_role(BotRole::Transcription, Some(" ")).is_none()); + } + + #[test] + fn translation_accepts_any_pending() { + let policy = InvitePolicy::AcceptAll; + let pending = sample_group(&["+15550001111"], &["+15550002222"], &["+15550001111"]); + assert!(should_join(&pending, "+15550002222", &policy)); + assert!(!should_join(&pending, "+15550001111", &policy)); + } + + #[test] + fn transcription_requires_peer_member() { + let policy = InvitePolicy::AcceptIfPeerPresent { + peer: "+15550003333".into(), + }; + let with_peer = sample_group( + &["+15550003333", "+15550001111"], + &["+15550002222"], + &["+15550003333"], + ); + assert!(should_join(&with_peer, "+15550002222", &policy)); + + let without_peer = sample_group(&["+15550001111"], &["+15550002222"], &["+15550001111"]); + assert!(!should_join(&without_peer, "+15550002222", &policy)); + } + + #[tokio::test] + async fn accept_pending_joins_matching_groups() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/groups/%2B15550002222")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + group_json("group.skip==", &["+15550001111"], &[], &["+15550001111"]), + group_json( + "group.join==", + &["+15550001111"], + &["+15550002222"], + &["+15550001111"] + ), + ]))) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/v1/groups/%2B15550002222/group.join%3D%3D/join")) + .respond_with(ResponseTemplate::new(204)) + .expect(1) + .mount(&server) + .await; + + let signal = SignalClient::new(server.uri()).unwrap(); + let n = accept_pending_invites(&signal, "+15550002222", &InvitePolicy::AcceptAll).await; + assert_eq!(n, 1); + } + + #[tokio::test] + async fn transcription_skips_when_peer_absent() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/groups/%2B15550002222")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([group_json( + "group.nopeer==", + &["+15550001111"], + &["+15550002222"], + &["+15550001111"] + )]))) + .mount(&server) + .await; + + let signal = SignalClient::new(server.uri()).unwrap(); + let policy = InvitePolicy::AcceptIfPeerPresent { + peer: "+15550003333".into(), + }; + let n = accept_pending_invites(&signal, "+15550002222", &policy).await; + assert_eq!(n, 0); + } +} diff --git a/crates/signal-bot/src/group_preferences_store.rs b/crates/signal-bot/src/group_preferences_store.rs index 0472451..f94bf71 100644 --- a/crates/signal-bot/src/group_preferences_store.rs +++ b/crates/signal-bot/src/group_preferences_store.rs @@ -99,39 +99,73 @@ impl LanguageBridge { } } +/// Pending product switch after a refused enable (Threads ↔ in-chat mutual exclusion). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PendingSwitch { + /// Apply `!translate-me-thread ` after `!enable-threads`. + EnableThreads { + user: String, + lang: String, + #[serde(default)] + address: Option, + }, + /// Apply `!translate-all-on` after `!enable-in-chat`. + EnableAllOn { + user: String, + lang_a: String, + lang_b: String, + }, + /// Apply `!translate-me-on` after `!enable-in-chat`. + EnableMeOn { + user: String, + lang_a: String, + lang_b: String, + }, +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct GroupPreference { - #[serde(default = "default_true")] + #[serde(default = "default_false")] transcribe_enabled: bool, #[serde(default)] translate: Option, + /// Per-user in-chat auto-translate pairs (`message.source` → pair). + #[serde(default)] + translate_members: HashMap, #[serde(default)] menu_language: MenuLanguage, /// Mutual-aid language sidecar bridge (replaces legacy per-user translate map). #[serde(default)] language_bridge: Option, + #[serde(default)] + pending_switch: Option, } impl Default for GroupPreference { fn default() -> Self { Self { - transcribe_enabled: true, + transcribe_enabled: false, translate: None, + translate_members: HashMap::new(), menu_language: MenuLanguage::En, language_bridge: None, + pending_switch: None, } } } impl GroupPreference { fn is_default(&self) -> bool { - self.transcribe_enabled + !self.transcribe_enabled && self.translate.is_none() + && self.translate_members.is_empty() && self.menu_language == MenuLanguage::En && self .language_bridge .as_ref() .is_none_or(LanguageBridge::is_empty) + && self.pending_switch.is_none() } } @@ -237,7 +271,7 @@ impl GroupPreferencesStore { .read() .unwrap() .get(group_id) - .is_none_or(|p| p.transcribe_enabled) + .is_some_and(|p| p.transcribe_enabled) } pub fn set_transcribe_enabled(self: &Arc, group_id: &str, enabled: bool) { @@ -275,7 +309,7 @@ impl GroupPreferencesStore { self.schedule_persist(); } - // --- Auto-translate (per group) --- + // --- Auto-translate (per group + per-user) --- pub fn is_active(&self, group_id: &str) -> bool { self.groups @@ -286,6 +320,20 @@ impl GroupPreferencesStore { .is_some() } + /// Group-wide or any personal in-chat auto-translate is configured. + pub fn in_chat_auto_active(&self, group_id: &str) -> bool { + self.groups + .read() + .unwrap() + .get(group_id) + .is_some_and(|p| p.translate.is_some() || !p.translate_members.is_empty()) + } + + /// Language Threads bridge exists for this main group. + pub fn threads_active(&self, main_group_id: &str) -> bool { + self.get_bridge(main_group_id).is_some() + } + pub fn get(&self, group_id: &str) -> Option { self.groups .read() @@ -294,6 +342,16 @@ impl GroupPreferencesStore { .and_then(|p| p.translate.clone()) } + /// Resolve intercept pair: personal for `user` wins over group-wide. + pub fn resolve_in_chat_mode(&self, group_id: &str, user: &str) -> Option { + let groups = self.groups.read().unwrap(); + let pref = groups.get(group_id)?; + pref.translate_members + .get(user) + .cloned() + .or_else(|| pref.translate.clone()) + } + pub fn set(self: &Arc, group_id: String, mode: GroupTranslateMode) { { let mut groups = self.groups.write().unwrap(); @@ -320,6 +378,116 @@ impl GroupPreferencesStore { had_translate } + pub fn get_member_translate(&self, group_id: &str, user: &str) -> Option { + self.groups + .read() + .unwrap() + .get(group_id) + .and_then(|p| p.translate_members.get(user).cloned()) + } + + pub fn set_member_translate( + self: &Arc, + group_id: &str, + user: &str, + mode: GroupTranslateMode, + ) { + { + let mut groups = self.groups.write().unwrap(); + let entry = groups.entry(group_id.to_string()).or_default(); + entry.translate_members.insert(user.to_string(), mode); + } + self.schedule_persist(); + } + + pub fn clear_member_translate(self: &Arc, group_id: &str, user: &str) -> bool { + let cleared = { + let mut groups = self.groups.write().unwrap(); + let Some(entry) = groups.get_mut(group_id) else { + return false; + }; + let had = entry.translate_members.remove(user).is_some(); + if entry.is_default() { + groups.remove(group_id); + } + had + }; + self.schedule_persist(); + cleared + } + + /// Clear group-wide and all personal in-chat auto; returns whether anything was cleared. + pub fn disable_in_chat(self: &Arc, group_id: &str) -> bool { + let cleared = { + let mut groups = self.groups.write().unwrap(); + let Some(entry) = groups.get_mut(group_id) else { + return false; + }; + let had = entry.translate.is_some() || !entry.translate_members.is_empty(); + entry.translate = None; + entry.translate_members.clear(); + if entry.is_default() { + groups.remove(group_id); + } + had + }; + self.schedule_persist(); + cleared + } + + /// Clear in-chat auto and consume pending switch without removing the group row. + pub fn disable_in_chat_and_take_pending( + self: &Arc, + group_id: &str, + ) -> (bool, Option) { + let result = { + let mut groups = self.groups.write().unwrap(); + match groups.get_mut(group_id) { + None => (false, None), + Some(entry) => { + let had = entry.translate.is_some() || !entry.translate_members.is_empty(); + entry.translate = None; + entry.translate_members.clear(); + let pending = entry.pending_switch.take(); + (had, pending) + } + } + }; + self.schedule_persist(); + result + } + + pub fn set_pending_switch(self: &Arc, group_id: &str, pending: PendingSwitch) { + { + let mut groups = self.groups.write().unwrap(); + let entry = groups.entry(group_id.to_string()).or_default(); + entry.pending_switch = Some(pending); + } + self.schedule_persist(); + } + + pub fn take_pending_switch(self: &Arc, group_id: &str) -> Option { + let pending = { + let mut groups = self.groups.write().unwrap(); + let entry = groups.get_mut(group_id)?; + let pending = entry.pending_switch.take(); + if entry.is_default() { + groups.remove(group_id); + } + pending + }; + self.schedule_persist(); + pending + } + + pub fn get_pending_switch(&self, group_id: &str) -> Option { + self.groups + .read() + .unwrap() + .get(group_id) + .and_then(|p| p.pending_switch.clone()) + } + // --- Language sidecar bridge (keyed by main group internal_id) --- pub fn get_bridge(&self, main_group_id: &str) -> Option { @@ -331,6 +499,22 @@ impl GroupPreferencesStore { .filter(|b| !b.is_empty()) } + /// Remove and return the language bridge (for `!enable-in-chat` teardown). + pub fn take_bridge(self: &Arc, main_group_id: &str) -> Option { + let bridge = { + let mut groups = self.groups.write().unwrap(); + let entry = groups.get_mut(main_group_id)?; + let bridge = entry.language_bridge.take().filter(|b| !b.is_empty()); + if entry.is_default() { + groups.remove(main_group_id); + } + bridge + }; + self.rebuild_sidecar_index(); + self.schedule_persist(); + bridge + } + /// Resolve sidecar internal_id → (main_id, lang). pub fn lookup_sidecar(&self, sidecar_internal_id: &str) -> Option<(String, String)> { self.sidecar_index @@ -340,6 +524,81 @@ impl GroupPreferencesStore { .cloned() } + /// Match inbound sidecar send id (`group.…`) when index only has internal ids. + pub fn lookup_sidecar_by_send_id(&self, send_id: &str) -> Option<(String, String)> { + for (main_id, pref) in self.groups.read().unwrap().iter() { + if let Some(bridge) = &pref.language_bridge { + for (lang, sid) in &bridge.sidecars { + if sid == send_id { + return Some((main_id.clone(), lang.clone())); + } + } + } + } + None + } + + pub fn update_sidecar_internal( + self: &Arc, + main_group_id: &str, + lang: &str, + internal_id: &str, + ) { + let updated = { + let mut groups = self.groups.write().unwrap(); + match groups.get_mut(main_group_id) { + None => false, + Some(entry) => match entry.language_bridge.as_mut() { + None => false, + Some(bridge) => { + if bridge.sidecar_internal.get(lang).map(String::as_str) + == Some(internal_id) + { + return; + } + bridge + .sidecar_internal + .insert(lang.to_string(), internal_id.to_string()); + true + } + }, + } + }; + if updated { + self.rebuild_sidecar_index(); + self.schedule_persist(); + } + } + + /// Fix stored internal id using `list_groups` output; returns route when matched. + pub fn reconcile_sidecar_internal_from_groups( + self: &Arc, + inbound_internal_id: &str, + groups: &[signal_client::Group], + ) -> Option<(String, String)> { + let send_id = groups + .iter() + .find(|g| g.internal_id == inbound_internal_id) + .map(|g| g.id.as_str())?; + let mut matched: Option<(String, String)> = None; + for (main_id, pref) in self.groups.read().unwrap().iter() { + if let Some(bridge) = &pref.language_bridge { + for (lang, sid) in &bridge.sidecars { + if sid == send_id { + matched = Some((main_id.clone(), lang.clone())); + break; + } + } + } + if matched.is_some() { + break; + } + } + let (main_id, lang) = matched?; + self.update_sidecar_internal(&main_id, &lang, inbound_internal_id); + Some((main_id, lang)) + } + 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()) @@ -606,8 +865,8 @@ impl GroupPreferencesStore { } } -fn default_true() -> bool { - true +fn default_false() -> bool { + false } #[cfg(test)] @@ -636,19 +895,125 @@ mod tests { } #[test] - fn transcribe_defaults_on() { + fn personal_and_group_in_chat_helpers() { + let store = GroupPreferencesStore::new_in_memory(0); + let gid = "group.main"; + let mode = GroupTranslateMode::new( + resolve_language("es").unwrap(), + resolve_language("en").unwrap(), + ); + + assert!(!store.in_chat_auto_active(gid)); + store.set_member_translate(gid, "+alice", mode.clone()); + assert!(store.in_chat_auto_active(gid)); + assert!(!store.is_active(gid)); + assert_eq!( + store.resolve_in_chat_mode(gid, "+alice").unwrap().lang_a, + "es" + ); + assert!(store.resolve_in_chat_mode(gid, "+bob").is_none()); + + store.set(gid.into(), mode.clone()); + assert_eq!( + store.resolve_in_chat_mode(gid, "+bob").unwrap().lang_a, + "es" + ); + // Personal still wins for alice if we set a different pair. + let fr_en = GroupTranslateMode::new( + resolve_language("fr").unwrap(), + resolve_language("en").unwrap(), + ); + store.set_member_translate(gid, "+alice", fr_en); + assert_eq!( + store.resolve_in_chat_mode(gid, "+alice").unwrap().lang_a, + "fr" + ); + + assert!(store.disable_in_chat(gid)); + assert!(!store.in_chat_auto_active(gid)); + } + + #[test] + fn disable_in_chat_and_take_pending_keeps_group_row() { + let store = GroupPreferencesStore::new_in_memory(0); + let gid = "main"; + let mode = GroupTranslateMode::new( + resolve_language("es").unwrap(), + resolve_language("en").unwrap(), + ); + store.set_member_translate(gid, "+alice", mode); + store.set_pending_switch( + gid, + PendingSwitch::EnableThreads { + user: "+alice".into(), + lang: "es".into(), + address: Some("+alice".into()), + }, + ); + let (had, pending) = store.disable_in_chat_and_take_pending(gid); + assert!(had); + assert!(matches!(pending, Some(PendingSwitch::EnableThreads { .. }))); + assert!(!store.in_chat_auto_active(gid)); + assert!(store.groups.read().unwrap().contains_key(gid)); + } + + #[test] + fn reconcile_sidecar_internal_from_groups() { let store = GroupPreferencesStore::new_in_memory(0); - assert!(store.is_transcribe_enabled("group.new")); + store.set_sidecar("main-internal", "es", "group.es".into(), "group.es".into()); + let groups = vec![signal_client::Group { + name: "es".into(), + id: "group.es".into(), + internal_id: "es-internal".into(), + members: vec![], + pending_invites: vec![], + pending_requests: vec![], + admins: vec![], + }]; + let route = store.reconcile_sidecar_internal_from_groups("es-internal", &groups); + assert_eq!(route, Some(("main-internal".into(), "es".into()))); + assert_eq!( + store.lookup_sidecar("es-internal"), + Some(("main-internal".into(), "es".into())) + ); + } + + #[test] + fn pending_switch_and_take_bridge() { + let store = GroupPreferencesStore::new_in_memory(0); + let gid = "main"; + store.set_sidecar(gid, "es", "group.es".into(), "es-internal".into()); + assert!(store.threads_active(gid)); + store.set_pending_switch( + gid, + PendingSwitch::EnableAllOn { + user: "+1".into(), + lang_a: "es".into(), + lang_b: "en".into(), + }, + ); + let bridge = store.take_bridge(gid).unwrap(); + assert!(bridge.sidecars.contains_key("es")); + assert!(!store.threads_active(gid)); + let pending = store.take_pending_switch(gid).unwrap(); + assert!(matches!(pending, PendingSwitch::EnableAllOn { .. })); + assert!(store.take_pending_switch(gid).is_none()); + } + + #[test] + fn transcribe_defaults_off() { + let store = GroupPreferencesStore::new_in_memory(0); + assert!(!store.is_transcribe_enabled("group.new")); } #[test] fn transcribe_toggle_persists_in_memory() { let store = GroupPreferencesStore::new_in_memory(0); let gid = "group.abc"; - store.set_transcribe_enabled(gid, false); - assert!(!store.is_transcribe_enabled(gid)); store.set_transcribe_enabled(gid, true); assert!(store.is_transcribe_enabled(gid)); + store.set_transcribe_enabled(gid, false); + assert!(!store.is_transcribe_enabled(gid)); } #[test] @@ -680,14 +1045,14 @@ mod tests { resolve_language("en").unwrap(), ); store.set("group.one".into(), mode); - store.set_transcribe_enabled("group.two", false); + store.set_transcribe_enabled("group.two", true); store.set_menu_language("group.three", MenuLanguage::Es); store.persist_now().await.unwrap(); let store2 = GroupPreferencesStore::with_test_key(DstackClient::new("/x"), path, key, 30).await; assert!(store2.is_active("group.one")); - assert!(!store2.is_transcribe_enabled("group.two")); + assert!(store2.is_transcribe_enabled("group.two")); assert_eq!(store2.get_menu_language("group.three"), MenuLanguage::Es); } diff --git a/crates/signal-bot/src/handlers_setup.rs b/crates/signal-bot/src/handlers_setup.rs index 7a32f8b..1b6f540 100644 --- a/crates/signal-bot/src/handlers_setup.rs +++ b/crates/signal-bot/src/handlers_setup.rs @@ -31,7 +31,7 @@ pub async fn build_handlers( } } -/// Transcription CVM: voice / !transcribe* / help / privacy / verify. +/// Transcription CVM: voice / !transcribe* / !transcription / help-transcription / verify. pub async fn build_transcription_handlers( config: &Config, signal: Arc, @@ -71,14 +71,12 @@ pub async fn build_transcription_handlers( Arc::new(GroupTranscribePrefs(group_prefs.clone())), ); handlers.push(Box::new(TranscriptionMenuHandler::new())); - handlers.push(Box::new(VerifyHandler::new(dstack))); - handlers.push(Box::new(HelpHandler::new( - group_prefs, - BotRole::Transcription, - ))); - handlers.push(Box::new(PrivacyHandler::new(BotRole::Transcription))); + handlers.push(Box::new(HelpTranscriptionHandler::new())); + handlers.push(Box::new(VerifyHandler::new(dstack, BotRole::Transcription))); - info!("Transcription role: voice / !transcribe* / !transcription / help / privacy / verify"); + info!( + "Transcription role: voice / !transcribe* / !transcription / help-transcription / verify (hub !help / !info / !privacy on translation bot only)" + ); Ok(handlers) } @@ -134,22 +132,33 @@ pub async fn build_translation_handlers( bot_identity, ))); info!( - "Language Threads enabled: !translate-me-on / !translate-me-off (max {}/min)", + "Language Threads enabled: !translate-me-thread / !leave / !enable-in-chat (max {}/min)", config.translate_all.max_messages_per_minute ); if config.translate_all.enabled { - handlers.push(Box::new(TranslateAllHandler::new( + handlers.push(Box::new(TranslateAllHandler::with_peer( group_prefs.clone(), near_ai.clone(), signal.clone(), + config.signal.peer_phone.clone(), + DEFAULT_TRANSCRIPT_PREFIX, ))); - info!("In-chat translation enabled: !translate-on / !translate-off"); + info!( + "In-chat translation enabled: !translate-all-on / !translate-me-on / !enable-threads" + ); } handlers.push(Box::new(TranslationMenuHandler::new( config.translate_all.enabled, ))); + handlers.push(Box::new(TranslationThreadsMenuHandler::new())); + handlers.push(Box::new(TranslationInChatMenuHandler::new( + config.translate_all.enabled, + ))); + handlers.push(Box::new(HelpThreadsHandler::new())); + handlers.push(Box::new(HelpInChatHandler::new())); + handlers.push(Box::new(HelpTranscriptionHandler::new())); handlers.push(Box::new(TranscriptionPairingHandler::new( signal.clone(), config.signal.peer_phone.clone(), @@ -168,12 +177,17 @@ pub async fn build_translation_handlers( group_prefs.clone(), signal.clone(), ))); - handlers.push(Box::new(VerifyHandler::new(dstack))); - handlers.push(Box::new(HelpHandler::new( + handlers.push(Box::new(CommandsHandler::new(group_prefs.clone()))); + handlers.push(Box::new(VerifyHandler::new( + dstack.clone(), + BotRole::Translation, + ))); + handlers.push(Box::new(HelpHandler::new(BotRole::Translation))); + handlers.push(Box::new(InfoHandler::new( group_prefs, BotRole::Translation, ))); - handlers.push(Box::new(PrivacyHandler::new(BotRole::Translation))); + handlers.push(Box::new(PrivacyHandler::new())); info!("Translation role: hub menus + in-chat + Language Threads"); Ok(handlers) @@ -244,7 +258,7 @@ mod tests { .await .expect("transcription handlers"); - assert_eq!(handlers.len(), 7); + assert_eq!(handlers.len(), 6); assert_eq!( labels(&handlers), vec![ @@ -252,9 +266,8 @@ mod tests { "manual_transcribe", "transcribe", "transcription_menu", + "help_transcription", "command", // verify - "help", - "privacy", ] ); } @@ -284,11 +297,16 @@ mod tests { .await .expect("translation handlers"); - assert_eq!(handlers.len(), 11); + assert_eq!(handlers.len(), 18); let got = labels(&handlers); assert!(got.contains(&"translate_me")); assert!(got.contains(&"translate_all")); assert!(got.contains(&"translation_menu")); + assert!(got.contains(&"translation_threads_menu")); + assert!(got.contains(&"translation_in_chat_menu")); + assert!(got.contains(&"help_threads")); + assert!(got.contains(&"help_in_chat")); + assert!(got.contains(&"help_transcription")); assert!(got.contains(&"transcription_pairing")); assert!(got.contains(&"in_chat_menu")); assert!(!got.contains(&"translate_parallel")); @@ -297,8 +315,10 @@ mod tests { assert!(got.contains(&"translate")); assert!(got.contains(&"translate_langs")); assert!(got.contains(&"rename")); + assert!(got.contains(&"commands")); assert!(!got.contains(&"set_language")); assert!(got.contains(&"help")); + assert!(got.contains(&"info")); assert!(got.contains(&"privacy")); } @@ -328,10 +348,15 @@ mod tests { .await .expect("translation handlers"); - assert_eq!(handlers.len(), 10); + assert_eq!(handlers.len(), 17); assert!(!labels(&handlers).contains(&"translate_all")); assert!(labels(&handlers).contains(&"translate_me")); assert!(labels(&handlers).contains(&"in_chat_menu")); + assert!(labels(&handlers).contains(&"translation_threads_menu")); + assert!(labels(&handlers).contains(&"help_threads")); + assert!(labels(&handlers).contains(&"help_in_chat")); + assert!(labels(&handlers).contains(&"help_transcription")); + assert!(labels(&handlers).contains(&"info")); } #[tokio::test] diff --git a/crates/signal-bot/src/lib.rs b/crates/signal-bot/src/lib.rs index c19f3fb..d33c580 100644 --- a/crates/signal-bot/src/lib.rs +++ b/crates/signal-bot/src/lib.rs @@ -3,6 +3,7 @@ pub mod commands; pub mod config; pub mod dispatch; pub mod error; +pub mod group_invite_acceptor; pub mod group_preferences_store; pub mod handlers_setup; pub mod menu_language; diff --git a/crates/signal-bot/src/main.rs b/crates/signal-bot/src/main.rs index 1c41e2a..4b379c1 100644 --- a/crates/signal-bot/src/main.rs +++ b/crates/signal-bot/src/main.rs @@ -6,6 +6,9 @@ use signal_bot::bot_identity::BotIdentity; use signal_bot::config::Config; use signal_bot::dispatch::dispatch_message; use signal_bot::error::AppResult; +use signal_bot::group_invite_acceptor::{ + run_invite_acceptor, InvitePolicy, DEFAULT_INVITE_POLL_INTERVAL, +}; use signal_bot::handlers_setup::build_handlers; use signal_client::{MessageReceiver, SignalClient}; use std::sync::Arc; @@ -48,6 +51,23 @@ async fn main() -> AppResult<()> { } info!("Signal API healthy"); + if let (Some(self_phone), Some(peer_raw)) = ( + config.signal.phone_number.as_deref(), + config.signal.peer_phone.as_deref(), + ) { + let peer = peer_raw.trim(); + if !peer.is_empty() { + match signal.trust_identity(self_phone, peer).await { + Ok(()) => info!(peer, "Trusted Signal peer identity (PEER_PHONE)"), + Err(e) => warn!( + peer, + error = %e, + "Could not trust PEER_PHONE identity — peer messages may not decrypt until trusted" + ), + } + } + } + let bot_identity = BotIdentity::new(); let handlers = build_handlers( @@ -59,6 +79,23 @@ async fn main() -> AppResult<()> { .await?; info!("Registered {} command handlers", handlers.len()); + + match InvitePolicy::for_role(config.bot.role, config.signal.peer_phone.as_deref()) { + Some(policy) => { + let signal_invites = signal.clone(); + let phone = config.signal.phone_number.clone(); + tokio::spawn(async move { + run_invite_acceptor(signal_invites, phone, policy, DEFAULT_INVITE_POLL_INTERVAL) + .await; + }); + } + None => { + warn!( + "Group invite auto-accept disabled (transcription requires SIGNAL__PEER_PHONE = translation bot)" + ); + } + } + info!("Listening for messages..."); let receiver = MessageReceiver::new((*signal).clone(), config.signal.poll_interval); diff --git a/crates/signal-client/src/client.rs b/crates/signal-client/src/client.rs index a1a192d..9ec0a4c 100644 --- a/crates/signal-client/src/client.rs +++ b/crates/signal-client/src/client.rs @@ -101,20 +101,31 @@ impl SignalClient { } 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(), - members: vec![], - pending_invites: vec![], - pending_requests: vec![], - admins: vec![], - }); + const LIST_RETRIES: u32 = 5; + const LIST_BACKOFF_MS: u64 = 200; + + let mut group = Group { + name: name.to_string(), + id: created.id.clone(), + internal_id: created.id.clone(), + members: vec![], + pending_invites: vec![], + pending_requests: vec![], + admins: vec![], + }; + + for attempt in 0..LIST_RETRIES { + let groups = self.list_groups(phone_number).await?; + if let Some(found) = groups.into_iter().find(|g| g.id == created.id) { + group = found; + if group.internal_id != group.id { + break; + } + } + if attempt + 1 < LIST_RETRIES { + tokio::time::sleep(Duration::from_millis(LIST_BACKOFF_MS)).await; + } + } self.cache_group_mapping(phone_number, &group).await; debug!( @@ -178,6 +189,33 @@ impl SignalClient { Ok(()) } + /// Accept a pending group invite (`POST /v1/groups/{number}/{groupid}/join`). + #[instrument(skip(self))] + pub async fn join_group( + &self, + phone_number: &str, + group_send_id: &str, + ) -> Result<(), SignalError> { + let encoded_number = encode(phone_number); + let encoded_group = encode(group_send_id); + let response = self + .client + .post(format!( + "{}/v1/groups/{}/{}/join", + self.base_url, encoded_number, encoded_group + )) + .send() + .await?; + + if !response.status().is_success() { + let msg = response.text().await.unwrap_or_default(); + return Err(SignalError::Api(msg)); + } + + debug!("Joined group {} as {}", group_send_id, phone_number); + Ok(()) + } + async fn change_members( &self, phone_number: &str, @@ -327,6 +365,62 @@ impl SignalClient { Ok(messages) } + /// Trust a peer identity (`PUT /v1/identities/{number}/trust/{numberToTrust}`). + /// + /// Uses `trust_all_known_keys` so paired product bots can exchange group messages after + /// re-registration (safety-number change). Intended for the configured `PEER_PHONE` only. + #[instrument(skip(self))] + pub async fn trust_identity( + &self, + phone_number: &str, + number_to_trust: &str, + ) -> Result<(), SignalError> { + let encoded_number = encode(phone_number); + let encoded_peer = encode(number_to_trust); + let response = self + .client + .put(format!( + "{}/v1/identities/{}/trust/{}", + self.base_url, encoded_number, encoded_peer + )) + .json(&serde_json::json!({ "trust_all_known_keys": true })) + .send() + .await?; + + let status = response.status(); + if status.is_success() || status.as_u16() == 204 { + return Ok(()); + } + let msg = response.text().await.unwrap_or_default(); + Err(SignalError::Api(format!( + "Trust identity failed ({status}): {msg}" + ))) + } + + /// List known identities for an account (`GET /v1/identities/{number}`). + #[instrument(skip(self))] + pub async fn list_identities( + &self, + phone_number: &str, + ) -> Result, SignalError> { + let encoded_number = encode(phone_number); + let response = self + .client + .get(format!( + "{}/v1/identities/{}", + self.base_url, encoded_number + )) + .send() + .await?; + + if !response.status().is_success() { + let msg = response.text().await.unwrap_or_default(); + return Err(SignalError::Api(msg)); + } + + Ok(response.json().await?) + } + /// Download attachment bytes by ID (auto-downloaded during receive). #[instrument(skip(self))] pub async fn download_attachment(&self, attachment_id: &str) -> Result, SignalError> { diff --git a/crates/signal-client/src/lib.rs b/crates/signal-client/src/lib.rs index 8eea8be..fb89277 100644 --- a/crates/signal-client/src/lib.rs +++ b/crates/signal-client/src/lib.rs @@ -477,6 +477,55 @@ mod tests { assert_eq!(group.internal_id, "es-internal-id"); } + #[tokio::test] + async fn test_create_group_retries_list_for_internal_id() { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/groups/%2B15555555555")) + .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!([])) + .append_header("x-attempt", "1"), + ) + .up_to_n_times(1) + .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": "Language Thread 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", + "Language Thread Spanish", + vec!["+14155551234".into()], + None, + ) + .await + .unwrap(); + + assert_eq!(group.internal_id, "es-internal-id"); + } + #[tokio::test] async fn test_add_and_remove_members() { let mock_server = MockServer::start().await; @@ -529,7 +578,7 @@ mod tests { Mock::given(method("PUT")) .and(path("/v1/groups/%2B15555555555/group.sidecarEs%3D%3D")) .and(body_json(serde_json::json!({ - "name": "SigLang Spanish · Stacked" + "name": "Spanish · Stacked" }))) .respond_with(ResponseTemplate::new(204)) .mount(&mock_server) @@ -537,11 +586,44 @@ mod tests { let client = create_test_client(&mock_server).await; client - .update_group( - "+15555555555", - "group.sidecarEs==", - "SigLang Spanish · Stacked", - ) + .update_group("+15555555555", "group.sidecarEs==", "Spanish · Stacked") + .await + .unwrap(); + } + + #[tokio::test] + async fn test_join_group() { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/groups/%2B15555555555/group.pending%3D%3D/join")) + .respond_with(ResponseTemplate::new(204)) + .mount(&mock_server) + .await; + + let client = create_test_client(&mock_server).await; + client + .join_group("+15555555555", "group.pending==") + .await + .unwrap(); + } + + #[tokio::test] + async fn test_trust_identity() { + let mock_server = MockServer::start().await; + + Mock::given(method("PUT")) + .and(path("/v1/identities/%2B15550001111/trust/%2B15550002222")) + .and(body_json( + serde_json::json!({ "trust_all_known_keys": true }), + )) + .respond_with(ResponseTemplate::new(204)) + .mount(&mock_server) + .await; + + let client = create_test_client(&mock_server).await; + client + .trust_identity("+15550001111", "+15550002222") .await .unwrap(); } diff --git a/crates/signal-client/src/types.rs b/crates/signal-client/src/types.rs index 9cc331e..46f820d 100644 --- a/crates/signal-client/src/types.rs +++ b/crates/signal-client/src/types.rs @@ -2,6 +2,18 @@ use serde::{Deserialize, Serialize}; +/// Identity row from `GET /v1/identities/{number}`. +#[derive(Debug, Clone, Deserialize)] +pub struct IdentityEntry { + #[serde(default)] + pub number: String, + pub status: String, + #[serde(default)] + pub uuid: Option, + #[serde(default)] + pub safety_number: Option, +} + /// Incoming Signal message. #[derive(Debug, Clone, Deserialize)] pub struct IncomingMessage { @@ -73,6 +85,22 @@ impl Group { .chain(self.pending_requests.iter()) .any(|m| identities_match(m, identity)) } + + /// True if `identity` has been invited or has a pending join request (not yet a member). + pub fn is_pending_for(&self, identity: &str) -> bool { + self.pending_invites + .iter() + .chain(self.pending_requests.iter()) + .any(|m| identities_match(m, identity)) + } + + /// True if `identity` is an active member or admin. + pub fn has_member_or_admin(&self, identity: &str) -> bool { + self.members + .iter() + .chain(self.admins.iter()) + .any(|m| identities_match(m, identity)) + } } /// Compare Signal identities: exact match, or digit-only match for E.164 phones. @@ -389,6 +417,10 @@ mod tests { assert!(g.contains_member_or_pending("+15551110001")); assert!(g.contains_member_or_pending("15551110002")); // digit match on pending assert!(!g.contains_member_or_pending("+19999999999")); + assert!(g.is_pending_for("+15551110002")); + assert!(!g.is_pending_for("+15551110001")); + assert!(g.has_member_or_admin("+15551110001")); + assert!(!g.has_member_or_admin("+15551110002")); } #[test] diff --git a/docker/phala.transcription.env.example b/docker/phala.transcription.env.example index ca8b873..dbf903b 100644 --- a/docker/phala.transcription.env.example +++ b/docker/phala.transcription.env.example @@ -3,7 +3,7 @@ # phala deploy … -c docker/phala.transcription.yaml -e docker/phala.transcription.env --wait -t tdx.medium SIGNAL_PHONE=+1XXXXXXXXXX -# Optional: translation bot E.164 (peer identity; pairing is led by the translation CVM). +# Translation bot E.164 — required for auto-accepting group invites from the hub. PEER_PHONE=+1YYYYYYYYYY SIGNAL_BOT_IMAGE=YOUR_DOCKERHUB/signal-bot-tee:latest diff --git a/docker/transcription.env.example b/docker/transcription.env.example index 27de7cd..058b657 100644 --- a/docker/transcription.env.example +++ b/docker/transcription.env.example @@ -2,7 +2,7 @@ # Phone A — transcription bot (must differ from translation.env SIGNAL_PHONE). SIGNAL_PHONE=+1XXXXXXXXXX -# Translation bot phone (optional; used if TX ever needs peer identity). +# Translation bot phone — required so this bot auto-accepts group invites from the hub. PEER_PHONE=+1YYYYYYYYYY WHISPER_MODEL=small WHISPER_TIMEOUT=120s diff --git a/docs/in-chat-translation.md b/docs/in-chat-translation.md index 15cf43b..7c5c894 100644 --- a/docs/in-chat-translation.md +++ b/docs/in-chat-translation.md @@ -1,42 +1,65 @@ # In-chat (group) translation -Status: **MVP implemented** on the translation bot. +Status: **MVP implemented** on the translation bot (Bread Bot **hub** — manages menus, in-chat, and Language Threads; the transcription bot is a separate voice worker). 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 [Language Threads](language-threads.md) (multilingual main + N sidecars). In-chat stays in one Signal group; Language Threads creates sidecar groups. +Distinct from [Language Threads](language-threads.md) (multilingual main + N sidecars). In-chat stays in one Signal group; Language Threads creates sidecar groups. **In-chat auto and Language Threads are mutually exclusive** — enabling one while the other is active refuses with a switch path (`!enable-in-chat` / `!enable-threads`). ## Setup -In the group: +Menus: `!help` → `!translation-in-chat` (or legacy `!in-chat` / `!translation` redirect). Feature guide: `!help-in-chat`. + +### Group-wide ```text -!translate-on es en +!translate-all-on es en ``` - Stores a bilingual pair for this group (`lang_a` ↔ `lang_b`) - Order does not matter for detection (either side maps to the other) +- Aliases: `!translate-on`, `!translation-on`, `!translation-all-on` -Stop: +Stop group-wide only: ```text -!translate-off +!translate-all-off ``` -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. +### Personal (per subscriber) + +```text +!translate-me-on es en +``` + +- Auto-translates **that user’s** messages only (quote-reply in the same chat) +- Other members’ messages are unchanged unless group-wide is also on + +Stop personal: + +```text +!translate-me-off +``` + +Clear **all** in-chat auto (group-wide + every personal), and apply a pending Language Threads subscribe if one was refused earlier: + +```text +!enable-threads +``` ## Behavior | Mode | How | Effect | |------|-----|--------| -| **Auto** | `!translate-on` active | Every non-command group text message: detect language → if it matches one side of the pair → NEAR translate → quote-reply with `{flag} {translation}` | -| **Manual** | Reply to a message with `!translate ` | Translate only that quoted message | +| **Group auto** | `!translate-all-on` active | Every non-command group text: detect → if in pair → NEAR translate → quote-reply `{flag} {translation}` | +| **Personal auto** | `!translate-me-on` for author | Same as group auto, but only for that author’s messages. Personal pair wins over group-wide for that author (one quote-reply max). | +| **Manual** | Reply with `!translate ` | Translate only that quoted message (always allowed) | Not dual-post: the original stays as the human message; the bot only quote-replies the translation. -Skip when language is undetected or not in the pair. Bot messages are never processed (`BotIdentity`). Rate-limited per group (`TRANSLATE_ALL__MAX_MESSAGES_PER_MINUTE`). +Skip when language is undetected or not in the pair. Rate-limited per group (`TRANSLATE_ALL__MAX_MESSAGES_PER_MINUTE`). -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. +Voice notes: the **transcription** bot posts a transcript in-group; with `!translate-all-on` (or personal auto for the speaker), the **translation** bot intercepts that text like any other message — including when the transcript quote-reply still carries voice attachment metadata. The `📝 Transcript:` label is stripped before detect/translate (same as manual quote `!translate`). ## Key code @@ -45,5 +68,5 @@ Voice notes: the **transcription** bot posts a transcript in-group; with auto-tr | Auto on/off + intercept | [`crates/signal-bot/src/commands/translate_all.rs`](../crates/signal-bot/src/commands/translate_all.rs) | | 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) (flat `!translation`) | +| Prefs (`GroupTranslateMode`, `translate_members`) | [`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) (`!translation-in-chat`) | diff --git a/docs/language-threads.md b/docs/language-threads.md index 688b534..9a9c4b8 100644 --- a/docs/language-threads.md +++ b/docs/language-threads.md @@ -2,7 +2,7 @@ Status: **implemented and verified locally**; Phala TEE redeploy paused (image `daopunk/signal-bot-tee:latest` already pushed for `linux/amd64`). -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). +The sole **cross-group** bridging product on the translation bot (**hub**): 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). Voice and hub menus live on other roles — see [two-cvm-architecture.md — Bot hierarchy](two-cvm-architecture.md#bot-hierarchy). ## Problem @@ -13,18 +13,18 @@ In multilingual mutual-aid groups, organizers often dual-post by hand. Monolingu | Room | Role | |------|------| | **Main group** | Multilingual hub; bot already a member | -| **SigLang {Language} · {disambiguator}** | One Signal sidecar per subscribed language (e.g. `SigLang Spanish · Stacked`) | +| **{Language} · {disambiguator}** | One Signal sidecar per subscribed language (e.g. `Spanish · Stacked`) | -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. +Users who want a monolingual lane run `!translate-me-thread ` in **main**. The bot creates or joins the sidecar and invites them. Messages fan out across main and all active threads. ```text Main (multilingual hub) - ├── SigLang Spanish · Stacked ← monolingual ES users - ├── SigLang English · Stacked ← monolingual EN users + ├── Spanish · Stacked ← monolingual ES users + ├── English · Stacked ← monolingual EN users └── … (any !list-langs code) ``` -Default title is English `SigLang {Language} · {disambiguator}` (main group name when available, else a short hash of the main group id). Members can rename a sidecar with `!rename` from that thread’s `!help`. +Default title is English `{Language} · {disambiguator}` (main group name when available, else a short hash of the main group id). Members can rename a sidecar with `!rename` from that thread’s `!commands` menu. N=1 (one sidecar) uses the same relay rules as N=3 — add another language later with no reconfiguration. @@ -32,22 +32,24 @@ N=1 (one sidecar) uses the same relay rules as N=3 — add another language late | Command | Where | Effect | |---------|--------|--------| -| `!translate-me-on ` | Main only | Create/join sidecar; invite user | -| `!translate-me-off` | Main or sidecar | Leave sidecar | +| `!translate-me-thread ` | Main only | Create/join sidecar; invite user | +| `!leave` | Sidecar only | Leave this Language Thread | +| `!enable-in-chat` | Main | Tear down Language Threads for the group (best-effort remove members); apply pending in-chat enable if any | | `!rename ` | Sidecar only | Change this Language Thread’s group name | +| `!commands` | Sidecar only | Compact Language Thread command list | | `!list-langs` | Any | Language codes | -| `!help` / `!privacy` | Any | Hub / privacy menus (`!help` in a sidecar shows the thread menu) | -| `!verify` | As before | TEE attestation | +| `!help-threads` | Any | How Language Threads works (use case + flow) | +| `!help` / `!privacy` | Any | Hub menus (`!help` is always the Bread Bot hub; `!privacy` and `!verify` on translation bot) | -Menus are English-only for now (multi-language UI deferred). +Menus: `!help` → `!translation-threads`. English-only for now (multi-language UI deferred). -Aliases: `!translate-me on es`, `!translation-me-on es`, etc. +Aliases: `!translation-me-thread es`. -**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). +**Also on the translation bot:** [in-chat translation](in-chat-translation.md) (`!translate-all-on` / `!translate-me-on` / quote `!translate`) — same-group only, not a sidecar bridge. **Mutually exclusive with Language Threads** at setup time (refuse + `!enable-threads` / `!enable-in-chat` switch path). -**Not registered:** `!ask`, DM chat, voice/`!transcribe*` (transcription CVM). +**Not registered on translation (worker CVM handles these):** `!ask`, DM chat, voice/`!transcribe*`, `!transcription` product menu on the transcription bot, `!models`. -Menus: `!help` → `!translation` (Language Threads commands first; in-chat below). `!in-chat` redirects to the same menu. +Menus: `!help` → `!translation-threads` / `!translation-in-chat`. `!in-chat` opens the in-chat menu; `!translation` redirects to both. ## Relay rules (fan-out + BotIdentity) @@ -64,17 +66,18 @@ Same-language relay skips NEAR. Cross-language calls `near_ai_translate` (config 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. +**Ops note:** Legacy Parallel Translation Signal groups (if any) are unmanaged after that product’s retirement. Leave them manually and use `!translate-me-thread ` instead. ## Subscribe / unsubscribe flow -1. User in main: `!translate-me-on es` +1. User in main: `!translate-me-thread es` 2. Resolve language; need invite address (`sourceNumber` preferred, else usable `source`) -3. **First subscriber for that lang:** build English `SigLang …` title/description/welcome → `POST /v1/groups/{bot}` → persist send id + internal id → welcome in sidecar → confirm in main +3. **First subscriber for that lang:** build English ` …` title/description/welcome → `POST /v1/groups/{bot}` → 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 -7. Sidecar `!help` → thread menu; `!rename ` → `PUT /v1/groups/{bot}/{sendId}` +6. `!leave` (from sidecar): remove from Signal group + store +7. `!enable-in-chat` (from main): notify each Language Thread, remove members from sidecars (best-effort), clear bridge; sidecar Signal groups may remain unmanaged +8. Sidecar `!commands` → thread menu; `!rename ` → `PUT /v1/groups/{bot}/{sendId}` If Signal omits phone number, bot asks the user to DM once, then retry. @@ -123,16 +126,17 @@ Only **signal-bot** on the translation stack needs rebuild for Language Threads ### 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. +1. Main group → `!translate-me-thread es` → accept invite → message in main appears in Language Thread (translated or relayed). +2. Add a second lang (`!translate-me-thread 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). +5. From sidecar → `!leave` unsubscribes; from main → `!enable-in-chat` tears down the product. 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). +- **Transcription** (worker CVM) composes with Language Threads or in-chat in the same Signal groups. The **translation hub** invites via `!transcription`; the worker only transcribes voice. - **In-chat** translates inside one group thread; **Language Threads** bridges a multilingual main to N monolingual sidecars. ## Phala / TEE (paused) diff --git a/docs/local-dev/README.md b/docs/local-dev/README.md index f7d8226..7c0c1b9 100644 --- a/docs/local-dev/README.md +++ b/docs/local-dev/README.md @@ -6,6 +6,8 @@ Quick start (env copy + `up -d`) stays in [README.md](../README.md#local-dual-st Each stack has its own `signal-api`, network, and Signal CLI data volume. Follow **Transcription stack** and/or **Translation stack** end-to-end; use [Using both together](#using-both-together) when you need pairing in one Signal group. +**Already running and code changed?** Plain `up -d` / `restart` keep the old binary — see [Code changes (rebuild the bot)](#code-changes-rebuild-the-bot). + ## Prerequisites - Docker with Compose v2 @@ -27,6 +29,61 @@ Tokens expire quickly — generate a fresh one if registration fails with a capt --- +## Code changes (rebuild the bot) + +Local Compose **builds** `signal-bot` from this repo’s `docker/Dockerfile`. It does **not** pull a published bot image. + +That means: + +| Command | Picks up new Rust / menu code? | +|---------|--------------------------------| +| `up -d` (no `--build`) | **No** — reuses the image already on your machine | +| `restart signal-bot` | **No** — same container, same binary | +| `up -d --build --force-recreate signal-bot` | **Yes** | + +After you pull or edit bot code, rebuild and recreate the bot container (Signal registration volumes are untouched): + +```bash +# Transcription stack +docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ + up -d --build --force-recreate signal-bot + +# Translation stack +docker compose -f docker/compose.translation.yaml --env-file docker/translation.env \ + up -d --build --force-recreate signal-bot +``` + +Confirm the container is new (Created time should be “seconds/minutes ago”): + +```bash +docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ + ps signal-bot +docker compose -f docker/compose.translation.yaml --env-file docker/translation.env \ + ps signal-bot +``` + +Then tail logs and look for a fresh `Starting sigstack Signal bot` / `Listening for messages...` line: + +```bash +docker compose -f docker/compose.translation.yaml --env-file docker/translation.env \ + logs -f --tail=50 signal-bot +``` + +Still on old behavior after that? Force a no-cache image rebuild, then recreate: + +```bash +docker compose -f docker/compose.translation.yaml --env-file docker/translation.env \ + build --no-cache signal-bot +docker compose -f docker/compose.translation.yaml --env-file docker/translation.env \ + up -d --force-recreate signal-bot +``` + +(Same pattern with `compose.transcription.yaml` / `transcription.env` for the transcription bot.) + +Do **not** use `down -v` to “force a refresh” — that wipes Signal CLI state and you must re-register the phone. + +--- + ## Transcription stack Compose file: `docker/compose.transcription.yaml` @@ -43,6 +100,8 @@ cp docker/transcription.env.example docker/transcription.env docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env up -d ``` +First `up -d` builds `signal-bot` if needed. After later code changes, use [Code changes (rebuild the bot)](#code-changes-rebuild-the-bot) — plain `up -d` keeps the old binary. + Confirm network: ```bash @@ -146,20 +205,13 @@ docker compose -f docker/compose.transcription.yaml --env-file docker/transcript ### Stop / rebuild -```bash -docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env down -``` - -Rebuild after code changes: +Stop the stack (keeps Signal CLI volumes): ```bash -docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ - build signal-bot -docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ - up -d signal-bot +docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env down ``` -Do **not** use `down -v` unless you intend to wipe Signal CLI state (you will need to re-register the phone). +After code changes, rebuild/recreate — see [Code changes (rebuild the bot)](#code-changes-rebuild-the-bot). Do **not** use `down -v` unless you intend to wipe Signal CLI state (you will need to re-register the phone). --- @@ -180,6 +232,8 @@ cp docker/translation.env.example docker/translation.env docker compose -f docker/compose.translation.yaml --env-file docker/translation.env up -d ``` +First `up -d` builds `signal-bot` if needed. After later code changes, use [Code changes (rebuild the bot)](#code-changes-rebuild-the-bot) — plain `up -d` keeps the old binary. + Confirm network: ```bash @@ -278,20 +332,13 @@ docker compose -f docker/compose.translation.yaml --env-file docker/translation. ### Stop / rebuild -```bash -docker compose -f docker/compose.translation.yaml --env-file docker/translation.env down -``` - -Rebuild after code changes: +Stop the stack (keeps Signal CLI volumes): ```bash -docker compose -f docker/compose.translation.yaml --env-file docker/translation.env \ - build signal-bot -docker compose -f docker/compose.translation.yaml --env-file docker/translation.env \ - up -d signal-bot +docker compose -f docker/compose.translation.yaml --env-file docker/translation.env down ``` -Do **not** use `down -v` unless you intend to wipe Signal CLI state (you will need to re-register the phone). +After code changes, rebuild/recreate — see [Code changes (rebuild the bot)](#code-changes-rebuild-the-bot). Do **not** use `down -v` unless you intend to wipe Signal CLI state (you will need to re-register the phone). --- @@ -306,10 +353,13 @@ docker network ls | grep sigstack There is **no** Docker network between the two CVMs/stacks — Signal is the bus. +**Hierarchy:** treat the **translation** bot as the Bread Bot hub (menus, translation products, `!transcription` pairing). The **transcription** bot is a voice worker only — see [two-cvm-architecture.md — Bot hierarchy](../two-cvm-architecture.md#bot-hierarchy). + After both numbers are registered: 1. Create (or open) a Signal group that includes both bot numbers and your personal account. 2. For transcription pairing, set `PEER_PHONE` on translation to phone A and follow [voice-transcription.md](../voice-transcription.md#pairing-translation-leads) (`!transcription` as group admin). +3. Confirm peer trust: each bot must not list the other as `UNTRUSTED` (`GET /v1/identities/{phone}`). Bots auto-trust `PEER_PHONE` on startup; if you still see `Untrusted Identity` in logs, rebuild/restart both bots after pairing. ## Related docs diff --git a/docs/solutions/signal-mobile-menus.md b/docs/solutions/signal-mobile-menus.md index 7ac9fc8..a9234fa 100644 --- a/docs/solutions/signal-mobile-menus.md +++ b/docs/solutions/signal-mobile-menus.md @@ -12,13 +12,32 @@ Menus are **English-only** for now; multi-language UI is deferred. 1. **Title** on its own line (product or hub name). 2. **Section header** (optional) + optional one-line blurb. -3. **Each command** on its own line; **description** on the next line, indented with two spaces. No `cmd — desc` on one line. +3. **Each command** on its own line. No `cmd — desc` on one line. 4. Prefer plain `!command` lines over `- !command` bullets. -5. Keep descriptions short (aim ≤~40 chars when possible). -6. **Footer** commands use the same stacked form (`!help` / `!verify`, etc.). -7. Prose blocks (privacy explanations, invite/status messages) stay paragraphs; only **command lists** (including footers) use the stacked form. +5. **Hub / nav lists** (e.g. main `!help` on the translation bot): commands only — skip indented descriptions when they would just restate the command name. +6. **Product / how-to lists** (e.g. `!translation-in-chat`, transcription toggles): put a short description on the next line, indented with two spaces (aim ≤~40 chars). +7. **`!help` footer** is the bare command — no “Main menu” / “Show this menu” line (`!help` is implicit). +8. Prose blocks (privacy explanations, invite/status messages) stay paragraphs; only **command lists** use the forms above. -Canonical shape: +Hub shape: + +```text +Bread Bot + +!translation-threads +!translation-in-chat +!transcription +!privacy +!help-transcription +!info +!help +``` + +`!info` returns the same commands with a short indented description under each and a blank line between entries. + +Product feature guides (`!help-threads`, `!help-in-chat`, `!help-transcription`) are short prose (use case + typical flow), not stacked command lists. + +Product shape: ```text Title @@ -31,10 +50,11 @@ Optional one-line blurb. !other-command Short description !help - Show this menu ``` -Language Thread sidecars use the same trigger `!help` but a different menu (rename / leave) when the group is a known sidecar. +Language Thread sidecars use `!commands` for the thread menu (rename / leave / info). Hub `!help` always returns the Bread Bot menu, including when sent from a sidecar. + +Voice transcription uses `!transcription` on the **transcription** bot for its product menu (not `!help`). Hub `!help` / `!info` / `!privacy` stay on the **translation** bot only. See [two-cvm-architecture.md — Bot hierarchy](../two-cvm-architecture.md#bot-hierarchy). ## When adding menus diff --git a/docs/two-cvm-architecture.md b/docs/two-cvm-architecture.md index 8ad0c36..66e4602 100644 --- a/docs/two-cvm-architecture.md +++ b/docs/two-cvm-architecture.md @@ -4,6 +4,19 @@ Product suite split across two Phala CVMs (and two local Docker Compose projects See also: [issue #10](https://github.com/BreadchainCoop/sigstack-bot/issues/10). +## Bot hierarchy + +In a shared Signal group, users interact with **one hub** and one optional **worker**: + +| Role | Phone | Duty | +|------|-------|------| +| **Translation bot** (Bread Bot hub) | Phone B | Product menus (`!help`, `!info`, `!privacy`, `!translation-*`), Language Threads, in-chat translation, pairing (`!transcription` invite), `!verify` (translation CVM quote) | +| **Transcription bot** (worker) | Phone A | Voice only: `!transcription`, `!transcribe*`, `!verify` (transcription CVM quote). **No hub** — does not answer `!help` / `!info` / `!privacy` | + +Signal still delivers every message to both members; the transcription stack **ignores** hub text commands and non-voice work. The translation bot **leads** suite navigation and inviting the transcription peer; the transcription bot **executes** voice→text and its own product toggles. + +Hub vs worker command split: [voice-transcription.md](voice-transcription.md#commands-transcription-bot). + ## Diagram ```mermaid @@ -31,7 +44,7 @@ flowchart LR ## Rules -- **Two phone numbers**, two bots in the group. +- **Two phone numbers**, two bots in the group. **Translation = hub manager; transcription = specialized worker** (see [Bot hierarchy](#bot-hierarchy)). - Signal delivers **all** group messages to every member bot. The transcription bot **receives** text but **ignores** it; it only **acts** on voice. The translation bot receives voice too but only **acts** on text (including transcripts posted by the transcription bot). - No cross-CVM Docker/HTTP link. Whisper stays **inside** the transcription stack only. - Same `signal-bot` image; role selected by `BOT__ROLE=transcription|translation`. @@ -73,7 +86,7 @@ Deploy each compose to its **own** CVM. Do not co-locate Whisper with the transl ### Interoperability -- **Transcription** composes with either translation product in the same Signal group (pair via `!transcription` on the translation bot). +- **Transcription** (worker CVM) composes with translation products in the same group. The **translation bot** invites via `!transcription` and posts the voice menu after invite; the **transcription bot** runs voice and answers `!transcription` when already paired. - **In-chat** translates inside one group thread (quote-reply). - **Language Threads** bridges a multilingual main to N monolingual sidecars (`!translate-me-on`). diff --git a/docs/voice-transcription.md b/docs/voice-transcription.md index 699a31e..6442276 100644 --- a/docs/voice-transcription.md +++ b/docs/voice-transcription.md @@ -4,6 +4,8 @@ Status: **implemented** on its own Phala / Compose stack (`BOT__ROLE=transcripti Speech → text inside Signal via Whisper in the same CVM as the transcription bot. See [two-CVM architecture](two-cvm-architecture.md) and [issue #8](https://github.com/BreadchainCoop/sigstack-bot/issues/8) under umbrella [#10](https://github.com/BreadchainCoop/sigstack-bot/issues/10). +This stack is a **specialized worker**, not the Bread Bot hub. Users discover products and pair bots through the **translation** bot (`!help`, `!transcription` invite). This bot only handles voice transcription, its product menu on `!transcription`, and transcription-side TEE attestation. Hierarchy: [two-cvm-architecture.md — Bot hierarchy](two-cvm-architecture.md#bot-hierarchy). + ## Where it runs | Stack | Contents | @@ -24,23 +26,33 @@ Both bots are members of the same Signal group (two phone numbers). ## Pairing (translation leads) 1. Set `PEER_PHONE` in translation env to the transcription bot’s E.164 (`SIGNAL__PEER_PHONE`). -2. Translation bot must be a **group admin**. -3. In the group: `!transcription` → translation bot invites the peer if missing. -4. Accept the Signal invite on the transcription number. -5. Send `!transcription` again → the transcription bot answers with its menu (translation stays silent when paired). +2. Set `PEER_PHONE` in transcription env to the **translation** bot’s E.164 (required for auto-join). +3. Translation bot must be a **group admin**. +4. In the group: `!transcription` → translation bot invites the peer if missing and posts the Voice Transcription menu. +5. Transcription bot auto-accepts the invite when the translation peer is already in the group (polls pending invites). + +Without `PEER_PHONE` on translation, `!transcription` stubs as unavailable. Without `PEER_PHONE` on transcription, auto-join is disabled. + +**Peer trust:** each bot must trust the other’s Signal identity (`TRUSTED_*`, not `UNTRUSTED`). On startup the bot calls `PUT /v1/identities/{self}/trust/{PEER_PHONE}` with `trust_all_known_keys`. If the peer is `UNTRUSTED`, the translation bot will not decrypt transcription posts (so in-chat auto never sees transcripts), and group sends can fail with `Untrusted Identity`. -Without `PEER_PHONE`, translation still stubs `!transcription` as unavailable. +When the peer is already in the group (or invite pending), the hub stays silent on `!transcription` so the transcription bot can answer with its menu. + +**Group invites:** the translation hub auto-accepts any pending group invite. The transcription worker only auto-accepts invites for groups where the translation peer is already a member/admin. ## Commands (transcription bot) +Worker-only — no `!help` / `!info`. Use the translation bot for the Bread Bot hub. + | Command | Effect | |---------|--------| -| `!transcription` | Product menu | -| `!transcribe-on` / `!transcribe-off` | Toggle auto transcription (DM or group) | -| `!transcribe` | Quote a voice note to transcribe it | -| `!help` / `!privacy` / `!verify` | Help, privacy, TEE attestation | +| `!transcription` | Product menu (compact command list for this bot) | +| `!transcribe-on` / `!transcribe-off` | Toggle auto transcription (DM or group; **default off**) | +| `!transcribe` | Quote a voice note to transcribe it (refuses with a notice if auto is already on) | +| `!help-transcription` | How voice transcription works (separate CVM/TEE from translation) | + +Hub `!privacy` (translation bot only) covers both CVMs. In a paired group, `!verify ` returns two quotes (`Translation: …` / `Transcription: …`). -Auto path: inbound voice notes are transcribed when enabled (default on). +Auto path: inbound voice notes are transcribed only after `!transcribe-on` (default off). ## Ops @@ -48,7 +60,7 @@ Auto path: inbound voice notes are transcribed when enabled (default on). ```bash cp docker/transcription.env.example docker/transcription.env -# Set SIGNAL_PHONE (phone A); optional PEER_PHONE = translation phone +# Set SIGNAL_PHONE (phone A); PEER_PHONE = translation phone (required for auto-join) docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env up -d ```