1pub fn truncate_with_ellipsis(s: &str, max_chars: usize) -> String {
3 match s.char_indices().nth(max_chars) {
4 Some((idx, _)) => {
5 let truncated = &s[..idx];
6 format!("{}...", truncated.trim_end())
7 }
8 None => s.to_string(),
9 }
10}
11
12pub const BLOCK_KIT_PREFIX: &str = "__ZEROCLAW_BLOCK_KIT__";
13
14pub fn strip_tool_call_tags(message: &str) -> String {
15 const TOOL_CALL_OPEN_TAGS: [&str; 7] = [
16 "<function_calls>",
17 "<function_call>",
18 "<tool_call>",
19 "<toolcall>",
20 "<tool-call>",
21 "<tool>",
22 "<invoke>",
23 ];
24
25 fn find_first_tag<'a>(haystack: &str, tags: &'a [&'a str]) -> Option<(usize, &'a str)> {
26 tags.iter()
27 .filter_map(|tag| haystack.find(tag).map(|idx| (idx, *tag)))
28 .min_by_key(|(idx, _)| *idx)
29 }
30
31 fn matching_close_tag(open_tag: &str) -> Option<&'static str> {
32 match open_tag {
33 "<function_calls>" => Some("</function_calls>"),
34 "<function_call>" => Some("</function_call>"),
35 "<tool_call>" => Some("</tool_call>"),
36 "<toolcall>" => Some("</toolcall>"),
37 "<tool-call>" => Some("</tool-call>"),
38 "<tool>" => Some("</tool>"),
39 "<invoke>" => Some("</invoke>"),
40 _ => None,
41 }
42 }
43
44 fn extract_first_json_end(input: &str) -> Option<usize> {
45 let trimmed = input.trim_start();
46 let trim_offset = input.len().saturating_sub(trimmed.len());
47
48 for (byte_idx, ch) in trimmed.char_indices() {
49 if ch != '{' && ch != '[' {
50 continue;
51 }
52
53 let slice = &trimmed[byte_idx..];
54 let mut stream =
55 serde_json::Deserializer::from_str(slice).into_iter::<serde_json::Value>();
56 if let Some(Ok(_value)) = stream.next() {
57 let consumed = stream.byte_offset();
58 if consumed > 0 {
59 return Some(trim_offset + byte_idx + consumed);
60 }
61 }
62 }
63
64 None
65 }
66
67 fn strip_leading_close_tags(mut input: &str) -> &str {
68 loop {
69 let trimmed = input.trim_start();
70 if !trimmed.starts_with("</") {
71 return trimmed;
72 }
73
74 let Some(close_end) = trimmed.find('>') else {
75 return "";
76 };
77 input = &trimmed[close_end + 1..];
78 }
79 }
80
81 fn tool_structure_runs_to_end(inner: &str) -> bool {
88 let mut rest = inner.trim_start();
89 while rest.starts_with('<') {
91 match rest.find('>') {
92 Some(gt) => rest = rest[gt + 1..].trim_start(),
93 None => return true,
95 }
96 }
97 let tail = rest.trim();
98 if tail.is_empty() {
99 return true;
101 }
102 !looks_like_prose(tail)
105 }
106
107 fn looks_like_prose(text: &str) -> bool {
112 let bytes = text.as_bytes();
113 for i in 0..bytes.len().saturating_sub(1) {
114 if matches!(bytes[i], b'.' | b'!' | b'?')
115 && matches!(bytes[i + 1], b' ' | b'\n' | b'\t')
116 && text[i + 1..]
117 .trim_start()
118 .chars()
119 .next()
120 .is_some_and(|c| c.is_alphabetic())
121 {
122 return true;
123 }
124 }
125 let trimmed = text.trim_end();
126 let ends_like_sentence = trimmed
127 .chars()
128 .last()
129 .is_some_and(|c| matches!(c, '.' | '!' | '?'))
130 && trimmed
131 .chars()
132 .rev()
133 .nth(1)
134 .is_some_and(|c| c.is_alphabetic());
135 ends_like_sentence && text.trim().contains(' ')
136 }
137
138 let mut kept_segments = Vec::new();
139 let mut remaining = message;
140
141 while let Some((start, open_tag)) = find_first_tag(remaining, &TOOL_CALL_OPEN_TAGS) {
142 let before = &remaining[..start];
143 if !before.is_empty() {
144 kept_segments.push(before.to_string());
145 }
146
147 let Some(close_tag) = matching_close_tag(open_tag) else {
148 break;
149 };
150 let after_open = &remaining[start + open_tag.len()..];
151
152 if let Some(close_idx) = after_open.find(close_tag) {
153 remaining = &after_open[close_idx + close_tag.len()..];
154 continue;
155 }
156
157 if let Some(consumed_end) = extract_first_json_end(after_open) {
158 remaining = strip_leading_close_tags(&after_open[consumed_end..]);
159 continue;
160 }
161
162 let inner = after_open.trim_start();
171 let inner_lower = inner.to_ascii_lowercase();
172 let looks_like_tool_structure = inner_lower.starts_with("<invoke")
173 || inner_lower.starts_with("<parameter")
174 || inner_lower.starts_with("<tool")
175 || inner_lower.starts_with("<function")
176 || inner.starts_with('{')
177 || inner.starts_with('[');
178 if looks_like_tool_structure && tool_structure_runs_to_end(inner) {
179 remaining = "";
180 break;
181 }
182
183 kept_segments.push(remaining[start..].to_string());
184 remaining = "";
185 break;
186 }
187
188 if !remaining.is_empty() {
189 kept_segments.push(remaining.to_string());
190 }
191
192 let mut result = kept_segments.concat();
193
194 while result.contains("\n\n\n") {
196 result = result.replace("\n\n\n", "\n\n");
197 }
198
199 result.trim().to_string()
200}
201
202const ATTACHMENT_KINDS: &[&str] = &[
204 "IMAGE", "PHOTO", "DOCUMENT", "FILE", "VIDEO", "AUDIO", "VOICE",
205];
206
207pub fn parse_attachment_markers(message: &str) -> (String, Vec<(String, String)>) {
210 let mut cleaned = String::with_capacity(message.len());
211 let mut attachments = Vec::new();
212 let mut cursor = 0usize;
213
214 while let Some(rel_start) = message[cursor..].find('[') {
215 let start = cursor + rel_start;
216 cleaned.push_str(&message[cursor..start]);
217
218 let Some(rel_end) = message[start..].find(']') else {
219 cleaned.push_str(&message[start..]);
220 cursor = message.len();
221 break;
222 };
223 let end = start + rel_end;
224 let marker_text = &message[start + 1..end];
225
226 let parsed = marker_text.split_once(':').and_then(|(kind, target)| {
227 let kind_upper = kind.trim().to_ascii_uppercase();
228 let target = target.trim();
229 if target.is_empty() || !ATTACHMENT_KINDS.contains(&kind_upper.as_str()) {
230 return None;
231 }
232 Some((kind_upper, target.to_string()))
233 });
234
235 if let Some(attachment) = parsed {
236 attachments.push(attachment);
237 } else {
238 cleaned.push_str(&message[start..=end]);
239 }
240
241 cursor = end + 1;
242 }
243
244 if cursor < message.len() {
245 cleaned.push_str(&message[cursor..]);
246 }
247
248 (cleaned.trim().to_string(), attachments)
249}
250
251#[cfg(any(
258 feature = "channel-discord",
259 feature = "channel-signal",
260 feature = "channel-slack",
261 feature = "channel-whatsapp-cloud",
262 feature = "whatsapp-web",
263 test
264))]
265pub(crate) fn new_approval_token() -> String {
266 use rand::RngExt;
267 const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
268 let mut rng = rand::rng();
269 (0..6)
270 .map(|_| CHARSET[rng.random_range(0..CHARSET.len())] as char)
271 .collect()
272}
273
274pub fn parse_approval_reply(
280 text: &str,
281) -> Option<(String, zeroclaw_api::channel::ChannelApprovalResponse)> {
282 use zeroclaw_api::channel::ChannelApprovalResponse;
283 let lower = text.trim().to_lowercase();
284 let mut parts = lower.splitn(2, ' ');
285 let token = parts.next()?.to_string();
286 if token.len() != 6 || !token.chars().all(|c| c.is_ascii_alphanumeric()) {
287 return None;
288 }
289 let action_word = parts.next()?.split_whitespace().next()?;
290 let response = match action_word {
291 "yes" | "y" | "approve" => ChannelApprovalResponse::Approve,
292 "no" | "n" | "deny" => ChannelApprovalResponse::Deny,
293 "always" => ChannelApprovalResponse::AlwaysApprove,
294 _ => return None,
295 };
296 Some((token, response))
297}
298
299pub fn conversation_history_key(msg: &zeroclaw_api::channel::ChannelMessage) -> String {
301 match &msg.thread_ts {
302 Some(tid) => format!(
303 "{}_{}_{}_{}",
304 msg.channel, msg.reply_target, tid, msg.sender
305 ),
306 None => format!("{}_{}_{}", msg.channel, msg.reply_target, msg.sender),
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[test]
315 fn strip_drops_truncated_function_calls_envelope_keeps_prose() {
316 let msg = "Here's the result:\n<function_calls>\n<invoke name=\"shell\">\n<parameter name=\"command\">sed -n '1,5p' file.rs";
319 assert_eq!(strip_tool_call_tags(msg), "Here's the result:");
320
321 let only = "<function_calls>\n<invoke name=\"shell\">\n<parameter name=\"command\">date";
323 assert_eq!(strip_tool_call_tags(only), "");
324
325 let complete = "before <function_calls><invoke name=\"shell\"><parameter name=\"command\">date</parameter></invoke></function_calls> after";
327 assert_eq!(strip_tool_call_tags(complete), "before after");
328 }
329
330 #[test]
331 fn strip_keeps_prose_that_merely_mentions_a_tag() {
332 let msg =
335 "The bug is that models emit <function_calls> and never close it, hanging the parser.";
336 assert_eq!(strip_tool_call_tags(msg), msg);
337 }
338
339 #[test]
340 fn strip_keeps_unterminated_example_followed_by_prose() {
341 let xml_example = "The model emits <function_calls><invoke name=\"x\"> and then keeps going. This sentence matters.";
345 assert_eq!(strip_tool_call_tags(xml_example), xml_example);
346
347 let json_example = "Emit <tool_call> {then describe the schema} in your docs.";
348 assert_eq!(strip_tool_call_tags(json_example), json_example);
349 }
350
351 #[test]
352 fn strip_still_drops_genuine_truncation_to_end() {
353 let truncated = "Here's the result:\n<function_calls>\n<invoke name=\"shell\">\n<parameter name=\"command\">sed -n '1,5p' file.rs";
355 assert_eq!(strip_tool_call_tags(truncated), "Here's the result:");
356
357 let mid_tag = "Working on it <function_calls><invoke name=\"sh";
359 assert_eq!(strip_tool_call_tags(mid_tag), "Working on it");
360 }
361
362 #[test]
363 fn parse_attachment_markers_extracts_known_kinds() {
364 let (cleaned, attachments) =
365 parse_attachment_markers("Here [IMAGE:/tmp/a.png] and [DOCUMENT:/tmp/b.pdf] done");
366 assert_eq!(cleaned, "Here and done");
367 assert_eq!(attachments.len(), 2);
368 assert_eq!(attachments[0], ("IMAGE".into(), "/tmp/a.png".into()));
369 assert_eq!(attachments[1], ("DOCUMENT".into(), "/tmp/b.pdf".into()));
370 }
371
372 #[test]
373 fn parse_attachment_markers_preserves_unknown_kinds() {
374 let (cleaned, attachments) = parse_attachment_markers("Check [UNKNOWN:foo] out");
375 assert_eq!(cleaned, "Check [UNKNOWN:foo] out");
376 assert!(attachments.is_empty());
377 }
378
379 #[test]
380 fn parse_attachment_markers_preserves_empty_target() {
381 let (cleaned, attachments) = parse_attachment_markers("See [IMAGE:] here");
382 assert_eq!(cleaned, "See [IMAGE:] here");
383 assert!(attachments.is_empty());
384 }
385
386 #[test]
387 fn parse_attachment_markers_no_markers() {
388 let (cleaned, attachments) = parse_attachment_markers("Hello world");
389 assert_eq!(cleaned, "Hello world");
390 assert!(attachments.is_empty());
391 }
392
393 #[test]
394 fn parse_attachment_markers_all_kinds() {
395 let input = "[IMAGE:a] [PHOTO:b] [DOCUMENT:c] [FILE:d] [VIDEO:e] [AUDIO:f] [VOICE:g]";
396 let (_, attachments) = parse_attachment_markers(input);
397 assert_eq!(attachments.len(), 7);
398 }
399
400 #[test]
401 fn parse_attachment_markers_case_insensitive_kind() {
402 let (_, attachments) = parse_attachment_markers("[image:/tmp/a.png]");
403 assert_eq!(attachments.len(), 1);
404 assert_eq!(attachments[0].0, "IMAGE");
405 }
406
407 #[test]
408 fn new_approval_token_is_6_char_alphanumeric() {
409 let token = super::new_approval_token();
410 assert_eq!(token.len(), 6);
411 assert!(token.chars().all(|c| c.is_ascii_alphanumeric()));
412 }
413
414 #[test]
415 fn parse_approval_reply_accepts_canonical_forms() {
416 use zeroclaw_api::channel::ChannelApprovalResponse;
417 let cases = [
418 ("abc123 yes", ChannelApprovalResponse::Approve),
419 ("abc123 y", ChannelApprovalResponse::Approve),
420 ("abc123 approve", ChannelApprovalResponse::Approve),
421 ("ABC123 YES", ChannelApprovalResponse::Approve),
422 (
423 "abc123 yes please go ahead",
424 ChannelApprovalResponse::Approve,
425 ),
426 ("abc123 no", ChannelApprovalResponse::Deny),
427 ("abc123 n", ChannelApprovalResponse::Deny),
428 ("abc123 deny", ChannelApprovalResponse::Deny),
429 ("abc123 always", ChannelApprovalResponse::AlwaysApprove),
430 ];
431 for (input, expected) in cases {
432 let (token, response) = super::parse_approval_reply(input)
433 .unwrap_or_else(|| panic!("expected Some for input {:?}", input));
434 assert_eq!(
435 token,
436 input.trim().to_lowercase().split(' ').next().unwrap()
437 );
438 assert_eq!(response, expected, "input: {input:?}");
439 }
440 }
441
442 #[test]
443 fn parse_approval_reply_rejects_bad_input() {
444 let bad = [
445 "yes",
446 "abc123",
447 "abc 123 yes",
448 "toolname yes",
449 "abc123 maybe",
450 "",
451 "abc123 ",
452 ];
453 for input in bad {
454 assert!(
455 super::parse_approval_reply(input).is_none(),
456 "expected None for input {:?}",
457 input
458 );
459 }
460 }
461}