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
19 changes: 15 additions & 4 deletions .agents/docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ Fail-fast if role is missing/invalid or required deps are missing.

```
crates/
signal-bot/ # Binary
signal-bot/ # Binary (role-selected handlers)
signal-bot-core/ # CommandHandler + AppResult
signal-bot-transcription/ # Voice / !transcribe* product crate
whisper-client/
near-ai-client/
signal-client/
Expand All @@ -69,6 +71,7 @@ docker/
Dockerfile / Dockerfile.whisper / Dockerfile.proxy
docs/
two-cvm-architecture.md
voice-transcription.md
language-threads.md
```

Expand All @@ -77,6 +80,8 @@ docs/
```bash
cp docker/transcription.env.example docker/transcription.env
cp docker/translation.env.example docker/translation.env
# Different SIGNAL_PHONE values; PEER_PHONE on translation = transcription phone;
# NEAR_AI_API_KEY in translation.env

docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env up -d
docker compose -f docker/compose.translation.yaml --env-file docker/translation.env up -d
Expand All @@ -93,18 +98,24 @@ docker buildx build --platform linux/amd64 -t YOUR/signal-bot-tee:latest -f dock
docker buildx build --platform linux/amd64 -t YOUR/signal-whisper-api:latest -f docker/Dockerfile.whisper --push .
docker buildx build --platform linux/amd64 -t YOUR/signal-registration-proxy:latest -f docker/Dockerfile.proxy --push .

phala deploy … -c docker/phala.transcription.yaml
phala deploy … -c docker/phala.translation.yaml
phala deploy … -c docker/phala.transcription.yaml -e docker/phala.transcription.env --wait -t tdx.medium
phala deploy … -c docker/phala.translation.yaml -e docker/phala.translation.env --wait -t tdx.medium
```

Encrypted secrets: phone numbers per CVM; `NEAR_AI_API_KEY` on translation only.
Env templates: `docker/phala.transcription.env.example`, `docker/phala.translation.env.example`.

Encrypted secrets: phone numbers per CVM; `PEER_PHONE` for pairing; `NEAR_AI_API_KEY` on translation only.

Health (transcription): Whisper `GET /health` on `:9000`, Signal CLI `GET /v1/health` on `:8080`. Attestation: `!verify <challenge>`.

## Configuration

