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
4 changes: 2 additions & 2 deletions crates/signal-bot/src/commands/menu_locale.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <lang>

Typical use:
Expand Down
29 changes: 25 additions & 4 deletions crates/signal-bot/src/commands/translate_all.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down
25 changes: 25 additions & 0 deletions crates/signal-bot/src/commands/translate_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)) = (
Expand Down Expand Up @@ -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");
}
}
29 changes: 19 additions & 10 deletions crates/signal-bot/src/group_preferences_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<GroupTranslateMode> {
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<Self>, group_id: String, mode: GroupTranslateMode) {
Expand Down Expand Up @@ -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));
Expand Down
9 changes: 5 additions & 4 deletions docs/in-chat-translation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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 <lang>` | 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 <lang>` | 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.

Expand Down
Loading