Skip to main content

mail4agent_messenger_shell/
nick.rs

1//! Nick derived from a Grok Bot web session's display name.
2//!
3//! The host passes the name it already shows (`Alice`, `Привет мир`).
4//! This module turns that into the session nick. It is not a slug a person
5//! types, and it does not read `M4A_NICK`. Local grok CLI sessions are not
6//! named here.
7
8use crate::ShellError;
9
10/// Cyrillic (and a few adjacent letters) to Latin, then the host's routine
11/// slug rule, so a bot's nick, its wake routine name, and that routine's
12/// folder id are one string.
13///
14/// Letters are transliterated and lowercased; `ь` and `ъ` vanish. Every run
15/// of anything else (spaces, punctuation, letters with no mapping) becomes
16/// one `-`. Leading and trailing `-` are trimmed. The result is cut to 32
17/// characters (the server's nick limit) and trimmed again, so
18/// [`routine_folder_id`] of the nick is the nick itself.
19/// `Привет мир` -> `privet-mir`, `foo+bar` ->
20/// `foo-bar`, `Alice` -> `alice`.
21pub fn nick_from_display_name(display_name: &str) -> Result<String, ShellError> {
22    let mut out = String::new();
23    let mut pending_dash = false;
24    for ch in display_name.chars() {
25        match latin_piece(ch) {
26            Some("") => {}
27            Some(piece) => {
28                if pending_dash && !out.is_empty() {
29                    out.push('-');
30                }
31                pending_dash = false;
32                out.push_str(piece);
33            }
34            None => pending_dash = true,
35        }
36    }
37    let nick: String = out.chars().take(32).collect();
38    let nick = nick.trim_end_matches('-').to_string();
39    if !is_nick_token(&nick) {
40        return Err(ShellError::Nick);
41    }
42    Ok(nick)
43}
44
45/// Folder id the Grok Bot routine store gives a routine named `name`.
46///
47/// Same rule as the host's routine slug, used both by the bot's own
48/// `UpdateRoutine` and by the gateway's `createAgentAutomation`: lowercase,
49/// every run of characters outside `[a-z0-9]` becomes one `-`, leading and
50/// trailing `-` are trimmed, then the result is cut to 48 characters. So a
51/// routine named `chief_bob` would live in folder `chief-bob`;
52/// wake routines avoid the mismatch by using the hyphen nick as the name.
53/// The host falls back to a timestamped name when the slug is empty; that
54/// is not reproducible, so this returns `None` instead.
55/// A second routine with the same name gets `-2`, `-3`, ...; callers treat
56/// that as a mismatch, not as the bot's routine.
57///
58/// For a nick from [`nick_from_display_name`] this returns the nick
59/// unchanged: nick, routine name, and folder id are one string.
60pub fn routine_folder_id(name: &str) -> Option<String> {
61    let mut slug = String::new();
62    let mut pending_dash = false;
63    for ch in name.to_lowercase().chars() {
64        if ch.is_ascii_lowercase() || ch.is_ascii_digit() {
65            if pending_dash && !slug.is_empty() {
66                slug.push('-');
67            }
68            pending_dash = false;
69            slug.push(ch);
70        } else {
71            pending_dash = true;
72        }
73    }
74    // The host trims before it cuts, so a cut can end on `-`. Keep it.
75    let slug: String = slug.chars().take(48).collect();
76    if slug.is_empty() {
77        None
78    } else {
79        Some(slug)
80    }
81}
82
83/// A directory lookup key. A string that is already a nick is used as-is
84/// (the server compares case-insensitively; older nicks with `_` still
85/// resolve). Anything else is treated as a display name and derived, so
86/// `Привет мир` finds `privet-mir`.
87pub fn lookup_nick(raw: &str) -> Result<String, ShellError> {
88    let trimmed = raw.trim();
89    if is_nick_token(trimmed) {
90        return Ok(trimmed.to_string());
91    }
92    nick_from_display_name(trimmed)
93}
94
95fn is_nick_token(nick: &str) -> bool {
96    !nick.is_empty()
97        && nick.len() <= 32
98        && nick
99            .chars()
100            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
101}
102
103fn latin_piece(ch: char) -> Option<&'static str> {
104    if ch.is_ascii_alphanumeric() {
105        return Some(ascii_lower(ch));
106    }
107    let lower = ch.to_lowercase().next()?;
108    Some(match lower {
109        'а' => "a",
110        'б' => "b",
111        'в' => "v",
112        'г' => "g",
113        'д' => "d",
114        'е' => "e",
115        'ё' => "yo",
116        'ж' => "zh",
117        'з' => "z",
118        'и' => "i",
119        'й' => "i",
120        'к' => "k",
121        'л' => "l",
122        'м' => "m",
123        'н' => "n",
124        'о' => "o",
125        'п' => "p",
126        'р' => "r",
127        'с' => "s",
128        'т' => "t",
129        'у' => "u",
130        'ф' => "f",
131        'х' => "kh",
132        'ц' => "ts",
133        'ч' => "ch",
134        'ш' => "sh",
135        'щ' => "shch",
136        'ъ' | 'ь' => "",
137        'ы' => "y",
138        'э' => "e",
139        'ю' => "yu",
140        'я' => "ya",
141        // Ukrainian / Belarusian letters that are not in the Russian set above.
142        'є' => "ye",
143        'і' => "i",
144        'ї' => "yi",
145        'ґ' => "g",
146        'ў' => "u",
147        _ => return None,
148    })
149}
150
151fn ascii_lower(ch: char) -> &'static str {
152    match ch {
153        'A' | 'a' => "a",
154        'B' | 'b' => "b",
155        'C' | 'c' => "c",
156        'D' | 'd' => "d",
157        'E' | 'e' => "e",
158        'F' | 'f' => "f",
159        'G' | 'g' => "g",
160        'H' | 'h' => "h",
161        'I' | 'i' => "i",
162        'J' | 'j' => "j",
163        'K' | 'k' => "k",
164        'L' | 'l' => "l",
165        'M' | 'm' => "m",
166        'N' | 'n' => "n",
167        'O' | 'o' => "o",
168        'P' | 'p' => "p",
169        'Q' | 'q' => "q",
170        'R' | 'r' => "r",
171        'S' | 's' => "s",
172        'T' | 't' => "t",
173        'U' | 'u' => "u",
174        'V' | 'v' => "v",
175        'W' | 'w' => "w",
176        'X' | 'x' => "x",
177        'Y' | 'y' => "y",
178        'Z' | 'z' => "z",
179        '0' => "0",
180        '1' => "1",
181        '2' => "2",
182        '3' => "3",
183        '4' => "4",
184        '5' => "5",
185        '6' => "6",
186        '7' => "7",
187        '8' => "8",
188        '9' => "9",
189        _ => "",
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn display_names_become_nicks() {
199        assert_eq!(nick_from_display_name("Alice").unwrap(), "alice");
200        assert_eq!(nick_from_display_name("  Alice  ").unwrap(), "alice");
201        assert_eq!(
202            nick_from_display_name("Привет мир").unwrap(),
203            "privet-mir"
204        );
205        assert_eq!(
206            nick_from_display_name("Свой браузер").unwrap(),
207            "svoi-brauzer"
208        );
209        assert_eq!(
210            nick_from_display_name("foo+bar").unwrap(),
211            "foo-bar"
212        );
213        assert_eq!(nick_from_display_name("Sample Bot").unwrap(), "sample-bot");
214        assert_eq!(
215            nick_from_display_name("Объём, данные").unwrap(),
216            "obyom-dannye"
217        );
218        assert_eq!(
219            nick_from_display_name(&("x".repeat(31) + " y")).unwrap(),
220            "x".repeat(31)
221        );
222        assert_ne!(
223            nick_from_display_name("Привет мир").unwrap(),
224            "nachshtab"
225        );
226        assert_eq!(nick_from_display_name("Foo   Bar!!").unwrap(), "foo-bar");
227        assert_eq!(nick_from_display_name("__a__b__").unwrap(), "a-b");
228        assert_eq!(
229            nick_from_display_name(&"A".repeat(40)).unwrap(),
230            "a".repeat(32)
231        );
232        assert!(nick_from_display_name("...").is_err());
233        assert_eq!(lookup_nick("Привет мир").unwrap(), "privet-mir");
234        assert_eq!(lookup_nick("chief_bob").unwrap(), "chief_bob");
235        assert_eq!(lookup_nick("Alice").unwrap(), "Alice");
236    }
237
238    #[test]
239    fn routine_folder_matches_the_host_slug() {
240        // nick -> folder, the way UpdateRoutine and createAgentAutomation name it.
241        assert_eq!(routine_folder_id("alice").as_deref(), Some("alice"));
242        assert_eq!(
243            routine_folder_id("chief_bob").as_deref(),
244            Some("chief-bob")
245        );
246        assert_eq!(
247            routine_folder_id("sample_bot").as_deref(),
248            Some("sample-bot")
249        );
250        assert_eq!(
251            routine_folder_id("foobar").as_deref(),
252            Some("foobar")
253        );
254        assert_eq!(
255            routine_folder_id("foo+bar").as_deref(),
256            Some("foo-bar")
257        );
258        assert_eq!(routine_folder_id("Alice").as_deref(), Some("alice"));
259        assert_eq!(routine_folder_id("__a__b__").as_deref(), Some("a-b"));
260        assert_eq!(routine_folder_id("Привет"), None);
261        assert_eq!(routine_folder_id("___"), None);
262        assert_eq!(
263            routine_folder_id(&"x".repeat(60)).map(|s| s.len()),
264            Some(48)
265        );
266        assert_eq!(
267            routine_folder_id(&format!("{}_b", "a".repeat(47))).as_deref(),
268            Some(format!("{}-", "a".repeat(47)).as_str())
269        );
270        // Display name -> nick -> folder: the folder is the nick itself.
271        let chain = |name: &str| routine_folder_id(&nick_from_display_name(name).unwrap());
272        for name in [
273            "Alice",
274            "Привет мир",
275            "foo+bar",
276            "Sample Bot",
277            "carol",
278        ] {
279            let nick = nick_from_display_name(name).unwrap();
280            assert_eq!(routine_folder_id(&nick).as_deref(), Some(nick.as_str()));
281        }
282        assert_eq!(
283            chain("Привет мир").as_deref(),
284            Some("privet-mir")
285        );
286        assert_eq!(chain("foo+bar").as_deref(), Some("foo-bar"));
287        assert_eq!(chain("Sample Bot").as_deref(), Some("sample-bot"));
288        assert_eq!(chain("carol").as_deref(), Some("carol"));
289    }
290}