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 let mut kept_segments = Vec::new();
82 let mut remaining = message;
83
84 while let Some((start, open_tag)) = find_first_tag(remaining, &TOOL_CALL_OPEN_TAGS) {
85 let before = &remaining[..start];
86 if !before.is_empty() {
87 kept_segments.push(before.to_string());
88 }
89
90 let Some(close_tag) = matching_close_tag(open_tag) else {
91 break;
92 };
93 let after_open = &remaining[start + open_tag.len()..];
94
95 if let Some(close_idx) = after_open.find(close_tag) {
96 remaining = &after_open[close_idx + close_tag.len()..];
97 continue;
98 }
99
100 if let Some(consumed_end) = extract_first_json_end(after_open) {
101 remaining = strip_leading_close_tags(&after_open[consumed_end..]);
102 continue;
103 }
104
105 kept_segments.push(remaining[start..].to_string());
106 remaining = "";
107 break;
108 }
109
110 if !remaining.is_empty() {
111 kept_segments.push(remaining.to_string());
112 }
113
114 let mut result = kept_segments.concat();
115
116 while result.contains("\n\n\n") {
118 result = result.replace("\n\n\n", "\n\n");
119 }
120
121 result.trim().to_string()
122}
123
124const ATTACHMENT_KINDS: &[&str] = &[
126 "IMAGE", "PHOTO", "DOCUMENT", "FILE", "VIDEO", "AUDIO", "VOICE",
127];
128
129pub fn parse_attachment_markers(message: &str) -> (String, Vec<(String, String)>) {
132 let mut cleaned = String::with_capacity(message.len());
133 let mut attachments = Vec::new();
134 let mut cursor = 0usize;
135
136 while let Some(rel_start) = message[cursor..].find('[') {
137 let start = cursor + rel_start;
138 cleaned.push_str(&message[cursor..start]);
139
140 let Some(rel_end) = message[start..].find(']') else {
141 cleaned.push_str(&message[start..]);
142 cursor = message.len();
143 break;
144 };
145 let end = start + rel_end;
146 let marker_text = &message[start + 1..end];
147
148 let parsed = marker_text.split_once(':').and_then(|(kind, target)| {
149 let kind_upper = kind.trim().to_ascii_uppercase();
150 let target = target.trim();
151 if target.is_empty() || !ATTACHMENT_KINDS.contains(&kind_upper.as_str()) {
152 return None;
153 }
154 Some((kind_upper, target.to_string()))
155 });
156
157 if let Some(attachment) = parsed {
158 attachments.push(attachment);
159 } else {
160 cleaned.push_str(&message[start..=end]);
161 }
162
163 cursor = end + 1;
164 }
165
166 if cursor < message.len() {
167 cleaned.push_str(&message[cursor..]);
168 }
169
170 (cleaned.trim().to_string(), attachments)
171}
172
173#[cfg(any(
180 feature = "channel-discord",
181 feature = "channel-signal",
182 feature = "channel-slack",
183 feature = "channel-whatsapp-cloud",
184 feature = "whatsapp-web",
185 test
186))]
187pub(crate) fn new_approval_token() -> String {
188 use rand::RngExt;
189 const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
190 let mut rng = rand::rng();
191 (0..6)
192 .map(|_| CHARSET[rng.random_range(0..CHARSET.len())] as char)
193 .collect()
194}
195
196pub fn parse_approval_reply(
202 text: &str,
203) -> Option<(String, zeroclaw_api::channel::ChannelApprovalResponse)> {
204 use zeroclaw_api::channel::ChannelApprovalResponse;
205 let lower = text.trim().to_lowercase();
206 let mut parts = lower.splitn(2, ' ');
207 let token = parts.next()?.to_string();
208 if token.len() != 6 || !token.chars().all(|c| c.is_ascii_alphanumeric()) {
209 return None;
210 }
211 let action_word = parts.next()?.split_whitespace().next()?;
212 let response = match action_word {
213 "yes" | "y" | "approve" => ChannelApprovalResponse::Approve,
214 "no" | "n" | "deny" => ChannelApprovalResponse::Deny,
215 "always" => ChannelApprovalResponse::AlwaysApprove,
216 _ => return None,
217 };
218 Some((token, response))
219}
220
221pub fn conversation_history_key(msg: &zeroclaw_api::channel::ChannelMessage) -> String {
223 match &msg.thread_ts {
224 Some(tid) => format!(
225 "{}_{}_{}_{}",
226 msg.channel, msg.reply_target, tid, msg.sender
227 ),
228 None => format!("{}_{}_{}", msg.channel, msg.reply_target, msg.sender),
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn parse_attachment_markers_extracts_known_kinds() {
238 let (cleaned, attachments) =
239 parse_attachment_markers("Here [IMAGE:/tmp/a.png] and [DOCUMENT:/tmp/b.pdf] done");
240 assert_eq!(cleaned, "Here and done");
241 assert_eq!(attachments.len(), 2);
242 assert_eq!(attachments[0], ("IMAGE".into(), "/tmp/a.png".into()));
243 assert_eq!(attachments[1], ("DOCUMENT".into(), "/tmp/b.pdf".into()));
244 }
245
246 #[test]
247 fn parse_attachment_markers_preserves_unknown_kinds() {
248 let (cleaned, attachments) = parse_attachment_markers("Check [UNKNOWN:foo] out");
249 assert_eq!(cleaned, "Check [UNKNOWN:foo] out");
250 assert!(attachments.is_empty());
251 }
252
253 #[test]
254 fn parse_attachment_markers_preserves_empty_target() {
255 let (cleaned, attachments) = parse_attachment_markers("See [IMAGE:] here");
256 assert_eq!(cleaned, "See [IMAGE:] here");
257 assert!(attachments.is_empty());
258 }
259
260 #[test]
261 fn parse_attachment_markers_no_markers() {
262 let (cleaned, attachments) = parse_attachment_markers("Hello world");
263 assert_eq!(cleaned, "Hello world");
264 assert!(attachments.is_empty());
265 }
266
267 #[test]
268 fn parse_attachment_markers_all_kinds() {
269 let input = "[IMAGE:a] [PHOTO:b] [DOCUMENT:c] [FILE:d] [VIDEO:e] [AUDIO:f] [VOICE:g]";
270 let (_, attachments) = parse_attachment_markers(input);
271 assert_eq!(attachments.len(), 7);
272 }
273
274 #[test]
275 fn parse_attachment_markers_case_insensitive_kind() {
276 let (_, attachments) = parse_attachment_markers("[image:/tmp/a.png]");
277 assert_eq!(attachments.len(), 1);
278 assert_eq!(attachments[0].0, "IMAGE");
279 }
280
281 #[test]
282 fn new_approval_token_is_6_char_alphanumeric() {
283 let token = super::new_approval_token();
284 assert_eq!(token.len(), 6);
285 assert!(token.chars().all(|c| c.is_ascii_alphanumeric()));
286 }
287
288 #[test]
289 fn parse_approval_reply_accepts_canonical_forms() {
290 use zeroclaw_api::channel::ChannelApprovalResponse;
291 let cases = [
292 ("abc123 yes", ChannelApprovalResponse::Approve),
293 ("abc123 y", ChannelApprovalResponse::Approve),
294 ("abc123 approve", ChannelApprovalResponse::Approve),
295 ("ABC123 YES", ChannelApprovalResponse::Approve),
296 (
297 "abc123 yes please go ahead",
298 ChannelApprovalResponse::Approve,
299 ),
300 ("abc123 no", ChannelApprovalResponse::Deny),
301 ("abc123 n", ChannelApprovalResponse::Deny),
302 ("abc123 deny", ChannelApprovalResponse::Deny),
303 ("abc123 always", ChannelApprovalResponse::AlwaysApprove),
304 ];
305 for (input, expected) in cases {
306 let (token, response) = super::parse_approval_reply(input)
307 .unwrap_or_else(|| panic!("expected Some for input {:?}", input));
308 assert_eq!(
309 token,
310 input.trim().to_lowercase().split(' ').next().unwrap()
311 );
312 assert_eq!(response, expected, "input: {input:?}");
313 }
314 }
315
316 #[test]
317 fn parse_approval_reply_rejects_bad_input() {
318 let bad = [
319 "yes",
320 "abc123",
321 "abc 123 yes",
322 "toolname yes",
323 "abc123 maybe",
324 "",
325 "abc123 ",
326 ];
327 for input in bad {
328 assert!(
329 super::parse_approval_reply(input).is_none(),
330 "expected None for input {:?}",
331 input
332 );
333 }
334 }
335}