diff --git a/.agents/docs/DEVELOPMENT.md b/.agents/docs/DEVELOPMENT.md index fa0de40..13cff83 100644 --- a/.agents/docs/DEVELOPMENT.md +++ b/.agents/docs/DEVELOPMENT.md @@ -55,7 +55,9 @@ Fail-fast if role is missing/invalid or required deps are missing. ``` crates/ - signal-bot/ # Binary + signal-bot/ # Binary (role-selected handlers) + signal-bot-core/ # CommandHandler + AppResult + signal-bot-transcription/ # Voice / !transcribe* product crate whisper-client/ near-ai-client/ signal-client/ @@ -69,6 +71,7 @@ docker/ Dockerfile / Dockerfile.whisper / Dockerfile.proxy docs/ two-cvm-architecture.md + voice-transcription.md language-threads.md ``` @@ -77,6 +80,8 @@ docs/ ```bash cp docker/transcription.env.example docker/transcription.env cp docker/translation.env.example docker/translation.env +# Different SIGNAL_PHONE values; PEER_PHONE on translation = transcription phone; +# NEAR_AI_API_KEY in translation.env docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env up -d docker compose -f docker/compose.translation.yaml --env-file docker/translation.env up -d @@ -93,11 +98,15 @@ docker buildx build --platform linux/amd64 -t YOUR/signal-bot-tee:latest -f dock docker buildx build --platform linux/amd64 -t YOUR/signal-whisper-api:latest -f docker/Dockerfile.whisper --push . docker buildx build --platform linux/amd64 -t YOUR/signal-registration-proxy:latest -f docker/Dockerfile.proxy --push . -phala deploy … -c docker/phala.transcription.yaml … -phala deploy … -c docker/phala.translation.yaml … +phala deploy … -c docker/phala.transcription.yaml -e docker/phala.transcription.env --wait -t tdx.medium +phala deploy … -c docker/phala.translation.yaml -e docker/phala.translation.env --wait -t tdx.medium ``` -Encrypted secrets: phone numbers per CVM; `NEAR_AI_API_KEY` on translation only. +Env templates: `docker/phala.transcription.env.example`, `docker/phala.translation.env.example`. + +Encrypted secrets: phone numbers per CVM; `PEER_PHONE` for pairing; `NEAR_AI_API_KEY` on translation only. + +Health (transcription): Whisper `GET /health` on `:9000`, Signal CLI `GET /v1/health` on `:8080`. Attestation: `!verify `. ## Configuration @@ -105,6 +114,8 @@ Encrypted secrets: phone numbers per CVM; `NEAR_AI_API_KEY` on translation only. |----------|-------| | `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) | | `NEAR_AI__*` | Translation role | | `WHISPER__*` | Transcription role | | `TRANSLATE_ALL__*` | In-chat translation | diff --git a/AGENTS.md b/AGENTS.md index 5e462e5..8d3aa29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,7 @@ docker compose -f docker/compose.translation.yaml --env-file docker/translation. |-----|-----| | [`.agents/docs/DEVELOPMENT.md`](.agents/docs/DEVELOPMENT.md) | TEE trust model, `BOT__ROLE`, Phala dual-CVM ops | | [`docs/two-cvm-architecture.md`](docs/two-cvm-architecture.md) | Architecture diagram and compose/Phala split | +| [`docs/voice-transcription.md`](docs/voice-transcription.md) | Voice transcription product + pairing | | [`docs/in-chat-translation.md`](docs/in-chat-translation.md) | In-chat (group) bilingual auto/manual translate | | [`docs/parallel-translation.md`](docs/parallel-translation.md) | Parallel Translation product behavior | | [`docs/language-threads.md`](docs/language-threads.md) | Language Threads product behavior | diff --git a/Cargo.lock b/Cargo.lock index f9b2dee..0e16ac1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2085,6 +2085,8 @@ dependencies = [ "serde", "serde_json", "sha2", + "signal-bot-core", + "signal-bot-transcription", "signal-client", "tempfile", "thiserror", @@ -2098,6 +2100,35 @@ dependencies = [ "wiremock", ] +[[package]] +name = "signal-bot-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "dstack-client", + "near-ai-client", + "signal-client", + "thiserror", + "whisper-client", +] + +[[package]] +name = "signal-bot-transcription" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "serde_json", + "signal-bot-core", + "signal-client", + "tokio", + "tokio-test", + "tracing", + "whisper-client", + "wiremock", +] + [[package]] name = "signal-client" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 0fb2a2f..38bee4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,8 @@ resolver = "2" members = [ "crates/signal-bot", + "crates/signal-bot-core", + "crates/signal-bot-transcription", "crates/near-ai-client", "crates/dstack-client", "crates/signal-client", diff --git a/README.md b/README.md index 554654a..d68131b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ 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. -Details: [docs/two-cvm-architecture.md](docs/two-cvm-architecture.md) · [docs/in-chat-translation.md](docs/in-chat-translation.md) · [docs/parallel-translation.md](docs/parallel-translation.md) · [docs/language-threads.md](docs/language-threads.md) +Details: [docs/two-cvm-architecture.md](docs/two-cvm-architecture.md) · [docs/voice-transcription.md](docs/voice-transcription.md) · [docs/in-chat-translation.md](docs/in-chat-translation.md) · [docs/parallel-translation.md](docs/parallel-translation.md) · [docs/language-threads.md](docs/language-threads.md) ## Architecture diff --git a/crates/signal-bot-core/Cargo.toml b/crates/signal-bot-core/Cargo.toml new file mode 100644 index 0000000..2564a3a --- /dev/null +++ b/crates/signal-bot-core/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "signal-bot-core" +version.workspace = true +edition.workspace = true + +[dependencies] +# Workspace crates (AppError From impls) +near-ai-client = { path = "../near-ai-client" } +dstack-client = { path = "../dstack-client" } +signal-client = { path = "../signal-client" } +whisper-client = { path = "../whisper-client" } + +# Workspace dependencies +anyhow.workspace = true +async-trait.workspace = true +thiserror.workspace = true diff --git a/crates/signal-bot-core/src/error.rs b/crates/signal-bot-core/src/error.rs new file mode 100644 index 0000000..e83f62e --- /dev/null +++ b/crates/signal-bot-core/src/error.rs @@ -0,0 +1,25 @@ +//! Shared application error types. + +use thiserror::Error; + +/// Main application error type. +#[derive(Error, Debug)] +pub enum AppError { + #[error("Configuration error: {0}")] + Config(#[from] anyhow::Error), + + #[error("Signal error: {0}")] + Signal(#[from] signal_client::SignalError), + + #[error("NEAR AI error: {0}")] + NearAi(#[from] near_ai_client::NearAiError), + + #[error("Dstack error: {0}")] + Dstack(#[from] dstack_client::DstackError), + + #[error("Whisper error: {0}")] + Whisper(#[from] whisper_client::WhisperError), +} + +/// Result type alias for application errors. +pub type AppResult = Result; diff --git a/crates/signal-bot-core/src/handler.rs b/crates/signal-bot-core/src/handler.rs new file mode 100644 index 0000000..0e65511 --- /dev/null +++ b/crates/signal-bot-core/src/handler.rs @@ -0,0 +1,128 @@ +//! Shared command handler trait. + +use crate::error::AppResult; +use async_trait::async_trait; +use signal_client::BotMessage; + +/// Command handler trait. +#[async_trait] +pub trait CommandHandler: Send + Sync { + /// Command trigger (e.g., "!help"). + fn trigger(&self) -> Option<&str> { + None + } + + /// Whether this is the default handler for non-command messages. + fn is_default(&self) -> bool { + false + } + + /// Check if this handler matches the message. + fn matches(&self, message: &BotMessage) -> bool { + if let Some(trigger) = self.trigger() { + message.text.starts_with(trigger) + } else { + self.is_default() && !message.text.starts_with('!') && !message.is_voice_note() + } + } + + /// Execute the command. + async fn execute(&self, message: &BotMessage) -> AppResult; + + /// When true, bot replies with a Signal quote-reply to the source message. + fn reply_with_quote(&self) -> bool { + false + } + + /// When true, the handler sends its own Signal reply in `execute` (main loop skips send). + fn handles_own_reply(&self) -> bool { + false + } + + /// Short name for dispatch / debug logs. + fn label(&self) -> &'static str { + if self.is_default() { + "default" + } else { + "command" + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Stub { + trigger: Option<&'static str>, + default: bool, + } + + #[async_trait] + impl CommandHandler for Stub { + fn trigger(&self) -> Option<&str> { + self.trigger + } + + fn is_default(&self) -> bool { + self.default + } + + async fn execute(&self, _message: &BotMessage) -> AppResult { + Ok("ok".into()) + } + } + + fn msg(text: &str, voice: bool) -> BotMessage { + use signal_client::Attachment; + BotMessage { + source: "+1".into(), + source_number: None, + source_name: None, + text: text.into(), + timestamp: 0, + message_timestamp: 0, + is_group: false, + group_id: None, + group_name: None, + receiving_account: "+2".into(), + attachments: if voice { + vec![Attachment { + content_type: "audio/aac".into(), + filename: None, + id: "a1".into(), + size: Some(10), + upload_timestamp: None, + }] + } else { + vec![] + }, + quote: None, + } + } + + #[test] + fn default_matches_uses_trigger_prefix() { + let h = Stub { + trigger: Some("!help"), + default: false, + }; + assert!(h.matches(&msg("!help please", false))); + assert!(!h.matches(&msg("help", false))); + assert_eq!(h.label(), "command"); + assert!(!h.reply_with_quote()); + assert!(!h.handles_own_reply()); + } + + #[test] + fn default_handler_skips_commands_and_voice() { + let h = Stub { + trigger: None, + default: true, + }; + assert!(h.matches(&msg("hello", false))); + assert!(!h.matches(&msg("!help", false))); + assert!(!h.matches(&msg("", true))); + assert_eq!(h.label(), "default"); + } +} diff --git a/crates/signal-bot-core/src/lib.rs b/crates/signal-bot-core/src/lib.rs new file mode 100644 index 0000000..3a67961 --- /dev/null +++ b/crates/signal-bot-core/src/lib.rs @@ -0,0 +1,7 @@ +//! Shared types for signal-bot product crates (handlers trait + errors). + +pub mod error; +pub mod handler; + +pub use error::{AppError, AppResult}; +pub use handler::CommandHandler; diff --git a/crates/signal-bot-transcription/Cargo.toml b/crates/signal-bot-transcription/Cargo.toml new file mode 100644 index 0000000..e919ef3 --- /dev/null +++ b/crates/signal-bot-transcription/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "signal-bot-transcription" +version.workspace = true +edition.workspace = true + +[dependencies] +signal-bot-core = { path = "../signal-bot-core" } +signal-client = { path = "../signal-client" } +whisper-client = { path = "../whisper-client" } + +anyhow.workspace = true +async-trait.workspace = true +serde_json.workspace = true +tracing.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tokio-test.workspace = true +wiremock.workspace = true diff --git a/crates/signal-bot-transcription/src/handlers.rs b/crates/signal-bot-transcription/src/handlers.rs new file mode 100644 index 0000000..f60165b --- /dev/null +++ b/crates/signal-bot-transcription/src/handlers.rs @@ -0,0 +1,46 @@ +//! Build the transcription product handler stack (voice / !transcribe*). + +use crate::manual_transcribe::ManualTranscribeHandler; +use crate::prefs::SharedTranscribeGroupPrefs; +use crate::transcribe::TranscribeHandler; +use crate::transcribe_store::TranscribeStore; +use crate::voice::VoiceHandler; +use crate::voice_attachment_cache::VoiceAttachmentCache; +use signal_bot_core::CommandHandler; +use signal_client::SignalClient; +use std::sync::Arc; +use whisper_client::WhisperClient; + +/// Voice + `!transcribe` + `!transcribe-on/off` handlers for the transcription role. +pub fn build_voice_handlers( + whisper: Arc, + signal: Arc, + reply_prefix: impl Into, + max_attachment_bytes: usize, + group_prefs: SharedTranscribeGroupPrefs, +) -> Vec> { + let reply_prefix = reply_prefix.into(); + let transcribe_store = Arc::new(TranscribeStore::new(Some(group_prefs))); + let voice_cache = VoiceAttachmentCache::with_default_capacity(); + + vec![ + Box::new( + VoiceHandler::new( + whisper.clone(), + signal.clone(), + reply_prefix.clone(), + max_attachment_bytes, + ) + .with_transcribe_store(transcribe_store.clone()) + .with_voice_cache(voice_cache.clone()), + ), + Box::new(ManualTranscribeHandler::new( + whisper, + signal, + reply_prefix, + max_attachment_bytes, + voice_cache, + )), + Box::new(TranscribeHandler::new(transcribe_store, true)), + ] +} diff --git a/crates/signal-bot-transcription/src/lib.rs b/crates/signal-bot-transcription/src/lib.rs new file mode 100644 index 0000000..5c6b60e --- /dev/null +++ b/crates/signal-bot-transcription/src/lib.rs @@ -0,0 +1,17 @@ +//! Voice transcription product handlers (Whisper pipeline). + +mod handlers; +mod manual_transcribe; +mod prefs; +mod transcribe; +mod transcribe_store; +mod voice; +mod voice_attachment_cache; + +pub use handlers::build_voice_handlers; +pub use manual_transcribe::ManualTranscribeHandler; +pub use prefs::{SharedTranscribeGroupPrefs, TranscribeGroupPrefs}; +pub use transcribe::TranscribeHandler; +pub use transcribe_store::TranscribeStore; +pub use voice::VoiceHandler; +pub use voice_attachment_cache::VoiceAttachmentCache; diff --git a/crates/signal-bot/src/commands/manual_transcribe.rs b/crates/signal-bot-transcription/src/manual_transcribe.rs similarity index 98% rename from crates/signal-bot/src/commands/manual_transcribe.rs rename to crates/signal-bot-transcription/src/manual_transcribe.rs index 2a8365f..e07c3ef 100644 --- a/crates/signal-bot/src/commands/manual_transcribe.rs +++ b/crates/signal-bot-transcription/src/manual_transcribe.rs @@ -1,10 +1,9 @@ //! `!transcribe` — quote-reply manual voice transcription via Whisper. -use crate::commands::voice::VoiceHandler; -use crate::commands::CommandHandler; -use crate::error::AppResult; +use crate::voice::VoiceHandler; use crate::voice_attachment_cache::VoiceAttachmentCache; use async_trait::async_trait; +use signal_bot_core::{AppResult, CommandHandler}; use signal_client::{Attachment, BotMessage, QuotedMessage, SignalClient}; use std::sync::Arc; use tracing::{info, instrument, warn}; @@ -206,7 +205,6 @@ impl CommandHandler for ManualTranscribeHandler { #[cfg(test)] mod tests { use super::*; - use crate::voice_attachment_cache::VoiceAttachmentCache; use signal_client::{BotMessage, QuotedMessage}; fn sample_audio() -> signal_client::Attachment { diff --git a/crates/signal-bot-transcription/src/prefs.rs b/crates/signal-bot-transcription/src/prefs.rs new file mode 100644 index 0000000..3d561e8 --- /dev/null +++ b/crates/signal-bot-transcription/src/prefs.rs @@ -0,0 +1,12 @@ +//! Group-level transcription preference bridge (implemented by signal-bot prefs store). + +use std::sync::Arc; + +/// Per-group voice transcription toggle, owned by the shared preferences store. +pub trait TranscribeGroupPrefs: Send + Sync { + fn is_transcribe_enabled(&self, group_id: &str) -> bool; + fn set_transcribe_enabled(&self, group_id: &str, enabled: bool); +} + +/// Helper so callers can pass `Arc` via a thin adapter. +pub type SharedTranscribeGroupPrefs = Arc; diff --git a/crates/signal-bot/src/commands/transcribe.rs b/crates/signal-bot-transcription/src/transcribe.rs similarity index 80% rename from crates/signal-bot/src/commands/transcribe.rs rename to crates/signal-bot-transcription/src/transcribe.rs index 8c1ae0a..2016544 100644 --- a/crates/signal-bot/src/commands/transcribe.rs +++ b/crates/signal-bot-transcription/src/transcribe.rs @@ -1,9 +1,8 @@ //! `!transcribe-on` / `!transcribe-off` — per-chat voice transcription toggle. -use crate::commands::CommandHandler; -use crate::error::AppResult; use crate::transcribe_store::TranscribeStore; use async_trait::async_trait; +use signal_bot_core::{AppResult, CommandHandler}; use signal_client::BotMessage; use std::sync::Arc; @@ -58,7 +57,31 @@ impl CommandHandler for TranscribeHandler { #[cfg(test)] mod tests { use super::*; - use crate::group_preferences_store::GroupPreferencesStore; + use crate::prefs::{SharedTranscribeGroupPrefs, TranscribeGroupPrefs}; + use std::collections::HashMap; + use std::sync::RwLock; + + struct MemoryPrefs { + enabled: RwLock>, + } + + impl TranscribeGroupPrefs for MemoryPrefs { + fn is_transcribe_enabled(&self, group_id: &str) -> bool { + self.enabled + .read() + .unwrap() + .get(group_id) + .copied() + .unwrap_or(true) + } + + fn set_transcribe_enabled(&self, group_id: &str, enabled: bool) { + self.enabled + .write() + .unwrap() + .insert(group_id.to_string(), enabled); + } + } fn msg(text: &str, group: bool) -> BotMessage { BotMessage { @@ -89,7 +112,9 @@ mod tests { #[tokio::test] async fn execute_toggles_dm_and_group() { - let prefs = GroupPreferencesStore::new_in_memory(0); + let prefs: SharedTranscribeGroupPrefs = Arc::new(MemoryPrefs { + enabled: RwLock::new(HashMap::new()), + }); let store = Arc::new(TranscribeStore::new(Some(prefs))); let handler = TranscribeHandler::new(store.clone(), true); diff --git a/crates/signal-bot/src/transcribe_store.rs b/crates/signal-bot-transcription/src/transcribe_store.rs similarity index 62% rename from crates/signal-bot/src/transcribe_store.rs rename to crates/signal-bot-transcription/src/transcribe_store.rs index d6b5a31..ffd83e7 100644 --- a/crates/signal-bot/src/transcribe_store.rs +++ b/crates/signal-bot-transcription/src/transcribe_store.rs @@ -1,19 +1,19 @@ //! Per-chat voice transcription preference (`!transcribe-on` / `!transcribe-off`). //! -//! Group preferences are persisted via [`GroupPreferencesStore`]; DM toggles are ephemeral. +//! Group preferences go through [`TranscribeGroupPrefs`]; DM toggles are ephemeral. -use crate::group_preferences_store::GroupPreferencesStore; +use crate::prefs::SharedTranscribeGroupPrefs; use std::collections::HashSet; -use std::sync::{Arc, RwLock}; +use std::sync::RwLock; /// DM-only in-memory transcription toggle (default: enabled). pub struct TranscribeStore { dm_disabled: RwLock>, - group_prefs: Option>, + group_prefs: Option, } impl TranscribeStore { - pub fn new(group_prefs: Option>) -> Self { + pub fn new(group_prefs: Option) -> Self { Self { dm_disabled: RwLock::new(HashSet::new()), group_prefs, @@ -51,6 +51,31 @@ impl TranscribeStore { #[cfg(test)] mod tests { use super::*; + use crate::prefs::TranscribeGroupPrefs; + use std::collections::HashMap; + use std::sync::Arc; + + struct MemoryPrefs { + enabled: RwLock>, + } + + impl TranscribeGroupPrefs for MemoryPrefs { + fn is_transcribe_enabled(&self, group_id: &str) -> bool { + self.enabled + .read() + .unwrap() + .get(group_id) + .copied() + .unwrap_or(true) + } + + fn set_transcribe_enabled(&self, group_id: &str, enabled: bool) { + self.enabled + .write() + .unwrap() + .insert(group_id.to_string(), enabled); + } + } #[test] fn dm_enabled_by_default() { @@ -70,8 +95,10 @@ mod tests { #[test] fn group_uses_preferences_store() { - let prefs = GroupPreferencesStore::new_in_memory(0); - let store = TranscribeStore::new(Some(prefs.clone())); + let prefs: SharedTranscribeGroupPrefs = Arc::new(MemoryPrefs { + enabled: RwLock::new(HashMap::new()), + }); + let store = TranscribeStore::new(Some(prefs)); store.set_enabled("group.x", false, true); assert!(!store.is_enabled("group.x", true)); } diff --git a/crates/signal-bot/src/commands/voice.rs b/crates/signal-bot-transcription/src/voice.rs similarity index 99% rename from crates/signal-bot/src/commands/voice.rs rename to crates/signal-bot-transcription/src/voice.rs index 8745d18..0c30b62 100644 --- a/crates/signal-bot/src/commands/voice.rs +++ b/crates/signal-bot-transcription/src/voice.rs @@ -1,10 +1,9 @@ //! Implicit voice note handler — transcribe via Whisper and quote-reply. -use crate::commands::CommandHandler; -use crate::error::AppResult; use crate::transcribe_store::TranscribeStore; use crate::voice_attachment_cache::VoiceAttachmentCache; use async_trait::async_trait; +use signal_bot_core::{AppResult, CommandHandler}; use signal_client::{Attachment, BotMessage, SignalClient}; use std::sync::Arc; use tracing::{info, instrument, warn}; diff --git a/crates/signal-bot/src/voice_attachment_cache.rs b/crates/signal-bot-transcription/src/voice_attachment_cache.rs similarity index 100% rename from crates/signal-bot/src/voice_attachment_cache.rs rename to crates/signal-bot-transcription/src/voice_attachment_cache.rs diff --git a/crates/signal-bot/Cargo.toml b/crates/signal-bot/Cargo.toml index bbe9ad6..db1430b 100644 --- a/crates/signal-bot/Cargo.toml +++ b/crates/signal-bot/Cargo.toml @@ -9,6 +9,8 @@ path = "src/main.rs" [dependencies] # Workspace crates +signal-bot-core = { path = "../signal-bot-core" } +signal-bot-transcription = { path = "../signal-bot-transcription" } near-ai-client = { path = "../near-ai-client" } dstack-client = { path = "../dstack-client" } signal-client = { path = "../signal-client" } diff --git a/crates/signal-bot/src/commands/menu_locale.rs b/crates/signal-bot/src/commands/menu_locale.rs index c0c2634..cf8876f 100644 --- a/crates/signal-bot/src/commands/menu_locale.rs +++ b/crates/signal-bot/src/commands/menu_locale.rs @@ -55,6 +55,20 @@ pub fn transcription_unavailable(language: MenuLanguage) -> &'static str { } } +pub fn transcription_invited(language: MenuLanguage) -> &'static str { + match language { + MenuLanguage::En => TRANSCRIPTION_INVITED_EN, + MenuLanguage::Es => TRANSCRIPTION_INVITED_ES, + } +} + +pub fn transcription_group_only(language: MenuLanguage) -> &'static str { + match language { + MenuLanguage::En => TRANSCRIPTION_GROUP_ONLY_EN, + MenuLanguage::Es => TRANSCRIPTION_GROUP_ONLY_ES, + } +} + pub fn privacy_menu(language: MenuLanguage, role: BotRole) -> &'static str { match (role, language) { (BotRole::Transcription, MenuLanguage::En) => PRIVACY_TRANSCRIPTION_EN, @@ -74,6 +88,7 @@ const HELP_TRANSCRIPTION_EN: &str = r#"Voice transcription: Voice notes in this chat are transcribed to text (Whisper, inside the TEE). **Commands:** +- !transcription — This menu - !transcribe-on / !transcribe-off — toggle auto transcription - !transcribe — quote a voice note to transcribe it - !privacy — Privacy & TEE @@ -85,6 +100,7 @@ const HELP_TRANSCRIPTION_ES: &str = r#"Transcripción de voz: Las notas de voz en este chat se transcriben a texto (Whisper, dentro del TEE). **Comandos:** +- !transcription — Este menú - !transcribe-on / !transcribe-off — activar/desactivar transcripción automática - !transcribe — cita una nota de voz para transcribirla - !privacy — Privacidad y TEE @@ -217,6 +233,30 @@ El bot de transcripción aún no está emparejado con este grupo. Mientras tanto !help — Menú principal"#; +const TRANSCRIPTION_INVITED_EN: &str = r#"Invited the transcription bot to this group. + +Accept the Signal invite on that number, then send !transcription again (the transcription bot will answer with its menu). + +!help — Main menu"#; + +const TRANSCRIPTION_INVITED_ES: &str = r#"Se invitó al bot de transcripción a este grupo. + +Acepta la invitación de Signal en ese número y luego envía !transcription de nuevo (el bot de transcripción responderá con su menú). + +!help — Menú principal"#; + +const TRANSCRIPTION_GROUP_ONLY_EN: &str = r#"Voice transcription pairing works in a Signal group. + +Add both bots to a group, then send !transcription there. + +!help — Main menu"#; + +const TRANSCRIPTION_GROUP_ONLY_ES: &str = r#"El emparejamiento de transcripción funciona en un grupo de Signal. + +Añade ambos bots a un grupo y envía !transcription allí. + +!help — Menú principal"#; + const PRIVACY_TRANSCRIPTION_EN: &str = r#"**Sigstack transcription** (Private & Verifiable) **TEE Commands:** diff --git a/crates/signal-bot/src/commands/mod.rs b/crates/signal-bot/src/commands/mod.rs index b25875e..f891ae9 100644 --- a/crates/signal-bot/src/commands/mod.rs +++ b/crates/signal-bot/src/commands/mod.rs @@ -1,13 +1,11 @@ //! Bot command handlers. mod help; -mod manual_transcribe; mod menu_locale; mod models; mod privacy; mod product_menus; mod set_language; -mod transcribe; mod translate; mod translate_all; pub mod translate_lang; @@ -16,148 +14,19 @@ mod translate_me; mod translate_parallel; mod translate_service; mod verify; -mod voice; pub use help::HelpHandler; -pub use manual_transcribe::ManualTranscribeHandler; pub use models::ModelsHandler; pub use privacy::PrivacyHandler; pub use product_menus::{ - InChatMenuHandler, ParallelMenuHandler, TranscriptionStubHandler, TranslationMenuHandler, + InChatMenuHandler, ParallelMenuHandler, TranscriptionMenuHandler, TranscriptionPairingHandler, + TranslationMenuHandler, }; pub use set_language::SetLanguageHandler; -pub use transcribe::TranscribeHandler; +pub use signal_bot_core::CommandHandler; pub use translate::TranslateHandler; pub use translate_all::TranslateAllHandler; pub use translate_langs::TranslateLangsHandler; pub use translate_me::TranslateMeHandler; pub use translate_parallel::TranslateParallelHandler; pub use verify::VerifyHandler; -pub use voice::VoiceHandler; - -use crate::error::AppResult; -use async_trait::async_trait; -use signal_client::BotMessage; - -/// Command handler trait. -#[async_trait] -pub trait CommandHandler: Send + Sync { - /// Command trigger (e.g., "!help"). - fn trigger(&self) -> Option<&str> { - None - } - - /// Whether this is the default handler for non-command messages. - fn is_default(&self) -> bool { - false - } - - /// Check if this handler matches the message. - fn matches(&self, message: &BotMessage) -> bool { - if let Some(trigger) = self.trigger() { - message.text.starts_with(trigger) - } else { - self.is_default() && !message.text.starts_with('!') && !message.is_voice_note() - } - } - - /// Execute the command. - async fn execute(&self, message: &BotMessage) -> AppResult; - - /// When true, bot replies with a Signal quote-reply to the source message. - fn reply_with_quote(&self) -> bool { - false - } - - /// When true, the handler sends its own Signal reply in `execute` (main loop skips send). - fn handles_own_reply(&self) -> bool { - false - } - - /// Short name for dispatch / debug logs. - fn label(&self) -> &'static str { - if self.is_default() { - "default" - } else { - "command" - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - struct Stub { - trigger: Option<&'static str>, - default: bool, - } - - #[async_trait] - impl CommandHandler for Stub { - fn trigger(&self) -> Option<&str> { - self.trigger - } - - fn is_default(&self) -> bool { - self.default - } - - async fn execute(&self, _message: &BotMessage) -> AppResult { - Ok("ok".into()) - } - } - - fn msg(text: &str, voice: bool) -> BotMessage { - use signal_client::Attachment; - BotMessage { - source: "+1".into(), - source_number: None, - source_name: None, - text: text.into(), - timestamp: 0, - message_timestamp: 0, - is_group: false, - group_id: None, - group_name: None, - receiving_account: "+2".into(), - attachments: if voice { - vec![Attachment { - content_type: "audio/aac".into(), - filename: None, - id: "a1".into(), - size: Some(10), - upload_timestamp: None, - }] - } else { - vec![] - }, - quote: None, - } - } - - #[test] - fn default_matches_uses_trigger_prefix() { - let h = Stub { - trigger: Some("!help"), - default: false, - }; - assert!(h.matches(&msg("!help please", false))); - assert!(!h.matches(&msg("help", false))); - assert_eq!(h.label(), "command"); - assert!(!h.reply_with_quote()); - assert!(!h.handles_own_reply()); - } - - #[test] - fn default_handler_skips_commands_and_voice() { - let h = Stub { - trigger: None, - default: true, - }; - assert!(h.matches(&msg("hello", false))); - assert!(!h.matches(&msg("!help", false))); - assert!(!h.matches(&msg("", true))); - assert_eq!(h.label(), "default"); - } -} diff --git a/crates/signal-bot/src/commands/product_menus.rs b/crates/signal-bot/src/commands/product_menus.rs index f6e9b9a..21234c5 100644 --- a/crates/signal-bot/src/commands/product_menus.rs +++ b/crates/signal-bot/src/commands/product_menus.rs @@ -1,15 +1,18 @@ //! Nested product menus: `!translation`, `!transcription`, `!in-chat`, `!parallel`. use crate::commands::menu_locale::{ - in_chat_menu, is_exact_command, menu_language_for_message, parallel_menu, - transcription_unavailable, translation_products_menu, + help_menu, in_chat_menu, is_exact_command, menu_language_for_message, parallel_menu, + transcription_group_only, transcription_invited, transcription_unavailable, + translation_products_menu, }; use crate::commands::CommandHandler; +use crate::config::BotRole; use crate::error::AppResult; use crate::group_preferences_store::GroupPreferencesStore; use async_trait::async_trait; -use signal_client::BotMessage; +use signal_client::{BotMessage, SignalClient}; use std::sync::Arc; +use tracing::warn; pub struct TranslationMenuHandler { group_prefs: Arc, @@ -37,29 +40,164 @@ impl CommandHandler for TranslationMenuHandler { } } -pub struct TranscriptionStubHandler { +/// Translation role: invite the transcription peer, or stay silent when already paired. +pub struct TranscriptionPairingHandler { group_prefs: Arc, + signal: Arc, + peer_phone: Option, } -impl TranscriptionStubHandler { +impl TranscriptionPairingHandler { + pub fn new( + group_prefs: Arc, + signal: Arc, + peer_phone: Option, + ) -> Self { + Self { + group_prefs, + signal, + peer_phone: peer_phone.and_then(|p| { + let t = p.trim().to_string(); + if t.is_empty() { + None + } else { + Some(t) + } + }), + } + } + + async fn send(&self, message: &BotMessage, body: &str) -> AppResult<()> { + self.signal.reply(message, body).await?; + Ok(()) + } +} + +#[async_trait] +impl CommandHandler for TranscriptionPairingHandler { + fn matches(&self, message: &BotMessage) -> bool { + is_exact_command(&message.text, "!transcription") + } + + fn handles_own_reply(&self) -> bool { + true + } + + fn label(&self) -> &'static str { + "transcription_pairing" + } + + async fn execute(&self, message: &BotMessage) -> AppResult { + let language = menu_language_for_message(message, &self.group_prefs); + + if !message.is_group { + self.send(message, transcription_group_only(language)) + .await?; + return Ok(String::new()); + } + + let Some(peer) = self.peer_phone.as_deref() else { + self.send(message, transcription_unavailable(language)) + .await?; + return Ok(String::new()); + }; + + let Some(group_id) = message.group_id.as_deref() else { + self.send(message, transcription_unavailable(language)) + .await?; + return Ok(String::new()); + }; + + let bot = message.receiving_account.as_str(); + let groups = match self.signal.list_groups(bot).await { + Ok(g) => g, + Err(e) => { + warn!(error = %e, "Failed to list groups for transcription pairing"); + self.send(message, "Could not look up this group. Try again shortly.") + .await?; + return Ok(String::new()); + } + }; + + let Some(group) = groups + .iter() + .find(|g| g.internal_id == group_id || g.id == group_id) + else { + self.send(message, transcription_unavailable(language)) + .await?; + return Ok(String::new()); + }; + + if group.contains_member_or_pending(peer) { + // Paired (or invite pending): stay silent so the transcription bot can answer. + return Ok(String::new()); + } + + let send_id = match self + .signal + .resolve_group_send_id_for_account(bot, group_id) + .await + { + Ok(id) => id, + Err(e) => { + warn!(error = %e, "Failed to resolve group send id for pairing"); + self.send( + message, + "Could not resolve this group for invites. Try again shortly.", + ) + .await?; + return Ok(String::new()); + } + }; + + match self + .signal + .add_members(bot, &send_id, vec![peer.to_string()]) + .await + { + Ok(()) => { + self.send(message, transcription_invited(language)).await?; + } + Err(e) => { + warn!(error = %e, peer, "Failed to invite transcription bot"); + let body = format!( + "Could not add the transcription bot ({peer}): {e}\n\n\ + This bot must be a group admin to invite members. \ + Or set SIGNAL__PEER_PHONE and try again.\n\n\ + !help — Main menu" + ); + self.send(message, &body).await?; + } + } + + Ok(String::new()) + } +} + +/// Transcription role: product menu for `!transcription`. +pub struct TranscriptionMenuHandler { + group_prefs: Arc, +} + +impl TranscriptionMenuHandler { pub fn new(group_prefs: Arc) -> Self { Self { group_prefs } } } #[async_trait] -impl CommandHandler for TranscriptionStubHandler { +impl CommandHandler for TranscriptionMenuHandler { fn matches(&self, message: &BotMessage) -> bool { is_exact_command(&message.text, "!transcription") } fn label(&self) -> &'static str { - "transcription_stub" + "transcription_menu" } async fn execute(&self, message: &BotMessage) -> AppResult { let language = menu_language_for_message(message, &self.group_prefs); - Ok(transcription_unavailable(language).into()) + Ok(help_menu(language, BotRole::Transcription).into()) } } @@ -122,6 +260,9 @@ impl CommandHandler for ParallelMenuHandler { #[cfg(test)] mod tests { use super::*; + use serde_json::json; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; fn msg(text: &str) -> BotMessage { BotMessage { @@ -132,9 +273,9 @@ mod tests { timestamp: 0, message_timestamp: 0, is_group: true, - group_id: Some("g".into()), + group_id: Some("g-internal".into()), group_name: None, - receiving_account: "+2".into(), + receiving_account: "+15550001111".into(), attachments: vec![], quote: None, } @@ -154,16 +295,119 @@ mod tests { let i = InChatMenuHandler::new(store.clone(), true); assert!(i.matches(&msg("!in-chat"))); - let s = TranscriptionStubHandler::new(store); + let s = TranscriptionPairingHandler::new( + store.clone(), + Arc::new(SignalClient::new("http://127.0.0.1:9").unwrap()), + None, + ); assert!(s.matches(&msg("!transcription"))); + + let m = TranscriptionMenuHandler::new(store); + assert!(m.matches(&msg("!transcription"))); + } + + #[tokio::test] + async fn pairing_without_peer_reports_unavailable() { + 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"] + }]))) + .mount(&signal_mock) + .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 = GroupPreferencesStore::new_in_memory(0); + let handler = TranscriptionPairingHandler::new( + store, + Arc::new(SignalClient::new(signal_mock.uri()).unwrap()), + None, + ); + let out = handler.execute(&msg("!transcription")).await.unwrap(); + assert!(out.is_empty()); + } + + #[tokio::test] + async fn pairing_invites_missing_peer() { + 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"], + "pending_invites": [], + "pending_requests": [], + "admins": ["+15550001111"] + }]))) + .mount(&signal_mock) + .await; + Mock::given(method("POST")) + .and(path("/v1/groups/%2B15550001111/group.send%3D/members")) + .respond_with(ResponseTemplate::new(204)) + .expect(1) + .mount(&signal_mock) + .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 = GroupPreferencesStore::new_in_memory(0); + let handler = TranscriptionPairingHandler::new( + store, + 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 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 store = GroupPreferencesStore::new_in_memory(0); + let handler = TranscriptionPairingHandler::new( + store, + 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_stub_offers_translation() { + async fn transcription_menu_returns_voice_help() { let store = GroupPreferencesStore::new_in_memory(0); - let handler = TranscriptionStubHandler::new(store); + let handler = TranscriptionMenuHandler::new(store); let out = handler.execute(&msg("!transcription")).await.unwrap(); - assert!(out.contains("!translation")); - assert!(out.to_lowercase().contains("unavailable")); + assert!(out.contains("!transcribe")); + assert!(out.to_lowercase().contains("voice")); } } diff --git a/crates/signal-bot/src/config.rs b/crates/signal-bot/src/config.rs index e4679a9..e0d5ebc 100644 --- a/crates/signal-bot/src/config.rs +++ b/crates/signal-bot/src/config.rs @@ -52,6 +52,14 @@ pub struct SignalConfig { /// Poll interval for messages #[serde(default = "default_poll_interval", with = "humantime_serde")] pub poll_interval: Duration, + + /// This bot's Signal phone (ops/registration; identity still learned from inbound). + #[serde(default)] + pub phone_number: Option, + + /// Peer product bot phone (E.164). Translation uses this to invite transcription. + #[serde(default)] + pub peer_phone: Option, } #[derive(Debug, Clone, Deserialize)] @@ -152,6 +160,8 @@ impl Default for SignalConfig { Self { service_url: default_signal_service(), poll_interval: default_poll_interval(), + phone_number: None, + peer_phone: None, } } } @@ -374,6 +384,8 @@ mod tests { SignalConfig::default().service_url, "http://signal-api:8080" ); + assert!(SignalConfig::default().phone_number.is_none()); + assert!(SignalConfig::default().peer_phone.is_none()); assert_eq!(WhisperConfig::default().model, "small"); assert!(TranslateAllConfig::default().enabled); assert!(GroupPreferencesConfig::default().persist); diff --git a/crates/signal-bot/src/error.rs b/crates/signal-bot/src/error.rs index d927b6d..0cc4c0e 100644 --- a/crates/signal-bot/src/error.rs +++ b/crates/signal-bot/src/error.rs @@ -1,25 +1,3 @@ -//! Application error types. +//! Application error types (re-exported from signal-bot-core). -use thiserror::Error; - -/// Main application error type. -#[derive(Error, Debug)] -pub enum AppError { - #[error("Configuration error: {0}")] - Config(#[from] anyhow::Error), - - #[error("Signal error: {0}")] - Signal(#[from] signal_client::SignalError), - - #[error("NEAR AI error: {0}")] - NearAi(#[from] near_ai_client::NearAiError), - - #[error("Dstack error: {0}")] - Dstack(#[from] dstack_client::DstackError), - - #[error("Whisper error: {0}")] - Whisper(#[from] whisper_client::WhisperError), -} - -/// Result type alias for application errors. -pub type AppResult = Result; +pub use signal_bot_core::{AppError, AppResult}; diff --git a/crates/signal-bot/src/handlers_setup.rs b/crates/signal-bot/src/handlers_setup.rs index 37988a4..1e8fcc0 100644 --- a/crates/signal-bot/src/handlers_setup.rs +++ b/crates/signal-bot/src/handlers_setup.rs @@ -5,11 +5,11 @@ use crate::commands::*; use crate::config::{BotRole, Config}; use crate::error::AppResult; use crate::group_preferences_store::GroupPreferencesStore; -use crate::transcribe_store::TranscribeStore; -use crate::voice_attachment_cache::VoiceAttachmentCache; +use crate::transcribe_prefs::GroupTranscribePrefs; use anyhow::Context; use dstack_client::DstackClient; use near_ai_client::NearAiClient; +use signal_bot_transcription::build_voice_handlers; use signal_client::SignalClient; use std::path::PathBuf; use std::sync::Arc; @@ -62,38 +62,26 @@ pub async fn build_transcription_handlers( ) .await; - let transcribe_store = Arc::new(TranscribeStore::new(Some(group_prefs.clone()))); - let voice_cache = VoiceAttachmentCache::with_default_capacity(); - // Voice only — no NEAR translate-on-voice; translation CVM handles posted text. - let handlers: Vec> = vec![ - Box::new( - VoiceHandler::new( - whisper.clone(), - signal.clone(), - config.whisper.reply_prefix.clone(), - config.whisper.max_attachment_bytes, - ) - .with_transcribe_store(transcribe_store.clone()) - .with_voice_cache(voice_cache.clone()), - ), - Box::new(ManualTranscribeHandler::new( - whisper.clone(), - signal.clone(), - config.whisper.reply_prefix.clone(), - config.whisper.max_attachment_bytes, - voice_cache, - )), - Box::new(TranscribeHandler::new(transcribe_store, true)), - Box::new(VerifyHandler::new(dstack)), - Box::new(HelpHandler::new( - group_prefs.clone(), - BotRole::Transcription, - )), - Box::new(PrivacyHandler::new(group_prefs, BotRole::Transcription)), - ]; + let mut handlers = build_voice_handlers( + whisper, + signal, + config.whisper.reply_prefix.clone(), + config.whisper.max_attachment_bytes, + Arc::new(GroupTranscribePrefs(group_prefs.clone())), + ); + handlers.push(Box::new(TranscriptionMenuHandler::new(group_prefs.clone()))); + handlers.push(Box::new(VerifyHandler::new(dstack))); + handlers.push(Box::new(HelpHandler::new( + group_prefs.clone(), + BotRole::Transcription, + ))); + handlers.push(Box::new(PrivacyHandler::new( + group_prefs, + BotRole::Transcription, + ))); - info!("Transcription role: voice / !transcribe* / help / privacy / verify"); + info!("Transcription role: voice / !transcribe* / !transcription / help / privacy / verify"); Ok(handlers) } @@ -171,7 +159,11 @@ pub async fn build_translation_handlers( } handlers.push(Box::new(TranslationMenuHandler::new(group_prefs.clone()))); - handlers.push(Box::new(TranscriptionStubHandler::new(group_prefs.clone()))); + handlers.push(Box::new(TranscriptionPairingHandler::new( + group_prefs.clone(), + signal.clone(), + config.signal.peer_phone.clone(), + ))); handlers.push(Box::new(InChatMenuHandler::new( group_prefs.clone(), config.translate_all.enabled, @@ -216,6 +208,8 @@ mod tests { signal: SignalConfig { service_url: "http://127.0.0.1:9".into(), poll_interval: Duration::from_millis(50), + phone_number: None, + peer_phone: None, }, near_ai: None, bot: BotConfig { @@ -263,13 +257,14 @@ mod tests { .await .expect("transcription handlers"); - assert_eq!(handlers.len(), 6); + assert_eq!(handlers.len(), 7); assert_eq!( labels(&handlers), vec![ "voice", "manual_transcribe", "transcribe", + "transcription_menu", "command", // verify "help", "privacy", @@ -307,7 +302,7 @@ mod tests { assert!(got.contains(&"translate_parallel")); assert!(got.contains(&"translate_all")); assert!(got.contains(&"translation_menu")); - assert!(got.contains(&"transcription_stub")); + assert!(got.contains(&"transcription_pairing")); assert!(got.contains(&"in_chat_menu")); assert!(got.contains(&"parallel_menu")); assert!(got.contains(&"translate")); diff --git a/crates/signal-bot/src/lib.rs b/crates/signal-bot/src/lib.rs index 69e3b6b..c19f3fb 100644 --- a/crates/signal-bot/src/lib.rs +++ b/crates/signal-bot/src/lib.rs @@ -6,5 +6,4 @@ pub mod error; pub mod group_preferences_store; pub mod handlers_setup; pub mod menu_language; -pub mod transcribe_store; -pub mod voice_attachment_cache; +pub mod transcribe_prefs; diff --git a/crates/signal-bot/src/transcribe_prefs.rs b/crates/signal-bot/src/transcribe_prefs.rs new file mode 100644 index 0000000..22c06e4 --- /dev/null +++ b/crates/signal-bot/src/transcribe_prefs.rs @@ -0,0 +1,18 @@ +//! Adapter so [`GroupPreferencesStore`] implements transcription prefs trait. + +use crate::group_preferences_store::GroupPreferencesStore; +use signal_bot_transcription::TranscribeGroupPrefs; +use std::sync::Arc; + +/// Thin wrapper so `set_transcribe_enabled` can use `Arc`. +pub struct GroupTranscribePrefs(pub Arc); + +impl TranscribeGroupPrefs for GroupTranscribePrefs { + fn is_transcribe_enabled(&self, group_id: &str) -> bool { + self.0.is_transcribe_enabled(group_id) + } + + fn set_transcribe_enabled(&self, group_id: &str, enabled: bool) { + self.0.set_transcribe_enabled(group_id, enabled); + } +} diff --git a/crates/signal-client/src/client.rs b/crates/signal-client/src/client.rs index 7cb673e..5aceba4 100644 --- a/crates/signal-client/src/client.rs +++ b/crates/signal-client/src/client.rs @@ -110,6 +110,10 @@ impl SignalClient { name: name.to_string(), id: created.id.clone(), internal_id: created.id.clone(), + members: vec![], + pending_invites: vec![], + pending_requests: vec![], + admins: vec![], }); self.cache_group_mapping(phone_number, &group).await; @@ -454,6 +458,10 @@ mod tests { name: "test".into(), id: "group.TUIzYitaQy85SmtteUpTMEo2ZE9wZ3lib0tOWVZrcDEzNFA3bDU0N1BrOD0=".into(), internal_id: "MB3b+ZC/9JkmyJS0J6dOpgyboKNYVkp134P7l547Pk8=".into(), + members: vec![], + pending_invites: vec![], + pending_requests: vec![], + admins: vec![], }]; assert_eq!( diff --git a/crates/signal-client/src/types.rs b/crates/signal-client/src/types.rs index 3cec8ce..0dbc13c 100644 --- a/crates/signal-client/src/types.rs +++ b/crates/signal-client/src/types.rs @@ -50,6 +50,43 @@ pub struct Group { pub id: String, #[serde(rename = "internal_id")] pub internal_id: String, + /// Active members (E.164 phones and/or UUIDs), when the API includes them. + #[serde(default)] + pub members: Vec, + /// Pending invites (not yet accepted). + #[serde(default)] + pub pending_invites: Vec, + /// Pending join requests. + #[serde(default)] + pub pending_requests: Vec, + /// Group admins. + #[serde(default)] + pub admins: Vec, +} + +impl Group { + /// True if `identity` (phone or UUID) appears in members or pending invites/requests. + pub fn contains_member_or_pending(&self, identity: &str) -> bool { + self.members + .iter() + .chain(self.pending_invites.iter()) + .chain(self.pending_requests.iter()) + .any(|m| identities_match(m, identity)) + } +} + +/// Compare Signal identities: exact match, or digit-only match for E.164 phones. +pub fn identities_match(a: &str, b: &str) -> bool { + if a == b { + return true; + } + let da = digits_only(a); + let db = digits_only(b); + !da.is_empty() && da == db +} + +fn digits_only(s: &str) -> String { + s.chars().filter(|c| c.is_ascii_digit()).collect() } /// Request body for `POST /v1/groups/{number}`. @@ -324,3 +361,45 @@ fn quoted_attachment_as_audio(quoted: &QuotedAttachment) -> Option { None } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn group_deserializes_members_and_pending() { + let g: Group = serde_json::from_value(serde_json::json!({ + "name": "Main", + "id": "group.abc=", + "internal_id": "internal-1", + "members": ["+15551110001", "uuid-x"], + "pending_invites": ["+15551110002"], + "pending_requests": [], + "admins": ["+15551110001"] + })) + .unwrap(); + assert_eq!(g.members.len(), 2); + 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")); + } + + #[test] + fn group_members_default_empty_when_omitted() { + let g: Group = serde_json::from_value(serde_json::json!({ + "name": "Main", + "id": "group.abc=", + "internal_id": "internal-1" + })) + .unwrap(); + assert!(g.members.is_empty()); + assert!(g.pending_invites.is_empty()); + } + + #[test] + fn identities_match_phones_and_exact_uuid() { + assert!(identities_match("+15551234567", "15551234567")); + assert!(identities_match("uuid-abc", "uuid-abc")); + assert!(!identities_match("uuid-abc", "uuid-xyz")); + } +} diff --git a/docker/Dockerfile b/docker/Dockerfile index 99bca66..38a07e9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -14,6 +14,8 @@ ENV CARGO_NET_RETRY=10 \ # downloads inside the container; use the image's native musl target instead) COPY Cargo.toml Cargo.lock ./ COPY crates/signal-bot/Cargo.toml crates/signal-bot/ +COPY crates/signal-bot-core/Cargo.toml crates/signal-bot-core/ +COPY crates/signal-bot-transcription/Cargo.toml crates/signal-bot-transcription/ COPY crates/near-ai-client/Cargo.toml crates/near-ai-client/ COPY crates/dstack-client/Cargo.toml crates/dstack-client/ COPY crates/signal-client/Cargo.toml crates/signal-client/ @@ -22,13 +24,15 @@ COPY crates/whisper-client/Cargo.toml crates/whisper-client/ # Minimal stubs so `cargo fetch` can resolve path dependencies RUN mkdir -p crates/signal-bot/src/commands && \ - echo "pub mod commands; pub mod config; pub mod error; pub mod group_preferences_store; pub mod transcribe_store;" > crates/signal-bot/src/lib.rs && \ + echo "pub mod commands; pub mod config; pub mod error; pub mod group_preferences_store; pub mod transcribe_prefs;" > crates/signal-bot/src/lib.rs && \ echo "fn main() {}" > crates/signal-bot/src/main.rs && \ echo "" > crates/signal-bot/src/commands/mod.rs && \ echo "" > crates/signal-bot/src/config.rs && \ echo "" > crates/signal-bot/src/error.rs && \ echo "" > crates/signal-bot/src/group_preferences_store.rs && \ - echo "" > crates/signal-bot/src/transcribe_store.rs && \ + echo "" > crates/signal-bot/src/transcribe_prefs.rs && \ + mkdir -p crates/signal-bot-core/src && echo "pub fn dummy() {}" > crates/signal-bot-core/src/lib.rs && \ + mkdir -p crates/signal-bot-transcription/src && echo "pub fn dummy() {}" > crates/signal-bot-transcription/src/lib.rs && \ mkdir -p crates/near-ai-client/src && echo "pub fn dummy() {}" > crates/near-ai-client/src/lib.rs && \ mkdir -p crates/dstack-client/src && echo "pub fn dummy() {}" > crates/dstack-client/src/lib.rs && \ mkdir -p crates/signal-client/src && echo "pub fn dummy() {}" > crates/signal-client/src/lib.rs && \ diff --git a/docker/compose.transcription.yaml b/docker/compose.transcription.yaml index 1453805..5bb5a05 100644 --- a/docker/compose.transcription.yaml +++ b/docker/compose.transcription.yaml @@ -52,6 +52,7 @@ services: - BOT__ROLE=transcription - SIGNAL__SERVICE_URL=http://signal-api:8080 - SIGNAL__PHONE_NUMBER=${SIGNAL_PHONE} + - SIGNAL__PEER_PHONE=${PEER_PHONE:-} - WHISPER__ENABLED=true - WHISPER__SERVICE_URL=http://whisper-api:9000 - WHISPER__MODEL=${WHISPER_MODEL:-small} diff --git a/docker/compose.translation.yaml b/docker/compose.translation.yaml index e7bb0a9..aa3dcd4 100644 --- a/docker/compose.translation.yaml +++ b/docker/compose.translation.yaml @@ -33,6 +33,7 @@ services: - BOT__ROLE=translation - SIGNAL__SERVICE_URL=http://signal-api:8080 - SIGNAL__PHONE_NUMBER=${SIGNAL_PHONE} + - SIGNAL__PEER_PHONE=${PEER_PHONE:-} - NEAR_AI__API_KEY=${NEAR_AI_API_KEY} - NEAR_AI__BASE_URL=${NEAR_AI_BASE_URL:-https://cloud-api.near.ai/v1} - NEAR_AI__MODEL=${NEAR_AI_MODEL:-deepseek-ai/DeepSeek-V3.1} diff --git a/docker/phala.transcription.env.example b/docker/phala.transcription.env.example new file mode 100644 index 0000000..ca8b873 --- /dev/null +++ b/docker/phala.transcription.env.example @@ -0,0 +1,15 @@ +# Phala transcription CVM env — copy to phala.transcription.env (do not commit secrets). +# +# 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). +PEER_PHONE=+1YYYYYYYYYY + +SIGNAL_BOT_IMAGE=YOUR_DOCKERHUB/signal-bot-tee:latest +WHISPER_IMAGE=YOUR_DOCKERHUB/signal-whisper-api:latest + +WHISPER_MODEL=small +WHISPER_TIMEOUT=120s +LOG_LEVEL=info +BOT_GITHUB_REPO=https://github.com/BreadchainCoop/sigstack-bot diff --git a/docker/phala.transcription.yaml b/docker/phala.transcription.yaml index 5213906..716de87 100644 --- a/docker/phala.transcription.yaml +++ b/docker/phala.transcription.yaml @@ -43,6 +43,8 @@ services: environment: - BOT__ROLE=transcription - SIGNAL__SERVICE_URL=http://signal-api:8080 + - SIGNAL__PHONE_NUMBER=${SIGNAL_PHONE} + - SIGNAL__PEER_PHONE=${PEER_PHONE:-} - WHISPER__ENABLED=true - WHISPER__SERVICE_URL=http://whisper-api:9000 - WHISPER__MODEL=${WHISPER_MODEL:-small} diff --git a/docker/phala.translation.env.example b/docker/phala.translation.env.example new file mode 100644 index 0000000..b35182a --- /dev/null +++ b/docker/phala.translation.env.example @@ -0,0 +1,23 @@ +# Phala translation CVM env — copy to phala.translation.env (do not commit secrets). +# +# phala deploy … -c docker/phala.translation.yaml -e docker/phala.translation.env --wait -t tdx.medium + +SIGNAL_PHONE=+1YYYYYYYYYY +# Transcription bot E.164 — used by !transcription to invite/pair into the group. +PEER_PHONE=+1XXXXXXXXXX + +SIGNAL_BOT_IMAGE=YOUR_DOCKERHUB/signal-bot-tee:latest +SIGNAL_REGISTRATION_PROXY_IMAGE=YOUR_DOCKERHUB/signal-registration-proxy:latest + +NEAR_AI_API_KEY=sk-your-api-key-here +NEAR_AI_BASE_URL=https://cloud-api.near.ai/v1 +NEAR_AI_MODEL=deepseek-ai/DeepSeek-V3.1 +NEAR_AI_TIMEOUT=120s + +TRANSLATE_ALL_ENABLED=true +TRANSLATE_ALL_MAX_MESSAGES_PER_MINUTE=30 +LOG_LEVEL=info +BOT_GITHUB_REPO=https://github.com/BreadchainCoop/sigstack-bot +BOT_SIGNAL_USERNAME= +RATE_LIMIT_GLOBAL_PER_MINUTE=10 +RATE_LIMIT_PER_NUMBER_PER_HOUR=3 diff --git a/docker/phala.translation.yaml b/docker/phala.translation.yaml index 4eb28a9..7b9e52b 100644 --- a/docker/phala.translation.yaml +++ b/docker/phala.translation.yaml @@ -29,6 +29,8 @@ services: environment: - BOT__ROLE=translation - SIGNAL__SERVICE_URL=http://signal-api:8080 + - SIGNAL__PHONE_NUMBER=${SIGNAL_PHONE} + - SIGNAL__PEER_PHONE=${PEER_PHONE:-} - NEAR_AI__API_KEY=${NEAR_AI_API_KEY} - NEAR_AI__BASE_URL=${NEAR_AI_BASE_URL:-https://cloud-api.near.ai/v1} - NEAR_AI__MODEL=${NEAR_AI_MODEL:-deepseek-ai/DeepSeek-V3.1} diff --git a/docker/transcription.env.example b/docker/transcription.env.example index ed9baf5..27de7cd 100644 --- a/docker/transcription.env.example +++ b/docker/transcription.env.example @@ -2,6 +2,8 @@ # 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). +PEER_PHONE=+1YYYYYYYYYY WHISPER_MODEL=small WHISPER_TIMEOUT=120s LOG_LEVEL=info diff --git a/docker/translation.env.example b/docker/translation.env.example index 999dc8d..c1265b5 100644 --- a/docker/translation.env.example +++ b/docker/translation.env.example @@ -2,6 +2,8 @@ # Phone B — translation bot (must differ from transcription.env SIGNAL_PHONE). SIGNAL_PHONE=+1YYYYYYYYYY +# Transcription bot phone — used by !transcription to invite/pair into the group. +PEER_PHONE=+1XXXXXXXXXX NEAR_AI_API_KEY=sk-your-api-key-here NEAR_AI_BASE_URL=https://cloud-api.near.ai/v1 NEAR_AI_MODEL=deepseek-ai/DeepSeek-V3.1 diff --git a/docs/language-threads.md b/docs/language-threads.md index 43fab78..70e0902 100644 --- a/docs/language-threads.md +++ b/docs/language-threads.md @@ -99,19 +99,23 @@ Local Docker without dstack may not persist prefs across restarts; Phala with ds ## Local testing ```bash -cd docker -docker compose build signal-bot && docker compose up -d signal-bot +cp docker/translation.env.example docker/translation.env +# Set SIGNAL_PHONE (phone B) and NEAR_AI_API_KEY + +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 ``` -Only **signal-bot** needs rebuild. Smoke: main group → `!translate-me-on es` → accept invite → message in main appears in Language Thread (translated or relayed). +Only **signal-bot** on the translation stack needs rebuild for Language Threads changes. Smoke: main group → `!translate-me-on es` → accept invite → message in main appears in Language Thread (translated or relayed). + +Whisper / voice live on the **transcription** stack — see [voice-transcription.md](voice-transcription.md) and [two-cvm-architecture.md](two-cvm-architecture.md). ## Phala / TEE (paused) -- Deploy uses **Docker images** in compose, not a public git clone. Pushing `daopunk/signal-bot-tee:latest` is what the CVM pulls. -- Target was a **4 GB** CVM (`tdx.medium`) named `dstack-app-hqvaf`; previous `dstack-app-cxswu` was removed. -- Env: `deploy/dstack-app-cxswu/phala.env` (secrets; do not commit). +- Deploy uses **Docker images** in compose, not a public git clone. +- Translation CVM target: **4 GB** (`tdx.medium`) via [`docker/phala.translation.yaml`](../docker/phala.translation.yaml) — **no Whisper** on this box. +- Env template: [`docker/phala.translation.env.example`](../docker/phala.translation.env.example) (secrets; do not commit filled env). - Fresh CVM ⇒ expect **re-register** Signal phone (volume died with old CVM). -- Image already pushed: `daopunk/signal-bot-tee:latest` @ `sha256:09bb7aca…` (linux/amd64). ## Trust / privacy notes @@ -124,5 +128,4 @@ Only **signal-bot** needs rebuild. Smoke: main group → `!translate-me-on es` - Resume Phala deploy at 4 GB; confirm memory with `phala cvms get` - Pin image digest in compose for stronger attestation -- Capacity: whisper + bot + signal-api may be tight on 4 GB - Optional: delete empty sidecars after last member leaves diff --git a/docs/two-cvm-architecture.md b/docs/two-cvm-architecture.md index 6b8b724..e9b584f 100644 --- a/docs/two-cvm-architecture.md +++ b/docs/two-cvm-architecture.md @@ -67,7 +67,7 @@ Deploy each compose to its **own** CVM. Do not co-locate Whisper with the transl | Product | CVM | Doc | |---------|-----|-----| -| Voice transcription | Transcription | — | +| Voice transcription | Transcription | [voice-transcription.md](voice-transcription.md) | | In-chat (group) translation | Translation | [in-chat-translation.md](in-chat-translation.md) | | Parallel Translation (MVP) | Translation | [parallel-translation.md](parallel-translation.md) | | Language Threads | Translation | [language-threads.md](language-threads.md) | diff --git a/docs/voice-transcription.md b/docs/voice-transcription.md new file mode 100644 index 0000000..2f95e13 --- /dev/null +++ b/docs/voice-transcription.md @@ -0,0 +1,81 @@ +# Voice transcription + +Status: **implemented** on its own Phala / Compose stack (`BOT__ROLE=transcription`). + +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). + +## Where it runs + +| Stack | Contents | +|-------|----------| +| Transcription CVM / Compose | `signal-api` (phone A) + `whisper-api` + `signal-bot` (`BOT__ROLE=transcription`) | +| Translation CVM | Does **not** run Whisper | + +Whisper HTTP (`http://whisper-api:9000`) is **intra**-transcription-stack only. There is no cross-CVM Docker or HTTP link to the translation bot. + +## Signal as bus + +Both bots are members of the same Signal group (two phone numbers). + +1. Human sends a voice note. +2. Transcription bot downloads the attachment, calls Whisper, quote-replies with text prefixed by `📝 Transcript:` (configurable via `WHISPER__REPLY_PREFIX`). +3. Translation bot treats that text like any other group message (in-chat auto-translate, Parallel, Language Threads). + +## 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). + +Without `PEER_PHONE`, translation still stubs `!transcription` as unavailable. + +## Commands (transcription bot) + +| 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 | + +Auto path: inbound voice notes are transcribed when enabled (default on). + +## Ops + +### Local + +```bash +cp docker/transcription.env.example docker/transcription.env +# Set SIGNAL_PHONE (phone A); optional PEER_PHONE = translation phone + +docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env up -d +``` + +Register phone A against this stack’s `signal-api`. Health: + +```bash +docker compose -f docker/compose.transcription.yaml exec whisper-api curl -sf http://localhost:9000/health +docker compose -f docker/compose.transcription.yaml exec signal-api curl -sf http://localhost:8080/v1/health +``` + +### Phala + +```bash +# Build & push linux/amd64 images, then: +phala deploy … -c docker/phala.transcription.yaml -e docker/phala.transcription.env --wait -t tdx.medium +``` + +Env template: [`docker/phala.transcription.env.example`](../docker/phala.transcription.env.example). Target size: **4 GB** (`tdx.medium`). Attestation: `!verify ` inside Signal. + +## Key code + +| Area | Path | +|------|------| +| Voice / `!transcribe*` handlers | [`crates/signal-bot-transcription`](../crates/signal-bot-transcription) | +| Shared handler trait / errors | [`crates/signal-bot-core`](../crates/signal-bot-core) | +| Whisper HTTP client | [`crates/whisper-client`](../crates/whisper-client) | +| Pairing (`!transcription` on translation) | [`crates/signal-bot/src/commands/product_menus.rs`](../crates/signal-bot/src/commands/product_menus.rs) | +| Role wiring | [`crates/signal-bot/src/handlers_setup.rs`](../crates/signal-bot/src/handlers_setup.rs) | +| Compose / Phala | [`docker/compose.transcription.yaml`](../docker/compose.transcription.yaml), [`docker/phala.transcription.yaml`](../docker/phala.transcription.yaml) |