diff --git a/src/channel.rs b/src/channel.rs index 2ee3d39..8106470 100644 --- a/src/channel.rs +++ b/src/channel.rs @@ -642,7 +642,7 @@ impl ChannelContract for Slack { } fn outbound_chunks(&self, text: &str, _marker: &str) -> Vec { - crate::slack::split_text(text) + crate::slack::split_text(&crate::markdown::to_slack_mrkdwn_for_chunking(text)) .into_iter() .map(|text| OutboundChunk { text, @@ -1005,6 +1005,36 @@ mod tests { ); } + #[test] + fn slack_formats_markdown_before_chunking() { + let text = format!("**Title:** {}", "x".repeat(crate::slack::MAX_TEXT_CHARS)); + + let chunks = slack().outbound_chunks(&text, "ignored"); + + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].text, format!("*Title:* {}", "x".repeat(3_991))); + assert!(chunks.iter().all(|chunk| chunk.rich_markdown)); + assert_eq!( + chunks + .into_iter() + .map(|chunk| chunk.text) + .collect::(), + format!("*Title:* {}", "x".repeat(crate::slack::MAX_TEXT_CHARS)) + ); + + let chunks = slack().outbound_chunks( + "Read [the *guide*](https://example.com) and `code`.", + "ignored", + ); + assert_eq!( + chunks[0].text, + "Read and `code`." + ); + assert!(!chunks[0] + .text + .contains(crate::markdown::SLACK_FORMAT_MARKER)); + } + #[test] fn imessage_outbound_reply_remains_one_unsplit_message() { let channel = imessage(); diff --git a/src/markdown.rs b/src/markdown.rs index 55a5866..00aabf2 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -1,13 +1,248 @@ -//! Renders Markdown to the HTML subset accepted by Telegram's `parse_mode=HTML`. +//! Renders Markdown for channel-specific rich text formats. //! -//! Telegram only allows a small set of inline tags (`b`, `i`, `s`, `u`, `code`, -//! `pre`, `a`, `blockquote`). Everything else must become plain text: headings -//! render as bold lines, list items as bullet lines, and tables fall back to -//! their raw text. All text content is entity-escaped so untrusted job output -//! cannot inject tags. +//! Slack uses `mrkdwn`, while Telegram accepts a small HTML subset through +//! `parse_mode=HTML`. Unsupported structures become readable plain text and +//! channel control characters are entity-escaped. use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; +#[cfg(test)] +pub fn to_slack_mrkdwn(markdown: &str) -> String { + render_slack_mrkdwn(markdown, false) +} + +pub(crate) const SLACK_FORMAT_MARKER: char = '\u{E000}'; + +pub(crate) fn to_slack_mrkdwn_for_chunking(markdown: &str) -> String { + render_slack_mrkdwn(markdown, true) +} + +fn render_slack_mrkdwn(markdown: &str, mark_delimiters: bool) -> String { + let mut options = Options::empty(); + options.insert(Options::ENABLE_STRIKETHROUGH); + let parser = Parser::new_ext(markdown, options); + + let mut out = SlackOutput::with_capacity(markdown.len(), mark_delimiters); + let mut list_stack: Vec> = Vec::new(); + let mut item_stack: Vec = Vec::new(); + let mut in_heading = false; + let mut in_code_block = false; + + for event in parser { + match event { + Event::Start(tag) => match tag { + Tag::Strong if !in_heading => out.push_delimiter("*"), + Tag::Emphasis => out.push_delimiter("_"), + Tag::Strikethrough => out.push_delimiter("~"), + Tag::Heading { .. } => { + slack_ensure_block_start(&mut out, &item_stack); + out.push_delimiter("*"); + in_heading = true; + } + Tag::Paragraph => match item_stack.last_mut() { + Some(has_content) if !*has_content => *has_content = true, + _ => slack_ensure_blank_line(&mut out), + }, + Tag::BlockQuote(_) => { + slack_ensure_block_start(&mut out, &item_stack); + out.quote_depth += 1; + } + Tag::CodeBlock(_) => { + slack_ensure_block_start(&mut out, &item_stack); + out.push_delimiter("```"); + out.push("\n"); + in_code_block = true; + } + Tag::List(start) => { + list_stack.push(start); + slack_ensure_newline(&mut out); + } + Tag::Item => { + slack_ensure_newline(&mut out); + let depth = list_stack.len().saturating_sub(1); + out.push(&" ".repeat(depth)); + match list_stack.last_mut() { + Some(Some(number)) => { + out.push(&format!("{number}. ")); + *number += 1; + } + _ => out.push("• "), + } + item_stack.push(false); + } + Tag::Link { dest_url, .. } => { + out.push("<"); + out.push(&slack_escape_url(&dest_url)); + out.push("|"); + } + _ => {} + }, + Event::End(tag) => match tag { + TagEnd::Strong if !in_heading => out.push_delimiter("*"), + TagEnd::Emphasis => out.push_delimiter("_"), + TagEnd::Strikethrough => out.push_delimiter("~"), + TagEnd::Heading(_) => { + in_heading = false; + out.push_delimiter("*"); + out.push("\n"); + } + TagEnd::Paragraph => out.push("\n"), + TagEnd::BlockQuote(_) => { + slack_ensure_newline(&mut out); + out.quote_depth = out.quote_depth.saturating_sub(1); + if item_stack.is_empty() { + slack_ensure_blank_line(&mut out); + } + } + TagEnd::CodeBlock => { + in_code_block = false; + out.push_delimiter("```"); + out.push("\n"); + } + TagEnd::List(_) => { + list_stack.pop(); + out.push("\n"); + } + TagEnd::Item => { + item_stack.pop(); + slack_ensure_newline(&mut out); + } + TagEnd::Link => out.push(">"), + _ => {} + }, + Event::Text(text) => { + if let Some(has_content) = item_stack.last_mut() { + *has_content = true; + } + if in_code_block { + out.push(&slack_escape_code(&text)); + } else { + out.push(&slack_escape_text(&text)); + } + } + Event::Code(code) => { + if let Some(has_content) = item_stack.last_mut() { + *has_content = true; + } + if code.contains('`') { + // Slack has no documented variable-length inline code + // delimiter, so keep the content readable without turning + // surrounding prose into a fenced code block. + out.push(&slack_escape_text(&code)); + } else { + out.push_delimiter("`"); + out.push(&slack_escape_code(&code)); + out.push_delimiter("`"); + } + } + Event::SoftBreak | Event::HardBreak => out.push("\n"), + Event::Rule => { + slack_ensure_blank_line(&mut out); + out.push("———\n"); + } + Event::TaskListMarker(done) => { + out.push(if done { "☑ " } else { "☐ " }); + } + Event::Html(html) | Event::InlineHtml(html) => { + out.push(&slack_escape_text(&html)); + } + _ => {} + } + } + + out.finish() +} + +struct SlackOutput { + text: String, + quote_depth: usize, + line_start: bool, + mark_delimiters: bool, +} + +impl SlackOutput { + fn with_capacity(capacity: usize, mark_delimiters: bool) -> Self { + Self { + text: String::with_capacity(capacity), + quote_depth: 0, + line_start: true, + mark_delimiters, + } + } + + fn push(&mut self, text: &str) { + for character in text.chars() { + if self.line_start && character != '\n' && self.quote_depth > 0 { + self.text.push_str(&"> ".repeat(self.quote_depth)); + self.line_start = false; + } + self.text.push(character); + self.line_start = character == '\n'; + } + } + + fn push_delimiter(&mut self, delimiter: &str) { + if self.mark_delimiters { + self.push(&SLACK_FORMAT_MARKER.to_string()); + } + self.push(delimiter); + } + + fn finish(self) -> String { + self.text.trim().to_string() + } +} + +fn slack_escape_controls(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace( + SLACK_FORMAT_MARKER, + &format!("{SLACK_FORMAT_MARKER}\u{200B}"), + ) +} + +fn slack_escape_text(text: &str) -> String { + let mut escaped = String::with_capacity(text.len()); + for character in slack_escape_controls(text).chars() { + escaped.push(character); + if matches!(character, '*' | '_' | '~' | '`') { + escaped.push('\u{200B}'); + } + } + escaped +} + +fn slack_escape_code(text: &str) -> String { + slack_escape_controls(text).replace("```", "``\u{200B}`") +} + +fn slack_escape_url(url: &str) -> String { + slack_escape_controls(url).replace('|', "%7C") +} + +fn slack_ensure_newline(out: &mut SlackOutput) { + if !out.text.is_empty() && !out.text.ends_with('\n') { + out.push("\n"); + } +} + +fn slack_ensure_blank_line(out: &mut SlackOutput) { + if out.text.is_empty() { + return; + } + while !out.text.ends_with("\n\n") { + out.push("\n"); + } +} + +fn slack_ensure_block_start(out: &mut SlackOutput, item_stack: &[bool]) { + if !matches!(item_stack.last(), Some(false)) { + slack_ensure_blank_line(out); + } +} + pub fn to_telegram_html(markdown: &str) -> String { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); @@ -172,6 +407,72 @@ fn ensure_block_start(out: &mut String, item_stack: &[bool]) { mod tests { use super::*; + #[test] + fn renders_slack_bold_strikethrough_and_headings() { + let mrkdwn = to_slack_mrkdwn("# Title\n\n**Bold** and ~~gone~~"); + assert_eq!(mrkdwn, "*Title*\n\n*Bold* and ~gone~"); + } + + #[test] + fn renders_slack_links_and_escapes_control_characters() { + let mrkdwn = + to_slack_mrkdwn("[Push & docs](https://example.com/a|b?a=1&b=2) & text"); + assert_eq!( + mrkdwn, + " <unsafe> & text" + ); + } + + #[test] + fn renders_slack_blockquotes_and_lists() { + let mrkdwn = to_slack_mrkdwn("> quoted\n> twice\n\n- one\n- two\n\n1. first\n2. second"); + assert_eq!( + mrkdwn, + "> quoted\n> twice\n\n• one\n• two\n\n1. first\n2. second" + ); + } + + #[test] + fn preserves_slack_inline_and_fenced_code_contents() { + let mrkdwn = to_slack_mrkdwn("Use `**raw** & `.\n\n```rust\n**raw** & \n```"); + assert_eq!( + mrkdwn, + "Use `**raw** & <tag>`.\n\n```\n**raw** & <tag>\n```" + ); + } + + #[test] + fn keeps_backticks_in_code_spans_without_creating_a_block() { + let mrkdwn = to_slack_mrkdwn("Use ``a ` b`` here."); + assert_eq!(mrkdwn, "Use a `\u{200B} b here."); + } + + #[test] + fn preserves_triple_backticks_inside_slack_code() { + let mrkdwn = to_slack_mrkdwn("````\na ``` b\n````"); + assert_eq!(mrkdwn, "```\na ``\u{200B}` b\n```"); + } + + #[test] + fn keeps_escaped_markdown_markers_literal_for_slack() { + let mrkdwn = to_slack_mrkdwn(r"\*literal\* and \_plain\_"); + assert_eq!( + mrkdwn, + "*\u{200B}literal*\u{200B} and _\u{200B}plain_\u{200B}" + ); + } + + #[test] + fn renders_mixed_slack_formatting() { + let mrkdwn = to_slack_mrkdwn( + "## **Title:**\n\n> Read [the *guide*](https://example.com).\n\n- `code`", + ); + assert_eq!( + mrkdwn, + "*Title:*\n\n> Read .\n\n• `code`" + ); + } + #[test] fn renders_bold_italic_and_code() { let html = to_telegram_html("**bold** and *italic* and `code`"); diff --git a/src/slack.rs b/src/slack.rs index 5e1233a..f553332 100644 --- a/src/slack.rs +++ b/src/slack.rs @@ -20,7 +20,7 @@ use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; use crate::channel::RawMessage; const API_BASE: &str = "https://slack.com/api"; -const MAX_TEXT_CHARS: usize = 4_000; +pub(crate) const MAX_TEXT_CHARS: usize = 4_000; type Socket = WebSocketStream>; @@ -571,13 +571,206 @@ fn parse_event(payload: &Value, identity: &Identity) -> Option { } pub fn split_text(text: &str) -> Vec { + use std::borrow::Cow; + use std::collections::VecDeque; + + #[derive(Clone, Default)] + struct Formatting { + fenced_code: bool, + inline_code: bool, + styles: Vec, + quote_prefix: String, + } + + impl Formatting { + fn apply(&mut self, token: &str) { + if token == "```" && !self.inline_code { + self.fenced_code = !self.fenced_code; + } else if token == "`" && !self.fenced_code { + self.inline_code = !self.inline_code; + } else if token.len() == 1 && !self.fenced_code && !self.inline_code { + let style = token.chars().next().unwrap(); + if self.styles.last() == Some(&style) { + self.styles.pop(); + } else { + self.styles.push(style); + } + } + } + + fn closing(&self) -> String { + if self.fenced_code { + "```".to_string() + } else if self.inline_code { + "`".to_string() + } else { + self.styles.iter().rev().collect() + } + } + + fn opening(&self) -> String { + let mut opening = self.quote_prefix.clone(); + if self.fenced_code { + opening.push_str("```"); + } else if self.inline_code { + opening.push('`'); + } else { + opening.extend(&self.styles); + } + opening + } + } + + fn readable_link(token: &str) -> String { + let inner = token + .strip_prefix('<') + .and_then(|value| value.strip_suffix('>')) + .unwrap_or(token); + inner + .split_once('|') + .map(|(url, label)| format!("{label} ({url})")) + .unwrap_or_else(|| inner.to_string()) + } + + fn strip_semantic_markers(text: &str) -> String { + let marker = crate::markdown::SLACK_FORMAT_MARKER.to_string(); + ["```", "`", "*", "_", "~"] + .into_iter() + .fold(text.to_string(), |value, delimiter| { + value.replace(&format!("{marker}{delimiter}"), delimiter) + }) + } + + let mut tokens = VecDeque::new(); + let mut offset = 0; + let mut line_start = true; + while offset < text.len() { + let rest = &text[offset..]; + if line_start && rest.starts_with("> ") { + let mut prefix_len = 0; + while rest[prefix_len..].starts_with("> ") { + prefix_len += 2; + } + let prefix = if prefix_len >= MAX_TEXT_CHARS { + "> " + } else { + &rest[..prefix_len] + }; + tokens.push_back(prefix.to_string()); + offset += prefix_len; + line_start = false; + continue; + } + if rest.starts_with(crate::markdown::SLACK_FORMAT_MARKER) { + let marker_len = crate::markdown::SLACK_FORMAT_MARKER.len_utf8(); + let marked = &rest[marker_len..]; + let delimiter = if marked.starts_with("```") { + Some("```") + } else { + marked + .get(..1) + .filter(|value| matches!(*value, "`" | "*" | "_" | "~")) + }; + if let Some(delimiter) = delimiter { + tokens.push_back(format!( + "{}{delimiter}", + crate::markdown::SLACK_FORMAT_MARKER + )); + offset += marker_len + delimiter.len(); + } else { + tokens.push_back(crate::markdown::SLACK_FORMAT_MARKER.to_string()); + offset += marker_len; + }; + line_start = false; + continue; + } + if rest.starts_with('<') { + if let Some(end) = rest.find('>') { + tokens.push_back(rest[..=end].to_string()); + offset += end + 1; + line_start = false; + continue; + } + } + if let Some(entity) = ["&", "<", ">"] + .into_iter() + .find(|entity| rest.starts_with(entity)) + { + tokens.push_back(entity.to_string()); + offset += entity.len(); + line_start = false; + continue; + } + let character = rest.chars().next().unwrap(); + let character_len = character.len_utf8(); + tokens.push_back(character.to_string()); + offset += character_len; + line_start = character == '\n'; + } + let mut chunks = Vec::new(); let mut current = String::new(); - for character in text.chars() { - if current.chars().count() == MAX_TEXT_CHARS { + let mut current_chars = 0; + let mut current_has_content = false; + let mut formatting = Formatting::default(); + while let Some(token) = tokens.pop_front() { + let delimiter = token + .strip_prefix(crate::markdown::SLACK_FORMAT_MARKER) + .filter(|value| matches!(*value, "```" | "`" | "*" | "_" | "~")); + let rendered = if let Some(delimiter) = delimiter { + Cow::Borrowed(delimiter) + } else if token.starts_with('<') && token.contains(crate::markdown::SLACK_FORMAT_MARKER) { + Cow::Owned(strip_semantic_markers(&token)) + } else { + Cow::Borrowed(token.as_str()) + }; + let is_delimiter = delimiter.is_some(); + let is_quote_prefix = rendered.starts_with("> ") + && rendered + .as_bytes() + .chunks_exact(2) + .all(|pair| pair == b"> "); + let mut after = formatting.clone(); + if is_quote_prefix { + after.quote_prefix = rendered.to_string(); + } else if rendered == "\n" { + after.quote_prefix.clear(); + } else if is_delimiter { + after.apply(&rendered); + } + let closing = after.closing(); + let token_chars = rendered.chars().count(); + if current_chars + token_chars + closing.chars().count() > MAX_TEXT_CHARS { + if !current_has_content && rendered.starts_with('<') && rendered.ends_with('>') { + for character in readable_link(&rendered).chars().rev() { + tokens.push_front(character.to_string()); + } + continue; + } + if !current_has_content { + if formatting.quote_prefix.chars().count() > 2 { + formatting.quote_prefix = "> ".to_string(); + } else { + formatting = Formatting::default(); + } + current = formatting.opening(); + current_chars = current.chars().count(); + tokens.push_front(token); + continue; + } + current.push_str(&formatting.closing()); chunks.push(std::mem::take(&mut current)); + let opening = formatting.opening(); + current_chars = opening.chars().count(); + current.push_str(&opening); + current_has_content = false; + tokens.push_front(token); + continue; } - current.push(character); + current.push_str(&rendered); + current_chars += token_chars; + current_has_content |= !is_delimiter && !is_quote_prefix; + formatting = after; } if !current.is_empty() { chunks.push(current); @@ -714,6 +907,159 @@ mod tests { assert_eq!(chunks[1], "🦀"); } + #[test] + fn chunks_slack_escape_entities_atomically() { + for entity in ["&", "<", ">"] { + let text = format!("{}{entity}", "x".repeat(MAX_TEXT_CHARS - 1)); + let chunks = split_text(&text); + + assert_eq!( + chunks, + vec!["x".repeat(MAX_TEXT_CHARS - 1), entity.to_string()] + ); + assert!(chunks + .iter() + .all(|chunk| chunk.chars().count() <= MAX_TEXT_CHARS)); + } + } + + #[test] + fn chunks_long_blockquotes_with_a_prefix_in_every_message() { + let markdown = format!("> {}", "x".repeat(MAX_TEXT_CHARS)); + let text = crate::markdown::to_slack_mrkdwn_for_chunking(&markdown); + let chunks = split_text(&text); + + assert_eq!(chunks.len(), 2); + assert!(chunks.iter().all(|chunk| chunk.starts_with("> "))); + assert!(chunks + .iter() + .all(|chunk| chunk.chars().count() <= MAX_TEXT_CHARS)); + assert_eq!( + chunks + .iter() + .map(|chunk| chunk.matches('x').count()) + .sum::(), + MAX_TEXT_CHARS + ); + + let nested = format!("> > {}", "x".repeat(MAX_TEXT_CHARS)); + let chunks = split_text(&nested); + assert!(chunks.iter().all(|chunk| chunk.starts_with("> > "))); + assert!(chunks + .iter() + .all(|chunk| chunk.chars().count() <= MAX_TEXT_CHARS)); + } + + #[test] + fn oversized_quote_prefixes_degrade_without_stalling() { + for depth in [MAX_TEXT_CHARS / 2, MAX_TEXT_CHARS / 2 + 1] { + let text = format!("{}content", "> ".repeat(depth)); + let chunks = split_text(&text); + + assert_eq!(chunks, vec!["> content"]); + assert!(chunks.iter().all(|chunk| !chunk.is_empty())); + assert!(chunks + .iter() + .all(|chunk| chunk.chars().count() <= MAX_TEXT_CHARS)); + } + } + + #[test] + fn chunks_are_independently_valid_mrkdwn() { + let markdown = format!("**{}**", "x".repeat(MAX_TEXT_CHARS)); + let text = crate::markdown::to_slack_mrkdwn_for_chunking(&markdown); + let chunks = split_text(&text); + + assert_eq!(chunks.len(), 2); + assert!(chunks + .iter() + .all(|chunk| chunk.chars().count() <= MAX_TEXT_CHARS)); + assert!(chunks + .iter() + .all(|chunk| chunk.starts_with('*') && chunk.ends_with('*'))); + + let markdown = format!("```\n{}\n```", "x".repeat(MAX_TEXT_CHARS)); + let code = crate::markdown::to_slack_mrkdwn_for_chunking(&markdown); + let chunks = split_text(&code); + assert!(chunks + .iter() + .all(|chunk| chunk.chars().count() <= MAX_TEXT_CHARS)); + assert!(chunks + .iter() + .all(|chunk| chunk.starts_with("```") && chunk.ends_with("```"))); + } + + #[test] + fn oversized_links_degrade_to_safe_bounded_text() { + let link = format!("", "x".repeat(MAX_TEXT_CHARS)); + let chunks = split_text(&link); + + assert!(chunks + .iter() + .all(|chunk| chunk.chars().count() <= MAX_TEXT_CHARS)); + assert!(!chunks.iter().any(|chunk| chunk.contains("(), + 1 + ); + assert!(chunks.concat().contains("*\u{200B}literal")); + + let markdown = format!( + "A{}*B {}", + crate::markdown::SLACK_FORMAT_MARKER, + "x".repeat(MAX_TEXT_CHARS) + ); + let formatted = crate::markdown::to_slack_mrkdwn_for_chunking(&markdown); + let clean = crate::markdown::to_slack_mrkdwn(&markdown); + let chunks = split_text(&formatted); + assert!(chunks + .iter() + .all(|chunk| chunk.chars().count() <= MAX_TEXT_CHARS)); + assert_eq!(chunks.concat(), clean); + assert!(clean.contains(crate::markdown::SLACK_FORMAT_MARKER)); + + let markdown = format!( + "[A{}*B](https://example.com) {}", + crate::markdown::SLACK_FORMAT_MARKER, + "x".repeat(MAX_TEXT_CHARS) + ); + let formatted = crate::markdown::to_slack_mrkdwn_for_chunking(&markdown); + let clean = crate::markdown::to_slack_mrkdwn(&markdown); + let chunks = split_text(&formatted); + assert!(chunks + .iter() + .all(|chunk| chunk.chars().count() <= MAX_TEXT_CHARS)); + assert_eq!(chunks.concat(), clean); + assert!(clean.contains(crate::markdown::SLACK_FORMAT_MARKER)); + } + #[tokio::test] async fn socket_mode_persists_before_ack_and_deduplicates_retries() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();