Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cb9703d
feat: split threads and in-chat menus with per-person auto-translate
daopunk Aug 5, 2026
3131c85
chore: slim hub menus and document local compose rebuild
daopunk Aug 5, 2026
67e88b2
feat: add !explain hub menu with spaced command descriptions
daopunk Aug 5, 2026
e832023
refactor: menu ui
daopunk Aug 5, 2026
ea613f3
feat: add feature guides and rename product switch to enable
daopunk Aug 5, 2026
09d34e8
feat: rebrand menus to bread bot and refine threads/in-chat copy
daopunk Aug 5, 2026
344b47d
fix: notify language threads when enable-in-chat tears down bridge
daopunk Aug 5, 2026
4b7e576
feat: accept near-miss product hub menu commands
daopunk Aug 5, 2026
2fdd214
style: clean
daopunk Aug 5, 2026
4d6cf26
chore: format
daopunk Aug 5, 2026
7d03930
fix: harden sidecar mapping when switching from in-chat to threads
daopunk Aug 5, 2026
d88fcdb
docs: document translation hub vs transcription worker hierarchy
daopunk Aug 5, 2026
e423b15
feat: single hub !privacy and translation/transcription !verify labels
daopunk Aug 5, 2026
e439f42
feat: refine menu copy and sidecar welcome commands
daopunk Aug 5, 2026
9945bd9
feat: rename language-thread help to !commands
daopunk Aug 5, 2026
acb7a82
feat: post transcription menu on invite and default auto-transcribe off
daopunk Aug 5, 2026
d3094c8
feat: auto-accept group invites by bot role
daopunk Aug 5, 2026
a27561b
fix: translate transcription transcripts under translate-all-on
daopunk Aug 5, 2026
240c8ef
fix: auto-trust peer_phone identity on bot startup
daopunk Aug 5, 2026
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
14 changes: 8 additions & 6 deletions .agents/docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Sigstack Bot — Development Guide
# Bread Bot — Development Guide

## Product direction

Expand Down Expand Up @@ -26,11 +26,13 @@ Signal E2E encryption terminates at Signal CLI. Each product CVM runs its own `s

### Two CVMs, Signal as bus

- Transcription CVM: Whisper + transcription bot (phone A)
- Translation CVM: translation bot (phone B) + NEAR AI for text
- Transcription CVM: Whisper + transcription bot (phone A) — **worker** (voice only; no hub)
- Translation CVM: translation bot (phone B) + NEAR AI — **hub** (menus, translation products, transcription pairing)
- No cross-CVM Docker network. Integration = both bots in the same Signal group.
- Whisper HTTP (`http://whisper-api:9000`) is **intra**-transcription-stack only.

