storkit: merge 385_story_slack_markdown_to_mrkdwn_formatting_conversion
This commit is contained in:
@@ -682,6 +682,7 @@ pub async fn slash_command_receive(
|
|||||||
|
|
||||||
let response_text = try_handle_command(&dispatch, &synthetic_message)
|
let response_text = try_handle_command(&dispatch, &synthetic_message)
|
||||||
.unwrap_or_else(|| format!("Command `{keyword}` did not produce a response."));
|
.unwrap_or_else(|| format!("Command `{keyword}` did not produce a response."));
|
||||||
|
let response_text = markdown_to_slack(&response_text);
|
||||||
|
|
||||||
let resp = SlashCommandResponse {
|
let resp = SlashCommandResponse {
|
||||||
response_type: "ephemeral",
|
response_type: "ephemeral",
|
||||||
@@ -714,6 +715,7 @@ async fn handle_incoming_message(
|
|||||||
|
|
||||||
if let Some(response) = try_handle_command(&dispatch, message) {
|
if let Some(response) = try_handle_command(&dispatch, message) {
|
||||||
slog!("[slack] Sending command response to {channel}");
|
slog!("[slack] Sending command response to {channel}");
|
||||||
|
let response = markdown_to_slack(&response);
|
||||||
if let Err(e) = ctx.transport.send_message(channel, &response, "").await {
|
if let Err(e) = ctx.transport.send_message(channel, &response, "").await {
|
||||||
slog!("[slack] Failed to send reply to {channel}: {e}");
|
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.
|
// On Slack, htop uses native message editing for live updates.
|
||||||
let snapshot =
|
let snapshot =
|
||||||
crate::chat::transport::matrix::htop::build_htop_message(&ctx.agents, 0, duration_secs);
|
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 {
|
let msg_id = match ctx.transport.send_message(channel, &snapshot, "").await {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -760,6 +763,7 @@ async fn handle_incoming_message(
|
|||||||
(tick * 2) as u32,
|
(tick * 2) as u32,
|
||||||
duration_secs,
|
duration_secs,
|
||||||
);
|
);
|
||||||
|
let updated = markdown_to_slack(&updated);
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
transport.edit_message(&ch, &msg_id, &updated, "").await
|
transport.edit_message(&ch, &msg_id, &updated, "").await
|
||||||
{
|
{
|
||||||
@@ -793,6 +797,7 @@ async fn handle_incoming_message(
|
|||||||
format!("Usage: `{} delete <number>`", ctx.bot_name)
|
format!("Usage: `{} delete <number>`", ctx.bot_name)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let response = markdown_to_slack(&response);
|
||||||
let _ = ctx.transport.send_message(channel, &response, "").await;
|
let _ = ctx.transport.send_message(channel, &response, "").await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -839,7 +844,8 @@ async fn handle_llm_message(
|
|||||||
let post_channel = channel.to_string();
|
let post_channel = channel.to_string();
|
||||||
let post_task = tokio::spawn(async move {
|
let post_task = tokio::spawn(async move {
|
||||||
while let Some(chunk) = msg_rx.recv().await {
|
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: ®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 <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 ───────────────────────────────────────────────────────────────
|
// ── Tests ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1460,4 +1523,71 @@ mod tests {
|
|||||||
let output = result.unwrap();
|
let output = result.unwrap();
|
||||||
assert!(output.contains("999"), "show output should reference the story number: {output}");
|
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(""), "");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user