Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .agents/docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`.
Expand All @@ -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`.

Expand All @@ -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).

Expand Down
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
.env.*
!.env.example
docker/.env
docker/env
docker/*.env
!docker/*.env.example
!**/*.env.example
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).
Expand All @@ -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).
Expand All @@ -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
Expand Down
156 changes: 156 additions & 0 deletions crates/signal-bot-core/src/command_match.rs
Original file line number Diff line number Diff line change
@@ -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());
}
}
5 changes: 5 additions & 0 deletions crates/signal-bot-core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 2 additions & 2 deletions crates/signal-bot-voice/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
10 changes: 8 additions & 2 deletions crates/signal-bot-voice/src/manual_transcribe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
20 changes: 14 additions & 6 deletions crates/signal-bot-voice/src/transcribe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TranscribeStore>,
whisper_available: bool,
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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)));
}

Expand All @@ -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."
Expand Down
Loading
Loading