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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/signal-bot/src/commands/command_aliases.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::commands::menu_locale::{
use crate::commands::translate_all::{
ALL_OFF_COMMANDS, ALL_ON_PREFIXES, ENABLE_THREADS, ME_OFF_COMMANDS, ME_ON_PREFIXES,
};
use crate::commands::translate_langs::LIST_LANGS_COMMANDS;
use crate::commands::translate_langs::{LIST_LANGS_COMMANDS, LIST_LANGS_IN_CHAT_COMMANDS};
use crate::commands::translate_me::{ENABLE_IN_CHAT_CMDS, LEAVE_CMDS, THREAD_ON_PREFIXES};
use signal_bot_core::normalize_exact;
use signal_bot_voice::{TRANSCRIBE_COMMANDS, TRANSCRIBE_OFF_COMMANDS, TRANSCRIBE_ON_COMMANDS};
Expand Down Expand Up @@ -55,6 +55,7 @@ fn all_families() -> &'static [(&'static str, &'static [&'static str])] {
("enable_in_chat", ENABLE_IN_CHAT_CMDS),
("leave", LEAVE_CMDS),
("list_langs", LIST_LANGS_COMMANDS),
("list_langs_in_chat", LIST_LANGS_IN_CHAT_COMMANDS),
("transcribe_on", TRANSCRIBE_ON_COMMANDS),
("transcribe_off", TRANSCRIBE_OFF_COMMANDS),
("transcribe", TRANSCRIBE_COMMANDS),
Expand Down
5 changes: 3 additions & 2 deletions crates/signal-bot/src/commands/menu_locale.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ Bilingual Threads: exactly two languages, one sidecar; each room is assigned a l

const TRANSLATION_IN_CHAT_MENU: &str = r#"In-chat Translation

!list-langs
!list-langs-in-chat
!translate-all-on <lang1> <lang2>
!translate-all-off
!translate-me-on <lang1> <lang2>
Expand All @@ -257,6 +257,7 @@ examples:
!translate-me-on ru ar
!translate es

Quote !translate accepts any Language Threads language (!list-langs).
!enable-threads (disable in-chat)
!help"#;

Expand Down Expand Up @@ -308,7 +309,7 @@ How it works:
- One-off: reply to a message with !translate <lang>

Typical use:
1. Pick two languages (!list-langs)
1. Pick two in-chat auto languages (!list-langs-in-chat)
2. !translate-all-on es en (or !translate-me-on for just you)
3. Chat normally; the bot quote-replies translations
4. !translate-all-off / !translate-me-off to stop
Expand Down
2 changes: 1 addition & 1 deletion crates/signal-bot/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ pub use rename::RenameHandler;
pub use signal_bot_core::CommandHandler;
pub use translate::TranslateHandler;
pub use translate_all::TranslateAllHandler;
pub use translate_langs::TranslateLangsHandler;
pub use translate_langs::{TranslateLangsHandler, TranslateLangsInChatHandler};
pub use translate_me::TranslateMeHandler;
pub use translate_service::DEFAULT_TRANSCRIPT_PREFIX;
pub use verify::VerifyHandler;
3 changes: 2 additions & 1 deletion crates/signal-bot/src/commands/translate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use crate::commands::menu_locale::{
};
use crate::commands::translate_all::is_translate_on_or_off_command;
use crate::commands::translate_lang::{resolve_language, Language};
use crate::commands::translate_langs::is_list_langs_command;
use crate::commands::translate_langs::{is_list_langs_command, is_list_langs_in_chat_command};
use crate::commands::translate_me::{TranslateMeHandler, ENABLE_IN_CHAT_CMDS};
use crate::commands::translate_service::{strip_transcript_prefix, truncate_snippet};
use crate::commands::CommandHandler;
Expand Down Expand Up @@ -127,6 +127,7 @@ pub(crate) fn is_non_quote_translate_command(text: &str) -> bool {
|| TranslateMeHandler::is_on_command(text)
|| is_exact_command_any(text, ENABLE_IN_CHAT_CMDS)
|| is_list_langs_command(text)
|| is_list_langs_in_chat_command(text)
|| is_transcription_menu_command(text)
}

Expand Down
38 changes: 33 additions & 5 deletions crates/signal-bot/src/commands/translate_all.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
//! In-chat auto-translate: `!translate-all-on/off`, `!translate-me-on/off`, `!enable-threads`.

use crate::bot_identity::BotIdentity;
use crate::commands::translate_lang::resolve_language;
use crate::commands::translate_lang::{
in_chat_auto_reject_message, resolve_in_chat_auto_language, resolve_language,
};
use crate::commands::translate_me::TranslateMeHandler;
use crate::commands::translate_service::{
format_text_auto_translation, near_ai_translate, strip_transcript_prefix,
Expand Down Expand Up @@ -227,11 +229,21 @@ impl TranslateAllHandler {
token_b: &str,
example: &str,
) -> Result<GroupTranslateMode, String> {
let lang_a = resolve_language(token_a).ok_or_else(|| {
format!("Unknown language: {token_a}. Use !list-langs for supported codes.")
let lang_a = resolve_in_chat_auto_language(token_a).ok_or_else(|| {
if let Some(lang) = resolve_language(token_a) {
return in_chat_auto_reject_message(lang);
}
format!(
"Unknown language: {token_a}. Use !list-langs-in-chat for in-chat auto-translate codes."
)
})?;
let lang_b = resolve_language(token_b).ok_or_else(|| {
format!("Unknown language: {token_b}. Use !list-langs for supported codes.")
let lang_b = resolve_in_chat_auto_language(token_b).ok_or_else(|| {
if let Some(lang) = resolve_language(token_b) {
return in_chat_auto_reject_message(lang);
}
format!(
"Unknown language: {token_b}. Use !list-langs-in-chat for in-chat auto-translate codes."
)
})?;
if lang_a.code == lang_b.code {
return Err(format!(
Expand Down Expand Up @@ -586,6 +598,22 @@ mod tests {
)
}

#[test]
fn resolve_pair_rejects_basque_for_in_chat_auto() {
let err = TranslateAllHandler::resolve_pair_tokens("eu", "en", "!translate-all-on es en")
.unwrap_err();
assert!(err.contains("Basque"));
assert!(err.contains("!list-langs-in-chat"));
}

#[test]
fn resolve_pair_accepts_spanish_english() {
let mode = TranslateAllHandler::resolve_pair_tokens("es", "en", "!translate-all-on es en")
.unwrap();
assert_eq!(mode.lang_a, "es");
assert_eq!(mode.lang_b, "en");
}

#[test]
fn parse_lang_pair_from_command() {
assert_eq!(
Expand Down
64 changes: 63 additions & 1 deletion crates/signal-bot/src/commands/translate_lang.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ pub struct Language {
pub flag: &'static str,
}

/// Full supported language catalog (for `!list-langs`).
/// Full supported language catalog (Language Threads, Bilingual Threads, manual `!translate`).
pub const ALL_LANGUAGES: &[Language] = &[
Language {
code: "en",
Expand Down Expand Up @@ -159,8 +159,38 @@ pub const ALL_LANGUAGES: &[Language] = &[
name: "Persian",
flag: "🇮🇷",
},
Language {
code: "eu",
name: "Basque",
flag: "🇪🇸",
},
Language {
code: "sw",
name: "Swahili",
flag: "🇰🇪",
},
];

/// ISO codes excluded from in-chat auto-translate (`whatlang` does not detect them).
const IN_CHAT_AUTO_EXCLUDED: &[&str] = &["eu", "sw"];

/// Whether `code` is in the in-chat auto-translate catalog (`!translate-all-on` / `!translate-me-on`).
pub fn is_in_chat_auto_language(code: &str) -> bool {
ALL_LANGUAGES.iter().any(|lang| lang.code == code) && !IN_CHAT_AUTO_EXCLUDED.contains(&code)
}

/// Languages supported for in-chat auto-translate (subset of [`ALL_LANGUAGES`]).
pub fn in_chat_auto_languages() -> impl Iterator<Item = &'static Language> {
ALL_LANGUAGES
.iter()
.filter(|lang| is_in_chat_auto_language(lang.code))
}

/// Resolve a language token for in-chat auto-translate commands.
pub fn resolve_in_chat_auto_language(input: &str) -> Option<&'static Language> {
resolve_language(input).filter(|lang| is_in_chat_auto_language(lang.code))
}

/// Resolve a user-provided language token (ISO code or common name).
pub fn resolve_language(input: &str) -> Option<&'static Language> {
let normalized = input.trim().to_lowercase();
Expand Down Expand Up @@ -192,10 +222,22 @@ pub fn resolve_language(input: &str) -> Option<&'static Language> {
"thai" => Some(&ALL_LANGUAGES[17]),
"indonesian" => Some(&ALL_LANGUAGES[18]),
"ukrainian" => Some(&ALL_LANGUAGES[19]),
"basque" | "euskara" => ALL_LANGUAGES.iter().find(|l| l.code == "eu"),
"swahili" | "kiswahili" => ALL_LANGUAGES.iter().find(|l| l.code == "sw"),
_ => None,
})
}

/// User-facing error when a language is valid for threads but not in-chat auto.
pub fn in_chat_auto_reject_message(lang: &Language) -> String {
format!(
"{} ({}) is not supported for in-chat auto-translate — the bot must detect the message language locally.\n\
Use Language Threads for {}, or quote-reply with !translate {} for one-off translation.\n\
In-chat auto languages: !list-langs-in-chat",
lang.name, lang.code, lang.code, lang.code
)
}

pub fn format_language_list(languages: &[Language]) -> String {
let mut lines: Vec<String> = languages
.iter()
Expand Down Expand Up @@ -226,4 +268,24 @@ mod tests {
fn unknown_language_returns_none() {
assert!(resolve_language("klingon").is_none());
}

#[test]
fn resolve_basque_and_swahili() {
assert_eq!(resolve_language("eu").unwrap().name, "Basque");
assert_eq!(resolve_language("euskara").unwrap().code, "eu");
assert_eq!(resolve_language("sw").unwrap().name, "Swahili");
assert_eq!(resolve_language("kiswahili").unwrap().code, "sw");
}

#[test]
fn in_chat_auto_excludes_basque_and_swahili() {
assert!(!is_in_chat_auto_language("eu"));
assert!(!is_in_chat_auto_language("sw"));
assert!(is_in_chat_auto_language("es"));
assert_eq!(in_chat_auto_languages().count(), 30);
assert_eq!(ALL_LANGUAGES.len(), 32);
assert!(resolve_in_chat_auto_language("es").is_some());
assert!(resolve_in_chat_auto_language("eu").is_none());
assert!(resolve_in_chat_auto_language("basque").is_none());
}
}
113 changes: 108 additions & 5 deletions crates/signal-bot/src/commands/translate_langs.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
//! `!list-langs` list supported translation languages.
//! `!list-langs` and `!list-langs-in-chat` — supported translation languages.

use crate::commands::translate_lang::{format_language_list, ALL_LANGUAGES};
use crate::commands::translate_lang::{
format_language_list, in_chat_auto_languages, ALL_LANGUAGES,
};
use crate::commands::CommandHandler;
use crate::error::AppResult;
use async_trait::async_trait;
Expand All @@ -14,10 +16,21 @@ pub(crate) const LIST_LANGS_COMMANDS: &[&str] = &[
"!list-language",
];

pub(crate) const LIST_LANGS_IN_CHAT_COMMANDS: &[&str] = &[
"!list-langs-in-chat",
"!list-langs-auto",
"!list-lang-in-chat",
"!list-languages-in-chat",
];

pub(crate) fn is_list_langs_command(text: &str) -> bool {
starts_with_word_any(text, LIST_LANGS_COMMANDS)
}

pub(crate) fn is_list_langs_in_chat_command(text: &str) -> bool {
starts_with_word_any(text, LIST_LANGS_IN_CHAT_COMMANDS)
}

pub struct TranslateLangsHandler;

impl TranslateLangsHandler {
Expand Down Expand Up @@ -48,12 +61,51 @@ impl CommandHandler for TranslateLangsHandler {

async fn execute(&self, _message: &BotMessage) -> AppResult<String> {
Ok(format!(
"**Supported languages** (use code with !translate-me-thread or !translate-me-on):\n\n{}",
"**Language Threads & manual translate** (use code with !translate-me-thread or quote !translate):\n\n{}\n\n\
In-chat auto-translate uses a smaller list: !list-langs-in-chat",
format_language_list(ALL_LANGUAGES)
))
}
}

pub struct TranslateLangsInChatHandler;

impl TranslateLangsInChatHandler {
pub fn new() -> Self {
Self
}
}

impl Default for TranslateLangsInChatHandler {
fn default() -> Self {
Self::new()
}
}

#[async_trait]
impl CommandHandler for TranslateLangsInChatHandler {
fn trigger(&self) -> Option<&str> {
Some("!list-langs-in-chat")
}

fn matches(&self, message: &BotMessage) -> bool {
is_list_langs_in_chat_command(&message.text)
}

fn label(&self) -> &'static str {
"translate_langs_in_chat"
}

async fn execute(&self, _message: &BotMessage) -> AppResult<String> {
let langs: Vec<_> = in_chat_auto_languages().copied().collect();
Ok(format!(
"**In-chat auto-translate** (use code with !translate-all-on or !translate-me-on):\n\n{}\n\n\
Language Threads and quote !translate support more languages: !list-langs",
format_language_list(&langs)
))
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -88,6 +140,30 @@ mod tests {
assert!(!h.matches(&msg));
}

#[test]
fn list_langs_in_chat_matches_aliases() {
let h = TranslateLangsInChatHandler::new();
let mut msg = BotMessage {
source: "+1".into(),
source_number: None,
source_name: None,
text: "!list-langs-in-chat".into(),
timestamp: 0,
message_timestamp: 0,
is_group: true,
group_id: Some("g".into()),
group_name: None,
receiving_account: "+2".into(),
attachments: vec![],
quote: None,
};
assert!(h.matches(&msg));
msg.text = "!list-langs-auto".into();
assert!(h.matches(&msg));
msg.text = "!list-lang-in-chat".into();
assert!(h.matches(&msg));
}

#[tokio::test]
async fn execute_lists_supported_languages() {
let h = TranslateLangsHandler::new();
Expand All @@ -106,9 +182,36 @@ mod tests {
quote: None,
};
let out = h.execute(&msg).await.unwrap();
assert!(out.contains("**Supported languages**"));
assert!(out.contains("Language Threads"));
assert!(out.contains("!translate-me-thread"));
assert!(out.contains("!translate-me-on") || out.contains("translate-me-thread"));
assert!(out.contains("!list-langs-in-chat"));
assert!(out.contains("es"));
assert!(out.contains("eu"));
}

#[tokio::test]
async fn execute_in_chat_list_excludes_basque() {
let h = TranslateLangsInChatHandler::new();
let msg = BotMessage {
source: "+1".into(),
source_number: None,
source_name: None,
text: "!list-langs-in-chat".into(),
timestamp: 0,
message_timestamp: 0,
is_group: true,
group_id: Some("g".into()),
group_name: None,
receiving_account: "+2".into(),
attachments: vec![],
quote: None,
};
let out = h.execute(&msg).await.unwrap();
assert!(out.contains("In-chat auto-translate"));
assert!(out.contains("!translate-all-on"));
assert!(out.contains("!list-langs"));
assert!(out.contains("es"));
assert!(!out.contains(" eu "));
assert!(!out.contains("Basque"));
}
}
Loading
Loading