From 18755aac96de0a62898f21f2823b16a370e69bce Mon Sep 17 00:00:00 2001 From: dave Date: Tue, 24 Mar 2026 22:09:59 +0000 Subject: [PATCH] storkit: merge 385_story_slack_markdown_to_mrkdwn_formatting_conversion --- server/src/chat/transport/slack.rs | 132 ++++++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 1 deletion(-) diff --git a/server/src/chat/transport/slack.rs b/server/src/chat/transport/slack.rs index cad7293..be2ca87 100644 --- a/server/src/chat/transport/slack.rs +++ b/server/src/chat/transport/slack.rs @@ -682,6 +682,7 @@ pub async fn slash_command_receive( let response_text = try_handle_command(&dispatch, &synthetic_message) .unwrap_or_else(|| format!("Command `{keyword}` did not produce a response.")); + let response_text = markdown_to_slack(&response_text); let resp = SlashCommandResponse { response_type: "ephemeral", @@ -714,6 +715,7 @@ async fn handle_incoming_message( if let Some(response) = try_handle_command(&dispatch, message) { slog!("[slack] Sending command response to {channel}"); + let response = markdown_to_slack(&response); if let Err(e) = ctx.transport.send_message(channel, &response, "").await { slog!("[slack] Failed to send reply to {channel}: {e}"); } @@ -739,6 +741,7 @@ async fn handle_incoming_message( // On Slack, htop uses native message editing for live updates. let snapshot = crate::chat::transport::matrix::htop::build_htop_message(&ctx.agents, 0, duration_secs); + let snapshot = markdown_to_slack(&snapshot); let msg_id = match ctx.transport.send_message(channel, &snapshot, "").await { Ok(id) => id, Err(e) => { @@ -760,6 +763,7 @@ async fn handle_incoming_message( (tick * 2) as u32, duration_secs, ); + let updated = markdown_to_slack(&updated); if let Err(e) = transport.edit_message(&ch, &msg_id, &updated, "").await { @@ -793,6 +797,7 @@ async fn handle_incoming_message( format!("Usage: `{} delete `", ctx.bot_name) } }; + let response = markdown_to_slack(&response); let _ = ctx.transport.send_message(channel, &response, "").await; return; } @@ -839,7 +844,8 @@ async fn handle_llm_message( let post_channel = channel.to_string(); let post_task = tokio::spawn(async move { while let Some(chunk) = msg_rx.recv().await { - let _ = post_transport.send_message(&post_channel, &chunk, "").await; + let formatted = markdown_to_slack(&chunk); + let _ = post_transport.send_message(&post_channel, &formatted, "").await; } }); @@ -944,6 +950,63 @@ async fn handle_llm_message( } } +// ── Markdown → mrkdwn conversion ──────────────────────────────────────── + +/// Convert Markdown text to Slack mrkdwn format. +/// +/// Slack uses its own "mrkdwn" syntax which differs from standard Markdown. +/// This function converts common Markdown constructs so messages render +/// nicely in Slack instead of showing raw Markdown syntax. +pub fn markdown_to_slack(text: &str) -> String { + use regex::Regex; + use std::sync::LazyLock; + + // Regexes are compiled once and reused across calls. + static RE_FENCED_BLOCK: LazyLock = + LazyLock::new(|| Regex::new(r"(?ms)^```.*?\n(.*?)^```").unwrap()); + static RE_HEADER: LazyLock = + LazyLock::new(|| Regex::new(r"(?m)^#{1,6}\s+(.+)$").unwrap()); + static RE_BOLD_ITALIC: LazyLock = + LazyLock::new(|| Regex::new(r"\*\*\*(.+?)\*\*\*").unwrap()); + static RE_BOLD: LazyLock = + LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap()); + static RE_STRIKETHROUGH: LazyLock = + LazyLock::new(|| Regex::new(r"~~(.+?)~~").unwrap()); + static RE_LINK: LazyLock = + LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap()); + + // 1. Protect fenced code blocks by replacing them with placeholders. + let mut code_blocks: Vec = Vec::new(); + let protected = RE_FENCED_BLOCK.replace_all(text, |caps: ®ex::Captures| { + let idx = code_blocks.len(); + code_blocks.push(caps[0].to_string()); + format!("\x00CODEBLOCK{idx}\x00") + }); + let mut out = protected.into_owned(); + + // 2. Headers → bold text. + out = RE_HEADER.replace_all(&out, "*$1*").into_owned(); + + // 3. Bold+italic (***text***) → bold italic (*_text_*). + out = RE_BOLD_ITALIC.replace_all(&out, "*_${1}_*").into_owned(); + + // 4. Bold (**text**) → Slack bold (*text*). + out = RE_BOLD.replace_all(&out, "*$1*").into_owned(); + + // 5. Strikethrough (~~text~~) → Slack strikethrough (~text~). + out = RE_STRIKETHROUGH.replace_all(&out, "~$1~").into_owned(); + + // 6. Links [text](url) → Slack mrkdwn format . + out = RE_LINK.replace_all(&out, "<$2|$1>").into_owned(); + + // 7. Restore code blocks. + for (idx, block) in code_blocks.iter().enumerate() { + out = out.replace(&format!("\x00CODEBLOCK{idx}\x00"), block); + } + + out +} + // ── Tests ─────────────────────────────────────────────────────────────── #[cfg(test)] @@ -1460,4 +1523,71 @@ mod tests { let output = result.unwrap(); assert!(output.contains("999"), "show output should reference the story number: {output}"); } + + // ── markdown_to_slack tests ────────────────────────────────────────── + + #[test] + fn slack_headers_become_bold() { + assert_eq!(markdown_to_slack("# Title"), "*Title*"); + assert_eq!(markdown_to_slack("## Subtitle"), "*Subtitle*"); + assert_eq!(markdown_to_slack("### Section"), "*Section*"); + assert_eq!(markdown_to_slack("###### Deep"), "*Deep*"); + } + + #[test] + fn slack_bold_converted() { + assert_eq!(markdown_to_slack("**bold text**"), "*bold text*"); + } + + #[test] + fn slack_bold_italic_converted() { + assert_eq!(markdown_to_slack("***emphasis***"), "*_emphasis_*"); + } + + #[test] + fn slack_strikethrough_converted() { + assert_eq!(markdown_to_slack("~~removed~~"), "~removed~"); + } + + #[test] + fn slack_links_converted_to_mrkdwn() { + assert_eq!( + markdown_to_slack("[click here](https://example.com)"), + "" + ); + } + + #[test] + fn slack_inline_code_preserved() { + assert_eq!(markdown_to_slack("use `foo()` here"), "use `foo()` here"); + } + + #[test] + fn slack_fenced_code_block_preserved() { + let input = "```rust\nlet x = 1;\n```"; + let output = markdown_to_slack(input); + assert!(output.contains("let x = 1;"), "code block content must be preserved"); + assert!(output.contains("```"), "fenced code delimiters must be preserved"); + } + + #[test] + fn slack_code_block_content_not_transformed() { + let input = "```\n**not bold** # not header\n```"; + let output = markdown_to_slack(input); + assert!( + output.contains("**not bold**"), + "markdown inside code blocks must not be transformed" + ); + } + + #[test] + fn slack_plain_text_unchanged() { + let plain = "Hello, this is a plain message with no formatting."; + assert_eq!(markdown_to_slack(plain), plain); + } + + #[test] + fn slack_empty_string_unchanged() { + assert_eq!(markdown_to_slack(""), ""); + } }