storkit: merge 385_story_slack_markdown_to_mrkdwn_formatting_conversion

This commit is contained in:
dave
2026-03-24 22:09:59 +00:00
parent 5d37421f70
commit 18755aac96

View File

@@ -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 <number>`", 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<Regex> =
LazyLock::new(|| Regex::new(r"(?ms)^```.*?\n(.*?)^```").unwrap());
static RE_HEADER: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?m)^#{1,6}\s+(.+)$").unwrap());
static RE_BOLD_ITALIC: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\*\*\*(.+?)\*\*\*").unwrap());
static RE_BOLD: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap());
static RE_STRIKETHROUGH: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"~~(.+?)~~").unwrap());
static RE_LINK: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap());
// 1. Protect fenced code blocks by replacing them with placeholders.
let mut code_blocks: Vec<String> = Vec::new();
let protected = RE_FENCED_BLOCK.replace_all(text, |caps: &regex::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 <url|text>.
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)"),
"<https://example.com|click here>"
);
}
#[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(""), "");
}
}