diff --git a/.agents/docs/DEVELOPMENT.md b/.agents/docs/DEVELOPMENT.md index 17e97a1..ae2232b 100644 --- a/.agents/docs/DEVELOPMENT.md +++ b/.agents/docs/DEVELOPMENT.md @@ -46,7 +46,7 @@ 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, models, verify | `NEAR_AI__API_KEY` | +| `translation` | Language Threads, `!translate-on`, quote `!translate`, menus, verify | `NEAR_AI__API_KEY` | Fail-fast if role is missing/invalid or required deps are missing. diff --git a/.cursor/rules/commit-messages.mdc b/.cursor/rules/commit-messages.mdc index 6a6a76d..5e7e884 100644 --- a/.cursor/rules/commit-messages.mdc +++ b/.cursor/rules/commit-messages.mdc @@ -30,3 +30,5 @@ ci: Harden local Actions mirror. ``` Commits must still pass commitlint (`type: subject`). + +Never add Cursor / AI `Co-authored-by` trailers — see [`no-cursor-coauthor.mdc`](no-cursor-coauthor.mdc). diff --git a/.cursor/rules/no-cursor-coauthor.mdc b/.cursor/rules/no-cursor-coauthor.mdc new file mode 100644 index 0000000..1186fbd --- /dev/null +++ b/.cursor/rules/no-cursor-coauthor.mdc @@ -0,0 +1,35 @@ +--- +description: Never add Cursor or AI co-author trailers to git commits +alwaysApply: true +--- + +# No Cursor / AI co-authors on commits + +Never attribute commits to Cursor, the agent, or any AI model. + +## Product setting (source of the problem) + +Cursor’s agent harness can **rewrite** `git commit` to inject +`--trailer "Co-authored-by: Cursor "` even when the +agent did not write that flag. That is unwanted in this repo. + +- **IDE Agent:** turn off commit attribution at **Cursor Settings → Agent → Attribution** (commit / co-authored-by). +- **CLI Agent:** set in `~/.cursor/cli-config.json`: + `"attribution": { "attributeCommitsToAgent": false, "attributePRsToAgent": false }` + +## Agent behavior + +- Do **not** add `Co-authored-by:` trailers for Cursor, Copilot, Claude, GPT, Grok, Composer, or any other AI/tool identity. +- Do **not** set commit author/committer to a Cursor or AI email (`cursoragent@cursor.com`, etc.). +- Commits use the human operator’s normal git identity only. +- Prefer `git commit -m` / HEREDOC with **no** `--trailer`. If the harness still injects a trailer, use plumbing (`git commit-tree`) or strip it before push — never leave Cursor as co-author on a published commit. + +```text +# BAD +feat: add menu localization + +Co-authored-by: Cursor + +# GOOD +feat: add menu localization +``` diff --git a/.env.example b/.env.example index d4cb4ec..79ddce0 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,7 @@ SIGNAL__POLL_INTERVAL=1s # NEAR AI Configuration (required when BOT__ROLE=translation) NEAR_AI__API_KEY=your-api-key-here NEAR_AI__BASE_URL=https://cloud-api.near.ai/v1 -NEAR_AI__MODEL=deepseek-ai/DeepSeek-V3.1 +NEAR_AI__MODEL=deepseek-ai/DeepSeek-V4-Flash NEAR_AI__TIMEOUT=60s # Bot Configuration diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index ddac77c..7505d55 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -4,6 +4,7 @@ on: pull_request: push: branches: [main, master] + workflow_dispatch: jobs: commitlint: @@ -21,6 +22,9 @@ jobs: run: | if [ "${{ github.event_name }}" = "pull_request" ]; then npx --no -- commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.event.pull_request.head.sha }}" --verbose + elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + git fetch origin "${{ github.event.repository.default_branch }}" --depth=0 + npx --no -- commitlint --from "origin/${{ github.event.repository.default_branch }}" --to "${{ github.sha }}" --verbose else npx --no -- commitlint --from "${{ github.event.before }}" --to "${{ github.sha }}" --verbose fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e2ce34b..cc1ff87 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,6 +4,7 @@ on: pull_request: push: branches: [main, master] + workflow_dispatch: jobs: rust: diff --git a/README.md b/README.md index e5ce77f..6701dac 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ docker compose -f docker/compose.transcription.yaml --env-file docker/transcript docker compose -f docker/compose.translation.yaml --env-file docker/translation.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/). + ## Phala ```bash diff --git a/crates/near-ai-client/src/client.rs b/crates/near-ai-client/src/client.rs index a81a72c..bf6c690 100644 --- a/crates/near-ai-client/src/client.rs +++ b/crates/near-ai-client/src/client.rs @@ -223,6 +223,12 @@ impl NearAiClient { // NEAR AI Cloud doesn't expose a /models endpoint // Return a list of known available models (as of Dec 2025) Ok(vec![ + Model { + id: "deepseek-ai/DeepSeek-V4-Flash".to_string(), + object: "model".to_string(), + created: 0, + owned_by: "deepseek".to_string(), + }, Model { id: "deepseek-ai/DeepSeek-V3.1".to_string(), object: "model".to_string(), @@ -293,7 +299,6 @@ impl NearAiClient { match self.chat(messages.clone(), temperature, max_tokens).await { Ok(response) => return Ok(response), Err(NearAiError::Unauthorized) => return Err(NearAiError::Unauthorized), - Err(NearAiError::EmptyResponse) => return Err(NearAiError::EmptyResponse), Err(e) => { warn!("Chat request failed (attempt {}): {}", attempt + 1, e); last_error = Some(e); diff --git a/crates/near-ai-client/src/lib.rs b/crates/near-ai-client/src/lib.rs index f31d9ea..bc21139 100644 --- a/crates/near-ai-client/src/lib.rs +++ b/crates/near-ai-client/src/lib.rs @@ -133,8 +133,8 @@ mod tests { assert!(result.is_ok()); let models = result.unwrap(); - assert_eq!(models.len(), 4); - assert_eq!(models[0].id, "deepseek-ai/DeepSeek-V3.1"); + assert_eq!(models.len(), 5); + assert_eq!(models[0].id, "deepseek-ai/DeepSeek-V4-Flash"); } #[tokio::test] @@ -186,6 +186,52 @@ mod tests { assert_eq!(result.unwrap(), "Success on first try"); } + #[tokio::test] + async fn test_chat_with_retry_retries_empty_response() { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "1", + "object": "chat.completion", + "created": 1, + "model": "test-model", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": "" }, + "finish_reason": "stop" + }] + }))) + .up_to_n_times(1) + .expect(1) + .mount(&mock_server) + .await; + + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "2", + "object": "chat.completion", + "created": 1, + "model": "test-model", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": "Hola" }, + "finish_reason": "stop" + }] + }))) + .expect(1) + .mount(&mock_server) + .await; + + let client = create_test_client(&mock_server).await; + let result = client + .chat_with_retry(vec![Message::user("hi")], None, None, Some(2)) + .await; + assert_eq!(result.unwrap(), "Hola"); + } + #[tokio::test] async fn test_message_constructors() { let system = Message::system("You are a helpful assistant"); diff --git a/crates/signal-bot/src/commands/help.rs b/crates/signal-bot/src/commands/help.rs index 5ea262a..128097e 100644 --- a/crates/signal-bot/src/commands/help.rs +++ b/crates/signal-bot/src/commands/help.rs @@ -1,6 +1,6 @@ //! Help command - displays feature menu. -use crate::commands::menu_locale::{help_menu, menu_language_for_message}; +use crate::commands::menu_locale::{help_menu, thread_help_menu}; use crate::commands::CommandHandler; use crate::config::BotRole; use crate::error::AppResult; @@ -31,15 +31,20 @@ impl CommandHandler for HelpHandler { } async fn execute(&self, message: &BotMessage) -> AppResult { - let language = menu_language_for_message(message, &self.group_prefs); - Ok(help_menu(language, self.role).into()) + if self.role == BotRole::Translation { + if let Some(group_id) = message.group_id.as_deref() { + if self.group_prefs.lookup_sidecar(group_id).is_some() { + return Ok(thread_help_menu().into()); + } + } + } + Ok(help_menu(self.role).into()) } } #[cfg(test)] mod tests { use super::*; - use crate::menu_language::MenuLanguage; fn dm(text: &str) -> BotMessage { BotMessage { @@ -81,14 +86,30 @@ mod tests { assert!(t.contains("!transcription")); assert!(t.contains("!privacy")); assert!(!t.contains("Voice notes in this chat")); + assert!(!t.contains("!set-en")); + } + + #[tokio::test] + async fn help_in_sidecar_returns_thread_menu() { + let store = GroupPreferencesStore::new_in_memory(0); + store.set_sidecar("main-1", "it", "group.it".into(), "it-internal".into()); + let handler = HelpHandler::new(store, BotRole::Translation); + let out = handler + .execute(&group("!help", "it-internal")) + .await + .unwrap(); + assert!(out.contains("!rename ")); + assert!(out.contains("!translate-me-off")); + assert!(!out.contains("!translation")); } #[tokio::test] - async fn help_uses_group_menu_language() { + async fn help_in_main_stays_hub() { let store = GroupPreferencesStore::new_in_memory(0); - store.set_menu_language("g1", MenuLanguage::Es); - let handler = HelpHandler::new(store, BotRole::Transcription); - let out = handler.execute(&group("!help", "g1")).await.unwrap(); - assert!(out.contains("Transcripción de voz")); + store.set_sidecar("main-1", "it", "group.it".into(), "it-internal".into()); + let handler = HelpHandler::new(store, BotRole::Translation); + let out = handler.execute(&group("!help", "main-1")).await.unwrap(); + assert!(out.contains("!translation")); + assert!(!out.contains("!rename")); } } diff --git a/crates/signal-bot/src/commands/menu_locale.rs b/crates/signal-bot/src/commands/menu_locale.rs index 55f9d71..b00e0af 100644 --- a/crates/signal-bot/src/commands/menu_locale.rs +++ b/crates/signal-bot/src/commands/menu_locale.rs @@ -1,69 +1,46 @@ -//! Localized `!help`, product, and `!privacy` menu text. +//! English `!help`, product, and `!privacy` menu text. +//! +//! Menus are English-only for now; multi-language UI is deferred. +//! Command-list layout follows the Signal mobile menu standard: +//! [`docs/solutions/signal-mobile-menus.md`](../../../../docs/solutions/signal-mobile-menus.md). use crate::config::BotRole; -use crate::group_preferences_store::GroupPreferencesStore; -use crate::menu_language::MenuLanguage; -use signal_client::BotMessage; - -pub fn menu_language_for_message( - message: &BotMessage, - group_prefs: &GroupPreferencesStore, -) -> MenuLanguage { - message - .group_id - .as_deref() - .map(|group_id| group_prefs.get_menu_language(group_id)) - .unwrap_or_default() -} -pub fn help_menu(language: MenuLanguage, role: BotRole) -> &'static str { - match (role, language) { - (BotRole::Transcription, MenuLanguage::En) => HELP_TRANSCRIPTION_EN, - (BotRole::Transcription, MenuLanguage::Es) => HELP_TRANSCRIPTION_ES, - (BotRole::Translation, MenuLanguage::En) => HELP_HUB_EN, - (BotRole::Translation, MenuLanguage::Es) => HELP_HUB_ES, +pub fn help_menu(role: BotRole) -> &'static str { + match role { + BotRole::Transcription => HELP_TRANSCRIPTION, + BotRole::Translation => HELP_HUB, } } -pub fn translation_products_menu( - language: MenuLanguage, - translate_all_enabled: bool, -) -> &'static str { - match (language, translate_all_enabled) { - (MenuLanguage::En, true) => TRANSLATION_MENU_EN, - (MenuLanguage::En, false) => TRANSLATION_MENU_AUTO_DISABLED_EN, - (MenuLanguage::Es, true) => TRANSLATION_MENU_ES, - (MenuLanguage::Es, false) => TRANSLATION_MENU_AUTO_DISABLED_ES, - } +pub fn thread_help_menu() -> &'static str { + HELP_THREAD } -pub fn transcription_unavailable(language: MenuLanguage) -> &'static str { - match language { - MenuLanguage::En => TRANSCRIPTION_UNAVAILABLE_EN, - MenuLanguage::Es => TRANSCRIPTION_UNAVAILABLE_ES, +pub fn translation_products_menu(translate_all_enabled: bool) -> &'static str { + if translate_all_enabled { + TRANSLATION_MENU + } else { + TRANSLATION_MENU_AUTO_DISABLED } } -pub fn transcription_invited(language: MenuLanguage) -> &'static str { - match language { - MenuLanguage::En => TRANSCRIPTION_INVITED_EN, - MenuLanguage::Es => TRANSCRIPTION_INVITED_ES, - } +pub fn transcription_unavailable() -> &'static str { + TRANSCRIPTION_UNAVAILABLE } -pub fn transcription_group_only(language: MenuLanguage) -> &'static str { - match language { - MenuLanguage::En => TRANSCRIPTION_GROUP_ONLY_EN, - MenuLanguage::Es => TRANSCRIPTION_GROUP_ONLY_ES, - } +pub fn transcription_invited() -> &'static str { + TRANSCRIPTION_INVITED } -pub fn privacy_menu(language: MenuLanguage, role: BotRole) -> &'static str { - match (role, language) { - (BotRole::Transcription, MenuLanguage::En) => PRIVACY_TRANSCRIPTION_EN, - (BotRole::Transcription, MenuLanguage::Es) => PRIVACY_TRANSCRIPTION_ES, - (BotRole::Translation, MenuLanguage::En) => PRIVACY_TRANSLATION_EN, - (BotRole::Translation, MenuLanguage::Es) => PRIVACY_TRANSLATION_ES, +pub fn transcription_group_only() -> &'static str { + TRANSCRIPTION_GROUP_ONLY +} + +pub fn privacy_menu(role: BotRole) -> &'static str { + match role { + BotRole::Transcription => PRIVACY_TRANSCRIPTION, + BotRole::Translation => PRIVACY_TRANSLATION, } } @@ -72,156 +49,122 @@ pub fn is_exact_command(text: &str, command: &str) -> bool { text.trim() == command } -const HELP_TRANSCRIPTION_EN: &str = r#"Voice transcription: +const HELP_TRANSCRIPTION: &str = r#"Voice transcription Voice notes in this chat are transcribed to text (Whisper, inside the TEE). -**Commands:** -- !transcription — This menu -- !transcribe-on / !transcribe-off — toggle auto transcription -- !transcribe — quote a voice note to transcribe it -- !privacy — Privacy & TEE -- !help — Show this menu -- !verify — TEE attestation"#; - -const HELP_TRANSCRIPTION_ES: &str = r#"Transcripción de voz: - -Las notas de voz en este chat se transcriben a texto (Whisper, dentro del TEE). - -**Comandos:** -- !transcription — Este menú -- !transcribe-on / !transcribe-off — activar/desactivar transcripción automática -- !transcribe — cita una nota de voz para transcribirla -- !privacy — Privacidad y TEE -- !help — Mostrar este menú -- !verify — attestation TEE"#; - -const HELP_HUB_EN: &str = r#"Sigstack - -- !translation — Translation -- !transcription — Voice transcription -- !privacy — Privacy & TEE - -Menu language: !set-en / !set-es -!help — Show this menu"#; - -const HELP_HUB_ES: &str = r#"Sigstack - -- !translation — Traducción -- !transcription — Transcripción de voz -- !privacy — Privacidad y TEE - -Idioma del menú: !set-en / !set-es -!help — Mostrar este menú"#; - -const TRANSLATION_MENU_EN: &str = r#"Translation +!transcription + This menu +!transcribe-on / !transcribe-off + Toggle auto transcription +!transcribe + Quote a voice note to transcribe +!privacy + Privacy & TEE +!help + Show this menu +!verify + TEE attestation"#; + +const HELP_HUB: &str = r#"Sigstack + +!translation + Translation +!transcription + Voice transcription +!privacy + Privacy & TEE +!help + Show this menu"#; + +const HELP_THREAD: &str = r#"Language Thread + +!rename + Change this group's name +!translate-me-off + Leave this Language Thread +!help + Show this menu"#; + +const TRANSLATION_MENU: &str = r#"Translation Language Threads (recommended) Multilingual main + language sidecars. -- !translate-me-on — join/create a Language Thread (from main) -- !translate-me-off — leave your Language Thread -- !list-langs — language codes + +!translate-me-on + Join/create a Language Thread (from main) +!translate-me-off + Leave your Language Thread +!list-langs + Language codes In-chat (same group only) Stay in this thread; auto or quote one message. -- !translate-on — e.g. !translate-on es en -- !translate-off — stop auto-translate -- Reply to a message with !translate - -Also: !models · !verify -!help — Main menu"#; - -const TRANSLATION_MENU_ES: &str = r#"Traducción - -Language Threads (recomendado) -Principal multilingüe + sidecars por idioma. -- !translate-me-on — unirte/crear un Language Thread (desde el principal) -- !translate-me-off — salir de tu Language Thread -- !list-langs — códigos de idioma -En el chat (solo este grupo) -Quédate en este hilo; auto o cita un mensaje. -- !translate-on — ej. !translate-on es en -- !translate-off — detener auto-traducción -- Responde a un mensaje con !translate +!translate-on + e.g. !translate-on es en +!translate-off + Stop auto-translate +!translate + Reply to a message -También: !models · !verify -!help — Menú principal"#; +!verify + TEE attestation +!help + Main menu"#; -const TRANSLATION_MENU_AUTO_DISABLED_EN: &str = r#"Translation +const TRANSLATION_MENU_AUTO_DISABLED: &str = r#"Translation Language Threads (recommended) Multilingual main + language sidecars. -- !translate-me-on — join/create a Language Thread (from main) -- !translate-me-off — leave your Language Thread -- !list-langs — language codes + +!translate-me-on + Join/create a Language Thread (from main) +!translate-me-off + Leave your Language Thread +!list-langs + Language codes In-chat (same group only) Auto-translate is disabled on this bot (!translate-on). -- Reply to a message with !translate - -Also: !models · !verify -!help — Main menu"#; - -const TRANSLATION_MENU_AUTO_DISABLED_ES: &str = r#"Traducción - -Language Threads (recomendado) -Principal multilingüe + sidecars por idioma. -- !translate-me-on — unirte/crear un Language Thread (desde el principal) -- !translate-me-off — salir de tu Language Thread -- !list-langs — códigos de idioma -En el chat (solo este grupo) -La auto-traducción está desactivada en este bot (!translate-on). -- Responde a un mensaje con !translate +!translate + Reply to a message -También: !models · !verify -!help — Menú principal"#; +!verify + TEE attestation +!help + Main menu"#; -const TRANSCRIPTION_UNAVAILABLE_EN: &str = r#"Voice transcription is currently unavailable. +const TRANSCRIPTION_UNAVAILABLE: &str = r#"Voice transcription is currently unavailable. The transcription bot is not paired with this group yet. Meanwhile, try translation: -- !translation — Translation +!translation + Translation -!help — Main menu"#; +!help + Main menu"#; -const TRANSCRIPTION_UNAVAILABLE_ES: &str = r#"La transcripción de voz no está disponible por ahora. - -El bot de transcripción aún no está emparejado con este grupo. Mientras tanto, prueba la traducción: - -- !translation — Traducción - -!help — Menú principal"#; - -const TRANSCRIPTION_INVITED_EN: &str = r#"Invited the transcription bot to this group. +const TRANSCRIPTION_INVITED: &str = r#"Invited the transcription bot to this group. Accept the Signal invite on that number, then send !transcription again (the transcription bot will answer with its menu). -!help — Main menu"#; +!help + Main menu"#; -const TRANSCRIPTION_INVITED_ES: &str = r#"Se invitó al bot de transcripción a este grupo. - -Acepta la invitación de Signal en ese número y luego envía !transcription de nuevo (el bot de transcripción responderá con su menú). - -!help — Menú principal"#; - -const TRANSCRIPTION_GROUP_ONLY_EN: &str = r#"Voice transcription pairing works in a Signal group. +const TRANSCRIPTION_GROUP_ONLY: &str = r#"Voice transcription pairing works in a Signal group. Add both bots to a group, then send !transcription there. -!help — Main menu"#; - -const TRANSCRIPTION_GROUP_ONLY_ES: &str = r#"El emparejamiento de transcripción funciona en un grupo de Signal. +!help + Main menu"#; -Añade ambos bots a un grupo y envía !transcription allí. - -!help — Menú principal"#; - -const PRIVACY_TRANSCRIPTION_EN: &str = r#"**Sigstack transcription** (Private & Verifiable) +const PRIVACY_TRANSCRIPTION: &str = r#"**Sigstack transcription** (Private & Verifiable) **TEE Commands:** -- !verify - Get TEE attestation with your challenge +!verify + Get TEE attestation with your challenge **Privacy:** Voice notes are decrypted by Signal CLI inside this TEE and transcribed with Whisper in the same CVM. Text transcripts are posted back to Signal. @@ -230,23 +173,11 @@ Neither the bot operator nor the host can read decrypted audio or text in TEE me Pair with the translation bot in the same group if you also want translation."#; -const PRIVACY_TRANSCRIPTION_ES: &str = r#"**Sigstack transcripción** (Privado y verificable) - -**Comandos TEE:** -- !verify - Obtener attestation TEE con tu challenge - -**Privacidad:** -Las notas de voz se descifran con Signal CLI dentro de este TEE y se transcriben con Whisper en el mismo CVM. El texto se publica de nuevo en Signal. - -Ni el operador del bot ni el host pueden leer el audio o texto descifrado en la memoria del TEE. - -Empareja con el bot de traducción en el mismo grupo si también quieres traducción."#; - -const PRIVACY_TRANSLATION_EN: &str = r#"**Sigstack translation** (Private & Verifiable) +const PRIVACY_TRANSLATION: &str = r#"**Sigstack translation** (Private & Verifiable) **TEE Commands:** -- !models - List AI models -- !verify - Get TEE attestation with your challenge +!verify + Get TEE attestation with your challenge **Verification:** `!verify my-random-text` to get cryptographic proof this bot runs in a TEE. Your challenge is embedded in the TDX quote. @@ -258,43 +189,45 @@ Voice transcription is a separate bot/CVM. This bot only acts on text (including Neither the bot operator nor NEAR AI can read your messages in plaintext outside the TEEs. -!help — Main menu"#; - -const PRIVACY_TRANSLATION_ES: &str = r#"**Sigstack traducción** (Privado y verificable) - -**Comandos TEE:** -- !models - Listar modelos de IA -- !verify - Obtener attestation TEE con tu challenge - -**Verificación:** -`!verify my-random-text` para obtener prueba criptográfica de que este bot corre en un TEE. Tu challenge se incluye en la cita TDX. - -**Privacidad:** -Los mensajes van cifrados de extremo a extremo con Signal, se procesan en un TEE verificado (Intel TDX) y se traducen vía inferencia privada de NEAR AI Cloud (NVIDIA GPU TEE). - -La transcripción de voz es un bot/CVM aparte. Este bot solo actúa sobre texto (incluidas las transcripciones del bot de transcripción). - -Ni el operador del bot ni NEAR AI pueden leer tus mensajes en texto plano fuera de los TEEs. - -!help — Menú principal"#; +!help + Main menu"#; #[cfg(test)] mod tests { use super::*; #[test] - fn help_translation_en_is_hub() { - let h = help_menu(MenuLanguage::En, BotRole::Translation); + fn help_translation_is_hub() { + let h = help_menu(BotRole::Translation); assert!(h.contains("!translation")); assert!(h.contains("!transcription")); assert!(h.contains("!privacy")); assert!(!h.contains("!translate-me-on")); assert!(!h.contains("!transcribe-on")); + assert!( + h.contains("!translation\n Translation"), + "hub commands should use stacked layout" + ); + assert!(!h.contains("!set-en")); + assert!(!h.contains("!set-es")); + assert!(h.contains("!help\n Show this menu")); + assert!(!h.contains("!translation —")); + assert!(!h.contains("!help —")); + } + + #[test] + fn thread_help_covers_rename() { + let h = thread_help_menu(); + assert!(h.contains("!rename ")); + assert!(h.contains("!translate-me-off")); + assert!(h.contains("!help\n Show this menu")); + assert!(!h.contains("!set-en")); + assert!(!h.contains("!translate-me-on")); } #[test] fn translation_menu_leads_with_language_threads() { - let h = translation_products_menu(MenuLanguage::En, true); + let h = translation_products_menu(true); assert!(h.contains("Language Threads (recommended)")); assert!(h.contains("!translate-me-on")); assert!(h.contains("!translate-me-off")); @@ -308,15 +241,21 @@ mod tests { lt < in_chat, "Language Threads should appear before In-chat" ); + assert!( + h.contains("!translate-me-on \n "), + "translation menu should use stacked layout" + ); + assert!(!h.contains("!translate-me-on —")); } #[test] fn translation_menu_auto_disabled_hides_translate_on() { - let h = translation_products_menu(MenuLanguage::En, false); + let h = translation_products_menu(false); assert!(h.contains("!translate-me-on")); assert!(h.contains("Auto-translate is disabled")); assert!(!h.contains("!translate-on ")); assert!(h.contains("!translate ")); + assert!(h.contains("!translate \n ")); } #[test] @@ -328,59 +267,33 @@ mod tests { } #[test] - fn help_transcription_en_covers_voice() { - let h = help_menu(MenuLanguage::En, BotRole::Transcription); + fn help_transcription_covers_voice() { + let h = help_menu(BotRole::Transcription); assert!(h.contains("!transcribe")); assert!(!h.contains("!ask")); assert!(!h.contains("!translate-me-on")); + assert!(h.contains("!transcribe-on / !transcribe-off\n ")); + assert!(!h.contains("!transcribe-on / !transcribe-off —")); } #[test] - fn help_es_hub() { - let h = help_menu(MenuLanguage::Es, BotRole::Translation); - assert!(h.contains("!translation")); - assert!(!h.contains("!ask")); - } - - #[test] - fn privacy_menus_cover_roles_and_languages() { - let en = privacy_menu(MenuLanguage::En, BotRole::Transcription); - assert!(en.contains("Sigstack transcription")); - let es = privacy_menu(MenuLanguage::Es, BotRole::Translation); - assert!(es.contains("Sigstack traducción")); + fn privacy_menus_cover_roles() { + let transcription = privacy_menu(BotRole::Transcription); + assert!(transcription.contains("Sigstack transcription")); + assert!(transcription.contains("!verify \n ")); + assert!(!transcription.contains("!verify -")); + let translation = privacy_menu(BotRole::Translation); + assert!(translation.contains("Sigstack translation")); + assert!(translation.contains("!verify \n ")); + assert!(!translation.contains("!models")); } #[test] fn transcription_unavailable_offers_translation() { - let m = transcription_unavailable(MenuLanguage::En); + let m = transcription_unavailable(); assert!(m.contains("unavailable")); assert!(m.contains("!translation")); - } - - #[test] - fn menu_language_for_message_uses_group_pref_or_default() { - let store = GroupPreferencesStore::new_in_memory(0); - store.set_menu_language("g-es", MenuLanguage::Es); - - let dm = BotMessage { - source: "+1".into(), - source_number: None, - source_name: None, - text: "!help".into(), - timestamp: 0, - message_timestamp: 0, - is_group: false, - group_id: None, - group_name: None, - receiving_account: "+2".into(), - attachments: vec![], - quote: None, - }; - assert_eq!(menu_language_for_message(&dm, &store), MenuLanguage::En); - - let mut group = dm.clone(); - group.is_group = true; - group.group_id = Some("g-es".into()); - assert_eq!(menu_language_for_message(&group, &store), MenuLanguage::Es); + assert!(m.contains("!translation\n Translation")); + assert!(!m.contains("!translation —")); } } diff --git a/crates/signal-bot/src/commands/mod.rs b/crates/signal-bot/src/commands/mod.rs index 0630dc1..ccc91ea 100644 --- a/crates/signal-bot/src/commands/mod.rs +++ b/crates/signal-bot/src/commands/mod.rs @@ -2,10 +2,9 @@ mod help; mod menu_locale; -mod models; mod privacy; mod product_menus; -mod set_language; +mod rename; mod translate; mod translate_all; pub mod translate_lang; @@ -15,13 +14,12 @@ mod translate_service; mod verify; pub use help::HelpHandler; -pub use models::ModelsHandler; pub use privacy::PrivacyHandler; pub use product_menus::{ InChatMenuHandler, TranscriptionMenuHandler, TranscriptionPairingHandler, TranslationMenuHandler, }; -pub use set_language::SetLanguageHandler; +pub use rename::RenameHandler; pub use signal_bot_core::CommandHandler; pub use translate::TranslateHandler; pub use translate_all::TranslateAllHandler; diff --git a/crates/signal-bot/src/commands/models.rs b/crates/signal-bot/src/commands/models.rs deleted file mode 100644 index eb60066..0000000 --- a/crates/signal-bot/src/commands/models.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! Models command - lists available AI models. - -use crate::commands::CommandHandler; -use crate::error::AppResult; -use async_trait::async_trait; -use near_ai_client::NearAiClient; -use signal_client::BotMessage; -use std::sync::Arc; -use tracing::error; - -pub struct ModelsHandler { - near_ai: Arc, -} - -impl ModelsHandler { - pub fn new(near_ai: Arc) -> Self { - Self { near_ai } - } -} - -#[async_trait] -impl CommandHandler for ModelsHandler { - fn trigger(&self) -> Option<&str> { - Some("!models") - } - - async fn execute(&self, _message: &BotMessage) -> AppResult { - match self.near_ai.list_models().await { - Ok(models) => { - let model_list: String = models - .iter() - .take(10) - .map(|m| format!("- {}", m.id)) - .collect::>() - .join("\n"); - - Ok(format!( - "**Available Models:**\n{}\n\n_Current: {}_", - model_list, - self.near_ai.model() - )) - } - Err(e) => { - error!("Failed to list models: {}", e); - Ok("Could not fetch model list.".into()) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::Duration; - - fn dm() -> BotMessage { - BotMessage { - source: "+1".into(), - source_number: None, - source_name: None, - text: "!models".into(), - timestamp: 0, - message_timestamp: 0, - is_group: false, - group_id: None, - group_name: None, - receiving_account: "+2".into(), - attachments: vec![], - quote: None, - } - } - - #[tokio::test] - async fn models_lists_known_near_ai_models() { - let near = Arc::new( - NearAiClient::new( - "key", - "http://127.0.0.1:9", - "test-model", - Duration::from_secs(2), - ) - .unwrap(), - ); - let handler = ModelsHandler::new(near); - assert!(handler.matches(&dm())); - let out = handler.execute(&dm()).await.unwrap(); - assert!(out.contains("**Available Models:**")); - assert!(out.contains("deepseek-ai/DeepSeek-V3.1")); - assert!(out.contains("_Current: test-model_")); - } -} diff --git a/crates/signal-bot/src/commands/privacy.rs b/crates/signal-bot/src/commands/privacy.rs index d27859c..81a1f7b 100644 --- a/crates/signal-bot/src/commands/privacy.rs +++ b/crates/signal-bot/src/commands/privacy.rs @@ -1,22 +1,19 @@ -//! `!privacy` — privacy, security, and TEE commands menu. +//! Privacy / TEE explanation menu. -use crate::commands::menu_locale::{menu_language_for_message, privacy_menu}; +use crate::commands::menu_locale::privacy_menu; use crate::commands::CommandHandler; use crate::config::BotRole; use crate::error::AppResult; -use crate::group_preferences_store::GroupPreferencesStore; use async_trait::async_trait; use signal_client::BotMessage; -use std::sync::Arc; pub struct PrivacyHandler { - group_prefs: Arc, role: BotRole, } impl PrivacyHandler { - pub fn new(group_prefs: Arc, role: BotRole) -> Self { - Self { group_prefs, role } + pub fn new(role: BotRole) -> Self { + Self { role } } } @@ -30,9 +27,8 @@ impl CommandHandler for PrivacyHandler { "privacy" } - async fn execute(&self, message: &BotMessage) -> AppResult { - let language = menu_language_for_message(message, &self.group_prefs); - Ok(privacy_menu(language, self.role).into()) + async fn execute(&self, _message: &BotMessage) -> AppResult { + Ok(privacy_menu(self.role).into()) } } @@ -42,34 +38,26 @@ mod tests { fn dm(text: &str) -> BotMessage { BotMessage { - source: "+15550002222".into(), + source: "+1".into(), source_number: None, source_name: None, text: text.into(), - timestamp: 1, - message_timestamp: 1, + timestamp: 0, + message_timestamp: 0, is_group: false, group_id: None, group_name: None, - receiving_account: "+15550001111".into(), + receiving_account: "+2".into(), attachments: vec![], quote: None, } } #[tokio::test] - async fn privacy_returns_role_menus() { - let store = GroupPreferencesStore::new_in_memory(0); - let transcription = PrivacyHandler::new(store.clone(), BotRole::Transcription); - let translation = PrivacyHandler::new(store, BotRole::Translation); - - assert!(transcription.matches(&dm("!privacy"))); - let t = transcription.execute(&dm("!privacy")).await.unwrap(); - assert!(t.contains("Sigstack transcription")); - assert!(t.contains("!verify")); - - let t = translation.execute(&dm("!privacy")).await.unwrap(); - assert!(t.contains("Sigstack translation")); - assert!(t.contains("!models")); + async fn privacy_returns_role_menu() { + let handler = PrivacyHandler::new(BotRole::Translation); + let out = handler.execute(&dm("!privacy")).await.unwrap(); + assert!(out.contains("Sigstack translation")); + assert!(out.contains("!verify")); } } diff --git a/crates/signal-bot/src/commands/product_menus.rs b/crates/signal-bot/src/commands/product_menus.rs index afd3288..0ccc626 100644 --- a/crates/signal-bot/src/commands/product_menus.rs +++ b/crates/signal-bot/src/commands/product_menus.rs @@ -1,27 +1,24 @@ //! Product menus: flat `!translation`, `!transcription`, `!in-chat` (redirects to translation). use crate::commands::menu_locale::{ - help_menu, is_exact_command, menu_language_for_message, transcription_group_only, - transcription_invited, transcription_unavailable, translation_products_menu, + help_menu, is_exact_command, transcription_group_only, transcription_invited, + transcription_unavailable, translation_products_menu, }; use crate::commands::CommandHandler; use crate::config::BotRole; use crate::error::AppResult; -use crate::group_preferences_store::GroupPreferencesStore; use async_trait::async_trait; use signal_client::{BotMessage, SignalClient}; use std::sync::Arc; use tracing::warn; pub struct TranslationMenuHandler { - group_prefs: Arc, translate_all_enabled: bool, } impl TranslationMenuHandler { - pub fn new(group_prefs: Arc, translate_all_enabled: bool) -> Self { + pub fn new(translate_all_enabled: bool) -> Self { Self { - group_prefs, translate_all_enabled, } } @@ -37,27 +34,20 @@ impl CommandHandler for TranslationMenuHandler { "translation_menu" } - async fn execute(&self, message: &BotMessage) -> AppResult { - let language = menu_language_for_message(message, &self.group_prefs); - Ok(translation_products_menu(language, self.translate_all_enabled).into()) + async fn execute(&self, _message: &BotMessage) -> AppResult { + Ok(translation_products_menu(self.translate_all_enabled).into()) } } /// Translation role: invite the transcription peer, or stay silent when already paired. pub struct TranscriptionPairingHandler { - group_prefs: Arc, signal: Arc, peer_phone: Option, } impl TranscriptionPairingHandler { - pub fn new( - group_prefs: Arc, - signal: Arc, - peer_phone: Option, - ) -> Self { + pub fn new(signal: Arc, peer_phone: Option) -> Self { Self { - group_prefs, signal, peer_phone: peer_phone.and_then(|p| { let t = p.trim().to_string(); @@ -91,23 +81,18 @@ impl CommandHandler for TranscriptionPairingHandler { } async fn execute(&self, message: &BotMessage) -> AppResult { - let language = menu_language_for_message(message, &self.group_prefs); - if !message.is_group { - self.send(message, transcription_group_only(language)) - .await?; + self.send(message, transcription_group_only()).await?; return Ok(String::new()); } let Some(peer) = self.peer_phone.as_deref() else { - self.send(message, transcription_unavailable(language)) - .await?; + self.send(message, transcription_unavailable()).await?; return Ok(String::new()); }; let Some(group_id) = message.group_id.as_deref() else { - self.send(message, transcription_unavailable(language)) - .await?; + self.send(message, transcription_unavailable()).await?; return Ok(String::new()); }; @@ -126,8 +111,7 @@ impl CommandHandler for TranscriptionPairingHandler { .iter() .find(|g| g.internal_id == group_id || g.id == group_id) else { - self.send(message, transcription_unavailable(language)) - .await?; + self.send(message, transcription_unavailable()).await?; return Ok(String::new()); }; @@ -159,7 +143,7 @@ impl CommandHandler for TranscriptionPairingHandler { .await { Ok(()) => { - self.send(message, transcription_invited(language)).await?; + self.send(message, transcription_invited()).await?; } Err(e) => { warn!(error = %e, peer, "Failed to invite transcription bot"); @@ -167,7 +151,7 @@ impl CommandHandler for TranscriptionPairingHandler { "Could not add the transcription bot ({peer}): {e}\n\n\ This bot must be a group admin to invite members. \ Or set SIGNAL__PEER_PHONE and try again.\n\n\ - !help — Main menu" + !help\n Main menu" ); self.send(message, &body).await?; } @@ -178,13 +162,17 @@ impl CommandHandler for TranscriptionPairingHandler { } /// Transcription role: product menu for `!transcription`. -pub struct TranscriptionMenuHandler { - group_prefs: Arc, -} +pub struct TranscriptionMenuHandler; impl TranscriptionMenuHandler { - pub fn new(group_prefs: Arc) -> Self { - Self { group_prefs } + pub fn new() -> Self { + Self + } +} + +impl Default for TranscriptionMenuHandler { + fn default() -> Self { + Self::new() } } @@ -198,21 +186,18 @@ impl CommandHandler for TranscriptionMenuHandler { "transcription_menu" } - async fn execute(&self, message: &BotMessage) -> AppResult { - let language = menu_language_for_message(message, &self.group_prefs); - Ok(help_menu(language, BotRole::Transcription).into()) + async fn execute(&self, _message: &BotMessage) -> AppResult { + Ok(help_menu(BotRole::Transcription).into()) } } pub struct InChatMenuHandler { - group_prefs: Arc, translate_all_enabled: bool, } impl InChatMenuHandler { - pub fn new(group_prefs: Arc, translate_all_enabled: bool) -> Self { + pub fn new(translate_all_enabled: bool) -> Self { Self { - group_prefs, translate_all_enabled, } } @@ -228,10 +213,9 @@ impl CommandHandler for InChatMenuHandler { "in_chat_menu" } - async fn execute(&self, message: &BotMessage) -> AppResult { + async fn execute(&self, _message: &BotMessage) -> AppResult { // Muscle-memory alias: same flat Translation menu as !translation. - let language = menu_language_for_message(message, &self.group_prefs); - Ok(translation_products_menu(language, self.translate_all_enabled).into()) + Ok(translation_products_menu(self.translate_all_enabled).into()) } } @@ -261,30 +245,27 @@ mod tests { #[test] fn menus_match_exact_only() { - let store = GroupPreferencesStore::new_in_memory(0); - let t = TranslationMenuHandler::new(store.clone(), true); + let t = TranslationMenuHandler::new(true); assert!(t.matches(&msg("!translation"))); assert!(!t.matches(&msg("!translation-on es en"))); - let i = InChatMenuHandler::new(store.clone(), true); + let i = InChatMenuHandler::new(true); assert!(i.matches(&msg("!in-chat"))); let s = TranscriptionPairingHandler::new( - store.clone(), Arc::new(SignalClient::new("http://127.0.0.1:9").unwrap()), None, ); assert!(s.matches(&msg("!transcription"))); - let m = TranscriptionMenuHandler::new(store); + let m = TranscriptionMenuHandler::new(); assert!(m.matches(&msg("!transcription"))); } #[tokio::test] async fn in_chat_redirects_to_flat_translation_menu() { - let store = GroupPreferencesStore::new_in_memory(0); - let translation = TranslationMenuHandler::new(store.clone(), true); - let in_chat = InChatMenuHandler::new(store, true); + let translation = TranslationMenuHandler::new(true); + let in_chat = InChatMenuHandler::new(true); let via_translation = translation.execute(&msg("!translation")).await.unwrap(); let via_in_chat = in_chat.execute(&msg("!in-chat")).await.unwrap(); assert_eq!(via_translation, via_in_chat); @@ -312,9 +293,7 @@ mod tests { .mount(&signal_mock) .await; - let store = GroupPreferencesStore::new_in_memory(0); let handler = TranscriptionPairingHandler::new( - store, Arc::new(SignalClient::new(signal_mock.uri()).unwrap()), None, ); @@ -351,9 +330,7 @@ mod tests { .mount(&signal_mock) .await; - let store = GroupPreferencesStore::new_in_memory(0); let handler = TranscriptionPairingHandler::new( - store, Arc::new(SignalClient::new(signal_mock.uri()).unwrap()), Some("+15550009999".into()), ); @@ -378,9 +355,7 @@ mod tests { .mount(&signal_mock) .await; - let store = GroupPreferencesStore::new_in_memory(0); let handler = TranscriptionPairingHandler::new( - store, Arc::new(SignalClient::new(signal_mock.uri()).unwrap()), Some("+15550009999".into()), ); @@ -390,8 +365,7 @@ mod tests { #[tokio::test] async fn transcription_menu_returns_voice_help() { - let store = GroupPreferencesStore::new_in_memory(0); - let handler = TranscriptionMenuHandler::new(store); + let handler = TranscriptionMenuHandler::new(); let out = handler.execute(&msg("!transcription")).await.unwrap(); assert!(out.contains("!transcribe")); assert!(out.to_lowercase().contains("voice")); diff --git a/crates/signal-bot/src/commands/rename.rs b/crates/signal-bot/src/commands/rename.rs new file mode 100644 index 0000000..1e8d3ab --- /dev/null +++ b/crates/signal-bot/src/commands/rename.rs @@ -0,0 +1,176 @@ +//! `!rename ` — rename a Language Thread sidecar (members only via presence). + +use crate::commands::CommandHandler; +use crate::error::AppResult; +use crate::group_preferences_store::GroupPreferencesStore; +use async_trait::async_trait; +use signal_client::{BotMessage, SignalClient}; +use std::sync::Arc; +use tracing::warn; + +const MAX_NAME_LEN: usize = 100; +const NOT_THREAD_MSG: &str = "!rename is only available in a Language Thread."; +const USAGE_MSG: &str = "Usage: !rename "; +const EMPTY_MSG: &str = "Group name cannot be empty."; +const TOO_LONG_MSG: &str = "Group name is too long (max 100 characters)."; + +pub struct RenameHandler { + store: Arc, + signal: Arc, +} + +impl RenameHandler { + pub fn new(store: Arc, signal: Arc) -> Self { + Self { store, signal } + } + + 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()) + } +} + +#[async_trait] +impl CommandHandler for RenameHandler { + fn matches(&self, message: &BotMessage) -> bool { + let t = message.text.trim(); + t == "!rename" || t.starts_with("!rename ") + } + + fn label(&self) -> &'static str { + "rename" + } + + async fn execute(&self, message: &BotMessage) -> AppResult { + let Some(name_arg) = Self::parse_name(&message.text) else { + return Ok(USAGE_MSG.into()); + }; + if name_arg.is_empty() { + return Ok(format!("{EMPTY_MSG}\n{USAGE_MSG}")); + } + if name_arg.chars().count() > MAX_NAME_LEN { + return Ok(TOO_LONG_MSG.into()); + } + + let Some(group_id) = message.group_id.as_deref() else { + return Ok(NOT_THREAD_MSG.into()); + }; + + let Some((main_id, lang)) = self.store.lookup_sidecar(group_id) else { + return Ok(NOT_THREAD_MSG.into()); + }; + + let Some(send_id) = self + .store + .get_bridge(&main_id) + .and_then(|b| b.sidecar_send_id(&lang).map(str::to_string)) + else { + return Ok(NOT_THREAD_MSG.into()); + }; + + let bot = message.receiving_account.as_str(); + match self.signal.update_group(bot, &send_id, name_arg).await { + Ok(()) => Ok(format!("Renamed this Language Thread to \"{name_arg}\".")), + Err(e) => { + warn!(error = %e, send_id, "Failed to rename Language Thread"); + Ok(format!( + "Could not rename this group: {e}. Try again shortly." + )) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use wiremock::matchers::{body_json, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn group_msg(text: &str, group_id: &str) -> BotMessage { + BotMessage { + source: "+15550002222".into(), + source_number: Some("+15550002222".into()), + source_name: Some("Maria".into()), + text: text.into(), + timestamp: 1, + message_timestamp: 1, + is_group: true, + group_id: Some(group_id.into()), + group_name: None, + receiving_account: "+15550001111".into(), + attachments: vec![], + quote: None, + } + } + + #[test] + fn matches_rename_commands() { + let store = GroupPreferencesStore::new_in_memory(0); + let signal = Arc::new(SignalClient::new("http://127.0.0.1:9").unwrap()); + let handler = RenameHandler::new(store, signal); + assert!(handler.matches(&group_msg("!rename Foo", "g"))); + assert!(handler.matches(&group_msg("!rename", "g"))); + assert!(!handler.matches(&group_msg("!renamex", "g"))); + assert!(!handler.matches(&group_msg("!help", "g"))); + } + + #[tokio::test] + async fn rejects_outside_sidecar() { + let store = GroupPreferencesStore::new_in_memory(0); + let signal = Arc::new(SignalClient::new("http://127.0.0.1:9").unwrap()); + let handler = RenameHandler::new(store, signal); + let out = handler + .execute(&group_msg("!rename New Name", "unknown")) + .await + .unwrap(); + assert!(out.contains("only available")); + } + + #[tokio::test] + async fn renames_known_sidecar() { + let mock = MockServer::start().await; + Mock::given(method("PUT")) + .and(path("/v1/groups/%2B15550001111/group.it%3D%3D")) + .and(body_json(json!({ "name": "Il nostro filo" }))) + .respond_with(ResponseTemplate::new(204)) + .expect(1) + .mount(&mock) + .await; + + let store = GroupPreferencesStore::new_in_memory(0); + store.set_sidecar("main-1", "it", "group.it==".into(), "it-internal".into()); + let handler = RenameHandler::new(store, Arc::new(SignalClient::new(mock.uri()).unwrap())); + let out = handler + .execute(&group_msg("!rename Il nostro filo", "it-internal")) + .await + .unwrap(); + assert!(out.contains("Il nostro filo")); + } + + #[tokio::test] + async fn rejects_empty_and_overlong() { + let store = GroupPreferencesStore::new_in_memory(0); + store.set_sidecar("main-1", "it", "group.it==".into(), "it-internal".into()); + let signal = Arc::new(SignalClient::new("http://127.0.0.1:9").unwrap()); + let handler = RenameHandler::new(store, signal); + + let empty = handler + .execute(&group_msg("!rename ", "it-internal")) + .await + .unwrap(); + assert!(empty.contains("cannot be empty")); + + let long = format!("!rename {}", "x".repeat(101)); + let too_long = handler + .execute(&group_msg(&long, "it-internal")) + .await + .unwrap(); + assert!(too_long.contains("too long")); + } +} diff --git a/crates/signal-bot/src/commands/set_language.rs b/crates/signal-bot/src/commands/set_language.rs deleted file mode 100644 index 9675a82..0000000 --- a/crates/signal-bot/src/commands/set_language.rs +++ /dev/null @@ -1,116 +0,0 @@ -//! `!set-en` / `!set-es` — per-group menu language for `!help` and `!privacy`. - -use crate::commands::menu_locale::help_menu; -use crate::commands::CommandHandler; -use crate::config::BotRole; -use crate::error::AppResult; -use crate::group_preferences_store::GroupPreferencesStore; -use crate::menu_language::MenuLanguage; -use async_trait::async_trait; -use signal_client::BotMessage; -use std::sync::Arc; - -const GROUP_ONLY_MSG: &str = "!set-es and !set-en are only available in group chats"; - -pub struct SetLanguageHandler { - group_prefs: Arc, -} - -impl SetLanguageHandler { - pub fn new(group_prefs: Arc) -> Self { - Self { group_prefs } - } -} - -#[async_trait] -impl CommandHandler for SetLanguageHandler { - fn matches(&self, message: &BotMessage) -> bool { - let text = message.text.trim(); - text == "!set-en" || text == "!set-es" - } - - fn label(&self) -> &'static str { - "set_language" - } - - async fn execute(&self, message: &BotMessage) -> AppResult { - let group_id = match message.group_id.as_deref() { - Some(id) => id, - None => return Ok(GROUP_ONLY_MSG.into()), - }; - - let language = if message.text.trim() == "!set-es" { - MenuLanguage::Es - } else { - MenuLanguage::En - }; - - self.group_prefs.set_menu_language(group_id, language); - - let confirmation = match language { - MenuLanguage::En => "Menu language set to English for this group.", - MenuLanguage::Es => "Idioma del menú configurado en español para este grupo.", - }; - - Ok(format!( - "{confirmation}\n\n{}", - help_menu(language, BotRole::Translation) - )) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn test_handler() -> SetLanguageHandler { - SetLanguageHandler::new(GroupPreferencesStore::new_in_memory(0)) - } - - #[test] - fn matches_set_commands_only() { - let handler = test_handler(); - let mut msg = BotMessage { - source: "+1".into(), - source_number: None, - source_name: None, - text: "!set-es".into(), - timestamp: 0, - message_timestamp: 0, - is_group: true, - group_id: Some("gid".into()), - group_name: None, - receiving_account: "+2".into(), - attachments: vec![], - quote: None, - }; - assert!(handler.matches(&msg)); - msg.text = "!set-español".into(); - assert!(!handler.matches(&msg)); - } - - #[tokio::test] - async fn sets_group_language() { - let handler = test_handler(); - let msg = BotMessage { - source: "+1".into(), - source_number: None, - source_name: None, - text: "!set-es".into(), - timestamp: 0, - message_timestamp: 0, - is_group: true, - group_id: Some("gid".into()), - group_name: None, - receiving_account: "+2".into(), - attachments: vec![], - quote: None, - }; - let response = handler.execute(&msg).await.unwrap(); - assert!(response.contains("español")); - assert_eq!( - handler.group_prefs.get_menu_language("gid"), - MenuLanguage::Es - ); - } -} diff --git a/crates/signal-bot/src/commands/translate_me.rs b/crates/signal-bot/src/commands/translate_me.rs index e927b36..aa136d1 100644 --- a/crates/signal-bot/src/commands/translate_me.rs +++ b/crates/signal-bot/src/commands/translate_me.rs @@ -1,11 +1,12 @@ //! Language Threads: `!translate-me-on` / `!translate-me-off` + relay engine. //! -//! Main group stays multilingual. Each subscribed language gets a `Language Thread {Language}` -//! Signal sidecar. Messages fan out: main→sidecars (relay/translate), -//! sidecar→main (relay) + other sidecars (translate). Bot never relays itself. +//! Main group stays multilingual. Each subscribed language gets a +//! `SigLang {Language} · {disambiguator}` Signal sidecar. Messages fan out: +//! main→sidecars (relay/translate), sidecar→main (relay) + other sidecars (translate). +//! Bot never relays itself. use crate::bot_identity::BotIdentity; -use crate::commands::translate_lang::resolve_language; +use crate::commands::translate_lang::{resolve_language, Language}; use crate::commands::translate_service::{detect_text_language, near_ai_translate}; use crate::commands::CommandHandler; use crate::error::AppResult; @@ -197,11 +198,9 @@ impl TranslateMeHandler { )); } } else { - let name = format!("Language Thread {}", lang.name); - let description = format!( - "{} sidecar bridged to the main mutual-aid group.", - lang.name - ); + // Default English SigLang title before create+invite. + let (name, description, welcome) = + sidecar_copy(lang, message.group_name.as_deref(), main_id); match self .signal .create_group(bot, &name, vec![address.clone()], Some(&description)) @@ -214,10 +213,6 @@ impl TranslateMeHandler { group.id.clone(), group.internal_id.clone(), ); - let welcome = format!( - "Welcome to Language Thread {}. Messages here are bridged with the main group.", - lang.name - ); if let Err(e) = self.signal.send(bot, &group.id, &welcome).await { warn!(error = %e, "Failed to send sidecar welcome"); } @@ -242,9 +237,9 @@ impl TranslateMeHandler { ); Ok(format!( - "Joined the {} sidecar (Language Thread {}). Accept the Signal group invite if prompted. \ -Use !translate-me-off to leave.", - lang.name, lang.name + "{} joined {} thread", + message.display_name(), + lang.name )) } @@ -429,6 +424,61 @@ fn format_attribution(display_name: &str, body: &str) -> String { format!("{display_name}:\n{body}") } +const DISAMBIGUATOR_MAX: usize = 24; + +/// English title / description / welcome for a new Language Thread sidecar. +fn sidecar_copy( + lang: &Language, + main_group_name: Option<&str>, + main_id: &str, +) -> (String, String, String) { + let disambiguator = sidecar_disambiguator(main_group_name, main_id); + let name = format!("SigLang {} · {}", lang.name, disambiguator); + let description = format!( + "{} Language Thread bridged to the main group ({}).", + lang.name, disambiguator + ); + let welcome = format!( + "Welcome to {name}. Messages here are bridged with the main group. Send !help for thread commands." + ); + (name, description, welcome) +} + +fn sidecar_disambiguator(main_group_name: Option<&str>, main_id: &str) -> String { + if let Some(label) = truncate_main_label(main_group_name) { + return label; + } + short_main_id_hash(main_id) +} + +fn truncate_main_label(name: Option<&str>) -> Option { + let raw = name?.trim(); + if raw.is_empty() { + return None; + } + let collapsed: String = raw.split_whitespace().collect::>().join(" "); + if collapsed.is_empty() { + return None; + } + let truncated: String = collapsed.chars().take(DISAMBIGUATOR_MAX).collect(); + let trimmed = truncated.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +fn short_main_id_hash(main_id: &str) -> String { + // FNV-1a 32-bit — stable, no extra deps, enough for a 4-hex chat-list suffix. + let mut hash: u32 = 0x811c_9dc5; + for b in main_id.as_bytes() { + hash ^= u32::from(*b); + hash = hash.wrapping_mul(0x0100_0193); + } + format!("{:04x}", hash & 0xffff) +} + fn starts_with_word(text: &str, prefix: &str) -> bool { text == prefix || text @@ -511,6 +561,39 @@ mod tests { } } + #[test] + fn sidecar_copy_uses_main_group_name() { + let it = resolve_language("it").unwrap(); + let (name, description, welcome) = sidecar_copy(it, Some(" Stacked "), "main-id"); + assert_eq!(name, "SigLang Italian · Stacked"); + assert!(description.contains("Stacked")); + assert!(welcome.starts_with("Welcome to SigLang Italian · Stacked")); + assert!(welcome.contains("!help")); + } + + #[test] + fn sidecar_copy_falls_back_to_hash_without_group_name() { + let es = resolve_language("es").unwrap(); + let (name, _, _) = sidecar_copy(es, None, "main-internal-abc"); + assert!(name.starts_with("SigLang Spanish · ")); + assert!(!name.contains("None")); + let suffix = name.rsplit('·').next().unwrap().trim(); + assert_eq!(suffix.len(), 4); + assert!(suffix.chars().all(|c| c.is_ascii_hexdigit())); + + let (name2, _, _) = sidecar_copy(es, Some(" "), "main-internal-abc"); + assert_eq!(name, name2); + } + + #[test] + fn sidecar_copy_truncates_long_main_names() { + let en = resolve_language("en").unwrap(); + let long = "A".repeat(40); + let (name, _, _) = sidecar_copy(en, Some(&long), "main"); + let label = name.rsplit('·').next().unwrap().trim(); + assert_eq!(label.chars().count(), DISAMBIGUATOR_MAX); + } + #[test] fn matches_on_off_commands() { assert!(TranslateMeHandler::is_on_command("!translate-me-on es")); @@ -657,25 +740,25 @@ mod tests { #[tokio::test] async fn create_sidecar_on_existing_add_switch_and_off() { use serde_json::json; + use std::sync::atomic::{AtomicUsize, Ordering}; use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; - struct CreateGroupResponder; + struct CreateGroupResponder { + count: Arc, + } impl Respond for CreateGroupResponder { - fn respond(&self, request: &Request) -> ResponseTemplate { - let body: serde_json::Value = - serde_json::from_slice(&request.body).unwrap_or_else(|_| json!({})); - let name = body["name"].as_str().unwrap_or(""); - let id = if name.contains("French") { - "group.fr" - } else { - "group.es" - }; + fn respond(&self, _request: &Request) -> ResponseTemplate { + // Call order: first subscribe creates es, language switch creates fr. + let n = self.count.fetch_add(1, Ordering::SeqCst); + let id = if n == 0 { "group.es" } else { "group.fr" }; ResponseTemplate::new(200).set_body_json(json!({"id": id})) } } let signal = MockServer::start().await; + let near = MockServer::start().await; + mount_near(&near).await; Mock::given(method("POST")) .and(path("/v2/send")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) @@ -683,7 +766,9 @@ mod tests { .await; Mock::given(method("POST")) .and(path("/v1/groups/%2B15550001111")) - .respond_with(CreateGroupResponder) + .respond_with(CreateGroupResponder { + count: Arc::new(AtomicUsize::new(0)), + }) .mount(&signal) .await; Mock::given(method("GET")) @@ -714,7 +799,7 @@ mod tests { .await; let store = GroupPreferencesStore::new_in_memory(0); - let handler = handler_pair(store.clone(), signal.uri(), "http://127.0.0.1:9".into()); + let handler = handler_pair(store.clone(), signal.uri(), near.uri()); let mut msg = group_msg("+15550002222", "!translate-me-on es"); msg.group_id = Some("main-internal".into()); diff --git a/crates/signal-bot/src/commands/translate_service.rs b/crates/signal-bot/src/commands/translate_service.rs index 4236764..5a6b398 100644 --- a/crates/signal-bot/src/commands/translate_service.rs +++ b/crates/signal-bot/src/commands/translate_service.rs @@ -4,7 +4,7 @@ use crate::commands::translate_lang::Language; use crate::group_preferences_store::GroupTranslateMode; use near_ai_client::{Message, NearAiClient, NearAiError, Role}; use tracing::debug; -use whatlang::Lang; +use whatlang::{Detector, Lang}; const MIN_DETECT_CONFIDENCE: f64 = 0.2; @@ -24,6 +24,64 @@ fn normalize_for_translate_all_pair(mode: &GroupTranslateMode, code: &str) -> Op None } +fn iso_to_whatlang(code: &str) -> Option { + Some(match code { + "en" => Lang::Eng, + "es" => Lang::Spa, + "zh" => Lang::Cmn, + "hi" => Lang::Hin, + "bn" => Lang::Ben, + "fr" => Lang::Fra, + "ar" => Lang::Ara, + "pt" => Lang::Por, + "ru" => Lang::Rus, + "ja" => Lang::Jpn, + "de" => Lang::Deu, + "ko" => Lang::Kor, + "it" => Lang::Ita, + "nl" => Lang::Nld, + "pl" => Lang::Pol, + "tr" => Lang::Tur, + "uk" => Lang::Ukr, + "sv" => Lang::Swe, + "cs" => Lang::Ces, + "el" => Lang::Ell, + "he" => Lang::Heb, + "ro" => Lang::Ron, + "hu" => Lang::Hun, + "fi" => Lang::Fin, + "da" => Lang::Dan, + "no" => Lang::Nob, + "fa" => Lang::Pes, + "vi" => Lang::Vie, + "th" => Lang::Tha, + "id" => Lang::Ind, + "ca" => Lang::Cat, + _ => return None, + }) +} + +/// Detect language restricted to the active bilingual pair. +/// +/// Open-vocabulary whatlang often mislabels short English as Norwegian (etc.) +/// at tiny confidence; constraining to the pair recovers both directions. +/// +/// Absolute confidence can stay very low even when the allowlist correctly +/// picks the winner (e.g. Italian vs Spanish on "ciao buongiorno" ~0.02). +/// Trust the allowlist result whenever it returns a language. +fn detect_text_language_in_pair(mode: &GroupTranslateMode, text: &str) -> Option { + let allowlist: Vec = [mode.lang_a.as_str(), mode.lang_b.as_str()] + .into_iter() + .filter_map(iso_to_whatlang) + .collect(); + if allowlist.len() < 2 { + return None; + } + + let info = Detector::with_allowlist(allowlist).detect(text)?; + lang_to_iso639_1(info.lang()).map(str::to_string) +} + /// Like [`detect_text_language`] but tuned for short / casual messages. fn detect_text_language_voice(text: &str) -> Option { const MIN_VOICE_CONFIDENCE: f64 = 0.08; @@ -43,7 +101,7 @@ fn detect_text_language_voice(text: &str) -> Option { } } -fn text_language_candidates(text: &str) -> Vec { +fn text_language_candidates(mode: &GroupTranslateMode, text: &str) -> Vec { let mut codes = Vec::new(); let mut push = |code: &str| { if !codes.iter().any(|c| c == code) { @@ -51,48 +109,19 @@ fn text_language_candidates(text: &str) -> Vec { } }; + // Prefer pair-constrained whatlang so short messages aren't lost to open-vocab mislabels. + if let Some(lang) = detect_text_language_in_pair(mode, text) { + push(&lang); + } if let Some(lang) = detect_text_language(text) { push(&lang); } if let Some(lang) = detect_text_language_voice(text) { push(&lang); } - for hint in casual_language_hints(text) { - push(hint); - } codes } -fn casual_language_hints(text: &str) -> Vec<&'static str> { - let lower = text.to_lowercase(); - let mut hints = Vec::new(); - - let english_markers = [ - " the ", - " i'm ", - " how ", - " your ", - "hello", - "english", - " day?", - "speaking in english", - "how are", - "doing?", - ]; - if english_markers.iter().any(|m| lower.contains(m)) { - hints.push("en"); - } - - let spanish_markers = [ - "¿", "cómo", "como ", "está", "está?", "hola", "gracias", "día", "hablo", - ]; - if spanish_markers.iter().any(|m| lower.contains(m)) { - hints.push("es"); - } - - hints -} - /// Detect ISO 639-1 language code from text (for `!translate-on` text messages). pub fn detect_text_language(text: &str) -> Option { let info = whatlang::detect(text)?; @@ -138,6 +167,7 @@ fn lang_to_iso639_1(lang: Lang) -> Option<&'static str> { Lang::Vie => "vi", Lang::Tha => "th", Lang::Ind => "id", + Lang::Cat => "ca", _ => return None, }) } @@ -154,7 +184,7 @@ pub async fn near_ai_translate( ); near_ai - .chat( + .chat_with_retry( vec![ Message { role: Role::System, @@ -174,6 +204,7 @@ pub async fn near_ai_translate( ], Some(0.3), Some(1024), + Some(2), ) .await } @@ -196,7 +227,7 @@ pub fn resolve_translate_all_text_pair( mode: &GroupTranslateMode, text: &str, ) -> Option<(&'static Language, &'static Language)> { - for code in text_language_candidates(text) { + for code in text_language_candidates(mode, text) { if let Some(normalized) = normalize_for_translate_all_pair(mode, &code) { if let (Some(target), Some(source)) = ( mode.target_for_source(&normalized), @@ -234,6 +265,24 @@ mod tests { assert_eq!(pair.1.code, "es"); } + #[test] + fn resolve_text_pair_short_english_both_directions() { + let mode = GroupTranslateMode::new( + resolve_language("en").unwrap(), + resolve_language("es").unwrap(), + ); + // Open whatlang labels this as Norwegian at ~3% confidence; pair allowlist recovers en→es. + let pair = resolve_translate_all_text_pair(&mode, "i'm doing quite fine thanks") + .expect("short English should translate to Spanish"); + assert_eq!(pair.0.code, "en"); + assert_eq!(pair.1.code, "es"); + + let pair = resolve_translate_all_text_pair(&mode, "estoy bien!") + .expect("short Spanish should translate to English"); + assert_eq!(pair.0.code, "es"); + assert_eq!(pair.1.code, "en"); + } + #[test] fn resolve_text_pair_maps_portuguese_to_spanish_in_es_en_pair() { let mode = GroupTranslateMode::new( @@ -252,4 +301,22 @@ mod tests { let out = format_text_auto_translation(es, " Buenos días "); assert_eq!(out, format!("{} Buenos días", es.flag)); } + + #[test] + fn resolve_text_pair_short_italian_in_it_es_pair() { + let mode = GroupTranslateMode::new( + resolve_language("it").unwrap(), + resolve_language("es").unwrap(), + ); + // Pair allowlist picks Italian at ~2% absolute confidence — still accept the winner. + let pair = resolve_translate_all_text_pair(&mode, "ciao buongiorno") + .expect("short Italian should translate to Spanish"); + assert_eq!(pair.0.code, "it"); + assert_eq!(pair.1.code, "es"); + + let pair = resolve_translate_all_text_pair(&mode, "buenos dias") + .expect("Spanish should translate to Italian"); + assert_eq!(pair.0.code, "es"); + assert_eq!(pair.1.code, "it"); + } } diff --git a/crates/signal-bot/src/config.rs b/crates/signal-bot/src/config.rs index e0d5ebc..fe9ba93 100644 --- a/crates/signal-bot/src/config.rs +++ b/crates/signal-bot/src/config.rs @@ -218,7 +218,7 @@ fn default_near_ai_url() -> String { } fn default_model() -> String { - "deepseek-ai/DeepSeek-V3.1".into() + "deepseek-ai/DeepSeek-V4-Flash".into() } fn default_timeout() -> Duration { diff --git a/crates/signal-bot/src/handlers_setup.rs b/crates/signal-bot/src/handlers_setup.rs index 63ee154..7a32f8b 100644 --- a/crates/signal-bot/src/handlers_setup.rs +++ b/crates/signal-bot/src/handlers_setup.rs @@ -70,16 +70,13 @@ pub async fn build_transcription_handlers( config.whisper.max_attachment_bytes, Arc::new(GroupTranscribePrefs(group_prefs.clone())), ); - handlers.push(Box::new(TranscriptionMenuHandler::new(group_prefs.clone()))); + handlers.push(Box::new(TranscriptionMenuHandler::new())); handlers.push(Box::new(VerifyHandler::new(dstack))); handlers.push(Box::new(HelpHandler::new( - group_prefs.clone(), - BotRole::Transcription, - ))); - handlers.push(Box::new(PrivacyHandler::new( group_prefs, BotRole::Transcription, ))); + handlers.push(Box::new(PrivacyHandler::new(BotRole::Transcription))); info!("Transcription role: voice / !transcribe* / !transcription / help / privacy / verify"); Ok(handlers) @@ -151,16 +148,13 @@ pub async fn build_translation_handlers( } handlers.push(Box::new(TranslationMenuHandler::new( - group_prefs.clone(), config.translate_all.enabled, ))); handlers.push(Box::new(TranscriptionPairingHandler::new( - group_prefs.clone(), signal.clone(), config.signal.peer_phone.clone(), ))); handlers.push(Box::new(InChatMenuHandler::new( - group_prefs.clone(), config.translate_all.enabled, ))); @@ -170,17 +164,16 @@ pub async fn build_translation_handlers( "📝 Transcript:", ))); handlers.push(Box::new(TranslateLangsHandler::new())); - handlers.push(Box::new(SetLanguageHandler::new(group_prefs.clone()))); - handlers.push(Box::new(VerifyHandler::new(dstack))); - handlers.push(Box::new(HelpHandler::new( + handlers.push(Box::new(RenameHandler::new( group_prefs.clone(), - BotRole::Translation, + signal.clone(), ))); - handlers.push(Box::new(PrivacyHandler::new( + handlers.push(Box::new(VerifyHandler::new(dstack))); + handlers.push(Box::new(HelpHandler::new( group_prefs, BotRole::Translation, ))); - handlers.push(Box::new(ModelsHandler::new(near_ai))); + handlers.push(Box::new(PrivacyHandler::new(BotRole::Translation))); info!("Translation role: hub menus + in-chat + Language Threads"); Ok(handlers) @@ -291,7 +284,7 @@ mod tests { .await .expect("translation handlers"); - assert_eq!(handlers.len(), 12); + assert_eq!(handlers.len(), 11); let got = labels(&handlers); assert!(got.contains(&"translate_me")); assert!(got.contains(&"translate_all")); @@ -300,9 +293,11 @@ mod tests { assert!(got.contains(&"in_chat_menu")); assert!(!got.contains(&"translate_parallel")); assert!(!got.contains(&"parallel_menu")); + assert!(!got.contains(&"models")); assert!(got.contains(&"translate")); assert!(got.contains(&"translate_langs")); - assert!(got.contains(&"set_language")); + assert!(got.contains(&"rename")); + assert!(!got.contains(&"set_language")); assert!(got.contains(&"help")); assert!(got.contains(&"privacy")); } @@ -333,7 +328,7 @@ mod tests { .await .expect("translation handlers"); - assert_eq!(handlers.len(), 11); + assert_eq!(handlers.len(), 10); assert!(!labels(&handlers).contains(&"translate_all")); assert!(labels(&handlers).contains(&"translate_me")); assert!(labels(&handlers).contains(&"in_chat_menu")); diff --git a/crates/signal-bot/src/menu_language.rs b/crates/signal-bot/src/menu_language.rs index c30e55e..a13ab0b 100644 --- a/crates/signal-bot/src/menu_language.rs +++ b/crates/signal-bot/src/menu_language.rs @@ -1,7 +1,9 @@ -//! UI menu language for `!help` and `!privacy` (per-group). +//! Persisted menu-language field (legacy). UI menus are English-only for now. use serde::{Deserialize, Serialize}; +/// Kept for forward-compatible deserialize of older group-pref snapshots. +/// Command paths no longer read this for UI. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum MenuLanguage { @@ -25,7 +27,7 @@ mod tests { use super::*; #[test] - fn from_code_parses_supported_languages() { + fn from_code_parses_legacy_codes() { assert_eq!(MenuLanguage::from_code("en"), Some(MenuLanguage::En)); assert_eq!(MenuLanguage::from_code("es"), Some(MenuLanguage::Es)); assert_eq!(MenuLanguage::from_code("fr"), None); diff --git a/crates/signal-client/src/client.rs b/crates/signal-client/src/client.rs index 5aceba4..a1a192d 100644 --- a/crates/signal-client/src/client.rs +++ b/crates/signal-client/src/client.rs @@ -148,6 +148,36 @@ impl SignalClient { .await } + /// Update a group's display name (`PUT /v1/groups/{number}/{groupid}`). + #[instrument(skip(self))] + pub async fn update_group( + &self, + phone_number: &str, + group_send_id: &str, + name: &str, + ) -> Result<(), SignalError> { + let encoded_number = encode(phone_number); + let encoded_group = encode(group_send_id); + let body = UpdateGroupRequest { + name: Some(name.to_string()), + }; + let response = self + .client + .put(format!( + "{}/v1/groups/{}/{}", + self.base_url, encoded_number, encoded_group + )) + .json(&body) + .send() + .await?; + + if !response.status().is_success() { + let msg = response.text().await.unwrap_or_default(); + return Err(SignalError::Api(msg)); + } + Ok(()) + } + async fn change_members( &self, phone_number: &str, diff --git a/crates/signal-client/src/lib.rs b/crates/signal-client/src/lib.rs index 719504f..8eea8be 100644 --- a/crates/signal-client/src/lib.rs +++ b/crates/signal-client/src/lib.rs @@ -522,6 +522,30 @@ mod tests { .unwrap(); } + #[tokio::test] + async fn test_update_group() { + let mock_server = MockServer::start().await; + + Mock::given(method("PUT")) + .and(path("/v1/groups/%2B15555555555/group.sidecarEs%3D%3D")) + .and(body_json(serde_json::json!({ + "name": "SigLang Spanish · Stacked" + }))) + .respond_with(ResponseTemplate::new(204)) + .mount(&mock_server) + .await; + + let client = create_test_client(&mock_server).await; + client + .update_group( + "+15555555555", + "group.sidecarEs==", + "SigLang Spanish · Stacked", + ) + .await + .unwrap(); + } + #[tokio::test] async fn test_create_group_error() { let mock_server = MockServer::start().await; diff --git a/crates/signal-client/src/types.rs b/crates/signal-client/src/types.rs index 0dbc13c..9cc331e 100644 --- a/crates/signal-client/src/types.rs +++ b/crates/signal-client/src/types.rs @@ -110,6 +110,13 @@ pub struct ChangeGroupMembersRequest { pub members: Vec, } +/// Request body for `PUT /v1/groups/{number}/{groupid}`. +#[derive(Debug, Clone, Serialize)] +pub struct UpdateGroupRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Attachment { #[serde(rename = "contentType")] diff --git a/docker/Dockerfile.proxy b/docker/Dockerfile.proxy index d7c827c..967c11f 100644 --- a/docker/Dockerfile.proxy +++ b/docker/Dockerfile.proxy @@ -5,36 +5,44 @@ RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static pkgconfig WORKDIR /app -# Cache dependencies (see docker/Dockerfile — no rust-toolchain.toml in image builds) +# Retry slow registry downloads inside Docker +ENV CARGO_NET_RETRY=10 \ + CARGO_HTTP_TIMEOUT=300 \ + CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse + +# Cache dependencies (omit rust-toolchain.toml — same pattern as docker/Dockerfile) COPY Cargo.toml Cargo.lock ./ COPY crates/signal-bot/Cargo.toml crates/signal-bot/ +COPY crates/signal-bot-core/Cargo.toml crates/signal-bot-core/ +COPY crates/signal-bot-transcription/Cargo.toml crates/signal-bot-transcription/ COPY crates/near-ai-client/Cargo.toml crates/near-ai-client/ -COPY crates/conversation-store/Cargo.toml crates/conversation-store/ COPY crates/dstack-client/Cargo.toml crates/dstack-client/ COPY crates/signal-client/Cargo.toml crates/signal-client/ COPY crates/signal-registration-proxy/Cargo.toml crates/signal-registration-proxy/ -COPY crates/tools/Cargo.toml crates/tools/ COPY crates/whisper-client/Cargo.toml crates/whisper-client/ -COPY crates/x402-payments/Cargo.toml crates/x402-payments/ - -ENV CARGO_NET_RETRY=10 \ - CARGO_HTTP_TIMEOUT=300 \ - CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse -# Create dummy source files for dependency caching -RUN mkdir -p crates/signal-registration-proxy/src && \ - echo "fn main() {}" > crates/signal-registration-proxy/src/main.rs && \ +# Minimal stubs so `cargo fetch` can resolve path dependencies +RUN mkdir -p crates/signal-bot/src/commands && \ + echo "pub mod commands; pub mod config; pub mod error; pub mod group_preferences_store; pub mod transcribe_prefs;" > crates/signal-bot/src/lib.rs && \ + echo "fn main() {}" > crates/signal-bot/src/main.rs && \ + echo "" > crates/signal-bot/src/commands/mod.rs && \ + echo "" > crates/signal-bot/src/config.rs && \ + echo "" > crates/signal-bot/src/error.rs && \ + echo "" > crates/signal-bot/src/group_preferences_store.rs && \ + echo "" > crates/signal-bot/src/transcribe_prefs.rs && \ + mkdir -p crates/signal-bot-core/src && echo "pub fn dummy() {}" > crates/signal-bot-core/src/lib.rs && \ + mkdir -p crates/signal-bot-transcription/src && echo "pub fn dummy() {}" > crates/signal-bot-transcription/src/lib.rs && \ mkdir -p crates/near-ai-client/src && echo "pub fn dummy() {}" > crates/near-ai-client/src/lib.rs && \ - mkdir -p crates/conversation-store/src && echo "pub fn dummy() {}" > crates/conversation-store/src/lib.rs && \ mkdir -p crates/dstack-client/src && echo "pub fn dummy() {}" > crates/dstack-client/src/lib.rs && \ mkdir -p crates/signal-client/src && echo "pub fn dummy() {}" > crates/signal-client/src/lib.rs && \ mkdir -p crates/whisper-client/src && echo "pub fn dummy() {}" > crates/whisper-client/src/lib.rs && \ - mkdir -p crates/x402-payments/src && echo "pub fn dummy() {}" > crates/x402-payments/src/lib.rs && \ - mkdir -p crates/tools/src && echo "pub fn dummy() {}" > crates/tools/src/lib.rs + mkdir -p crates/signal-registration-proxy/src && \ + echo "fn main() {}" > crates/signal-registration-proxy/src/main.rs +# Download all registry crates (cached layer; survives source COPY) RUN cargo fetch --locked -# Build dependencies only (ignore errors from dummy files) +# Warm compile cache (ignore stub compile errors) RUN cargo build --release -p signal-registration-proxy 2>/dev/null || true # Copy actual source diff --git a/docker/compose.translation.yaml b/docker/compose.translation.yaml index aa3dcd4..35307f8 100644 --- a/docker/compose.translation.yaml +++ b/docker/compose.translation.yaml @@ -36,7 +36,7 @@ services: - SIGNAL__PEER_PHONE=${PEER_PHONE:-} - NEAR_AI__API_KEY=${NEAR_AI_API_KEY} - NEAR_AI__BASE_URL=${NEAR_AI_BASE_URL:-https://cloud-api.near.ai/v1} - - NEAR_AI__MODEL=${NEAR_AI_MODEL:-deepseek-ai/DeepSeek-V3.1} + - NEAR_AI__MODEL=${NEAR_AI_MODEL:-deepseek-ai/DeepSeek-V4-Flash} - NEAR_AI__TIMEOUT=${NEAR_AI_TIMEOUT:-120s} - WHISPER__ENABLED=false - TRANSLATE_ALL__ENABLED=${TRANSLATE_ALL_ENABLED:-true} diff --git a/docker/phala.translation.env.example b/docker/phala.translation.env.example index b35182a..711567e 100644 --- a/docker/phala.translation.env.example +++ b/docker/phala.translation.env.example @@ -11,7 +11,7 @@ SIGNAL_REGISTRATION_PROXY_IMAGE=YOUR_DOCKERHUB/signal-registration-proxy:latest NEAR_AI_API_KEY=sk-your-api-key-here NEAR_AI_BASE_URL=https://cloud-api.near.ai/v1 -NEAR_AI_MODEL=deepseek-ai/DeepSeek-V3.1 +NEAR_AI_MODEL=deepseek-ai/DeepSeek-V4-Flash NEAR_AI_TIMEOUT=120s TRANSLATE_ALL_ENABLED=true diff --git a/docker/phala.translation.yaml b/docker/phala.translation.yaml index a0ac229..1e39b26 100644 --- a/docker/phala.translation.yaml +++ b/docker/phala.translation.yaml @@ -33,7 +33,7 @@ services: - SIGNAL__PEER_PHONE=${PEER_PHONE:-} - NEAR_AI__API_KEY=${NEAR_AI_API_KEY} - NEAR_AI__BASE_URL=${NEAR_AI_BASE_URL:-https://cloud-api.near.ai/v1} - - NEAR_AI__MODEL=${NEAR_AI_MODEL:-deepseek-ai/DeepSeek-V3.1} + - NEAR_AI__MODEL=${NEAR_AI_MODEL:-deepseek-ai/DeepSeek-V4-Flash} - NEAR_AI__TIMEOUT=${NEAR_AI_TIMEOUT:-120s} - WHISPER__ENABLED=false - TRANSLATE_ALL__ENABLED=${TRANSLATE_ALL_ENABLED:-true} diff --git a/docker/translation.env.example b/docker/translation.env.example index c1265b5..55400af 100644 --- a/docker/translation.env.example +++ b/docker/translation.env.example @@ -6,7 +6,7 @@ SIGNAL_PHONE=+1YYYYYYYYYY PEER_PHONE=+1XXXXXXXXXX NEAR_AI_API_KEY=sk-your-api-key-here NEAR_AI_BASE_URL=https://cloud-api.near.ai/v1 -NEAR_AI_MODEL=deepseek-ai/DeepSeek-V3.1 +NEAR_AI_MODEL=deepseek-ai/DeepSeek-V4-Flash NEAR_AI_TIMEOUT=120s TRANSLATE_ALL_ENABLED=true TRANSLATE_ALL_MAX_MESSAGES_PER_MINUTE=30 diff --git a/docs/language-threads.md b/docs/language-threads.md index 5d0339d..688b534 100644 --- a/docs/language-threads.md +++ b/docs/language-threads.md @@ -13,17 +13,19 @@ In multilingual mutual-aid groups, organizers often dual-post by hand. Monolingu | Room | Role | |------|------| | **Main group** | Multilingual hub; bot already a member | -| **Language Thread {Language}** | One Signal sidecar per subscribed language (e.g. `Language Thread Spanish`) | +| **SigLang {Language} · {disambiguator}** | One Signal sidecar per subscribed language (e.g. `SigLang Spanish · Stacked`) | Users who want a monolingual lane run `!translate-me-on ` in **main**. The bot creates or joins the sidecar and invites them. Messages fan out across main and all active threads. ```text Main (multilingual hub) - ├── Language Thread Spanish ← monolingual ES users - ├── Language Thread English ← monolingual EN users + ├── SigLang Spanish · Stacked ← monolingual ES users + ├── SigLang English · Stacked ← monolingual EN users └── … (any !list-langs code) ``` +Default title is English `SigLang {Language} · {disambiguator}` (main group name when available, else a short hash of the main group id). Members can rename a sidecar with `!rename` from that thread’s `!help`. + N=1 (one sidecar) uses the same relay rules as N=3 — add another language later with no reconfiguration. ## Commands @@ -32,10 +34,12 @@ N=1 (one sidecar) uses the same relay rules as N=3 — add another language late |---------|--------|--------| | `!translate-me-on ` | Main only | Create/join sidecar; invite user | | `!translate-me-off` | Main or sidecar | Leave sidecar | +| `!rename ` | Sidecar only | Change this Language Thread’s group name | | `!list-langs` | Any | Language codes | -| `!help` / `!privacy` | Any | Hub / privacy menus | -| `!set-en` / `!set-es` | Group | Menu language | -| `!verify` / `!models` | As before | TEE / session | +| `!help` / `!privacy` | Any | Hub / privacy menus (`!help` in a sidecar shows the thread menu) | +| `!verify` | As before | TEE attestation | + +Menus are English-only for now (multi-language UI deferred). Aliases: `!translate-me on es`, `!translation-me-on es`, etc. @@ -66,10 +70,11 @@ Rate limit: one `allow_message(main_id)` per inbound human event (covers fan-out 1. User in main: `!translate-me-on es` 2. Resolve language; need invite address (`sourceNumber` preferred, else usable `source`) -3. **First subscriber for that lang:** `POST /v1/groups/{bot}` → name `Language Thread Spanish` → persist send id + internal id → welcome in sidecar → confirm in main +3. **First subscriber for that lang:** build English `SigLang …` title/description/welcome → `POST /v1/groups/{bot}` → persist send id + internal id → welcome in sidecar → confirm in main 4. **Later subscribers:** `add_members` on existing sidecar 5. Language switch: remove from old sidecar, add/create new 6. `!translate-me-off`: remove from Signal group + store +7. Sidecar `!help` → thread menu; `!rename ` → `PUT /v1/groups/{bot}/{sendId}` If Signal omits phone number, bot asks the user to DM once, then retry. diff --git a/docs/local-dev/README.md b/docs/local-dev/README.md new file mode 100644 index 0000000..f7d8226 --- /dev/null +++ b/docs/local-dev/README.md @@ -0,0 +1,321 @@ +# Local development + +Thorough guide for running the dual Compose stacks on your machine, registering Signal phone numbers (including captcha), and tailing logs. + +Quick start (env copy + `up -d`) stays in [README.md](../README.md#local-dual-stack). Architecture context: [two-cvm-architecture.md](../two-cvm-architecture.md). TEE / role details: [`.agents/docs/DEVELOPMENT.md`](../../.agents/docs/DEVELOPMENT.md). + +Each stack has its own `signal-api`, network, and Signal CLI data volume. Follow **Transcription stack** and/or **Translation stack** end-to-end; use [Using both together](#using-both-together) when you need pairing in one Signal group. + +## Prerequisites + +- Docker with Compose v2 +- Two different Signal-capable phone numbers (E.164), one per stack +- `NEAR_AI_API_KEY` in `docker/translation.env` for the translation bot +- 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 + +Signal almost always requires a captcha before SMS/voice verification. Both stacks use the same flow: + +1. Open [Signal registration captcha](https://signalcaptchas.org/registration/generate.html) in a browser. +2. Solve the challenge. +3. The page redirects to a `signalcaptcha://…` URL. Copy the **entire** token string (starts with `signalcaptcha://`). + - If the browser does not show the token clearly, open DevTools → Network/Console, or right-click the failed-navigation link and copy the URL. +4. Use that string as the `captcha` field in the register request. Do not strip the `signalcaptcha://` prefix. + +Tokens expire quickly — generate a fresh one if registration fails with a captcha error. + +--- + +## Transcription stack + +Compose file: `docker/compose.transcription.yaml` +Env file: `docker/transcription.env` +Role: voice transcription (`BOT__ROLE=transcription`). Includes Whisper. + +### Env + start + +```bash +cp docker/transcription.env.example docker/transcription.env +# Edit: SIGNAL_PHONE = phone A (transcription bot) +# Optional: PEER_PHONE = phone B when pairing with translation + +docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env up -d +``` + +Confirm network: + +```bash +docker network ls | grep sigstack-transcription +# Expect: sigstack-transcription-internal +``` + +### Health + +`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.transcription.yaml --env-file docker/transcription.env \ + exec whisper-api curl -sf http://localhost:9000/health + +docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ + exec signal-api curl -sf http://localhost:8080/v1/health +``` + +### Register phone A + +`signal-api` is not published on the host; call it from inside the container. The number must match `SIGNAL_PHONE` in `docker/transcription.env`. + +Replace `+1XXXXXXXXXX` with phone A. + +Check whether the number is already registered (skip captcha/register if it appears in the list): + +```bash +docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ + exec signal-api curl -sS 'http://localhost:8080/v1/accounts' +# Expect JSON array, e.g. ["+1XXXXXXXXXX"]. Empty [] means not registered yet. +``` + +If not listed, generate a [captcha token](#captcha-token) and register: + +```bash +# Start registration (SMS by default) +docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ + exec signal-api curl -sS -X POST \ + -H 'Content-Type: application/json' \ + -d '{"captcha":"signalcaptcha://PASTE_TOKEN_HERE","use_voice":false}' \ + 'http://localhost:8080/v1/register/+1XXXXXXXXXX' +``` + +Use `"use_voice":true` if you prefer a voice call for the code. + +When the SMS/voice code arrives: + +```bash +docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ + exec signal-api curl -sS -X POST \ + -H 'Content-Type: application/json' \ + -d '{}' \ + 'http://localhost:8080/v1/register/+1XXXXXXXXXX/verify/123456' +``` + +Confirm the account is present (same accounts call as above): + +```bash +docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ + exec signal-api curl -sS 'http://localhost:8080/v1/accounts' +``` + +Restart the bot so it picks up the registered session: + +```bash +docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ + restart signal-bot +``` + +### Logs + +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.transcription.yaml --env-file docker/transcription.env \ + logs -f signal-bot +``` + +Useful variants: + +```bash +# All services on this stack +docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env logs -f + +# Last 100 lines, then follow +docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ + logs -f --tail=100 signal-bot + +# signal-api (registration / receive issues) +docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ + logs -f signal-api +``` + +Raise verbosity: set `LOG_LEVEL=debug` in `docker/transcription.env`, then recreate the bot: + +```bash +docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ + up -d signal-bot +``` + +### Stop / rebuild + +```bash +docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env down +``` + +Rebuild after code changes: + +```bash +docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ + build signal-bot +docker compose -f docker/compose.transcription.yaml --env-file docker/transcription.env \ + up -d signal-bot +``` + +Do **not** use `down -v` unless you intend to wipe Signal CLI state (you will need to re-register the phone). + +--- + +## Translation stack + +Compose file: `docker/compose.translation.yaml` +Env file: `docker/translation.env` +Role: in-chat translation + Language Threads (`BOT__ROLE=translation`). No Whisper; needs `NEAR_AI_API_KEY`. + +### Env + start + +```bash +cp docker/translation.env.example docker/translation.env +# Edit: SIGNAL_PHONE = phone B (translation bot) +# NEAR_AI_API_KEY = required +# Optional: PEER_PHONE = phone A when pairing with transcription + +docker compose -f docker/compose.translation.yaml --env-file docker/translation.env up -d +``` + +Confirm network: + +```bash +docker network ls | grep sigstack-translation +# Expect: sigstack-translation-internal +``` + +### Health + +`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.translation.yaml --env-file docker/translation.env \ + exec signal-api curl -sf http://localhost:8080/v1/health +``` + +Registration proxy (host port **8081**): + +```bash +curl -sf http://localhost:8081/health +``` + +### Register phone B + +The number must match `SIGNAL_PHONE` in `docker/translation.env`. + +Check whether the number is already registered with Signal CLI (skip captcha/register if it appears): + +```bash +docker compose -f docker/compose.translation.yaml --env-file docker/translation.env \ + exec signal-api curl -sS 'http://localhost:8080/v1/accounts' +# Expect JSON array, e.g. ["+1YYYYYYYYYY"]. Empty [] means not registered yet. +``` + +Or via the registration proxy: + +```bash +curl -sS http://localhost:8081/v1/debug/signal-accounts +``` + +If not listed, generate a [captcha token](#captcha-token). Preferred register path: proxy on **localhost:8081**. + +```bash +curl -sS -X POST "http://localhost:8081/v1/register/+1YYYYYYYYYY" \ + -H 'Content-Type: application/json' \ + -d '{"captcha":"signalcaptcha://PASTE_TOKEN_HERE","use_voice":false}' +``` + +Verify: + +```bash +curl -sS -X POST "http://localhost:8081/v1/register/+1YYYYYYYYYY/verify/123456" \ + -H 'Content-Type: application/json' \ + -d '{}' +``` + +Alternatively, register against translation `signal-api` via compose `exec` (same pattern as phone A on the transcription stack). + +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.translation.yaml --env-file docker/translation.env \ + restart signal-bot +``` + +### Logs + +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.translation.yaml --env-file docker/translation.env \ + logs -f signal-bot +``` + +Useful variants: + +```bash +# All services on this stack +docker compose -f docker/compose.translation.yaml --env-file docker/translation.env logs -f + +# Last 100 lines, then follow +docker compose -f docker/compose.translation.yaml --env-file docker/translation.env \ + logs -f --tail=100 signal-bot + +# signal-api (registration / receive issues) +docker compose -f docker/compose.translation.yaml --env-file docker/translation.env \ + logs -f signal-api +``` + +Raise verbosity: set `LOG_LEVEL=debug` in `docker/translation.env`, then recreate the bot: + +```bash +docker compose -f docker/compose.translation.yaml --env-file docker/translation.env \ + up -d signal-bot +``` + +### Stop / rebuild + +```bash +docker compose -f docker/compose.translation.yaml --env-file docker/translation.env down +``` + +Rebuild after code changes: + +```bash +docker compose -f docker/compose.translation.yaml --env-file docker/translation.env \ + build signal-bot +docker compose -f docker/compose.translation.yaml --env-file docker/translation.env \ + up -d signal-bot +``` + +Do **not** use `down -v` unless you intend to wipe Signal CLI state (you will need to re-register the phone). + +--- + +## Using both together + +Confirm both networks exist: + +```bash +docker network ls | grep sigstack +# Expect: sigstack-transcription-internal and sigstack-translation-internal +``` + +There is **no** Docker network between the two CVMs/stacks — Signal is the bus. + +After both numbers are registered: + +1. Create (or open) a Signal group that includes both bot numbers and your personal account. +2. For transcription pairing, set `PEER_PHONE` on translation to phone A and follow [voice-transcription.md](../voice-transcription.md#pairing-translation-leads) (`!transcription` as group admin). + +## Related docs + +| Doc | Why | +|-----|-----| +| [voice-transcription.md](../voice-transcription.md) | Transcription ops + pairing | +| [in-chat-translation.md](../in-chat-translation.md) | In-chat translate product | +| [language-threads.md](../language-threads.md) | Language Threads | +| [two-cvm-architecture.md](../two-cvm-architecture.md) | Why two stacks / no shared Docker network | diff --git a/docs/solutions/signal-mobile-menus.md b/docs/solutions/signal-mobile-menus.md new file mode 100644 index 0000000..7ac9fc8 --- /dev/null +++ b/docs/solutions/signal-mobile-menus.md @@ -0,0 +1,41 @@ +# Signal mobile menus + +## Problem + +Long one-liners like `- !translate-me-on — join/create a Language Thread (from main)` wrap mid-phrase on mobile Signal and look broken. + +## Standard + +Source of truth for bot command menus: [`crates/signal-bot/src/commands/menu_locale.rs`](../../crates/signal-bot/src/commands/menu_locale.rs). + +Menus are **English-only** for now; multi-language UI is deferred. + +1. **Title** on its own line (product or hub name). +2. **Section header** (optional) + optional one-line blurb. +3. **Each command** on its own line; **description** on the next line, indented with two spaces. No `cmd — desc` on one line. +4. Prefer plain `!command` lines over `- !command` bullets. +5. Keep descriptions short (aim ≤~40 chars when possible). +6. **Footer** commands use the same stacked form (`!help` / `!verify`, etc.). +7. Prose blocks (privacy explanations, invite/status messages) stay paragraphs; only **command lists** (including footers) use the stacked form. + +Canonical shape: + +```text +Title + +Section header +Optional one-line blurb. + +!command + Short description +!other-command + Short description +!help + Show this menu +``` + +Language Thread sidecars use the same trigger `!help` but a different menu (rename / leave) when the group is a known sidecar. + +## When adding menus + +Apply this layout in `menu_locale.rs` for any new `!help` / product / privacy command list. Do not reintroduce `!cmd — long description` one-liners. diff --git a/scripts/ci.sh b/scripts/ci.sh index 3ecaecd..dd12b5d 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -14,6 +14,10 @@ # npm run ci # pnpm run ci # note: NOT `pnpm ci` (that is install-only) # +# Remote re-run (no new commit; Actions tab, not PR checks): +# gh workflow run test.yml --ref +# gh workflow run commitlint.yml --ref +# set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"