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
95/// A dotted node auto-nick `<head>.<node_id>.<owner>` (letters, digits, `-`, `_`, two dots).
96pub(crate) fn is_node_nick(nick: &str) -> bool {
97    let parts: Vec<&str> = nick.split('.').collect();
98    nick.len() <= 64 && parts.len() == 3 && parts.iter().all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'))
99}
100
101fn is_nick_token(nick: &str) -> bool {
102    !nick.is_empty()
103        && nick.len() <= 64
104        && nick
105            .chars()
106            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || ch == '.')
107}
108
109fn latin_piece(ch: char) -> Option<&'static str> {
110    if ch.is_ascii_alphanumeric() {
111        return Some(ascii_lower(ch));
112    }
113    let lower = ch.to_lowercase().next()?;
114    Some(match lower {
115        'а' => "a",
116        'б' => "b",
117        'в' => "v",
118        'г' => "g",
119        'д' => "d",
120        'е' => "e",
121        'ё' => "yo",
122        'ж' => "zh",
123        'з' => "z",
124        'и' => "i",
125        'й' => "i",
126        'к' => "k",
127        'л' => "l",
128        'м' => "m",
129        'н' => "n",
130        'о' => "o",
131        'п' => "p",
132        'р' => "r",
133        'с' => "s",
134        'т' => "t",
135        'у' => "u",
136        'ф' => "f",
137        'х' => "kh",
138        'ц' => "ts",
139        'ч' => "ch",
140        'ш' => "sh",
141        'щ' => "shch",
142        'ъ' | 'ь' => "",
143        'ы' => "y",
144        'э' => "e",
145        'ю' => "yu",
146        'я' => "ya",
147        // Ukrainian / Belarusian letters that are not in the Russian set above.
148        'є' => "ye",
149        'і' => "i",
150        'ї' => "yi",
151        'ґ' => "g",
152        'ў' => "u",
153        _ => return None,
154    })
155}
156
157fn ascii_lower(ch: char) -> &'static str {
158    match ch {
159        'A' | 'a' => "a",
160        'B' | 'b' => "b",
161        'C' | 'c' => "c",
162        'D' | 'd' => "d",
163        'E' | 'e' => "e",
164        'F' | 'f' => "f",
165        'G' | 'g' => "g",
166        'H' | 'h' => "h",
167        'I' | 'i' => "i",
168        'J' | 'j' => "j",
169        'K' | 'k' => "k",
170        'L' | 'l' => "l",
171        'M' | 'm' => "m",
172        'N' | 'n' => "n",
173        'O' | 'o' => "o",
174        'P' | 'p' => "p",
175        'Q' | 'q' => "q",
176        'R' | 'r' => "r",
177        'S' | 's' => "s",
178        'T' | 't' => "t",
179        'U' | 'u' => "u",
180        'V' | 'v' => "v",
181        'W' | 'w' => "w",
182        'X' | 'x' => "x",
183        'Y' | 'y' => "y",
184        'Z' | 'z' => "z",
185        '0' => "0",
186        '1' => "1",
187        '2' => "2",
188        '3' => "3",
189        '4' => "4",
190        '5' => "5",
191        '6' => "6",
192        '7' => "7",
193        '8' => "8",
194        '9' => "9",
195        _ => "",
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn display_names_become_nicks() {
205        assert_eq!(nick_from_display_name("Alice").unwrap(), "alice");
206        assert_eq!(nick_from_display_name("  Alice  ").unwrap(), "alice");
207        assert_eq!(
208            nick_from_display_name("Привет мир").unwrap(),
209            "privet-mir"
210        );
211        assert_eq!(
212            nick_from_display_name("Свой браузер").unwrap(),
213            "svoi-brauzer"
214        );
215        assert_eq!(
216            nick_from_display_name("foo+bar").unwrap(),
217            "foo-bar"
218        );
219        assert_eq!(nick_from_display_name("Sample Bot").unwrap(), "sample-bot");
220        assert_eq!(
221            nick_from_display_name("Объём, данные").unwrap(),
222            "obyom-dannye"
223        );
224        assert_eq!(
225            nick_from_display_name(&("x".repeat(31) + " y")).unwrap(),
226            "x".repeat(31)
227        );
228        assert_ne!(
229            nick_from_display_name("Привет мир").unwrap(),
230            "nachshtab"
231        );
232        assert_eq!(nick_from_display_name("Foo   Bar!!").unwrap(), "foo-bar");
233        assert_eq!(nick_from_display_name("__a__b__").unwrap(), "a-b");
234        assert_eq!(
235            nick_from_display_name(&"A".repeat(40)).unwrap(),
236            "a".repeat(32)
237        );
238        assert!(nick_from_display_name("...").is_err());
239        assert_eq!(lookup_nick("Привет мир").unwrap(), "privet-mir");
240        assert_eq!(lookup_nick("chief_bob").unwrap(), "chief_bob");
241        assert_eq!(lookup_nick("Alice").unwrap(), "Alice");
242    }
243
244    #[test]
245    fn routine_folder_matches_the_host_slug() {
246        // nick -> folder, the way UpdateRoutine and createAgentAutomation name it.
247        assert_eq!(routine_folder_id("alice").as_deref(), Some("alice"));
248        assert_eq!(
249            routine_folder_id("chief_bob").as_deref(),
250            Some("chief-bob")
251        );
252        assert_eq!(
253            routine_folder_id("sample_bot").as_deref(),
254            Some("sample-bot")
255        );
256        assert_eq!(
257            routine_folder_id("foobar").as_deref(),
258            Some("foobar")
259        );
260        assert_eq!(
261            routine_folder_id("foo+bar").as_deref(),
262            Some("foo-bar")
263        );
264        assert_eq!(routine_folder_id("Alice").as_deref(), Some("alice"));
265        assert_eq!(routine_folder_id("__a__b__").as_deref(), Some("a-b"));
266        assert_eq!(routine_folder_id("Привет"), None);
267        assert_eq!(routine_folder_id("___"), None);
268        assert_eq!(
269            routine_folder_id(&"x".repeat(60)).map(|s| s.len()),
270            Some(48)
271        );
272        assert_eq!(
273            routine_folder_id(&format!("{}_b", "a".repeat(47))).as_deref(),
274            Some(format!("{}-", "a".repeat(47)).as_str())
275        );
276        // Display name -> nick -> folder: the folder is the nick itself.
277        let chain = |name: &str| routine_folder_id(&nick_from_display_name(name).unwrap());
278        for name in [
279            "Alice",
280            "Привет мир",
281            "foo+bar",
282            "Sample Bot",
283            "carol",
284        ] {
285            let nick = nick_from_display_name(name).unwrap();
286            assert_eq!(routine_folder_id(&nick).as_deref(), Some(nick.as_str()));
287        }
288        assert_eq!(
289            chain("Привет мир").as_deref(),
290            Some("privet-mir")
291        );
292        assert_eq!(chain("foo+bar").as_deref(), Some("foo-bar"));
293        assert_eq!(chain("Sample Bot").as_deref(), Some("sample-bot"));
294        assert_eq!(chain("carol").as_deref(), Some("carol"));
295    }
296}