Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion src/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -642,7 +642,7 @@ impl ChannelContract for Slack {
}

fn outbound_chunks(&self, text: &str, _marker: &str) -> Vec<OutboundChunk> {
crate::slack::split_text(text)
crate::slack::split_text(&crate::markdown::to_slack_mrkdwn_for_chunking(text))
.into_iter()
.map(|text| OutboundChunk {
text,
Expand Down Expand Up @@ -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::<String>(),
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 <https://example.com|the _guide_> and `code`."
);
assert!(!chunks[0]
.text
.contains(crate::markdown::SLACK_FORMAT_MARKER));
}

#[test]
fn imessage_outbound_reply_remains_one_unsplit_message() {
let channel = imessage();
Expand Down
313 changes: 307 additions & 6 deletions src/markdown.rs
Original file line number Diff line number Diff line change
@@ -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<Option<u64>> = Vec::new();
let mut item_stack: Vec<bool> = 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.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);
Expand Down Expand Up @@ -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) <unsafe> & text");
assert_eq!(
mrkdwn,
"<https://example.com/a%7Cb?a=1&amp;b=2|Push &amp; docs> &lt;unsafe&gt; &amp; 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** & <tag>`.\n\n```rust\n**raw** & <tag>\n```");
assert_eq!(
mrkdwn,
"Use `**raw** &amp; &lt;tag&gt;`.\n\n```\n**raw** &amp; &lt;tag&gt;\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 <https://example.com|the _guide_>.\n\n• `code`"
);
}

#[test]
fn renders_bold_italic_and_code() {
let html = to_telegram_html("**bold** and *italic* and `code`");
Expand Down
Loading
Loading