Full hierarchy table: [`docs/two-cvm-architecture.md`](../../docs/two-cvm-architecture.md#bot-hierarchy).

### What attestation proves / does not prove

| Property | Verified by |
Expand All @@ -45,8 +47,8 @@ Does **not** prove Signal CLI image integrity beyond pinning, or hide network me

| Role | Handlers | Requires |
|------|----------|----------|
| `transcription` | Voice, `!transcribe*`, help, privacy, verify | Whisper sidecar |
| `translation` | Language Threads, `!translate-on`, quote `!translate`, menus, verify | `NEAR_AI__API_KEY` |
| `transcription` | Voice, `!transcribe*`, `!transcription` menu, `!help-transcription`, `!verify` (worker — no hub `!help` / `!info` / `!privacy`) | Whisper sidecar |
| `translation` | Hub (`!help`, `!info`, `!privacy`, product menus), Language Threads, in-chat, quote `!translate`, `!transcription` pairing, `!verify` | `NEAR_AI__API_KEY` |

Fail-fast if role is missing/invalid or required deps are missing.

Expand Down Expand Up @@ -114,7 +116,7 @@ Health (transcription): Whisper `GET /health` on `:9000`, Signal CLI `GET /v1/he
| `BOT__ROLE` | `transcription` \| `translation` |
| `SIGNAL__SERVICE_URL` | Default `http://signal-api:8080` |
| `SIGNAL__PHONE_NUMBER` | Ops phone for this CVM |
| `SIGNAL__PEER_PHONE` | Peer product bot (translation invites transcription) |
| `SIGNAL__PEER_PHONE` | Peer product bot. Translation: invites transcription. Transcription: must be translation phone for auto-join |
| `NEAR_AI__*` | Translation role |
| `WHISPER__*` | Transcription role |
| `TRANSLATE_ALL__*` | In-chat translation |
Expand Down
31 changes: 29 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Sigstack Bot
# Bread Bot

TEE-hosted Signal bots for **voice transcription** and **group translation**, designed as an interoperable product suite (see [issue #10](https://github.com/BreadchainCoop/sigstack-bot/issues/10)).

Expand All @@ -14,8 +14,35 @@ Not a general AI chat assistant. Conversation history, tool-calling, and x402 cr

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

**Bot hierarchy:** the **translation** bot is the Bread Bot **hub** (`!help`, `!info`, `!privacy`, translation products, transcription pairing). The **transcription** bot is a **specialized worker** (voice → text via `!transcription` / `!transcribe*` only). See [docs/two-cvm-architecture.md](docs/two-cvm-architecture.md#bot-hierarchy).

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

## Translation commands

| Product | Command | Where | Effect |
|---------|---------|-------|--------|
| Hub | `!help` / `!info` | Translation bot only | Bread Bot hub menus |
| Hub | `!privacy` | Translation bot only | Privacy, TEE, and `!verify` (dual quotes in paired groups) |
| Hub | `!translation-threads` | Translation bot | Language Threads menu |
| Hub | `!translation-in-chat` | Translation bot | In-chat translation menu |
| Hub | `!help-threads` | Translation bot | How Language Threads works |
| Hub | `!help-in-chat` | Translation bot | How in-chat translation works |
| Hub | `!help-transcription` | Translation bot | How voice transcription works (guide; worker runs on transcription bot) |
| Transcription | `!transcription` | Transcription bot | Voice product menu |
| Language Threads | `!translate-me-thread <lang>` | Main only | Create/join sidecar |
| Language Threads | `!leave` | Sidecar only | Leave this Language Thread |
| Language Threads | `!commands` | Sidecar only | Compact Language Thread command list |
| Language Threads | `!enable-in-chat` | Main | Tear down Language Threads (switch path to in-chat) |
| In-chat group-wide | `!translate-all-on <lang1> <lang2>` | Group | Auto-translate all messages |
| In-chat group-wide | `!translate-all-off` | Group | Disable group-wide auto |
| In-chat personal | `!translate-me-on <lang1> <lang2>` | Group (not sidecar) | Auto-translate this user’s messages only |
| In-chat personal | `!translate-me-off` | Group (not sidecar) | Clear this user’s personal auto |
| In-chat | `!enable-threads` | Group | Clear all in-chat auto (switch path to Language Threads) |
| In-chat manual | `!translate <lang>` | Group | Quote-reply translate one message |

Language Threads and in-chat auto are mutually exclusive. Details in the product docs above.

## Architecture

```
Expand Down Expand Up @@ -93,7 +120,7 @@ Format: `type(scope): subject` — e.g. `feat: add whisper timeout`, `fix(docker
| `SIGNAL__SERVICE_URL` | Signal CLI REST URL (default `http://signal-api:8080`) |
| `NEAR_AI__API_KEY` | Required for translation role |
| `WHISPER__ENABLED` / `WHISPER__SERVICE_URL` | Required for transcription role |
| `TRANSLATE_ALL__ENABLED` | In-chat `!translate-on` (translation role) |
| `TRANSLATE_ALL__ENABLED` | In-chat `!translate-all-on` / `!translate-me-on` (translation role) |

See `.env.example` and the docker `*.env.example` files.

Expand Down
1 change: 1 addition & 0 deletions crates/signal-bot-transcription/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ pub fn build_voice_handlers(
reply_prefix,
max_attachment_bytes,
voice_cache,
transcribe_store.clone(),
)),
Box::new(TranscribeHandler::new(transcribe_store, true)),
]
Expand Down
74 changes: 74 additions & 0 deletions crates/signal-bot-transcription/src/manual_transcribe.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! `!transcribe` — quote-reply manual voice transcription via Whisper.

use crate::transcribe_store::TranscribeStore;
use crate::voice::VoiceHandler;
use crate::voice_attachment_cache::VoiceAttachmentCache;
use async_trait::async_trait;
Expand All @@ -15,22 +16,27 @@ pub struct ManualTranscribeHandler {
reply_prefix: String,
max_attachment_bytes: usize,
voice_cache: Arc<VoiceAttachmentCache>,
transcribe_store: Arc<TranscribeStore>,
}

const AUTO_ALREADY_ON_MSG: &str = "Automatic transcription is already on. Voice notes are transcribed as they arrive — no need to !transcribe.";

impl ManualTranscribeHandler {
pub fn new(
whisper: Arc<WhisperClient>,
signal: Arc<SignalClient>,
reply_prefix: impl Into<String>,
max_attachment_bytes: usize,
voice_cache: Arc<VoiceAttachmentCache>,
transcribe_store: Arc<TranscribeStore>,
) -> Self {
Self {
whisper,
signal,
reply_prefix: reply_prefix.into(),
max_attachment_bytes,
voice_cache,
transcribe_store,
}
}

Expand Down Expand Up @@ -130,6 +136,15 @@ impl CommandHandler for ManualTranscribeHandler {

#[instrument(skip(self, message), fields(source = %message.source, is_group = message.is_group))]
async fn execute(&self, message: &BotMessage) -> AppResult<String> {
if self
.transcribe_store
.is_enabled(message.reply_target(), message.is_group)
{
self.send_reply(message, message.quote.as_ref(), AUTO_ALREADY_ON_MSG)
.await?;
return Ok(String::new());
}

let quote = match &message.quote {
Some(q) => q,
None => {
Expand Down Expand Up @@ -235,6 +250,10 @@ mod tests {
assert_eq!(resolved.id, "cached-voice-id");
}

fn empty_store() -> Arc<TranscribeStore> {
Arc::new(TranscribeStore::new(None))
}

#[test]
fn matches_bare_command_only() {
let handler = ManualTranscribeHandler::new(
Expand All @@ -245,6 +264,7 @@ mod tests {
"📝 Transcript:",
5_000_000,
VoiceAttachmentCache::new(10),
empty_store(),
);
let mut msg = BotMessage {
source: "+1".into(),
Expand Down Expand Up @@ -290,6 +310,7 @@ mod tests {
"📝 Transcript:",
5_000_000,
VoiceAttachmentCache::new(10),
empty_store(),
);

let msg = BotMessage {
Expand Down Expand Up @@ -347,6 +368,59 @@ mod tests {
"📝 Transcript:",
5_000_000,
VoiceAttachmentCache::new(10),
empty_store(),
);

let msg = BotMessage {
source: "+15550002222".into(),
source_number: Some("+15550002222".into()),
source_name: None,
text: "!transcribe".into(),
timestamp: 2,
message_timestamp: 2,
is_group: false,
group_id: None,
group_name: None,
receiving_account: "+15550001111".into(),
attachments: vec![],
quote: Some(QuotedMessage {
id: 100,
author_number: Some("+15550003333".into()),
text: None,
audio_attachment: Some(sample_audio()),
}),
};
let out = handler.execute(&msg).await.unwrap();
assert!(out.is_empty());
}

#[tokio::test]
async fn execute_when_auto_on_replies_without_whisper() {
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

let signal_mock = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v2/send"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
.expect(1)
.mount(&signal_mock)
.await;

let store = empty_store();
store.set_enabled("+15550002222", true, false);

let handler = ManualTranscribeHandler::new(
Arc::new(
WhisperClient::new("http://127.0.0.1:9", std::time::Duration::from_secs(2))
.unwrap(),
),
Arc::new(SignalClient::new(signal_mock.uri()).unwrap()),
"📝 Transcript:",
5_000_000,
VoiceAttachmentCache::new(10),
store,
);

let msg = BotMessage {
Expand Down
9 changes: 5 additions & 4 deletions crates/signal-bot-transcription/src/transcribe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ mod tests {
.unwrap()
.get(group_id)
.copied()
.unwrap_or(true)
.unwrap_or(false)
}

fn set_transcribe_enabled(&self, group_id: &str, enabled: bool) {
Expand Down Expand Up @@ -120,12 +120,12 @@ mod tests {

assert_eq!(
handler
.execute(&msg("!transcribe-off", false))
.execute(&msg("!transcribe-on", false))
.await
.unwrap(),
"Voice transcription disabled."
"Voice transcription enabled."
);
assert!(!store.is_enabled("+15550002222", false));
assert!(store.is_enabled("+15550002222", false));

assert_eq!(
handler.execute(&msg("!transcribe-on", true)).await.unwrap(),
Expand All @@ -140,6 +140,7 @@ mod tests {
.unwrap(),
"Voice transcription disabled for this group."
);
assert!(!store.is_enabled("group-1", true));
}

#[tokio::test]
Expand Down
32 changes: 17 additions & 15 deletions crates/signal-bot-transcription/src/transcribe_store.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
//! Per-chat voice transcription preference (`!transcribe-on` / `!transcribe-off`).
//!
//! Group preferences go through [`TranscribeGroupPrefs`]; DM toggles are ephemeral.
//! Auto transcription defaults **off** until explicitly enabled.

use crate::prefs::SharedTranscribeGroupPrefs;
use std::collections::HashSet;
use std::sync::RwLock;

/// DM-only in-memory transcription toggle (default: enabled).
/// DM-only in-memory transcription toggle (default: disabled).
pub struct TranscribeStore {
dm_disabled: RwLock<HashSet<String>>,
dm_enabled: RwLock<HashSet<String>>,
group_prefs: Option<SharedTranscribeGroupPrefs>,
}

impl TranscribeStore {
pub fn new(group_prefs: Option<SharedTranscribeGroupPrefs>) -> Self {
Self {
dm_disabled: RwLock::new(HashSet::new()),
dm_enabled: RwLock::new(HashSet::new()),
group_prefs,
}
}
Expand All @@ -25,9 +26,9 @@ impl TranscribeStore {
self.group_prefs
.as_ref()
.map(|store| store.is_transcribe_enabled(context_id))
.unwrap_or(true)
.unwrap_or(false)
} else {
!self.dm_disabled.read().unwrap().contains(context_id)
self.dm_enabled.read().unwrap().contains(context_id)
}
}

Expand All @@ -39,11 +40,11 @@ impl TranscribeStore {
return;
}

let mut disabled = self.dm_disabled.write().unwrap();
let mut enabled_dms = self.dm_enabled.write().unwrap();
if enabled {
disabled.remove(context_id);
enabled_dms.insert(context_id.to_string());
} else {
disabled.insert(context_id.to_string());
enabled_dms.remove(context_id);
}
}
}
Expand All @@ -66,7 +67,7 @@ mod tests {
.unwrap()
.get(group_id)
.copied()
.unwrap_or(true)
.unwrap_or(false)
}

fn set_transcribe_enabled(&self, group_id: &str, enabled: bool) {
Expand All @@ -78,28 +79,29 @@ mod tests {
}

#[test]
fn dm_enabled_by_default() {
fn dm_disabled_by_default() {
let store = TranscribeStore::new(None);
assert!(store.is_enabled("dm:+1234", false));
assert!(!store.is_enabled("dm:+1234", false));
}

#[test]
fn dm_toggle_off_and_on() {
let store = TranscribeStore::new(None);
let ctx = "dm:+1234";
store.set_enabled(ctx, false, false);
assert!(!store.is_enabled(ctx, false));
store.set_enabled(ctx, true, false);
assert!(store.is_enabled(ctx, false));
store.set_enabled(ctx, false, false);
assert!(!store.is_enabled(ctx, false));
}

#[test]
fn group_uses_preferences_store() {
let prefs: SharedTranscribeGroupPrefs = Arc::new(MemoryPrefs {
enabled: RwLock::new(HashMap::new()),
});
let store = TranscribeStore::new(Some(prefs));
store.set_enabled("group.x", false, true);
let store = TranscribeStore::new(Some(prefs.clone()));
assert!(!store.is_enabled("group.x", true));
store.set_enabled("group.x", true, true);
assert!(store.is_enabled("group.x", true));
}
}
Loading
Loading