apollo/channels/
formatting.rs1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum FormatTarget {
5 Telegram,
6 Discord,
7 WhatsApp,
8 Plain,
9}
10
11pub fn format_outgoing_text(target: FormatTarget, text: &str) -> String {
12 match target {
13 FormatTarget::Telegram => sanitize_telegram_markdown(text),
14 FormatTarget::Discord | FormatTarget::Plain => text.to_string(),
15 FormatTarget::WhatsApp => sanitize_whatsapp_text(text),
16 }
17}
18
19pub fn chunk_outgoing_text(target: FormatTarget, text: &str, max_len: usize) -> Vec<String> {
20 match target {
21 FormatTarget::Telegram => chunk_telegram_message(text, max_len),
22 _ => vec![text.to_string()],
23 }
24}
25
26fn sanitize_telegram_markdown(text: &str) -> String {
27 let mut result = String::new();
28 let lines: Vec<&str> = text.lines().collect();
29 let mut in_code_block = false;
30 let mut in_table = false;
31
32 for line in lines {
33 let trimmed = line.trim();
34
35 if trimmed.starts_with("```") {
36 in_code_block = !in_code_block;
37 if in_code_block && trimmed == "```" {
38 result.push_str("```text\n");
39 continue;
40 }
41 }
42
43 if in_code_block {
44 result.push_str(line);
45 result.push('\n');
46 continue;
47 }
48
49 if trimmed.starts_with('#') {
50 let header_text = trimmed.trim_start_matches('#').trim();
51 if !header_text.is_empty() {
52 result.push_str(&format!("*{}*\n", header_text));
53 }
54 continue;
55 }
56
57 if trimmed.contains('|') && !trimmed.is_empty() {
58 let cells: Vec<&str> = trimmed
59 .split('|')
60 .map(|s| s.trim())
61 .filter(|s| !s.is_empty())
62 .collect();
63
64 if cells
65 .iter()
66 .all(|c| c.chars().all(|ch| ch == '-' || ch == ':' || ch == ' '))
67 {
68 continue;
69 }
70
71 if !cells.is_empty() {
72 in_table = true;
73 result.push_str(&format!("• {}\n", cells.join(" | ")));
74 continue;
75 }
76 } else if in_table && !trimmed.is_empty() {
77 in_table = false;
78 }
79
80 result.push_str(line);
81 result.push('\n');
82 }
83
84 result.trim_end().to_string()
85}
86
87fn sanitize_whatsapp_text(text: &str) -> String {
88 let mut result = String::new();
89 let mut in_code_block = false;
90
91 for line in text.lines() {
92 let trimmed = line.trim();
93
94 if trimmed.starts_with("```") {
95 in_code_block = !in_code_block;
96 continue;
97 }
98
99 if in_code_block {
100 result.push_str(line);
101 result.push('\n');
102 continue;
103 }
104
105 if trimmed.starts_with('#') {
106 let header_text = trimmed.trim_start_matches('#').trim();
107 if !header_text.is_empty() {
108 result.push_str(header_text);
109 result.push('\n');
110 }
111 continue;
112 }
113
114 if trimmed.contains('|') && !trimmed.is_empty() {
115 let cells: Vec<&str> = trimmed
116 .split('|')
117 .map(|s| s.trim())
118 .filter(|s| !s.is_empty())
119 .collect();
120
121 if cells
122 .iter()
123 .all(|c| c.chars().all(|ch| ch == '-' || ch == ':' || ch == ' '))
124 {
125 continue;
126 }
127
128 if !cells.is_empty() {
129 result.push_str("- ");
130 result.push_str(&strip_inline_markdown(&cells.join(" - ")));
131 result.push('\n');
132 continue;
133 }
134 }
135
136 result.push_str(&strip_inline_markdown(line));
137 result.push('\n');
138 }
139
140 result.trim_end().to_string()
141}
142
143fn strip_inline_markdown(line: &str) -> String {
144 line.replace("**", "")
145 .replace("__", "")
146 .replace(['`', '*', '_'], "")
147}
148
149fn chunk_telegram_message(text: &str, max_len: usize) -> Vec<String> {
150 if text.len() <= max_len {
151 return vec![text.to_string()];
152 }
153
154 let mut chunks = Vec::new();
155 for block in split_blocks(text) {
156 if block.trim().is_empty() {
157 continue;
158 }
159
160 if block.starts_with("```") {
161 chunks.extend(chunk_code_block(&block, max_len));
162 } else {
163 chunks.extend(chunk_prose_block(&block, max_len));
164 }
165 }
166
167 chunks
168}
169
170fn split_blocks(text: &str) -> Vec<String> {
171 let mut blocks = Vec::new();
172 let mut current = String::new();
173 let mut in_code_block = false;
174
175 for line in text.lines() {
176 let trimmed = line.trim_start();
177 if trimmed.starts_with("```") {
178 if !current.trim().is_empty() && !in_code_block {
179 blocks.push(current.trim_end().to_string());
180 current.clear();
181 }
182
183 current.push_str(line);
184 current.push('\n');
185 in_code_block = !in_code_block;
186
187 if !in_code_block {
188 blocks.push(current.trim_end().to_string());
189 current.clear();
190 }
191 continue;
192 }
193
194 current.push_str(line);
195 current.push('\n');
196 }
197
198 if !current.trim().is_empty() {
199 blocks.push(current.trim_end().to_string());
200 }
201
202 blocks
203}
204
205fn chunk_prose_block(text: &str, max_len: usize) -> Vec<String> {
206 if text.len() <= max_len {
207 return vec![text.to_string()];
208 }
209
210 let mut chunks = Vec::new();
211 let mut current = String::new();
212
213 for para in text.split("\n\n") {
214 if para.is_empty() {
215 continue;
216 }
217
218 if current.len() + para.len() + 2 > max_len {
219 if !current.is_empty() {
220 chunks.push(current.clone());
221 current.clear();
222 }
223
224 if para.len() > max_len {
225 for sentence in split_sentences(para) {
226 if current.len() + sentence.len() > max_len {
227 if !current.is_empty() {
228 chunks.push(current.clone());
229 current.clear();
230 }
231 if sentence.len() > max_len {
232 chunks.extend(hard_split_text(&sentence, max_len));
233 } else {
234 current = sentence;
235 }
236 } else {
237 current.push_str(&sentence);
238 }
239 }
240 } else {
241 current = para.to_string();
242 }
243 } else {
244 if !current.is_empty() {
245 current.push_str("\n\n");
246 }
247 current.push_str(para);
248 }
249 }
250
251 if !current.is_empty() {
252 chunks.push(current);
253 }
254
255 chunks
256}
257
258fn split_sentences(para: &str) -> Vec<String> {
259 let mut parts = Vec::new();
260 for (index, sentence) in para.split(". ").enumerate() {
261 if sentence.is_empty() {
262 continue;
263 }
264 if index < para.matches(". ").count() {
265 parts.push(format!("{}. ", sentence));
266 } else {
267 parts.push(sentence.to_string());
268 }
269 }
270 if parts.is_empty() {
271 vec![para.to_string()]
272 } else {
273 parts
274 }
275}
276
277fn hard_split_text(text: &str, max_len: usize) -> Vec<String> {
278 let mut chunks = Vec::new();
279 let mut current = String::new();
280
281 for ch in text.chars() {
282 if current.len() + ch.len_utf8() > max_len && !current.is_empty() {
283 chunks.push(current);
284 current = String::new();
285 }
286 current.push(ch);
287 }
288
289 if !current.is_empty() {
290 chunks.push(current);
291 }
292
293 chunks
294}
295
296fn chunk_code_block(block: &str, max_len: usize) -> Vec<String> {
297 if block.len() <= max_len {
298 return vec![block.to_string()];
299 }
300
301 let mut lines = block.lines();
302 let opening = lines.next().unwrap_or("```text");
303 let language = opening.trim_start_matches("```");
304 let closing = "```";
305 let content_lines: Vec<&str> = lines.filter(|line| *line != closing).collect();
306
307 let wrapper_len = opening.len() + closing.len() + 2;
308 let available = max_len.saturating_sub(wrapper_len).max(1);
309
310 let mut chunks = Vec::new();
311 let mut current = String::new();
312
313 for line in content_lines {
314 let candidate_len = if current.is_empty() {
315 line.len()
316 } else {
317 current.len() + 1 + line.len()
318 };
319
320 if candidate_len > available && !current.is_empty() {
321 chunks.push(wrap_code_chunk(language, ¤t));
322 current.clear();
323 }
324
325 if line.len() > available {
326 for part in hard_split_text(line, available) {
327 if !current.is_empty() {
328 chunks.push(wrap_code_chunk(language, ¤t));
329 current.clear();
330 }
331 chunks.push(wrap_code_chunk(language, &part));
332 }
333 continue;
334 }
335
336 if !current.is_empty() {
337 current.push('\n');
338 }
339 current.push_str(line);
340 }
341
342 if !current.is_empty() {
343 chunks.push(wrap_code_chunk(language, ¤t));
344 }
345
346 if chunks.is_empty() {
347 chunks.push(wrap_code_chunk(language, ""));
348 }
349
350 chunks
351}
352
353fn wrap_code_chunk(language: &str, body: &str) -> String {
354 if language.is_empty() {
355 format!("```\n{}\n```", body)
356 } else {
357 format!("```{}\n{}\n```", language, body)
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364
365 #[test]
366 fn telegram_tables_become_bullets() {
367 let input = "| Name | Value |\n| --- | --- |\n| A | B |";
368 let output = format_outgoing_text(FormatTarget::Telegram, input);
369 assert!(output.contains("• Name | Value"));
370 assert!(output.contains("• A | B"));
371 }
372
373 #[test]
374 fn whatsapp_strips_markdown_syntax() {
375 let input = "# Header\n**bold** and `code`\n| A | B |";
376 let output = format_outgoing_text(FormatTarget::WhatsApp, input);
377 assert!(output.contains("Header"));
378 assert!(output.contains("bold and code"));
379 assert!(output.contains("- A - B"));
380 assert!(!output.contains("**"));
381 assert!(!output.contains("```"));
382 }
383
384 #[test]
385 fn telegram_chunker_rewraps_code_blocks() {
386 let input =
387 "```rust\nfn main() {\n println!(\"hello\");\n println!(\"world\");\n}\n```";
388 let chunks = chunk_outgoing_text(FormatTarget::Telegram, input, 30);
389 assert!(chunks.len() > 1);
390 assert!(chunks.iter().all(|chunk| chunk.starts_with("```rust\n")));
391 assert!(chunks.iter().all(|chunk| chunk.ends_with("\n```")));
392 }
393}