From 7e2d801ffc5071d68b07a30c06d8a3f97ce58e62 Mon Sep 17 00:00:00 2001 From: "Aleksander W. Oleszkiewicz (Alek)" <24917047+alekwo@users.noreply.github.com> Date: Sun, 15 Feb 2026 15:51:19 +0100 Subject: [PATCH] Implement markdown conversion for Slack messages Add markdown conversion for Slack messages including italics, bold, and table formatting. --- nanobot/channels/slack.py | 114 +++++++++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index be95dd2..7298435 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -84,7 +84,7 @@ class SlackChannel(BaseChannel): use_thread = thread_ts and channel_type != "im" await self._web_client.chat_postMessage( channel=msg.chat_id, - text=msg.content or "", + text=self._convert_markdown(msg.content) or "", thread_ts=thread_ts if use_thread else None, ) except Exception as e: @@ -203,3 +203,115 @@ class SlackChannel(BaseChannel): if not text or not self._bot_user_id: return text return re.sub(rf"<@{re.escape(self._bot_user_id)}>\s*", "", text).strip() + + def _convert_markdown(self, text: str) -> str: + if not text: + return text + def convert_formatting(input: str) -> str: + # Convert italics + # Step 1: *text* -> _text_ + converted_text = re.sub( + r"(?m)(^|[^\*])\*([^\*].+?[^\*])\*([^\*]|$)", r"\1_\2_\3", input) + # Convert bold + # Step 2.a: **text** -> *text* + converted_text = re.sub( + r"(?m)(^|[^\*])\*\*([^\*].+?[^\*])\*\*([^\*]|$)", r"\1*\2*\3", converted_text) + # Step 2.b: __text__ -> *text* + converted_text = re.sub( + r"(?m)(^|[^_])__([^_].+?[^_])__([^_]|$)", r"\1*\2*\3", converted_text) + # convert bold italics + # Step 3.a: ***text*** -> *_text_* + converted_text = re.sub( + r"(?m)(^|[^\*])\*\*\*([^\*].+?[^\*])\*\*\*([^\*]|$)", r"\1*_\2_*\3", converted_text) + # Step 3.b - ___text___ to *_text_* + converted_text = re.sub( + r"(?m)(^|[^_])___([^_].+?[^_])___([^_]|$)", r"\1*_\2_*\3", converted_text) + return converted_text + def escape_mrkdwn(text: str) -> str: + return (text.replace('&', '&') + .replace('<', '<') + .replace('>', '>')) + def convert_table(match: re.Match) -> str: + # Slack doesn't support Markdown tables + # Convert table to bulleted list with sections + # -- input_md: + # Some text before the table. + # | Col1 | Col2 | Col3 | + # |-----|----------|------| + # | Row1 - A | Row1 - B | Row1 - C | + # | Row2 - D | Row2 - E | Row2 - F | + # + # Some text after the table. + # + # -- will be converted to: + # Some text before the table. + # > *Col1* : Row1 - A + # • *Col2*: Row1 - B + # • *Col3*: Row1 - C + # > *Col1* : Row2 - D + # • *Col2*: Row2 - E + # • *Col3*: Row2 - F + # + # Some text after the table. + + block = match.group(0).strip() + lines = [line.strip() + for line in block.split('\n') if line.strip()] + + if len(lines) < 2: + return block + + # 1. Parse Headers from the first line + # Split by pipe, filtering out empty start/end strings caused by outer pipes + header_line = lines[0].strip('|') + headers = [escape_mrkdwn(h.strip()) + for h in header_line.split('|')] + + # 2. Identify Data Start (Skip Separator) + data_start_idx = 1 + # If line 2 contains only separator chars (|-: ), skip it + if len(lines) > 1 and not re.search(r'[^|\-\s:]', lines[1]): + data_start_idx = 2 + + # 3. Process Data Rows + slack_lines = [] + for line in lines[data_start_idx:]: + # Clean and split cells + clean_line = line.strip('|') + cells = [escape_mrkdwn(c.strip()) + for c in clean_line.split('|')] + + # Normalize cell count to match headers + if len(cells) < len(headers): + cells += [''] * (len(headers) - len(cells)) + cells = cells[:len(headers)] + + # Skip empty rows + if not any(cells): + continue + + # Key is the first column + key = cells[0] + label = headers[0] + slack_lines.append( + f"> *{label}* : {key}" if key else "> *{label}* : --") + + # Sub-bullets for remaining columns + for i, cell in enumerate(cells[1:], 1): + if cell: + label = headers[i] if i < len(headers) else "Col" + slack_lines.append(f" • *{label}*: {cell}") + + slack_lines.append("") # Spacer between items + + return "\n".join(slack_lines).rstrip() + + # (?m) : Multiline mode so ^ matches start of line and $ end of line + # ^\| : Start of line and a literal pipe + # .*?\|$ : Rest of the line and a pipe at the end + # (?:\n(?:\|\:?-{3,}\:?)*?\|$) : A heading line with at least three dashes in each column, pipes, and : e.g. |:---|----|:---:| + # (?:\n\|.*?\|$)* : Zero or more subsequent lines that ALSO start and end with a pipe + table_pattern = r'(?m)^\|.*?\|$(?:\n(?:\|\:?-{3,}\:?)*?\|$)(?:\n\|.*?\|$)*' + + input_md = convert_formatting(text) + return re.sub(table_pattern, convert_table, input_md)