diff --git a/.agents/docs/DEVELOPMENT.md b/.agents/docs/DEVELOPMENT.md index 15c6a08..6fe7785 100644 --- a/.agents/docs/DEVELOPMENT.md +++ b/.agents/docs/DEVELOPMENT.md @@ -59,7 +59,7 @@ crates/ docker/ compose.yaml # local one-number stack phala.yaml # prod one-CVM suite - env.example / phala.env.example + .env.example / .phala.env.example Dockerfile / Dockerfile.proxy docs/ one-cvm-architecture.md @@ -70,10 +70,10 @@ docs/ ## Local Compose ```bash -cp docker/env.example docker/env +cp docker/.env.example docker/.env # SIGNAL_PHONE; NEAR_AI_API_KEY (chat + Whisper STT) -docker compose -f docker/compose.yaml --env-file docker/env up -d +docker compose -f docker/compose.yaml --env-file docker/.env up -d ``` Network: `sigstack-translation-internal`. @@ -87,10 +87,10 @@ docker buildx build --platform linux/amd64 -t YOUR/signal-bot-tee:latest -f dock docker buildx build --platform linux/amd64 -t YOUR/signal-registration-proxy:latest -f docker/Dockerfile.proxy --push . phala deploy --cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353 \ - -c docker/phala.yaml -e docker/phala.env --wait + -c docker/phala.yaml -e docker/.phala.env --wait ``` -Do **not** `phala deploy -n` against the live CVM. Env template: `docker/phala.env.example`. +Do **not** `phala deploy -n` against the live CVM. Env template: `docker/.phala.env.example`. Encrypted secrets: `SIGNAL_PHONE` (phone B), `NEAR_AI_API_KEY`. @@ -109,7 +109,7 @@ Do not re-register phone A. Proxy **:8081** only. Use `phala deploy --cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353`. Do **not** `phala cvms delete` this CVM, create a replacement, rename those volumes, or `down -v` for an image bump. [`scripts/deploy_phala.sh`](../../scripts/deploy_phala.sh) defaults to that `--cvm-id`. -After upgrade, logs should show `Loaded group preferences for N groups` (not `starting fresh` / `TEE deployment may have changed`), and `signal-api` should still list its account. +After upgrade, logs should show `Loaded group preferences for N groups` (not `starting fresh` / `TEE deployment may have changed`), and `signal-api` should still list its account. If DeriveKey is missing, set `GROUP_PREFERENCES_LEGACY_COMPOSE_HASH` to the previous compose hash so AppInfo-encrypted prefs still decrypt; the bot then re-saves with an app-id-only key. Canonical table: [`docs/one-cvm-architecture.md` — CVM storage](../../docs/one-cvm-architecture.md#cvm-storage-keep-intact). Agent rule: [`AGENTS.md` — CVM storage](../../AGENTS.md#cvm-storage-do-not-wipe). diff --git a/.gitignore b/.gitignore index 06a3126..490ce02 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,6 @@ .env.* !.env.example docker/.env -docker/env docker/*.env !docker/*.env.example !**/*.env.example diff --git a/AGENTS.md b/AGENTS.md index 8ab071e..82f2530 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,10 +50,10 @@ npm run ci # all GitHub Actions gates (fmt + clippy + coverage + c pnpm run ci # same as above if you use pnpm (NOT `pnpm ci` — that only installs) npm run prepush # alias of npm run ci (also run by husky pre-push) -cp docker/env.example docker/env +cp docker/.env.example docker/.env # SIGNAL_PHONE + NEAR_AI_API_KEY (chat + Whisper STT) -docker compose -f docker/compose.yaml --env-file docker/env up -d +docker compose -f docker/compose.yaml --env-file docker/.env up -d ``` ## Read next diff --git a/README.md b/README.md index 02f4d04..9804878 100644 --- a/README.md +++ b/README.md @@ -61,10 +61,10 @@ Signal group ## Local ```bash -cp docker/env.example docker/env +cp docker/.env.example docker/.env # Set SIGNAL_PHONE; NEAR_AI_API_KEY (chat + Whisper STT) -docker compose -f docker/compose.yaml --env-file docker/env up -d +docker compose -f docker/compose.yaml --env-file docker/.env up -d ``` More thorough local setup (Signal captcha registration, verify SMS/voice codes, and `docker compose logs -f` monitoring): [docs/local-dev/](docs/local-dev/). @@ -74,7 +74,7 @@ More thorough local setup (Signal captcha registration, verify SMS/voice codes, ```bash # In-place upgrade of the surviving CVM (phone B stays; do not create a replacement) phala deploy --cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353 \ - -c docker/phala.yaml -e docker/phala.env --wait + -c docker/phala.yaml -e docker/.phala.env --wait ``` **Do not replace this CVM or wipe its volumes** for a routine upgrade. Disk holds the **registered Signal phone** and **encrypted user prefs**. TEE RAM is cleared on reboot; Phala reattaches named volumes on in-place upgrade. Details: [docs/one-cvm-architecture.md — CVM storage](docs/one-cvm-architecture.md#cvm-storage-keep-intact). @@ -93,7 +93,7 @@ crates/ docker/ compose.yaml # local one-number stack phala.yaml # prod one-CVM suite - env.example / phala.env.example + .env.example / .phala.env.example docs/ one-cvm-architecture.md # one CVM / one phone; CVM storage in-chat-translation.md diff --git a/crates/signal-bot-core/src/command_match.rs b/crates/signal-bot-core/src/command_match.rs new file mode 100644 index 0000000..093404f --- /dev/null +++ b/crates/signal-bot-core/src/command_match.rs @@ -0,0 +1,156 @@ +//! Command-head matching: trim, ASCII case-fold, `_` → `-` on the token only. + +/// First whitespace-separated token after trim. +pub fn command_head(text: &str) -> &str { + text.split_whitespace().next().unwrap_or("") +} + +/// ASCII-lowercase and `_` → `-` for a single command token (not args). +pub fn normalize_token(token: &str) -> String { + token.to_ascii_lowercase().replace('_', "-") +} + +/// Normalize the command head only; args are not rewritten. +pub fn normalize_command_head(text: &str) -> String { + normalize_token(command_head(text)) +} + +/// Full-string normalize for exact (no-arg) commands. +/// +/// Trims, ASCII-lowercases, replaces `_` with `-`, and collapses internal +/// ASCII whitespace so `!help thread` matches the `!help thread` alias. +pub fn normalize_exact(text: &str) -> String { + let lowered = text.trim().to_ascii_lowercase().replace('_', "-"); + let mut out = String::with_capacity(lowered.len()); + let mut prev_space = false; + for c in lowered.chars() { + if c.is_ascii_whitespace() { + if !prev_space { + out.push(' '); + prev_space = true; + } + } else { + out.push(c); + prev_space = false; + } + } + out +} + +/// Exact command match after [`normalize_exact`] (avoids `!translation` matching `!translation-on`). +pub fn is_exact_command(text: &str, command: &str) -> bool { + normalize_exact(text) == normalize_exact(command) +} + +pub fn is_exact_command_any(text: &str, commands: &[&str]) -> bool { + let n = normalize_exact(text); + commands.iter().any(|c| normalize_exact(c) == n) +} + +/// True when the command head equals `prefix` after normalize (args allowed). +pub fn starts_with_word(text: &str, prefix: &str) -> bool { + normalize_command_head(text) == normalize_token(prefix.trim()) +} + +pub fn starts_with_word_any(text: &str, prefixes: &[&str]) -> bool { + prefixes.iter().any(|p| starts_with_word(text, p)) +} + +/// Remainder after a matching command head, with original args (not rewritten). +pub fn strip_word_prefix<'a>(text: &'a str, prefix: &str) -> Option<&'a str> { + let t = text.trim(); + let head = command_head(t); + if normalize_token(head) != normalize_token(prefix.trim()) { + return None; + } + if t.len() == head.len() { + return Some(""); + } + Some(t[head.len()..].trim()) +} + +pub fn strip_prefix_list<'a>(text: &'a str, prefixes: &[&str]) -> Option<&'a str> { + prefixes.iter().find_map(|p| strip_word_prefix(text, p)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn help_threads_head_normalizes_case_and_underscore() { + assert_eq!(normalize_command_head("!Help-Threads"), "!help-threads"); + assert_eq!(normalize_command_head("!help_thread"), "!help-thread"); + assert_eq!(normalize_command_head(" !HELP_THREADS "), "!help-threads"); + } + + #[test] + fn args_keep_underscores() { + assert_eq!( + normalize_command_head("!translate-me-on es_MX"), + "!translate-me-on" + ); + assert_eq!( + strip_word_prefix("!translate-me-on es_MX", "!translate-me-on"), + Some("es_MX") + ); + assert_eq!( + strip_word_prefix("!Translate_Me_On es_MX", "!translate-me-on"), + Some("es_MX") + ); + } + + #[test] + fn translation_is_not_prefix_of_translation_on() { + assert!(is_exact_command("!translation", "!translation")); + assert!(!is_exact_command("!translation-on es en", "!translation")); + assert!(!starts_with_word("!translation-on es en", "!translation")); + } + + #[test] + fn leading_trailing_whitespace_still_matches() { + assert!(is_exact_command(" !help ", "!help")); + assert!(is_exact_command_any( + "\t!help-thread\n", + &["!help-threads", "!help-thread"] + )); + assert!(starts_with_word( + " !translate-me-on es en ", + "!translate-me-on" + )); + } + + #[test] + fn space_alias_matches_after_collapse() { + assert!(is_exact_command("!help thread", "!help thread")); + assert!(is_exact_command("!help thread", "!help thread")); + assert!(is_exact_command("!Help Thread", "!help thread")); + assert!(!is_exact_command("!help extra", "!help thread")); + assert!(!is_exact_command("!help extra", "!help")); + } + + #[test] + fn starts_with_word_rejects_glued_suffix() { + assert!(starts_with_word("!translate-me-on", "!translate-me-on")); + assert!(starts_with_word( + "!translate-me-on es en", + "!translate-me-on" + )); + assert!(!starts_with_word("!translate-me-onx", "!translate-me-on")); + assert!(!starts_with_word("!help-threads", "!help")); + } + + #[test] + fn strip_prefix_list_picks_matching_head() { + let prefixes = ["!translate-me-thread", "!translate-me-threads"]; + assert_eq!( + strip_prefix_list("!translate-me-threads es", &prefixes), + Some("es") + ); + assert_eq!( + strip_prefix_list("!translate-me-thread", &prefixes), + Some("") + ); + assert!(strip_prefix_list("!translate-me-on es en", &prefixes).is_none()); + } +} diff --git a/crates/signal-bot-core/src/lib.rs b/crates/signal-bot-core/src/lib.rs index 3a67961..0912cd7 100644 --- a/crates/signal-bot-core/src/lib.rs +++ b/crates/signal-bot-core/src/lib.rs @@ -1,7 +1,12 @@ //! Shared types for signal-bot product crates (handlers trait + errors). +pub mod command_match; pub mod error; pub mod handler; +pub use command_match::{ + command_head, is_exact_command, is_exact_command_any, normalize_command_head, normalize_exact, + normalize_token, starts_with_word, starts_with_word_any, strip_prefix_list, strip_word_prefix, +}; pub use error::{AppError, AppResult}; pub use handler::CommandHandler; diff --git a/crates/signal-bot-voice/src/lib.rs b/crates/signal-bot-voice/src/lib.rs index a875845..dd6adb4 100644 --- a/crates/signal-bot-voice/src/lib.rs +++ b/crates/signal-bot-voice/src/lib.rs @@ -11,9 +11,9 @@ mod voice_attachment_cache; pub use fanout::{SharedTranscriptFanout, TranscriptFanout}; pub use handlers::build_voice_handlers; -pub use manual_transcribe::ManualTranscribeHandler; +pub use manual_transcribe::{ManualTranscribeHandler, TRANSCRIBE_COMMANDS}; pub use prefs::{SharedTranscribeGroupPrefs, TranscribeGroupPrefs}; -pub use transcribe::TranscribeHandler; +pub use transcribe::{TranscribeHandler, TRANSCRIBE_OFF_COMMANDS, TRANSCRIBE_ON_COMMANDS}; pub use transcribe_store::TranscribeStore; pub use voice::VoiceHandler; pub use voice_attachment_cache::VoiceAttachmentCache; diff --git a/crates/signal-bot-voice/src/manual_transcribe.rs b/crates/signal-bot-voice/src/manual_transcribe.rs index 11aa460..3a2d1d6 100644 --- a/crates/signal-bot-voice/src/manual_transcribe.rs +++ b/crates/signal-bot-voice/src/manual_transcribe.rs @@ -5,7 +5,9 @@ use crate::transcribe_store::TranscribeStore; use crate::voice::VoiceHandler; use crate::voice_attachment_cache::VoiceAttachmentCache; use async_trait::async_trait; -use signal_bot_core::{AppResult, CommandHandler}; +use signal_bot_core::{is_exact_command_any, AppResult, CommandHandler}; + +pub const TRANSCRIBE_COMMANDS: &[&str] = &["!transcribe"]; use signal_client::{Attachment, BotMessage, QuotedMessage, SignalClient}; use std::sync::Arc; use tracing::{info, instrument, warn}; @@ -143,7 +145,7 @@ fn speaker_msg_for_fanout(command: &BotMessage, quote: &QuotedMessage) -> BotMes #[async_trait] impl CommandHandler for ManualTranscribeHandler { fn matches(&self, message: &BotMessage) -> bool { - message.text.trim() == "!transcribe" + is_exact_command_any(&message.text, TRANSCRIBE_COMMANDS) } fn handles_own_reply(&self) -> bool { @@ -321,10 +323,14 @@ mod tests { quote: None, }; assert!(handler.matches(&msg)); + msg.text = "!Transcribe".into(); + assert!(handler.matches(&msg)); msg.text = "!transcribe-on".into(); assert!(!handler.matches(&msg)); msg.text = "!transcribe-off".into(); assert!(!handler.matches(&msg)); + msg.text = "!transcript".into(); + assert!(!handler.matches(&msg)); } fn quoted_transcribe_msg() -> BotMessage { diff --git a/crates/signal-bot-voice/src/transcribe.rs b/crates/signal-bot-voice/src/transcribe.rs index af030b9..e45027b 100644 --- a/crates/signal-bot-voice/src/transcribe.rs +++ b/crates/signal-bot-voice/src/transcribe.rs @@ -2,10 +2,13 @@ use crate::transcribe_store::TranscribeStore; use async_trait::async_trait; -use signal_bot_core::{AppResult, CommandHandler}; +use signal_bot_core::{is_exact_command_any, AppResult, CommandHandler}; use signal_client::BotMessage; use std::sync::Arc; +pub const TRANSCRIBE_ON_COMMANDS: &[&str] = &["!transcribe-on", "!transcript-on"]; +pub const TRANSCRIBE_OFF_COMMANDS: &[&str] = &["!transcribe-off", "!transcript-off"]; + pub struct TranscribeHandler { store: Arc, whisper_available: bool, @@ -23,8 +26,8 @@ impl TranscribeHandler { #[async_trait] impl CommandHandler for TranscribeHandler { fn matches(&self, message: &BotMessage) -> bool { - let text = message.text.trim(); - text == "!transcribe-on" || text == "!transcribe-off" + is_exact_command_any(&message.text, TRANSCRIBE_ON_COMMANDS) + || is_exact_command_any(&message.text, TRANSCRIBE_OFF_COMMANDS) } fn label(&self) -> &'static str { @@ -37,7 +40,7 @@ impl CommandHandler for TranscribeHandler { } let context_id = message.reply_target(); - let enable = message.text.trim() == "!transcribe-on"; + let enable = is_exact_command_any(&message.text, TRANSCRIBE_ON_COMMANDS); self.store.set_enabled(context_id, enable, message.is_group); if message.is_group { @@ -105,8 +108,13 @@ mod tests { let store = Arc::new(TranscribeStore::new(None)); let handler = TranscribeHandler::new(store, true); assert!(handler.matches(&msg("!transcribe-on", false))); + assert!(handler.matches(&msg("!transcript-on", false))); + assert!(handler.matches(&msg("!Transcribe-On", false))); assert!(handler.matches(&msg("!transcribe-off", true))); + assert!(handler.matches(&msg("!transcript-off", true))); assert!(!handler.matches(&msg("!transcribe", false))); + assert!(!handler.matches(&msg("!transcript", false))); + assert!(!handler.matches(&msg("!transcribe-on extra", false))); assert!(!handler.matches(&msg("!help", false))); } @@ -128,14 +136,14 @@ mod tests { assert!(store.is_enabled("+15550002222", false)); assert_eq!( - handler.execute(&msg("!transcribe-on", true)).await.unwrap(), + handler.execute(&msg("!transcript-on", true)).await.unwrap(), "Voice transcription enabled for this group." ); assert!(store.is_enabled("group-1", true)); assert_eq!( handler - .execute(&msg("!transcribe-off", true)) + .execute(&msg("!transcript-off", true)) .await .unwrap(), "Voice transcription disabled for this group." diff --git a/crates/signal-bot/src/commands/command_aliases.rs b/crates/signal-bot/src/commands/command_aliases.rs new file mode 100644 index 0000000..ddcd11b --- /dev/null +++ b/crates/signal-bot/src/commands/command_aliases.rs @@ -0,0 +1,182 @@ +//! Aggregate command alias tables and collision checks. + +use crate::commands::menu_locale::{ + COMMANDS_COMMANDS, HELP_COMMANDS, HELP_IN_CHAT_COMMANDS, HELP_THREADS_COMMANDS, + HELP_TRANSCRIPTION_COMMANDS, INFO_COMMANDS, IN_CHAT_MENU_COMMANDS, PRIVACY_COMMANDS, + TRANSCRIPTION_MENU_COMMANDS, TRANSLATION_IN_CHAT_MENU_COMMANDS, TRANSLATION_REDIRECT_COMMANDS, + TRANSLATION_THREADS_MENU_COMMANDS, +}; +use crate::commands::translate_all::{ + ALL_OFF_COMMANDS, ALL_ON_PREFIXES, ENABLE_THREADS, ME_OFF_COMMANDS, ME_ON_PREFIXES, +}; +use crate::commands::translate_langs::LIST_LANGS_COMMANDS; +use crate::commands::translate_me::{ENABLE_IN_CHAT_CMDS, LEAVE_CMDS, THREAD_ON_PREFIXES}; +use signal_bot_core::normalize_exact; +use signal_bot_voice::{TRANSCRIBE_COMMANDS, TRANSCRIBE_OFF_COMMANDS, TRANSCRIBE_ON_COMMANDS}; +use std::collections::HashMap; + +const RENAME_PREFIXES: &[&str] = &["!rename"]; +const VERIFY_PREFIXES: &[&str] = &["!verify"]; + +const RESERVED_STEMS: &[&str] = &[ + "!transcript", + "!translate-me", + "!translate-all", + "!transcription-on", + "!transcription-off", +]; + +fn all_families() -> &'static [(&'static str, &'static [&'static str])] { + &[ + ("help", HELP_COMMANDS), + ("info", INFO_COMMANDS), + ("privacy", PRIVACY_COMMANDS), + ("commands", COMMANDS_COMMANDS), + ("translation_redirect", TRANSLATION_REDIRECT_COMMANDS), + ( + "translation_threads_menu", + TRANSLATION_THREADS_MENU_COMMANDS, + ), + ( + "translation_in_chat_menu", + TRANSLATION_IN_CHAT_MENU_COMMANDS, + ), + ("in_chat_menu", IN_CHAT_MENU_COMMANDS), + ("transcription_menu", TRANSCRIPTION_MENU_COMMANDS), + ("help_threads", HELP_THREADS_COMMANDS), + ("help_in_chat", HELP_IN_CHAT_COMMANDS), + ("help_transcription", HELP_TRANSCRIPTION_COMMANDS), + ("translate_all_on", ALL_ON_PREFIXES), + ("translate_all_off", ALL_OFF_COMMANDS), + ("translate_me_on", ME_ON_PREFIXES), + ("translate_me_off", ME_OFF_COMMANDS), + ("enable_threads", ENABLE_THREADS), + ("thread_on", THREAD_ON_PREFIXES), + ("enable_in_chat", ENABLE_IN_CHAT_CMDS), + ("leave", LEAVE_CMDS), + ("list_langs", LIST_LANGS_COMMANDS), + ("transcribe_on", TRANSCRIBE_ON_COMMANDS), + ("transcribe_off", TRANSCRIBE_OFF_COMMANDS), + ("transcribe", TRANSCRIBE_COMMANDS), + ("rename", RENAME_PREFIXES), + ("verify", VERIFY_PREFIXES), + ] +} + +fn register_aliases<'a>( + map: &mut HashMap, + id: &'a str, + aliases: &[&str], +) -> Result<(), String> { + for alias in aliases { + let key = normalize_exact(alias); + if let Some(prev) = map.get(&key) { + if *prev != id { + return Err(format!( + "alias `{alias}` (key `{key}`) maps to both `{prev}` and `{id}`" + )); + } + } else { + map.insert(key, id); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::translate::TranslateHandler; + use crate::commands::CommandHandler; + use near_ai_client::NearAiClient; + use signal_client::{BotMessage, SignalClient}; + use std::sync::Arc; + use std::time::Duration; + + fn build_map() -> HashMap { + let mut map = HashMap::new(); + for (id, aliases) in all_families() { + register_aliases(&mut map, id, aliases).expect(id); + } + map + } + + #[test] + fn alias_tables_are_unique_across_families() { + let map = build_map(); + assert!(map.contains_key("!help-thread")); + assert!(map.contains_key("!help thread")); + assert!(map.contains_key("!enable-thread")); + assert!(map.contains_key("!translate-me-threads")); + assert!(map.contains_key("!transcript-on")); + assert_eq!(map.get("!help-thread"), Some(&"help_threads")); + assert_eq!(map.get("!help"), Some(&"help")); + assert_ne!(map.get("!help-thread"), map.get("!help")); + } + + #[test] + fn uniqueness_detects_cross_family_duplicate() { + let mut map = HashMap::new(); + register_aliases(&mut map, "help", &["!help"]).unwrap(); + let err = register_aliases(&mut map, "help_threads", &["!help"]).unwrap_err(); + assert!(err.contains("`!help`")); + assert!(err.contains("help_threads")); + } + + #[test] + fn reserved_stems_are_absent() { + let map = build_map(); + for stem in RESERVED_STEMS { + assert!( + !map.contains_key(*stem), + "reserved stem `{stem}` must not be an alias" + ); + } + assert!(!map.contains_key("!transcript")); + } + + fn quote_handler() -> TranslateHandler { + TranslateHandler::new( + Arc::new( + NearAiClient::new("key", "http://localhost", "model", Duration::from_secs(5)) + .unwrap(), + ), + Arc::new(SignalClient::new("http://localhost").unwrap()), + "📝 Transcript:", + ) + } + + fn msg(text: &str) -> BotMessage { + BotMessage { + source: "+1".into(), + source_number: None, + source_name: None, + text: text.into(), + timestamp: 0, + message_timestamp: 0, + is_group: true, + group_id: Some("g".into()), + group_name: None, + receiving_account: "+2".into(), + attachments: vec![], + quote: None, + } + } + + #[test] + fn quote_translate_excludes_every_non_quote_alias() { + let handler = quote_handler(); + for (id, aliases) in all_families() { + if *id == "verify" || *id == "rename" { + continue; + } + for alias in *aliases { + assert!( + !handler.matches(&msg(alias)), + "{alias} ({id}) must not match quote-translate" + ); + } + } + assert!(handler.matches(&msg("!translate es"))); + } +} diff --git a/crates/signal-bot/src/commands/help.rs b/crates/signal-bot/src/commands/help.rs index 2a29b74..23f0507 100644 --- a/crates/signal-bot/src/commands/help.rs +++ b/crates/signal-bot/src/commands/help.rs @@ -1,7 +1,8 @@ //! Help / info / thread-commands menus. use crate::commands::menu_locale::{ - help_menu, info_menu, is_exact_command, thread_help_menu, thread_info_menu, + help_menu, info_menu, is_exact_command_any, thread_help_menu, thread_info_menu, + COMMANDS_COMMANDS, HELP_COMMANDS, INFO_COMMANDS, }; use crate::commands::CommandHandler; use crate::error::AppResult; @@ -30,7 +31,7 @@ impl Default for HelpHandler { impl CommandHandler for HelpHandler { fn matches(&self, message: &BotMessage) -> bool { // Exact match so !help-threads / !help-in-chat are not swallowed. - is_exact_command(&message.text, "!help") + is_exact_command_any(&message.text, HELP_COMMANDS) } fn label(&self) -> &'static str { @@ -57,7 +58,7 @@ impl CommandsHandler { #[async_trait] impl CommandHandler for CommandsHandler { fn matches(&self, message: &BotMessage) -> bool { - is_exact_command(&message.text, "!commands") + is_exact_command_any(&message.text, COMMANDS_COMMANDS) } fn label(&self) -> &'static str { @@ -89,7 +90,7 @@ impl InfoHandler { #[async_trait] impl CommandHandler for InfoHandler { fn matches(&self, message: &BotMessage) -> bool { - is_exact_command(&message.text, "!info") + is_exact_command_any(&message.text, INFO_COMMANDS) } fn label(&self) -> &'static str { @@ -139,8 +140,11 @@ mod tests { let handler = HelpHandler::new(); assert!(handler.matches(&dm("!help"))); + assert!(handler.matches(&dm("!HELP"))); assert!(!handler.matches(&dm("!help-threads"))); + assert!(!handler.matches(&dm("!help-thread"))); assert!(!handler.matches(&dm("!help-in-chat"))); + assert!(!handler.matches(&dm("!help thread"))); let t = handler.execute(&dm("!help")).await.unwrap(); assert!(t.contains("!translation-threads")); assert!(t.contains("!translation-in-chat")); @@ -187,6 +191,7 @@ mod tests { 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("!command", "it-internal"))); assert!(!handler.matches(&group("!help", "it-internal"))); let out = handler .execute(&group("!commands", "it-internal")) diff --git a/crates/signal-bot/src/commands/menu_locale.rs b/crates/signal-bot/src/commands/menu_locale.rs index 688f4c9..9d7c377 100644 --- a/crates/signal-bot/src/commands/menu_locale.rs +++ b/crates/signal-bot/src/commands/menu_locale.rs @@ -62,24 +62,60 @@ pub fn privacy_menu() -> &'static str { PRIVACY_MENU } -/// Exact command match (avoids `!translation` matching `!translation-on`). -pub fn is_exact_command(text: &str, command: &str) -> bool { - text.trim() == command -} +pub use signal_bot_core::is_exact_command_any; -pub fn is_exact_command_any(text: &str, commands: &[&str]) -> bool { - let t = text.trim(); - commands.contains(&t) -} +pub(crate) const HELP_COMMANDS: &[&str] = &["!help"]; +pub(crate) const INFO_COMMANDS: &[&str] = &["!info"]; +pub(crate) const PRIVACY_COMMANDS: &[&str] = &["!privacy"]; +pub(crate) const COMMANDS_COMMANDS: &[&str] = &["!commands", "!command"]; +pub(crate) const TRANSLATION_REDIRECT_COMMANDS: &[&str] = &["!translation", "!translations"]; -const TRANSLATION_THREADS_MENU_COMMANDS: &[&str] = &[ +pub(crate) const TRANSLATION_THREADS_MENU_COMMANDS: &[&str] = &[ "!translation-threads", "!translate-threads", "!translate-thread", "!translation-thread", + "!translation threads", + "!translate threads", + "!translation thread", + "!translate thread", +]; + +pub(crate) const TRANSLATION_IN_CHAT_MENU_COMMANDS: &[&str] = &[ + "!translation-in-chat", + "!translate-in-chat", + "!translation-inchat", + "!translate-inchat", + "!translation in-chat", + "!translate in-chat", ]; -const TRANSLATION_IN_CHAT_MENU_COMMANDS: &[&str] = &["!translation-in-chat", "!translate-in-chat"]; +pub(crate) const IN_CHAT_MENU_COMMANDS: &[&str] = &["!in-chat", "!inchat"]; + +pub(crate) const TRANSCRIPTION_MENU_COMMANDS: &[&str] = &["!transcription", "!transcriptions"]; + +pub(crate) const HELP_THREADS_COMMANDS: &[&str] = &[ + "!help-threads", + "!help-thread", + "!help thread", + "!help threads", +]; + +pub(crate) const HELP_IN_CHAT_COMMANDS: &[&str] = &[ + "!help-in-chat", + "!help-inchat", + "!help in-chat", + "!help in chat", +]; + +pub(crate) const HELP_TRANSCRIPTION_COMMANDS: &[&str] = &[ + "!help-transcription", + "!help-transcribe", + "!help-transcript", + "!help transcription", + "!help transcribe", + "!help transcript", +]; /// Product hub menu for Language Threads (canonical + common typos). pub fn is_translation_threads_menu_command(text: &str) -> bool { @@ -91,6 +127,30 @@ pub fn is_translation_in_chat_menu_command(text: &str) -> bool { is_exact_command_any(text, TRANSLATION_IN_CHAT_MENU_COMMANDS) } +pub fn is_in_chat_menu_command(text: &str) -> bool { + is_exact_command_any(text, IN_CHAT_MENU_COMMANDS) +} + +pub fn is_transcription_menu_command(text: &str) -> bool { + is_exact_command_any(text, TRANSCRIPTION_MENU_COMMANDS) +} + +pub fn is_translation_redirect_command(text: &str) -> bool { + is_exact_command_any(text, TRANSLATION_REDIRECT_COMMANDS) +} + +pub fn is_help_threads_command(text: &str) -> bool { + is_exact_command_any(text, HELP_THREADS_COMMANDS) +} + +pub fn is_help_in_chat_command(text: &str) -> bool { + is_exact_command_any(text, HELP_IN_CHAT_COMMANDS) +} + +pub fn is_help_transcription_command(text: &str) -> bool { + is_exact_command_any(text, HELP_TRANSCRIPTION_COMMANDS) +} + const HELP_TRANSCRIPTION: &str = r#"Voice Transcription AUTO: @@ -305,6 +365,7 @@ Attestation: !verify attests this CVM's compose, not the remote Whis #[cfg(test)] mod tests { use super::*; + use signal_bot_core::is_exact_command; #[test] fn help_translation_is_hub() { @@ -421,6 +482,8 @@ mod tests { 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("!translate thread")); + assert!(is_translation_threads_menu_command("!translation threads")); assert!(is_translation_threads_menu_command( " !translation-threads " )); @@ -436,6 +499,8 @@ mod tests { 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-inchat")); + assert!(is_translation_in_chat_menu_command("!translate in-chat")); assert!(is_translation_in_chat_menu_command( " !translate-in-chat " )); @@ -447,6 +512,24 @@ mod tests { )); } + #[test] + fn help_guide_aliases() { + assert!(is_help_threads_command("!help-thread")); + assert!(is_help_threads_command("!help thread")); + assert!(is_help_threads_command("!Help-Threads")); + assert!(is_help_in_chat_command("!help-inchat")); + assert!(is_help_in_chat_command("!help in chat")); + assert!(is_help_transcription_command("!help-transcribe")); + assert!(is_help_transcription_command("!help transcript")); + assert!(!is_help_threads_command("!help")); + assert!(!is_help_threads_command("!help extra")); + assert!(!is_help_transcription_command("!transcription")); + assert!(!is_transcription_menu_command("!transcript")); + assert!(is_transcription_menu_command("!transcriptions")); + assert!(is_in_chat_menu_command("!inchat")); + assert!(is_translation_redirect_command("!translations")); + } + #[test] fn help_transcription_covers_voice() { let h = transcription_menu(); diff --git a/crates/signal-bot/src/commands/mod.rs b/crates/signal-bot/src/commands/mod.rs index fafd3e4..0b40c32 100644 --- a/crates/signal-bot/src/commands/mod.rs +++ b/crates/signal-bot/src/commands/mod.rs @@ -1,5 +1,7 @@ //! Bot command handlers. +#[cfg(test)] +mod command_aliases; mod help; mod menu_locale; mod privacy; diff --git a/crates/signal-bot/src/commands/privacy.rs b/crates/signal-bot/src/commands/privacy.rs index fd866a3..5567370 100644 --- a/crates/signal-bot/src/commands/privacy.rs +++ b/crates/signal-bot/src/commands/privacy.rs @@ -1,6 +1,6 @@ //! Privacy / TEE explanation menu (translation hub only). -use crate::commands::menu_locale::{is_exact_command, privacy_menu}; +use crate::commands::menu_locale::{is_exact_command_any, privacy_menu, PRIVACY_COMMANDS}; use crate::commands::CommandHandler; use crate::error::AppResult; use async_trait::async_trait; @@ -23,7 +23,7 @@ impl Default for PrivacyHandler { #[async_trait] impl CommandHandler for PrivacyHandler { fn matches(&self, message: &BotMessage) -> bool { - is_exact_command(&message.text, "!privacy") + is_exact_command_any(&message.text, PRIVACY_COMMANDS) } fn label(&self) -> &'static str { @@ -60,6 +60,7 @@ mod tests { async fn privacy_returns_unified_menu() { let handler = PrivacyHandler::new(); assert!(handler.matches(&dm("!privacy"))); + 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(); diff --git a/crates/signal-bot/src/commands/product_menus.rs b/crates/signal-bot/src/commands/product_menus.rs index 55296aa..5b7055b 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: `!translation-threads`, `!translation-in-chat`, `!transcription`, redirects. use crate::commands::menu_locale::{ - help_in_chat_guide, help_threads_guide, help_transcription_guide, is_exact_command, - is_translation_in_chat_menu_command, is_translation_threads_menu_command, transcription_menu, + help_in_chat_guide, help_threads_guide, help_transcription_guide, is_help_in_chat_command, + is_help_threads_command, is_help_transcription_command, is_in_chat_menu_command, + is_transcription_menu_command, is_translation_in_chat_menu_command, + is_translation_redirect_command, is_translation_threads_menu_command, transcription_menu, translation_in_chat_menu, translation_split_redirect, translation_threads_menu, }; use crate::commands::CommandHandler; @@ -22,7 +24,7 @@ impl TranslationMenuHandler { #[async_trait] impl CommandHandler for TranslationMenuHandler { fn matches(&self, message: &BotMessage) -> bool { - is_exact_command(&message.text, "!translation") + is_translation_redirect_command(&message.text) } fn label(&self) -> &'static str { @@ -108,7 +110,7 @@ impl Default for TranscriptionMenuHandler { #[async_trait] impl CommandHandler for TranscriptionMenuHandler { fn matches(&self, message: &BotMessage) -> bool { - is_exact_command(&message.text, "!transcription") + is_transcription_menu_command(&message.text) } fn label(&self) -> &'static str { @@ -135,7 +137,7 @@ impl InChatMenuHandler { #[async_trait] impl CommandHandler for InChatMenuHandler { fn matches(&self, message: &BotMessage) -> bool { - is_exact_command(&message.text, "!in-chat") + is_in_chat_menu_command(&message.text) } fn label(&self) -> &'static str { @@ -165,7 +167,7 @@ impl Default for HelpThreadsHandler { #[async_trait] impl CommandHandler for HelpThreadsHandler { fn matches(&self, message: &BotMessage) -> bool { - is_exact_command(&message.text, "!help-threads") + is_help_threads_command(&message.text) } fn label(&self) -> &'static str { @@ -195,7 +197,7 @@ impl Default for HelpInChatHandler { #[async_trait] impl CommandHandler for HelpInChatHandler { fn matches(&self, message: &BotMessage) -> bool { - is_exact_command(&message.text, "!help-in-chat") + is_help_in_chat_command(&message.text) } fn label(&self) -> &'static str { @@ -225,7 +227,7 @@ impl Default for HelpTranscriptionHandler { #[async_trait] impl CommandHandler for HelpTranscriptionHandler { fn matches(&self, message: &BotMessage) -> bool { - is_exact_command(&message.text, "!help-transcription") + is_help_transcription_command(&message.text) } fn label(&self) -> &'static str { @@ -262,6 +264,7 @@ mod tests { fn menus_match_exact_only() { let t = TranslationMenuHandler::new(true); assert!(t.matches(&msg("!translation"))); + assert!(t.matches(&msg("!translations"))); assert!(!t.matches(&msg("!translation-on es en"))); let threads = TranslationThreadsMenuHandler::new(); @@ -269,31 +272,44 @@ mod tests { assert!(threads.matches(&msg("!translate-threads"))); assert!(threads.matches(&msg("!translate-thread"))); assert!(threads.matches(&msg("!translation-thread"))); + assert!(threads.matches(&msg("!translate 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("!inchat"))); assert!(!in_chat_prod.matches(&msg("!translation-on es en"))); let i = InChatMenuHandler::new(true); assert!(i.matches(&msg("!in-chat"))); + assert!(i.matches(&msg("!inchat"))); let ht = HelpThreadsHandler::new(); assert!(ht.matches(&msg("!help-threads"))); + assert!(ht.matches(&msg("!help-thread"))); + assert!(ht.matches(&msg("!help thread"))); + assert!(ht.matches(&msg("!Help-Threads"))); + assert!(ht.matches(&msg("!help_threads"))); assert!(!ht.matches(&msg("!help"))); + assert!(!ht.matches(&msg("!help extra"))); let hi = HelpInChatHandler::new(); assert!(hi.matches(&msg("!help-in-chat"))); + assert!(hi.matches(&msg("!help-inchat"))); assert!(!hi.matches(&msg("!help"))); let htr = HelpTranscriptionHandler::new(); assert!(htr.matches(&msg("!help-transcription"))); + assert!(htr.matches(&msg("!help-transcribe"))); + assert!(htr.matches(&msg("!help transcript"))); assert!(!htr.matches(&msg("!help"))); assert!(!htr.matches(&msg("!transcription"))); let m = TranscriptionMenuHandler::new(); assert!(m.matches(&msg("!transcription"))); + assert!(m.matches(&msg("!transcriptions"))); + assert!(!m.matches(&msg("!transcript"))); } #[tokio::test] @@ -303,7 +319,11 @@ mod tests { .execute(&msg("!translation-in-chat")) .await .unwrap(); - for typo in ["!translate-in-chat"] { + for typo in [ + "!translate-in-chat", + "!translate-inchat", + "!translate in-chat", + ] { let got = canonical.execute(&msg(typo)).await.unwrap(); assert_eq!(got, expected); } @@ -317,6 +337,8 @@ mod tests { "!translate-threads", "!translate-thread", "!translation-thread", + "!translate thread", + "!translation threads", ] { let got = handler.execute(&msg(typo)).await.unwrap(); assert_eq!(got, expected); @@ -346,11 +368,16 @@ mod tests { #[tokio::test] async fn feature_guides_return_use_case_copy() { let threads = HelpThreadsHandler::new() - .execute(&msg("!help-threads")) + .execute(&msg("!help-thread")) .await .unwrap(); assert!(threads.contains("sidecar")); assert!(threads.contains("!translate-me-thread")); + let via_canonical = HelpThreadsHandler::new() + .execute(&msg("!help-threads")) + .await + .unwrap(); + assert_eq!(threads, via_canonical); let in_chat = HelpInChatHandler::new() .execute(&msg("!help-in-chat")) diff --git a/crates/signal-bot/src/commands/rename.rs b/crates/signal-bot/src/commands/rename.rs index 1e8d3ab..6b96bd7 100644 --- a/crates/signal-bot/src/commands/rename.rs +++ b/crates/signal-bot/src/commands/rename.rs @@ -4,6 +4,7 @@ use crate::commands::CommandHandler; use crate::error::AppResult; use crate::group_preferences_store::GroupPreferencesStore; use async_trait::async_trait; +use signal_bot_core::{starts_with_word, strip_word_prefix}; use signal_client::{BotMessage, SignalClient}; use std::sync::Arc; use tracing::warn; @@ -25,20 +26,14 @@ impl RenameHandler { } fn parse_name(text: &str) -> Option<&str> { - let t = text.trim(); - let rest = t.strip_prefix("!rename")?; - if !rest.is_empty() && !rest.starts_with(' ') && !rest.starts_with('\t') { - return None; - } - Some(rest.trim()) + Some(strip_word_prefix(text, "!rename")?.trim()) } } #[async_trait] impl CommandHandler for RenameHandler { fn matches(&self, message: &BotMessage) -> bool { - let t = message.text.trim(); - t == "!rename" || t.starts_with("!rename ") + starts_with_word(&message.text, "!rename") } fn label(&self) -> &'static str { diff --git a/crates/signal-bot/src/commands/translate.rs b/crates/signal-bot/src/commands/translate.rs index 912c4e6..76f2728 100644 --- a/crates/signal-bot/src/commands/translate.rs +++ b/crates/signal-bot/src/commands/translate.rs @@ -1,15 +1,19 @@ //! `!translate` — quote-reply translation via NEAR AI. use crate::commands::menu_locale::{ - is_translation_in_chat_menu_command, is_translation_threads_menu_command, + is_in_chat_menu_command, is_transcription_menu_command, is_translation_in_chat_menu_command, + is_translation_redirect_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_langs::is_list_langs_command; +use crate::commands::translate_me::{TranslateMeHandler, ENABLE_IN_CHAT_CMDS}; use crate::commands::translate_service::strip_transcript_prefix; use crate::commands::CommandHandler; use crate::error::AppResult; use async_trait::async_trait; use near_ai_client::{Message, NearAiClient, Role}; +use signal_bot_core::{is_exact_command_any, normalize_command_head, strip_word_prefix}; use signal_client::{BotMessage, QuotedMessage, SignalClient}; use std::sync::Arc; use tracing::{info, instrument, warn}; @@ -34,7 +38,7 @@ impl TranslateHandler { } fn parse_lang_token(text: &str) -> Option<&str> { - let rest = text.trim().strip_prefix("!translate")?.trim(); + let rest = strip_word_prefix(text, "!translate")?; if rest.is_empty() { return None; } @@ -113,6 +117,19 @@ impl TranslateHandler { } } +/// True when another `!translate*` / `!translation*` command already owns this text. +pub(crate) fn is_non_quote_translate_command(text: &str) -> bool { + is_translate_on_or_off_command(text) + || is_translation_threads_menu_command(text) + || is_translation_in_chat_menu_command(text) + || is_translation_redirect_command(text) + || is_in_chat_menu_command(text) + || TranslateMeHandler::is_on_command(text) + || is_exact_command_any(text, ENABLE_IN_CHAT_CMDS) + || is_list_langs_command(text) + || is_transcription_menu_command(text) +} + fn truncate_snippet(text: &str, max_len: usize) -> String { if text.chars().count() <= max_len { text.to_string() @@ -125,16 +142,8 @@ fn truncate_snippet(text: &str, max_len: usize) -> String { #[async_trait] impl CommandHandler for TranslateHandler { fn matches(&self, message: &BotMessage) -> bool { - 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") - && text != "!in-chat" - && !text.starts_with("!list-langs") + let head = normalize_command_head(&message.text); + head.starts_with("!translate") && !is_non_quote_translate_command(&message.text) } fn handles_own_reply(&self) -> bool { @@ -216,6 +225,10 @@ mod tests { TranslateHandler::parse_lang_token("!translate es"), Some("es") ); + assert_eq!( + TranslateHandler::parse_lang_token("!Translate ES"), + Some("ES") + ); assert_eq!( TranslateHandler::parse_lang_token("!translate Spanish"), Some("Spanish") @@ -293,13 +306,21 @@ mod tests { msg.text = "!translate es".into(); assert!(handler.matches(&msg)); + msg.text = "!Translate ES".into(); + assert!(handler.matches(&msg)); + for typo in [ "!translate-in-chat", "!translate-threads", "!translate-thread", + "!translate thread", + "!translate-me-threads es", + "!translation-me-threads es en", + "!enable-thread", + "!list-lang", ] { msg.text = typo.into(); - assert!(!handler.matches(&msg)); + assert!(!handler.matches(&msg), "{typo} must not be quote-translate"); } } diff --git a/crates/signal-bot/src/commands/translate_all.rs b/crates/signal-bot/src/commands/translate_all.rs index 73f4e55..5e7ec32 100644 --- a/crates/signal-bot/src/commands/translate_all.rs +++ b/crates/signal-bot/src/commands/translate_all.rs @@ -12,26 +12,34 @@ use crate::error::AppResult; use crate::group_preferences_store::{GroupPreferencesStore, GroupTranslateMode, PendingSwitch}; use async_trait::async_trait; use near_ai_client::NearAiClient; +use signal_bot_core::{is_exact_command_any, starts_with_word_any, strip_prefix_list}; use signal_client::{BotMessage, SignalClient}; use std::sync::Arc; use tracing::{debug, info, instrument, warn}; -const ALL_ON_PREFIXES: &[&str] = &[ +pub(crate) const ALL_ON_PREFIXES: &[&str] = &[ "!translate-all-on", "!translation-all-on", "!translate-on", "!translation-on", ]; -const ALL_OFF_COMMANDS: &[&str] = &[ +pub(crate) 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"]; +pub(crate) const ME_ON_PREFIXES: &[&str] = &["!translate-me-on", "!translation-me-on"]; +pub(crate) 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"]; +pub(crate) const ENABLE_THREADS: &[&str] = &[ + "!enable-threads", + "!translation-enable-threads", + "!enable-thread", + "!translation-enable-thread", + "!enable thread", + "!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"; @@ -49,47 +57,27 @@ fn group_blocks_personal_msg(mode: &GroupTranslateMode) -> String { /// 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(); is_all_on_command(text) - || ALL_OFF_COMMANDS.contains(&text) + || is_exact_command_any(text, ALL_OFF_COMMANDS) || is_me_on_command(text) - || ME_OFF_COMMANDS.contains(&text) - || ENABLE_THREADS.contains(&text) -} - -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(' ')) + || is_exact_command_any(text, ME_OFF_COMMANDS) + || is_exact_command_any(text, ENABLE_THREADS) } fn is_all_on_command(text: &str) -> bool { - ALL_ON_PREFIXES.iter().any(|p| starts_with_word(text, p)) + starts_with_word_any(text, ALL_ON_PREFIXES) } 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) - } - }) + starts_with_word_any(text, ME_ON_PREFIXES) } fn is_bare_all_on(text: &str) -> bool { - ALL_ON_PREFIXES.contains(&text.trim()) + is_exact_command_any(text, ALL_ON_PREFIXES) } fn is_bare_me_on(text: &str) -> bool { - ME_ON_PREFIXES.contains(&text.trim()) + is_exact_command_any(text, ME_ON_PREFIXES) } #[derive(Clone)] @@ -519,11 +507,11 @@ 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 ENABLE_THREADS.contains(&text) { + if is_exact_command_any(text, ENABLE_THREADS) { self.handle_enable_threads(message).await - } else if ME_OFF_COMMANDS.contains(&text) { + } else if is_exact_command_any(text, ME_OFF_COMMANDS) { self.handle_me_off(message).await - } else if ALL_OFF_COMMANDS.contains(&text) { + } else if is_exact_command_any(text, ALL_OFF_COMMANDS) { self.handle_all_off(message).await } else if is_me_on_command(text) { self.handle_me_on(message).await @@ -629,9 +617,13 @@ mod tests { 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("!enable-thread")); + assert!(is_translate_on_or_off_command("!enable thread")); assert!(is_translate_on_or_off_command("!translation-off")); + assert!(is_translate_on_or_off_command("!Translate-On es en")); assert!(!is_translate_on_or_off_command("!translate es")); assert!(!is_translate_on_or_off_command("!translate-me-thread es")); + assert!(!is_translate_on_or_off_command("!enable-in-chat")); } #[test] diff --git a/crates/signal-bot/src/commands/translate_langs.rs b/crates/signal-bot/src/commands/translate_langs.rs index 25c48a8..decd1c8 100644 --- a/crates/signal-bot/src/commands/translate_langs.rs +++ b/crates/signal-bot/src/commands/translate_langs.rs @@ -4,8 +4,20 @@ use crate::commands::translate_lang::{format_language_list, ALL_LANGUAGES}; use crate::commands::CommandHandler; use crate::error::AppResult; use async_trait::async_trait; +use signal_bot_core::starts_with_word_any; use signal_client::BotMessage; +pub(crate) const LIST_LANGS_COMMANDS: &[&str] = &[ + "!list-langs", + "!list-lang", + "!list-languages", + "!list-language", +]; + +pub(crate) fn is_list_langs_command(text: &str) -> bool { + starts_with_word_any(text, LIST_LANGS_COMMANDS) +} + pub struct TranslateLangsHandler; impl TranslateLangsHandler { @@ -27,11 +39,7 @@ impl CommandHandler for TranslateLangsHandler { } fn matches(&self, message: &BotMessage) -> bool { - let text = message.text.trim(); - text == "!list-langs" - || text - .strip_prefix("!list-langs") - .is_some_and(|rest| rest.starts_with(' ') || rest.starts_with('\n')) + is_list_langs_command(&message.text) } fn label(&self) -> &'static str { @@ -68,6 +76,14 @@ mod tests { quote: None, }; assert!(h.matches(&msg)); + msg.text = "!list-lang".into(); + assert!(h.matches(&msg)); + msg.text = "!list-languages".into(); + assert!(h.matches(&msg)); + msg.text = "!list-language".into(); + assert!(h.matches(&msg)); + msg.text = "!List-Langs".into(); + assert!(h.matches(&msg)); msg.text = "!list-langs-common".into(); assert!(!h.matches(&msg)); } diff --git a/crates/signal-bot/src/commands/translate_me.rs b/crates/signal-bot/src/commands/translate_me.rs index 1c61643..c984fa9 100644 --- a/crates/signal-bot/src/commands/translate_me.rs +++ b/crates/signal-bot/src/commands/translate_me.rs @@ -36,10 +36,21 @@ const LEAVE_SIDECAR_ONLY_MSG: &str = const IN_CHAT_BLOCK_MSG: &str = "In-chat auto-translate is already on in this group, so Language Threads and Bilingual Threads can't start alongside it.\n\nThe three products — in-chat auto, Language Threads, and Bilingual Threads — cannot run at the same time.\n\nTo switch, send:\n!enable-threads"; const LANGUAGE_THREADS_TWO_ARG_REFUSE: &str = "Language Threads is already on (multilingual hub). Tear down with !enable-in-chat before starting Bilingual 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"]; +pub(crate) const ENABLE_IN_CHAT_CMDS: &[&str] = &[ + "!enable-in-chat", + "!translation-enable-in-chat", + "!enable-inchat", + "!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 BILINGUAL_DISABLED_SIDECAR_MSG: &str = "Bilingual 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(crate) const LEAVE_CMDS: &[&str] = &["!leave"]; +pub(crate) const THREAD_ON_PREFIXES: &[&str] = &[ + "!translate-me-thread", + "!translation-me-thread", + "!translate-me-threads", + "!translation-me-threads", +]; #[derive(Debug, Clone, PartialEq, Eq)] enum ThreadCmdArgs { @@ -156,17 +167,16 @@ impl TranslateMeHandler { } } - fn is_on_command(text: &str) -> bool { - let t = text.trim(); - starts_with_word(t, "!translate-me-thread") || starts_with_word(t, "!translation-me-thread") + pub(crate) fn is_on_command(text: &str) -> bool { + signal_bot_core::starts_with_word_any(text, THREAD_ON_PREFIXES) } fn is_off_command(text: &str) -> bool { - LEAVE_CMDS.contains(&text.trim()) + signal_bot_core::is_exact_command_any(text, LEAVE_CMDS) } fn is_enable_in_chat(text: &str) -> bool { - ENABLE_IN_CHAT_CMDS.contains(&text.trim()) + signal_bot_core::is_exact_command_any(text, ENABLE_IN_CHAT_CMDS) } fn is_command(text: &str) -> bool { @@ -175,13 +185,8 @@ impl TranslateMeHandler { } fn thread_tokens(text: &str) -> Option> { - let t = text.trim(); - for prefix in ["!translate-me-thread", "!translation-me-thread"] { - if let Some(rest) = strip_word_prefix(t, prefix) { - return Some(rest.split_whitespace().collect()); - } - } - None + let rest = signal_bot_core::strip_prefix_list(text, THREAD_ON_PREFIXES)?; + Some(rest.split_whitespace().collect()) } fn is_relay_candidate(&self, message: &BotMessage) -> bool { @@ -932,22 +937,6 @@ fn short_main_id_hash(main_id: &str) -> String { format!("{:04x}", hash & 0xffff) } -fn starts_with_word(text: &str, prefix: &str) -> bool { - text == prefix - || text - .strip_prefix(prefix) - .is_some_and(|rest| rest.is_empty() || rest.starts_with(' ')) -} - -fn strip_word_prefix<'a>(text: &'a str, prefix: &str) -> Option<&'a str> { - if text == prefix { - return Some(""); - } - text.strip_prefix(prefix) - .filter(|rest| rest.is_empty() || rest.starts_with(' ')) - .map(str::trim) -} - #[async_trait] impl CommandHandler for TranslateMeHandler { fn matches(&self, message: &BotMessage) -> bool { @@ -1043,9 +1032,16 @@ mod tests { #[test] fn matches_on_off_commands() { 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_on_command( + "!translate-me-threads es" + )); + assert!(TranslateMeHandler::is_on_command( + "!translation-me-threads es en" + )); assert!(TranslateMeHandler::is_off_command("!leave")); + assert!(TranslateMeHandler::is_off_command("!LEAVE")); + assert!(TranslateMeHandler::is_enable_in_chat("!enable-inchat")); + assert!(TranslateMeHandler::is_enable_in_chat("!enable in-chat")); assert!(!TranslateMeHandler::is_command("!translate-on es en")); assert!(!TranslateMeHandler::is_command("!translate-me-on es en")); assert!(!TranslateMeHandler::is_command("!translate es")); @@ -1065,6 +1061,14 @@ mod tests { TranslateMeHandler::thread_tokens("!translation-me-thread es en"), Some(vec!["es", "en"]) ); + assert_eq!( + TranslateMeHandler::thread_tokens("!translate-me-threads es"), + Some(vec!["es"]) + ); + assert_eq!( + TranslateMeHandler::thread_tokens("!translation-me-threads es en"), + Some(vec!["es", "en"]) + ); assert_eq!( TranslateMeHandler::thread_tokens("!translate-me-thread"), Some(vec![]) diff --git a/crates/signal-bot/src/commands/verify.rs b/crates/signal-bot/src/commands/verify.rs index 2d427c5..38b4d63 100644 --- a/crates/signal-bot/src/commands/verify.rs +++ b/crates/signal-bot/src/commands/verify.rs @@ -62,15 +62,15 @@ impl VerifyHandler { /// Expected format: "!verify " or just "!verify" fn parse_challenge(&self, text: &str) -> Option { let trimmed = text.trim(); - if trimmed.starts_with("!verify") { - let rest = trimmed.strip_prefix("!verify").unwrap().trim(); - if rest.is_empty() { - None - } else { - Some(rest.to_string()) - } - } else { + const PREFIX: &str = "!verify"; + if trimmed.len() < PREFIX.len() || !trimmed[..PREFIX.len()].eq_ignore_ascii_case(PREFIX) { + return None; + } + let rest = trimmed[PREFIX.len()..].trim(); + if rest.is_empty() { None + } else { + Some(rest.to_string()) } } @@ -282,6 +282,12 @@ impl CommandHandler for VerifyHandler { Some("!verify") } + fn matches(&self, message: &BotMessage) -> bool { + let t = message.text.trim(); + const PREFIX: &str = "!verify"; + t.len() >= PREFIX.len() && t[..PREFIX.len()].eq_ignore_ascii_case(PREFIX) + } + async fn execute(&self, message: &BotMessage) -> AppResult { let raw = self.parse_challenge(&message.text); let prefixed = self.prefixed_challenge(raw); @@ -326,6 +332,10 @@ mod tests { handler.parse_challenge("!verify abc123"), Some("abc123".into()) ); + assert_eq!( + handler.parse_challenge("!VERIFY abc123"), + Some("abc123".into()) + ); assert_eq!( handler.parse_challenge("!verify my random challenge "), Some("my random challenge".into()) diff --git a/crates/signal-bot/src/config.rs b/crates/signal-bot/src/config.rs index 4ee70db..8b850b3 100644 --- a/crates/signal-bot/src/config.rs +++ b/crates/signal-bot/src/config.rs @@ -138,6 +138,12 @@ pub struct GroupPreferencesConfig { /// Encrypted preferences file path (Docker volume in production) #[serde(default = "default_group_preferences_path")] pub storage_path: String, + + /// Previous dstack `compose_hash` values (comma-separated). Used only when + /// DeriveKey is missing so AppInfo-encrypted `group_prefs.enc` still decrypts + /// after a compose/image bump; the bot then re-saves with an app-id-only key. + #[serde(default)] + pub legacy_compose_hash: String, } // Default implementations @@ -196,10 +202,22 @@ impl Default for GroupPreferencesConfig { Self { persist: default_true(), storage_path: default_group_preferences_path(), + legacy_compose_hash: String::new(), } } } +impl GroupPreferencesConfig { + pub fn legacy_compose_hashes(&self) -> Vec { + self.legacy_compose_hash + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() + } +} + fn default_signal_service() -> String { "http://signal-api:8080".into() } @@ -352,5 +370,24 @@ mod tests { assert_eq!(WhisperConfig::default().model, "openai/whisper-large-v3"); assert!(TranslateAllConfig::default().enabled); assert!(GroupPreferencesConfig::default().persist); + assert!(GroupPreferencesConfig::default() + .legacy_compose_hash + .is_empty()); + } + + #[test] + fn legacy_compose_hashes_split_and_trim() { + let cfg = GroupPreferencesConfig { + persist: true, + storage_path: "/data/group_prefs.enc".into(), + legacy_compose_hash: " abc ,def, ".into(), + }; + assert_eq!( + cfg.legacy_compose_hashes(), + vec!["abc".to_string(), "def".to_string()] + ); + assert!(GroupPreferencesConfig::default() + .legacy_compose_hashes() + .is_empty()); } } diff --git a/crates/signal-bot/src/group_preferences_store.rs b/crates/signal-bot/src/group_preferences_store.rs index b4f14b9..77df4f0 100644 --- a/crates/signal-bot/src/group_preferences_store.rs +++ b/crates/signal-bot/src/group_preferences_store.rs @@ -22,6 +22,61 @@ const DATA_VERSION: u32 = 1; const KEY_DERIVATION_PATH: &str = "signal-bot/group-preferences"; const NONCE_SIZE: usize = 12; +/// AppInfo fallback when dstack `DeriveKey` is missing. +/// +/// `compose_hash = Some` is the legacy mix (broke on every compose/image bump). +/// `None` is app-id-only and stays valid across in-place upgrades. +fn appinfo_fallback_key(app_id: &str, compose_hash: Option<&str>) -> [u8; 32] { + let mut hasher = Sha256::new(); + if let Some(compose_hash) = compose_hash { + hasher.update(compose_hash.as_bytes()); + } + hasher.update(app_id.as_bytes()); + hasher.update(KEY_DERIVATION_PATH.as_bytes()); + let hash = hasher.finalize(); + let mut key = [0u8; 32]; + key.copy_from_slice(&hash); + key +} + +fn decrypt_prefs_blob(data: &[u8], key: &[u8; 32]) -> Result { + if data.len() < NONCE_SIZE { + return Err("group preferences file too short".into()); + } + let cipher = Aes256Gcm::new(Key::::from_slice(key)); + let nonce = Nonce::from_slice(&data[..NONCE_SIZE]); + let ciphertext = &data[NONCE_SIZE..]; + let plaintext = cipher.decrypt(nonce, ciphertext).map_err(|_| { + "Failed to decrypt group preferences (TEE deployment may have changed)".to_string() + })?; + serde_json::from_slice(&plaintext).map_err(|e| format!("parse group preferences: {e}")) +} + +fn encrypt_prefs_blob( + snapshot: &GroupPreferencesSnapshot, + key: &[u8; 32], +) -> Result, String> { + let cipher = Aes256Gcm::new(Key::::from_slice(key)); + let mut nonce_bytes = [0u8; NONCE_SIZE]; + rand::thread_rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + let plaintext = + serde_json::to_vec(snapshot).map_err(|e| format!("serialize group preferences: {e}"))?; + let ciphertext = cipher + .encrypt(nonce, plaintext.as_ref()) + .map_err(|e| format!("encrypt group preferences: {e}"))?; + let mut data = nonce_bytes.to_vec(); + data.extend(ciphertext); + Ok(data) +} + +fn push_unique_key(out: &mut Vec<(String, [u8; 32])>, label: String, key: [u8; 32]) { + if out.iter().any(|(_, existing)| existing == &key) { + return; + } + out.push((label, key)); +} + /// Active bidirectional translation pair for a Signal group. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct GroupTranslateMode { @@ -210,6 +265,8 @@ pub struct GroupPreferencesStore { storage_path: Option, cached_key: RwLock>, persist_lock: Mutex<()>, + /// Previous dstack compose hashes (AppInfo fallback used to mix these into the key). + legacy_compose_hashes: Vec, } impl GroupPreferencesStore { @@ -224,6 +281,7 @@ impl GroupPreferencesStore { storage_path: None, cached_key: RwLock::new(None), persist_lock: Mutex::new(()), + legacy_compose_hashes: Vec::new(), }) } @@ -233,6 +291,7 @@ impl GroupPreferencesStore { storage_path: PathBuf, persist: bool, max_per_minute: u32, + legacy_compose_hashes: Vec, ) -> Arc { let store = Arc::new(Self { groups: RwLock::new(HashMap::new()), @@ -243,6 +302,7 @@ impl GroupPreferencesStore { storage_path: if persist { Some(storage_path) } else { None }, cached_key: RwLock::new(None), persist_lock: Mutex::new(()), + legacy_compose_hashes, }); if persist { @@ -271,6 +331,7 @@ impl GroupPreferencesStore { storage_path: Some(storage_path), cached_key: RwLock::new(Some(key)), persist_lock: Mutex::new(()), + legacy_compose_hashes: Vec::new(), }); let _ = store.load().await; store @@ -788,12 +849,20 @@ impl GroupPreferencesStore { if let Some(key) = *self.cached_key.read().unwrap() { return Ok(key); } + let (preferred, _) = self.encryption_keys().await?; + *self.cached_key.write().unwrap() = Some(preferred); + Ok(preferred) + } + /// Preferred persist key plus decrypt candidates (legacy compose-hash mixes last). + async fn encryption_keys(&self) -> Result<([u8; 32], Vec<(String, [u8; 32])>), String> { let dstack = self .dstack .as_ref() .ok_or_else(|| "persistence not configured".to_string())?; + let mut candidates = Vec::new(); + let mut derive_key = None; match dstack.derive_key(KEY_DERIVATION_PATH, None).await { Ok(key_bytes) => { if key_bytes.len() < 32 { @@ -804,9 +873,9 @@ impl GroupPreferencesStore { } let mut key = [0u8; 32]; key.copy_from_slice(&key_bytes[..32]); - *self.cached_key.write().unwrap() = Some(key); info!("Using DeriveKey endpoint for group preferences encryption"); - return Ok(key); + derive_key = Some(key); + push_unique_key(&mut candidates, "DeriveKey".into(), key); } Err(e) => { warn!("DeriveKey not available for group preferences, using AppInfo fallback: {e}"); @@ -817,24 +886,29 @@ impl GroupPreferencesStore { .get_app_info() .await .map_err(|e| format!("Failed to get AppInfo for key derivation: {e}"))?; - - let compose_hash = app_info.compose_hash.as_deref().unwrap_or("unknown"); let app_id = app_info.app_id.as_deref().unwrap_or("unknown"); + let compose_hash = app_info.compose_hash.as_deref().unwrap_or("unknown"); - let mut hasher = Sha256::new(); - hasher.update(compose_hash.as_bytes()); - hasher.update(app_id.as_bytes()); - hasher.update(KEY_DERIVATION_PATH.as_bytes()); - let hash = hasher.finalize(); - - let mut key = [0u8; 32]; - key.copy_from_slice(&hash); - *self.cached_key.write().unwrap() = Some(key); - + let stable = appinfo_fallback_key(app_id, None); info!( - "Using AppInfo-derived key for group preferences (compose_hash: {compose_hash}, app_id: {app_id})" + "Using AppInfo-derived key for group preferences (app_id: {app_id}, no compose_hash)" + ); + push_unique_key(&mut candidates, "AppInfo app_id".into(), stable); + push_unique_key( + &mut candidates, + format!("AppInfo compose_hash {compose_hash}"), + appinfo_fallback_key(app_id, Some(compose_hash)), ); - Ok(key) + for hash in &self.legacy_compose_hashes { + push_unique_key( + &mut candidates, + format!("legacy compose_hash {hash}"), + appinfo_fallback_key(app_id, Some(hash)), + ); + } + + let preferred = derive_key.unwrap_or(stable); + Ok((preferred, candidates)) } fn snapshot(&self) -> GroupPreferencesSnapshot { @@ -853,20 +927,7 @@ impl GroupPreferencesStore { .ok_or_else(|| "persistence not configured".to_string())?; let key = self.derive_key().await?; - let cipher = Aes256Gcm::new(Key::::from_slice(&key)); - - let mut nonce_bytes = [0u8; NONCE_SIZE]; - rand::thread_rng().fill_bytes(&mut nonce_bytes); - let nonce = Nonce::from_slice(&nonce_bytes); - - let plaintext = serde_json::to_vec(&self.snapshot()) - .map_err(|e| format!("serialize group preferences: {e}"))?; - let ciphertext = cipher - .encrypt(nonce, plaintext.as_ref()) - .map_err(|e| format!("encrypt group preferences: {e}"))?; - - let mut data = nonce_bytes.to_vec(); - data.extend(ciphertext); + let data = encrypt_prefs_blob(&self.snapshot(), &key)?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) @@ -900,24 +961,11 @@ impl GroupPreferencesStore { return Ok(0); } - let key = self.derive_key().await?; - let cipher = Aes256Gcm::new(Key::::from_slice(&key)); let data = fs::read(path) .await .map_err(|e| format!("read group preferences: {e}"))?; - if data.len() < NONCE_SIZE { - return Err("group preferences file too short".into()); - } - - let nonce = Nonce::from_slice(&data[..NONCE_SIZE]); - let ciphertext = &data[NONCE_SIZE..]; - let plaintext = cipher.decrypt(nonce, ciphertext).map_err(|_| { - "Failed to decrypt group preferences (TEE deployment may have changed)".to_string() - })?; - - let snapshot: GroupPreferencesSnapshot = serde_json::from_slice(&plaintext) - .map_err(|e| format!("parse group preferences: {e}"))?; + let (snapshot, used_key, preferred_key) = self.decrypt_with_candidates(&data).await?; if snapshot.version != DATA_VERSION { warn!( @@ -929,9 +977,37 @@ impl GroupPreferencesStore { let count = snapshot.groups.len(); *self.groups.write().unwrap() = snapshot.groups; self.rebuild_sidecar_index(); + *self.cached_key.write().unwrap() = Some(preferred_key); + if used_key != preferred_key { + info!("Re-encrypting group preferences with the stable persist key"); + self.persist().await?; + } Ok(count) } + async fn decrypt_with_candidates( + &self, + data: &[u8], + ) -> Result<(GroupPreferencesSnapshot, [u8; 32], [u8; 32]), String> { + if let Some(key) = *self.cached_key.read().unwrap() { + let snapshot = decrypt_prefs_blob(data, &key)?; + return Ok((snapshot, key, key)); + } + + let (preferred, candidates) = self.encryption_keys().await?; + let mut last_err = "no candidate keys".to_string(); + for (label, key) in candidates { + match decrypt_prefs_blob(data, &key) { + Ok(snapshot) => { + info!("Decrypted group preferences with {label}"); + return Ok((snapshot, key, preferred)); + } + Err(e) => last_err = e, + } + } + Err(last_err) + } + #[cfg(test)] pub async fn persist_now(&self) -> Result<(), String> { self.persist().await @@ -1275,4 +1351,45 @@ mod tests { assert!(store2.is_bilingual("main-1")); assert!(!store2.is_language_threads("main-1")); } + + #[test] + fn appinfo_key_without_compose_hash_differs_from_legacy_mix() { + let stable = appinfo_fallback_key("app-1", None); + let legacy = appinfo_fallback_key("app-1", Some("old-compose")); + assert_ne!(stable, legacy); + assert_eq!(stable, appinfo_fallback_key("app-1", None)); + } + + #[test] + fn decrypts_blob_encrypted_with_legacy_compose_hash_key() { + let mut groups = HashMap::new(); + groups.insert( + "group.one".into(), + GroupPreference { + transcribe_enabled: true, + ..Default::default() + }, + ); + let snapshot = GroupPreferencesSnapshot { + version: DATA_VERSION, + groups, + }; + let legacy = appinfo_fallback_key("app-1", Some("old-compose")); + let stable = appinfo_fallback_key("app-1", None); + let blob = encrypt_prefs_blob(&snapshot, &legacy).unwrap(); + + assert!(decrypt_prefs_blob(&blob, &stable).is_err()); + let loaded = decrypt_prefs_blob(&blob, &legacy).unwrap(); + assert!(loaded.groups["group.one"].transcribe_enabled); + } + + #[test] + fn push_unique_key_skips_duplicates() { + let mut keys = Vec::new(); + let key = [3u8; 32]; + push_unique_key(&mut keys, "a".into(), key); + push_unique_key(&mut keys, "b".into(), key); + assert_eq!(keys.len(), 1); + assert_eq!(keys[0].0, "a"); + } } diff --git a/crates/signal-bot/src/handlers_setup.rs b/crates/signal-bot/src/handlers_setup.rs index 14ddea3..e2a63c1 100644 --- a/crates/signal-bot/src/handlers_setup.rs +++ b/crates/signal-bot/src/handlers_setup.rs @@ -72,6 +72,7 @@ pub async fn build_handlers( PathBuf::from(&config.group_preferences.storage_path), config.group_preferences.persist, config.translate_all.max_messages_per_minute, + config.group_preferences.legacy_compose_hashes(), ) .await; @@ -199,6 +200,7 @@ mod tests { group_preferences: GroupPreferencesConfig { persist: false, storage_path: "/tmp/sigstack-bot-test-prefs.enc".into(), + ..Default::default() }, } } diff --git a/crates/signal-client/src/lib.rs b/crates/signal-client/src/lib.rs index fb89277..76efc20 100644 --- a/crates/signal-client/src/lib.rs +++ b/crates/signal-client/src/lib.rs @@ -183,6 +183,7 @@ mod tests { attachments: vec![], quote: None, }), + edit_message: None, }, account: "+15555555555".into(), }; @@ -217,6 +218,7 @@ mod tests { attachments: vec![], quote: None, }), + edit_message: None, }, account: "+15555555555".into(), }; @@ -243,6 +245,7 @@ mod tests { source_name: None, timestamp: 1677652288000, data_message: None, + edit_message: None, }, account: "+15555555555".into(), }; @@ -662,6 +665,7 @@ mod tests { attachments: vec![], quote: None, }), + edit_message: None, }, account: "+15555555555".into(), }; diff --git a/crates/signal-client/src/types.rs b/crates/signal-client/src/types.rs index 46f820d..7ce0dcb 100644 --- a/crates/signal-client/src/types.rs +++ b/crates/signal-client/src/types.rs @@ -33,6 +33,18 @@ pub struct Envelope { pub timestamp: i64, #[serde(rename = "dataMessage")] pub data_message: Option, + /// In-place edit. signal-cli delivers this instead of top-level `dataMessage`. + #[serde(rename = "editMessage", default)] + pub edit_message: Option, +} + +/// Signal edit envelope (`envelope.editMessage`). +#[derive(Debug, Clone, Deserialize)] +pub struct EditMessage { + #[serde(rename = "targetSentTimestamp")] + pub target_sent_timestamp: i64, + #[serde(rename = "dataMessage")] + pub data_message: DataMessage, } #[derive(Debug, Clone, Deserialize)] @@ -267,8 +279,25 @@ impl BotMessage { /// Extract bot message from incoming envelope. /// /// Returns `Some` for text messages and voice notes (audio attachments). + /// Edits (`editMessage`) are accepted only when the new body is a `!` command. pub fn from_incoming(msg: &IncomingMessage) -> Option { - let data = msg.envelope.data_message.as_ref()?; + let from_edit = msg.envelope.data_message.is_none(); + let data = msg.envelope.data_message.as_ref().or_else(|| { + msg.envelope + .edit_message + .as_ref() + .map(|edit| &edit.data_message) + })?; + if from_edit + && !data + .message + .as_deref() + .unwrap_or("") + .trim() + .starts_with('!') + { + return None; + } let has_text = data.message.as_ref().is_some_and(|text| !text.is_empty()); let has_audio = data.attachments.iter().any(Attachment::is_audio); @@ -441,4 +470,150 @@ mod tests { assert!(identities_match("uuid-abc", "uuid-abc")); assert!(!identities_match("uuid-abc", "uuid-xyz")); } + + fn incoming( + data_message: Option, + edit_message: Option, + ) -> IncomingMessage { + IncomingMessage { + envelope: Envelope { + source: "+14155551234".into(), + source_number: Some("+14155551234".into()), + source_uuid: None, + source_name: Some("Test User".into()), + timestamp: 1677652288000, + data_message, + edit_message, + }, + account: "+15555555555".into(), + } + } + + fn text_data(message: &str, timestamp: i64) -> DataMessage { + DataMessage { + message: Some(message.into()), + timestamp, + group_info: None, + attachments: vec![], + quote: None, + } + } + + #[test] + fn edit_command_becomes_bot_message() { + let incoming = incoming( + None, + Some(EditMessage { + target_sent_timestamp: 1000, + data_message: text_data("!help-threads", 1001), + }), + ); + let msg = BotMessage::from_incoming(&incoming).expect("edit command"); + assert_eq!(msg.text, "!help-threads"); + assert_eq!(msg.message_timestamp, 1001); + assert_eq!(msg.receiving_account, "+15555555555"); + } + + #[test] + fn edit_chat_without_command_is_dropped() { + let incoming = incoming( + None, + Some(EditMessage { + target_sent_timestamp: 1000, + data_message: text_data("hola", 1001), + }), + ); + assert!(BotMessage::from_incoming(&incoming).is_none()); + } + + #[test] + fn data_message_preferred_over_edit_without_command_gate() { + let incoming = incoming( + Some(text_data("hello", 1000)), + Some(EditMessage { + target_sent_timestamp: 1000, + data_message: text_data("!help-threads", 1001), + }), + ); + let msg = BotMessage::from_incoming(&incoming).expect("new send"); + assert_eq!(msg.text, "hello"); + assert_eq!(msg.message_timestamp, 1000); + } + + #[test] + fn empty_edit_is_dropped() { + let incoming = incoming( + None, + Some(EditMessage { + target_sent_timestamp: 1000, + data_message: text_data("", 1001), + }), + ); + assert!(BotMessage::from_incoming(&incoming).is_none()); + } + + #[test] + fn edit_command_keeps_group_and_quote() { + let incoming = incoming( + None, + Some(EditMessage { + target_sent_timestamp: 1000, + data_message: DataMessage { + message: Some("!help-threads".into()), + timestamp: 1001, + group_info: Some(GroupInfo { + group_id: "test-group-id".into(), + group_name: Some("Main".into()), + }), + attachments: vec![], + quote: Some(Quote { + id: 42, + author: None, + author_number: Some("+14155550000".into()), + author_uuid: None, + text: Some("prior".into()), + attachments: vec![], + }), + }, + }), + ); + let msg = BotMessage::from_incoming(&incoming).expect("group edit command"); + assert!(msg.is_group); + assert_eq!(msg.group_id.as_deref(), Some("test-group-id")); + assert_eq!(msg.group_name.as_deref(), Some("Main")); + let quote = msg.quote.expect("quote"); + assert_eq!(quote.id, 42); + assert_eq!(quote.author_number.as_deref(), Some("+14155550000")); + assert_eq!(quote.text.as_deref(), Some("prior")); + } + + #[test] + fn edit_message_deserializes_signal_cli_shape() { + let incoming: IncomingMessage = serde_json::from_value(serde_json::json!({ + "envelope": { + "source": "+14155551234", + "timestamp": 1000, + "editMessage": { + "targetSentTimestamp": 1000, + "dataMessage": { + "message": "!privacy", + "timestamp": 1001, + "attachments": [] + } + } + }, + "account": "+15555555555" + })) + .unwrap(); + let msg = BotMessage::from_incoming(&incoming).expect("json edit command"); + assert_eq!(msg.text, "!privacy"); + assert_eq!( + incoming + .envelope + .edit_message + .as_ref() + .map(|e| e.target_sent_timestamp), + Some(1000) + ); + } } diff --git a/docker/.env.example b/docker/.env.example index f3b8cc0..156a870 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1,4 +1,4 @@ -# Copy to docker/env and fill in. +# Copy to docker/.env and fill in. # Unified Bread Bot (hub + voice + translation). SIGNAL_PHONE=+1YYYYYYYYYY diff --git a/docker/.phala.env.example b/docker/.phala.env.example index e396e21..d80b796 100644 --- a/docker/.phala.env.example +++ b/docker/.phala.env.example @@ -1,7 +1,7 @@ -# Phala one-CVM env — copy to phala.env (do not commit secrets). +# Phala one-CVM env — copy to docker/.phala.env (do not commit secrets). # # Upgrade live: phala deploy --cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353 \ -# -c docker/phala.yaml -e docker/phala.env --wait +# -c docker/phala.yaml -e docker/.phala.env --wait # # SIGNAL_PHONE = the registered bot number on this CVM. @@ -20,6 +20,9 @@ WHISPER_TIMEOUT=120s TRANSLATE_ALL_ENABLED=true TRANSLATE_ALL_MAX_MESSAGES_PER_MINUTE=30 +# Previous dstack compose_hash so group_prefs.enc still decrypts after an image bump +# when DeriveKey is unavailable. The bot then re-saves with an app-id-only key. +# GROUP_PREFERENCES_LEGACY_COMPOSE_HASH= LOG_LEVEL=info BOT_GITHUB_REPO=https://github.com/BreadchainCoop/sigstack-bot BOT_SIGNAL_USERNAME= diff --git a/docker/compose.yaml b/docker/compose.yaml index ceaf681..64e3280 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -2,7 +2,7 @@ # Hub, translation, and voice in one signal-api + signal-bot. # STT is NEAR AI Whisper Large V3 (no Whisper sidecar). # -# docker compose -f docker/compose.yaml --env-file docker/env up -d +# docker compose -f docker/compose.yaml --env-file docker/.env up -d name: sigstack-translation diff --git a/docker/phala.yaml b/docker/phala.yaml index 76e70d8..77aebb7 100644 --- a/docker/phala.yaml +++ b/docker/phala.yaml @@ -12,7 +12,7 @@ # Later upgrades MUST use `--cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353` (or the live app id) # so volumes stay (Signal phone session + group prefs). Do not rename volumes below. # See docs/one-cvm-architecture.md#cvm-storage-keep-intact -# phala deploy --cvm-id YOUR_CVM -c docker/phala.yaml -e docker/phala.env --wait +# phala deploy --cvm-id YOUR_CVM -c docker/phala.yaml -e docker/.phala.env --wait # # Registration proxy :8081 (phone B). Do not re-register phone A. @@ -53,6 +53,7 @@ services: - DSTACK__SOCKET_PATH=/var/run/dstack.sock - GROUP_PREFERENCES__PERSIST=true - GROUP_PREFERENCES__STORAGE_PATH=/data/group_prefs.enc + - GROUP_PREFERENCES__LEGACY_COMPOSE_HASH=${GROUP_PREFERENCES_LEGACY_COMPOSE_HASH:-} volumes: - /var/run/dstack.sock:/var/run/dstack.sock:ro - group-prefs-translation:/data diff --git a/docs/language-threads.md b/docs/language-threads.md index 8cd5d39..7cd0c53 100644 --- a/docs/language-threads.md +++ b/docs/language-threads.md @@ -117,11 +117,11 @@ Legacy encrypted prefs that still contain a `parallel_bridge` key are ignored on ## Local testing ```bash -cp docker/env.example docker/env +cp docker/.env.example docker/.env # Set SIGNAL_PHONE (phone B) and NEAR_AI_API_KEY -docker compose -f docker/compose.yaml --env-file docker/env build signal-bot -docker compose -f docker/compose.yaml --env-file docker/env up -d +docker compose -f docker/compose.yaml --env-file docker/.env build signal-bot +docker compose -f docker/compose.yaml --env-file docker/.env up -d ``` Only **signal-bot** on the translation stack needs rebuild for Language Threads changes. @@ -145,7 +145,7 @@ Whisper / voice run in the same bot process (NEAR AI Whisper) — after STT, spo - One CVM on Phala (`tdx.medium` = 2 vCPU / 4 GB RAM): [`docker/phala.yaml`](../docker/phala.yaml) — one Signal number (phone B). No Whisper sidecar; STT is NEAR AI. See [CPU TEE Whisper does not scale](solutions/architecture-patterns/2026-08-13-cpu-tee-whisper-does-not-scale.md). - Deploy uses **Docker images** (digest-pinned in env), not a public git clone. Upgrade in place: `phala deploy --cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353`. -- Env template: [`docker/phala.env.example`](../docker/phala.env.example) (secrets; do not commit filled env). +- Env template: [`docker/.phala.env.example`](../docker/.phala.env.example) (secrets; do not commit filled env). - Registration proxy on this CVM: `:8081` phone B. ## Trust / privacy notes diff --git a/docs/local-dev/README.md b/docs/local-dev/README.md index 2db0126..ff74c3b 100644 --- a/docs/local-dev/README.md +++ b/docs/local-dev/README.md @@ -10,7 +10,7 @@ Quick start (env copy + `up -d`) stays in [README.md](../README.md#local). Archi - Docker with Compose v2 - One Signal-capable phone number (E.164) -- `NEAR_AI_API_KEY` in `docker/env` (chat + Whisper STT) +- `NEAR_AI_API_KEY` in `docker/.env` (chat + Whisper STT) - Optional: a local `/var/run/dstack.sock` if you care about attestation paths; local Compose mounts it read-only — registration and day-to-day bot traffic do not require a live Phala socket ## Captcha token @@ -42,30 +42,30 @@ That means: After you pull or edit bot code, rebuild and recreate the bot container (Signal registration volumes are untouched): ```bash -docker compose -f docker/compose.yaml --env-file docker/env \ +docker compose -f docker/compose.yaml --env-file docker/.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.yaml --env-file docker/env \ +docker compose -f docker/compose.yaml --env-file docker/.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.yaml --env-file docker/env \ +docker compose -f docker/compose.yaml --env-file docker/.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.yaml --env-file docker/env \ +docker compose -f docker/compose.yaml --env-file docker/.env \ build --no-cache signal-bot -docker compose -f docker/compose.yaml --env-file docker/env \ +docker compose -f docker/compose.yaml --env-file docker/.env \ up -d --force-recreate signal-bot ``` @@ -76,17 +76,17 @@ Do **not** use `down -v` to “force a refresh” — that wipes Signal CLI stat ## Stack Compose file: `docker/compose.yaml` -Env file: `docker/env` +Env file: `docker/.env` Unified bot — hub + voice + in-chat + Language Threads. Needs `NEAR_AI_API_KEY` and Whisper enabled. ### Env + start ```bash -cp docker/env.example docker/env +cp docker/.env.example docker/.env # Edit: SIGNAL_PHONE # NEAR_AI_API_KEY = required (chat + Whisper STT) -docker compose -f docker/compose.yaml --env-file docker/env up -d +docker compose -f docker/compose.yaml --env-file docker/.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. @@ -103,7 +103,7 @@ docker network ls | grep sigstack-translation `signal-api` `/v1/health` returns **HTTP 204** with an empty body — no printed output and exit code 0 means healthy. Failure exits non-zero (`curl -sf`). ```bash -docker compose -f docker/compose.yaml --env-file docker/env \ +docker compose -f docker/compose.yaml --env-file docker/.env \ exec signal-api curl -sf http://localhost:8080/v1/health ``` @@ -115,12 +115,12 @@ curl -sf http://localhost:8081/health ### Register the phone -The number must match `SIGNAL_PHONE` in `docker/env`. +The number must match `SIGNAL_PHONE` in `docker/.env`. Check whether the number is already registered with Signal CLI (skip captcha/register if it appears): ```bash -docker compose -f docker/compose.yaml --env-file docker/env \ +docker compose -f docker/compose.yaml --env-file docker/.env \ exec signal-api curl -sS 'http://localhost:8080/v1/accounts' # Expect JSON array, e.g. ["+1YYYYYYYYYY"]. Empty [] means not registered yet. ``` @@ -150,7 +150,7 @@ curl -sS -X POST "http://localhost:8081/v1/register/+1YYYYYYYYYY/verify/123456" Confirm again with `/v1/accounts` (or `v1/debug/signal-accounts`), then restart the bot so it picks up the registered session: ```bash -docker compose -f docker/compose.yaml --env-file docker/env \ +docker compose -f docker/compose.yaml --env-file docker/.env \ restart signal-bot ``` @@ -161,7 +161,7 @@ Add this one bot to a Signal group (it auto-accepts invites). Quote a voice note Idle bots are quiet at `info` — empty polls do not print. Incoming receive lines are mostly `debug`; successful command/handler work logs at `info`. ```bash -docker compose -f docker/compose.yaml --env-file docker/env \ +docker compose -f docker/compose.yaml --env-file docker/.env \ logs -f signal-bot ``` @@ -169,21 +169,21 @@ Useful variants: ```bash # All services on this stack -docker compose -f docker/compose.yaml --env-file docker/env logs -f +docker compose -f docker/compose.yaml --env-file docker/.env logs -f # Last 100 lines, then follow -docker compose -f docker/compose.yaml --env-file docker/env \ +docker compose -f docker/compose.yaml --env-file docker/.env \ logs -f --tail=100 signal-bot # signal-api (registration / receive issues) -docker compose -f docker/compose.yaml --env-file docker/env \ +docker compose -f docker/compose.yaml --env-file docker/.env \ logs -f signal-api ``` -Raise verbosity: set `LOG_LEVEL=debug` in `docker/env`, then recreate the bot: +Raise verbosity: set `LOG_LEVEL=debug` in `docker/.env`, then recreate the bot: ```bash -docker compose -f docker/compose.yaml --env-file docker/env \ +docker compose -f docker/compose.yaml --env-file docker/.env \ up -d signal-bot ``` @@ -192,7 +192,7 @@ docker compose -f docker/compose.yaml --env-file docker/env \ Stop the stack (keeps Signal CLI volumes): ```bash -docker compose -f docker/compose.yaml --env-file docker/env down +docker compose -f docker/compose.yaml --env-file docker/.env down ``` 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). diff --git a/docs/one-cvm-architecture.md b/docs/one-cvm-architecture.md index fc807ef..787f224 100644 --- a/docs/one-cvm-architecture.md +++ b/docs/one-cvm-architecture.md @@ -35,10 +35,10 @@ flowchart LR ## Local stack ```bash -cp docker/env.example docker/env +cp docker/.env.example docker/.env # Set SIGNAL_PHONE; NEAR_AI_API_KEY (chat + Whisper STT) -docker compose -f docker/compose.yaml --env-file docker/env up -d +docker compose -f docker/compose.yaml --env-file docker/.env up -d ``` Register the number against this stack’s `signal-api` (or the registration proxy on host port `8081`). @@ -55,7 +55,7 @@ Upgrade **in place** only: ```bash phala deploy --cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353 \ - -c docker/phala.yaml -e docker/phala.env --wait + -c docker/phala.yaml -e docker/.phala.env --wait ``` [`scripts/deploy_phala.sh`](../scripts/deploy_phala.sh) defaults to that `--cvm-id`. Do not `phala deploy -n` against the live CVM. @@ -81,7 +81,7 @@ Do **not** rename `signal-config-translation` / `group-prefs-translation` / `reg | New CVM / `phala cvms delete` / volume rename | Empty | Yes | Yes | | Prefs decrypt fail (key mismatch) | File present, unreadable | Yes (bot starts empty) | No (Signal volume is separate) | -Prefs are encrypted with dstack `DeriveKey` (path `signal-bot/group-preferences`), bound to the CVM **app id**, so a compose/image change should still decrypt. If DeriveKey is unavailable the AppInfo fallback includes `compose_hash` — a compose change then fails decrypt. After upgrade, logs should show `Loaded group preferences for N groups`, not `starting fresh` or `TEE deployment may have changed`. Confirm Signal accounts still listed on `signal-api`. +Prefs are encrypted with dstack `DeriveKey` (path `signal-bot/group-preferences`), bound to the CVM **app id**, so a compose/image change should still decrypt. If DeriveKey is unavailable the AppInfo fallback is **app-id-only** (stable across compose bumps). Blobs encrypted with the old `compose_hash` mix still decrypt when `GROUP_PREFERENCES_LEGACY_COMPOSE_HASH` is set; the bot then re-saves with the stable key. After upgrade, logs should show `Loaded group preferences for N groups`, not `starting fresh` or `TEE deployment may have changed`. Confirm Signal accounts still listed on `signal-api`. Do not change volume names in [`docker/phala.yaml`](../docker/phala.yaml) without a deliberate migration. diff --git a/docs/voice-transcription.md b/docs/voice-transcription.md index 0d271c9..737ffe2 100644 --- a/docs/voice-transcription.md +++ b/docs/voice-transcription.md @@ -45,10 +45,10 @@ Auto path: inbound voice notes are transcribed only after `!transcribe-on` (defa ### Local ```bash -cp docker/env.example docker/env +cp docker/.env.example docker/.env # Set SIGNAL_PHONE; NEAR_AI_API_KEY (chat + Whisper STT) -docker compose -f docker/compose.yaml --env-file docker/env up -d +docker compose -f docker/compose.yaml --env-file docker/.env up -d ``` Register the number against this stack’s `signal-api` or proxy `:8081`. Health: @@ -63,10 +63,10 @@ In-place upgrade of the **surviving** CVM (do not re-register a second number): ```bash phala deploy --cvm-id 0e82fa77-8b15-4dbd-89c4-9045ab911353 \ - -c docker/phala.yaml -e docker/phala.env --wait + -c docker/phala.yaml -e docker/.phala.env --wait ``` -Env template: [`docker/phala.env.example`](../docker/phala.env.example). SKU stays **tdx.medium** (2 vCPU / 4 GB) — remote GPU is the STT speed lever, not a larger TDX. Attestation: `!verify ` inside Signal. +Env template: [`docker/.phala.env.example`](../docker/.phala.env.example). SKU stays **tdx.medium** (2 vCPU / 4 GB) — remote GPU is the STT speed lever, not a larger TDX. Attestation: `!verify ` inside Signal. ## Key code diff --git a/scripts/deploy_phala.sh b/scripts/deploy_phala.sh index 829766f..c20125f 100755 --- a/scripts/deploy_phala.sh +++ b/scripts/deploy_phala.sh @@ -11,7 +11,7 @@ # First create (empty volumes) only when no CVM exists: # FIRST_CREATE=1 ./scripts/deploy_phala.sh # -# Requires: phala CLI logged in; filled docker/phala.env (never commit). +# Requires: phala CLI logged in; filled docker/.phala.env (never commit). # Images must already be pushed (linux/amd64). set -euo pipefail @@ -22,7 +22,7 @@ fi ROOT="$(cd "$(dirname "$0")/.." && pwd)" COMPOSE="${COMPOSE:-$ROOT/docker/phala.yaml}" -ENV_FILE="${ENV_FILE:-$ROOT/docker/phala.env}" +ENV_FILE="${ENV_FILE:-$ROOT/docker/.phala.env}" NAME="${NAME:-sigstack-translation}" INSTANCE_TYPE="${INSTANCE_TYPE:-tdx.medium}" DISK_SIZE="${DISK_SIZE:-40G}" @@ -35,7 +35,7 @@ if [[ ! -f "$COMPOSE" ]]; then exit 1 fi if [[ ! -f "$ENV_FILE" ]]; then - echo "Error: missing $ENV_FILE (copy docker/phala.env.example)" + echo "Error: missing $ENV_FILE (copy docker/.phala.env.example)" exit 1 fi