| Variable | Notes |
|----------|-------|
| `BOT__ROLE` | `transcription` \| `translation` |
| `SIGNAL__SERVICE_URL` | Default `http://signal-api:8080` |
| `SIGNAL__PHONE_NUMBER` | Ops phone for this CVM |
| `SIGNAL__PEER_PHONE` | Peer product bot (translation invites transcription) |
| `NEAR_AI__*` | Translation role |
| `WHISPER__*` | Transcription role |
| `TRANSLATE_ALL__*` | In-chat translation |
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ docker compose -f docker/compose.translation.yaml --env-file docker/translation.
|-----|-----|
| [`.agents/docs/DEVELOPMENT.md`](.agents/docs/DEVELOPMENT.md) | TEE trust model, `BOT__ROLE`, Phala dual-CVM ops |
| [`docs/two-cvm-architecture.md`](docs/two-cvm-architecture.md) | Architecture diagram and compose/Phala split |
| [`docs/voice-transcription.md`](docs/voice-transcription.md) | Voice transcription product + pairing |
| [`docs/in-chat-translation.md`](docs/in-chat-translation.md) | In-chat (group) bilingual auto/manual translate |
| [`docs/parallel-translation.md`](docs/parallel-translation.md) | Parallel Translation product behavior |
| [`docs/language-threads.md`](docs/language-threads.md) | Language Threads product behavior |
Expand Down
31 changes: 31 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
resolver = "2"
members = [
"crates/signal-bot",
"crates/signal-bot-core",
"crates/signal-bot-transcription",
"crates/near-ai-client",
"crates/dstack-client",
"crates/signal-client",
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Not a general AI chat assistant. Conversation history, tool-calling, and x402 cr

Pair products by adding **both bots** (two phone numbers) to the same Signal group. Signal is the bus — there is no Docker network between CVMs.

Details: [docs/two-cvm-architecture.md](docs/two-cvm-architecture.md) · [docs/in-chat-translation.md](docs/in-chat-translation.md) · [docs/parallel-translation.md](docs/parallel-translation.md) · [docs/language-threads.md](docs/language-threads.md)
Details: [docs/two-cvm-architecture.md](docs/two-cvm-architecture.md) · [docs/voice-transcription.md](docs/voice-transcription.md) · [docs/in-chat-translation.md](docs/in-chat-translation.md) · [docs/parallel-translation.md](docs/parallel-translation.md) · [docs/language-threads.md](docs/language-threads.md)

## Architecture

Expand Down
16 changes: 16 additions & 0 deletions crates/signal-bot-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[package]
name = "signal-bot-core"
version.workspace = true
edition.workspace = true

[dependencies]
# Workspace crates (AppError From impls)
near-ai-client = { path = "../near-ai-client" }
dstack-client = { path = "../dstack-client" }
signal-client = { path = "../signal-client" }
whisper-client = { path = "../whisper-client" }

# Workspace dependencies
anyhow.workspace = true
async-trait.workspace = true
thiserror.workspace = true
25 changes: 25 additions & 0 deletions crates/signal-bot-core/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//! Shared application error types.

use thiserror::Error;

/// Main application error type.
#[derive(Error, Debug)]
pub enum AppError {
#[error("Configuration error: {0}")]
Config(#[from] anyhow::Error),

#[error("Signal error: {0}")]
Signal(#[from] signal_client::SignalError),

#[error("NEAR AI error: {0}")]
NearAi(#[from] near_ai_client::NearAiError),

#[error("Dstack error: {0}")]
Dstack(#[from] dstack_client::DstackError),

#[error("Whisper error: {0}")]
Whisper(#[from] whisper_client::WhisperError),
}

/// Result type alias for application errors.
pub type AppResult<T> = Result<T, AppError>;
128 changes: 128 additions & 0 deletions crates/signal-bot-core/src/handler.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//! Shared command handler trait.

use crate::error::AppResult;
use async_trait::async_trait;
use signal_client::BotMessage;

/// Command handler trait.
#[async_trait]
pub trait CommandHandler: Send + Sync {
/// Command trigger (e.g., "!help").
fn trigger(&self) -> Option<&str> {
None
}

/// Whether this is the default handler for non-command messages.
fn is_default(&self) -> bool {
false
}

/// Check if this handler matches the message.
fn matches(&self, message: &BotMessage) -> bool {
if let Some(trigger) = self.trigger() {
message.text.starts_with(trigger)
} else {
self.is_default() && !message.text.starts_with('!') && !message.is_voice_note()
}
}

/// Execute the command.
async fn execute(&self, message: &BotMessage) -> AppResult<String>;

/// When true, bot replies with a Signal quote-reply to the source message.
fn reply_with_quote(&self) -> bool {
false
}

/// When true, the handler sends its own Signal reply in `execute` (main loop skips send).
fn handles_own_reply(&self) -> bool {
false
}

/// Short name for dispatch / debug logs.
fn label(&self) -> &'static str {
if self.is_default() {
"default"
} else {
"command"
}
}
}

#[cfg(test)]
mod tests {
use super::*;

struct Stub {
trigger: Option<&'static str>,
default: bool,
}

#[async_trait]
impl CommandHandler for Stub {
fn trigger(&self) -> Option<&str> {
self.trigger
}

fn is_default(&self) -> bool {
self.default
}

async fn execute(&self, _message: &BotMessage) -> AppResult<String> {
Ok("ok".into())
}
}

fn msg(text: &str, voice: bool) -> BotMessage {
use signal_client::Attachment;
BotMessage {
source: "+1".into(),
source_number: None,
source_name: None,
text: text.into(),
timestamp: 0,
message_timestamp: 0,
is_group: false,
group_id: None,
group_name: None,
receiving_account: "+2".into(),
attachments: if voice {
vec![Attachment {
content_type: "audio/aac".into(),
filename: None,
id: "a1".into(),
size: Some(10),
upload_timestamp: None,
}]
} else {
vec![]
},
quote: None,
}
}

#[test]
fn default_matches_uses_trigger_prefix() {
let h = Stub {
trigger: Some("!help"),
default: false,
};
assert!(h.matches(&msg("!help please", false)));
assert!(!h.matches(&msg("help", false)));
assert_eq!(h.label(), "command");
assert!(!h.reply_with_quote());
assert!(!h.handles_own_reply());
}

#[test]
fn default_handler_skips_commands_and_voice() {
let h = Stub {
trigger: None,
default: true,
};
assert!(h.matches(&msg("hello", false)));
assert!(!h.matches(&msg("!help", false)));
assert!(!h.matches(&msg("", true)));
assert_eq!(h.label(), "default");
}
}
7 changes: 7 additions & 0 deletions crates/signal-bot-core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//! Shared types for signal-bot product crates (handlers trait + errors).

pub mod error;
pub mod handler;

pub use error::{AppError, AppResult};
pub use handler::CommandHandler;
19 changes: 19 additions & 0 deletions crates/signal-bot-transcription/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[package]
name = "signal-bot-transcription"
version.workspace = true
edition.workspace = true

[dependencies]
signal-bot-core = { path = "../signal-bot-core" }
signal-client = { path = "../signal-client" }
whisper-client = { path = "../whisper-client" }

anyhow.workspace = true
async-trait.workspace = true
serde_json.workspace = true
tracing.workspace = true

[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
tokio-test.workspace = true
wiremock.workspace = true
46 changes: 46 additions & 0 deletions crates/signal-bot-transcription/src/handlers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//! Build the transcription product handler stack (voice / !transcribe*).

use crate::manual_transcribe::ManualTranscribeHandler;
use crate::prefs::SharedTranscribeGroupPrefs;
use crate::transcribe::TranscribeHandler;
use crate::transcribe_store::TranscribeStore;
use crate::voice::VoiceHandler;
use crate::voice_attachment_cache::VoiceAttachmentCache;
use signal_bot_core::CommandHandler;
use signal_client::SignalClient;
use std::sync::Arc;
use whisper_client::WhisperClient;

/// Voice + `!transcribe` + `!transcribe-on/off` handlers for the transcription role.
pub fn build_voice_handlers(
whisper: Arc<WhisperClient>,
signal: Arc<SignalClient>,
reply_prefix: impl Into<String>,
max_attachment_bytes: usize,
group_prefs: SharedTranscribeGroupPrefs,
) -> Vec<Box<dyn CommandHandler>> {
let reply_prefix = reply_prefix.into();
let transcribe_store = Arc::new(TranscribeStore::new(Some(group_prefs)));
let voice_cache = VoiceAttachmentCache::with_default_capacity();

vec![
Box::new(
VoiceHandler::new(
whisper.clone(),
signal.clone(),
reply_prefix.clone(),
max_attachment_bytes,
)
.with_transcribe_store(transcribe_store.clone())
.with_voice_cache(voice_cache.clone()),
),
Box::new(ManualTranscribeHandler::new(
whisper,
signal,
reply_prefix,
max_attachment_bytes,
voice_cache,
)),
Box::new(TranscribeHandler::new(transcribe_store, true)),
]
}
17 changes: 17 additions & 0 deletions crates/signal-bot-transcription/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//! Voice transcription product handlers (Whisper pipeline).

mod handlers;
mod manual_transcribe;
mod prefs;
mod transcribe;
mod transcribe_store;
mod voice;
mod voice_attachment_cache;

pub use handlers::build_voice_handlers;
pub use manual_transcribe::ManualTranscribeHandler;
pub use prefs::{SharedTranscribeGroupPrefs, TranscribeGroupPrefs};
pub use transcribe::TranscribeHandler;
pub use transcribe_store::TranscribeStore;
pub use voice::VoiceHandler;
pub use voice_attachment_cache::VoiceAttachmentCache;
Loading
Loading