diff --git a/crates/signal-bot/src/commands/menu_locale.rs b/crates/signal-bot/src/commands/menu_locale.rs index 614637e..afbee31 100644 --- a/crates/signal-bot/src/commands/menu_locale.rs +++ b/crates/signal-bot/src/commands/menu_locale.rs @@ -234,8 +234,8 @@ Use when everyone stays in one Signal group and wants bilingual (or quote) trans How it works: - No sidecar groups — replies stay in this chat as quote-replies. -- Group-wide: !translate-all-on es en auto-translates messages between that pair. -- Personal: !translate-me-on es en auto-translates only your messages. +- Group-wide: !translate-all-on es en auto-translates messages between that pair (everyone uses this pair while it is on). +- Personal: !translate-me-on es en auto-translates only your messages when group-wide is off. - One-off: reply to a message with !translate Typical use: diff --git a/crates/signal-bot/src/commands/translate_all.rs b/crates/signal-bot/src/commands/translate_all.rs index 2f13eda..c2f6d75 100644 --- a/crates/signal-bot/src/commands/translate_all.rs +++ b/crates/signal-bot/src/commands/translate_all.rs @@ -40,6 +40,13 @@ const SIDECAR_REJECT_MSG: &str = "In-chat auto-translate is only available in the main group (not a Language Thread)."; const THREADS_BLOCK_MSG: &str = "Language Threads is already on in this group, so in-chat auto-translate can't run alongside it.\n\nTo switch, send:\n!enable-in-chat"; +fn group_blocks_personal_msg(mode: &GroupTranslateMode) -> String { + format!( + "Group translate is already on ({}). Use !translate-all-off first if you only want personal translate.", + mode.display_pair() + ) +} + /// Whether the message is any in-chat auto on/off/disable command (excludes quote `!translate`). pub(crate) fn is_translate_on_or_off_command(text: &str) -> bool { let text = text.trim(); @@ -330,6 +337,10 @@ impl TranslateAllHandler { return Ok(msg); } + if let Some(group_mode) = self.store.get(group_id) { + return Ok(group_blocks_personal_msg(&group_mode)); + } + let pair_label = mode.display_pair(); self.set_member_prefs(group_id, message, mode); info!( @@ -940,7 +951,7 @@ mod tests { Mock::given(method("POST")) .and(path("/v2/send")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) - .expect(7) + .expect(8) .mount(&signal) .await; @@ -989,13 +1000,23 @@ mod tests { msg.text = "!translate-me-on fr en".into(); assert!(handler.execute(&msg).await.unwrap().is_empty()); - assert!(store - .get_member_translate("group.main", "+15550002222") - .is_some()); + assert!( + store + .get_member_translate("group.main", "+15550002222") + .is_none(), + "personal pair must not be stored while group-wide is on" + ); msg.text = "!translate-all-off".into(); assert!(handler.execute(&msg).await.unwrap().is_empty()); assert!(!store.is_active("group.main")); + assert!(!store.in_chat_auto_active("group.main")); + + msg.text = "!translate-me-on fr en".into(); + assert!(handler.execute(&msg).await.unwrap().is_empty()); + assert!(store + .get_member_translate("group.main", "+15550002222") + .is_some()); assert!(store.in_chat_auto_active("group.main")); msg.text = "!translate-me-off".into(); diff --git a/crates/signal-bot/src/commands/translate_service.rs b/crates/signal-bot/src/commands/translate_service.rs index b6de39b..216f38a 100644 --- a/crates/signal-bot/src/commands/translate_service.rs +++ b/crates/signal-bot/src/commands/translate_service.rs @@ -247,6 +247,15 @@ pub fn resolve_translate_all_text_pair( mode: &GroupTranslateMode, text: &str, ) -> Option<(&'static Language, &'static Language)> { + // Pair allowlist always picks a winner. If unconstrained detection is + // confident the text is a third language, skip rather than forcing it + // into the pair (e.g. "buenos dias" as Portuguese/Spanish in a fa/en + // allowlist collapsing to English → Persian). + let unconstrained = detect_text_language(text).or_else(|| detect_text_language_voice(text)); + if let Some(open) = unconstrained { + normalize_for_translate_all_pair(mode, &open)?; + } + 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)) = ( @@ -368,4 +377,20 @@ mod tests { assert_eq!(pair.0.code, "es"); assert_eq!(pair.1.code, "it"); } + + #[test] + fn resolve_text_pair_skips_confident_out_of_pair_language() { + let mode = GroupTranslateMode::new( + resolve_language("fa").unwrap(), + resolve_language("en").unwrap(), + ); + assert!( + resolve_translate_all_text_pair(&mode, "buenos dias").is_none(), + "Spanish must not collapse to en→fa in a fa/en pair" + ); + let pair = resolve_translate_all_text_pair(&mode, "hello good morning") + .expect("English in fa/en pair should target Persian"); + assert_eq!(pair.0.code, "en"); + assert_eq!(pair.1.code, "fa"); + } } diff --git a/crates/signal-bot/src/group_preferences_store.rs b/crates/signal-bot/src/group_preferences_store.rs index f94bf71..85760af 100644 --- a/crates/signal-bot/src/group_preferences_store.rs +++ b/crates/signal-bot/src/group_preferences_store.rs @@ -342,14 +342,13 @@ impl GroupPreferencesStore { .and_then(|p| p.translate.clone()) } - /// Resolve intercept pair: personal for `user` wins over group-wide. + /// Resolve intercept pair: group-wide wins while set; otherwise personal for `user`. pub fn resolve_in_chat_mode(&self, group_id: &str, user: &str) -> Option { let groups = self.groups.read().unwrap(); let pref = groups.get(group_id)?; - pref.translate_members - .get(user) - .cloned() - .or_else(|| pref.translate.clone()) + pref.translate + .clone() + .or_else(|| pref.translate_members.get(user).cloned()) } pub fn set(self: &Arc, group_id: String, mode: GroupTranslateMode) { @@ -918,15 +917,25 @@ mod tests { store.resolve_in_chat_mode(gid, "+bob").unwrap().lang_a, "es" ); - // Personal still wins for alice if we set a different pair. - let fr_en = GroupTranslateMode::new( - resolve_language("fr").unwrap(), + // Group-wide wins over a stale personal pair (e.g. fa/en left from !translate-me-on). + let fa_en = GroupTranslateMode::new( + resolve_language("fa").unwrap(), resolve_language("en").unwrap(), ); - store.set_member_translate(gid, "+alice", fr_en); + store.set_member_translate(gid, "+alice", fa_en); + assert_eq!( + store.resolve_in_chat_mode(gid, "+alice").unwrap().lang_a, + "es" + ); + assert_eq!( + store.resolve_in_chat_mode(gid, "+alice").unwrap().lang_b, + "en" + ); + + assert!(store.clear(gid)); assert_eq!( store.resolve_in_chat_mode(gid, "+alice").unwrap().lang_a, - "fr" + "fa" ); assert!(store.disable_in_chat(gid)); diff --git a/docs/in-chat-translation.md b/docs/in-chat-translation.md index de38c3c..1895e68 100644 --- a/docs/in-chat-translation.md +++ b/docs/in-chat-translation.md @@ -33,7 +33,8 @@ Stop group-wide only: ``` - Auto-translates **that user’s** messages only (quote-reply in the same chat) -- Other members’ messages are unchanged unless group-wide is also on +- Only while group-wide is off; refused if `!translate-all-on` is already active +- Other members’ messages are unchanged unless group-wide is on Stop personal: @@ -51,9 +52,9 @@ Clear **all** in-chat auto (group-wide + every personal), and apply a pending La | Mode | How | Effect | |------|-----|--------| -| **Group auto** | `!translate-all-on` active | Every non-command group text: detect → if in pair → NEAR translate → quote-reply `{flag} {translation}` | -| **Personal auto** | `!translate-me-on` for author | Same as group auto, but only for that author’s messages. Personal pair wins over group-wide for that author (one quote-reply max). | -| **Manual** | Reply with `!translate ` | Translate only that quoted message (always allowed) | +| **Group auto** | `!translate-all-on` active | Every non-command group text: detect → if in pair → NEAR translate → quote-reply `{flag} {translation}`. Used for **every** author while group-wide is on. | +| **Personal auto** | `!translate-me-on` for author | Same as group auto, but only for that author’s messages, and **only while group-wide is off**. `!translate-me-on` is refused until `!translate-all-off`. A leftover personal pair applies again after group-wide is turned off. | +| **Manual** | Reply with `!translate ` | Translate only that quoted message (always allowed; ignores stored pairs) | Not dual-post: the original stays as the human message; the bot only quote-replies the translation.