mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
Mechanically re-derived: bin/debrand runs over a fresh import of posthog/posthog@fa1a9a60d4 (2026-08-06). The pipeline (.hanzo/workflows), the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md and the squashed initial migration are restored from the shipped tree, same as the previous import. The prune's import-closure guard kept 21 products the new core now imports; dropping them is a refactor now, recorded rather than forced.
918 lines
33 KiB
Python
918 lines
33 KiB
Python
"""Convert between Insights rich content/markdown and Slack payload formats."""
|
|
|
|
import re
|
|
import html as html_mod
|
|
import unicodedata
|
|
from collections.abc import Iterable
|
|
from typing import Any
|
|
|
|
JSON = dict[str, Any]
|
|
|
|
# Pre-compiled regexes for performance (compiled once at module load)
|
|
# Slack mrkdwn patterns - negated character classes are safe from ReDoS
|
|
_RE_SLACK_USER_MENTION = re.compile(r"<@([A-Z0-9]+)>")
|
|
_RE_SLACK_LINK_WITH_LABEL = re.compile(r"<([^|>]+)\|([^>]+)>")
|
|
_RE_SLACK_LINK_BARE = re.compile(r"<([^>]+)>")
|
|
_RE_SLACK_BOLD_ITALIC = re.compile(r"\*_([^_]+)_\*")
|
|
_RE_SLACK_BOLD = re.compile(r"(?<!\*)\*([^*\n]+)\*(?!\*)")
|
|
_RE_SLACK_ITALIC = re.compile(r"(?<!_)_([^_\n]+)_(?!_)")
|
|
_RE_SLACK_STRIKE = re.compile(r"(?<!~)~([^~\n]+)~(?!~)")
|
|
|
|
# Markdown patterns
|
|
_RE_MD_IMAGE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
|
|
_RE_MD_BOLD_ITALIC = re.compile(r"\*\*\*(.+?)\*\*\*")
|
|
_RE_MD_BOLD = re.compile(r"\*\*(.+?)\*\*")
|
|
_RE_MD_ITALIC = re.compile(r"(?<!\*)\*([^*]+?)\*(?!\*)")
|
|
_RE_MD_STRIKE = re.compile(r"~~(.+?)~~")
|
|
_RE_MD_LINK = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
|
_RE_MD_MENTION = re.compile(r"@member:([a-f0-9-]+)")
|
|
_RE_SINGLE_NEWLINE = re.compile(r"(?<!\n)\n(?!\n)")
|
|
_RE_MD_ESCAPE = re.compile(r"([\\`*_{}\[\]()#+\-.!|])")
|
|
_RE_MD_ESCAPED_CHAR = re.compile(r"\\([\\`*_{}\[\]()#+\-.!|])")
|
|
_RE_ALT_ESCAPE = re.compile(r"([\\\]])")
|
|
_RE_SLACK_EMOJI = re.compile(r":([a-z0-9_+\-]+):")
|
|
|
|
|
|
def _slack_unicode_to_char(unicode_hex: str) -> str | None:
|
|
"""Convert Slack's unicode hex field (e.g. '1f44d' or '1f1fa-1f1f8') to a Unicode string."""
|
|
if not unicode_hex:
|
|
return None
|
|
try:
|
|
return "".join(chr(int(cp, 16)) for cp in unicode_hex.split("-"))
|
|
except (ValueError, OverflowError):
|
|
return None
|
|
|
|
|
|
_SLACK_EMOJI_NAME_TO_UNICODE: dict[str, str | None] = {
|
|
"THUMBSUP": "THUMBS UP SIGN",
|
|
"+1": "THUMBS UP SIGN",
|
|
"-1": "THUMBS DOWN SIGN",
|
|
"THUMBSDOWN": "THUMBS DOWN SIGN",
|
|
"HEART": "HEAVY BLACK HEART",
|
|
"HANKEY": "PILE OF POO",
|
|
"POOP": "PILE OF POO",
|
|
"SHIPIT": None,
|
|
"SIMPLE SMILE": "SLIGHTLY SMILING FACE",
|
|
"SMILE": "SMILING FACE WITH OPEN MOUTH AND SMILING EYES",
|
|
"LAUGHING": "SMILING FACE WITH OPEN MOUTH AND TIGHTLY-CLOSED EYES",
|
|
"WINK": "WINKING FACE",
|
|
"BLUSH": "SMILING FACE WITH SMILING EYES",
|
|
"GRINNING": "GRINNING FACE",
|
|
"RELAXED": "WHITE SMILING FACE",
|
|
"STUCK OUT TONGUE": "FACE WITH STUCK-OUT TONGUE",
|
|
"STUCK OUT TONGUE WINKING EYE": "FACE WITH STUCK-OUT TONGUE AND WINKING EYE",
|
|
"STUCK OUT TONGUE CLOSED EYES": "FACE WITH STUCK-OUT TONGUE AND TIGHTLY-CLOSED EYES",
|
|
"DISAPPOINTED": "DISAPPOINTED FACE",
|
|
"RAGE": "POUTING FACE",
|
|
"CRY": "CRYING FACE",
|
|
"SOB": "LOUDLY CRYING FACE",
|
|
"FEARFUL": "FEARFUL FACE",
|
|
"SCREAM": "FACE SCREAMING IN FEAR",
|
|
"SWEAT": "FACE WITH COLD SWEAT",
|
|
"SWEAT SMILE": "SMILING FACE WITH OPEN MOUTH AND COLD SWEAT",
|
|
"SUNGLASSES": "SMILING FACE WITH SUNGLASSES",
|
|
"TRIUMPH": "FACE WITH LOOK OF TRIUMPH",
|
|
"FLUSHED": "FLUSHED FACE",
|
|
"SLEEPING": "SLEEPING FACE",
|
|
"CONFUSED": "CONFUSED FACE",
|
|
"MASK": "FACE WITH MEDICAL MASK",
|
|
"SMIRK": "SMIRKING FACE",
|
|
"HUSHED": "HUSHED FACE",
|
|
"NO MOUTH": "FACE WITHOUT MOUTH",
|
|
"INNOCENT": "SMILING FACE WITH HALO",
|
|
"ALIEN": "EXTRATERRESTRIAL ALIEN",
|
|
"TADA": "PARTY POPPER",
|
|
"RAISED HANDS": "PERSON RAISING BOTH HANDS IN CELEBRATION",
|
|
"PRAY": "PERSON WITH FOLDED HANDS",
|
|
"CLAP": "CLAPPING HANDS SIGN",
|
|
"WAVE": "WAVING HAND SIGN",
|
|
"OK HAND": "OK HAND SIGN",
|
|
"POINT UP": "WHITE UP POINTING INDEX",
|
|
"POINT DOWN": "WHITE DOWN POINTING INDEX",
|
|
"POINT LEFT": "WHITE LEFT POINTING BACKHAND INDEX",
|
|
"POINT RIGHT": "WHITE RIGHT POINTING BACKHAND INDEX",
|
|
"MUSCLE": "FLEXED BICEPS",
|
|
"100": "HUNDRED POINTS SYMBOL",
|
|
"WHITE CHECK MARK": "WHITE HEAVY CHECK MARK",
|
|
"X": "CROSS MARK",
|
|
"WARNING": "WARNING SIGN",
|
|
"ZAP": "HIGH VOLTAGE SIGN",
|
|
"STAR": "WHITE MEDIUM STAR",
|
|
"STAR2": "GLOWING STAR",
|
|
"BOOM": "COLLISION SYMBOL",
|
|
"EXCLAMATION": "HEAVY EXCLAMATION MARK SYMBOL",
|
|
"QUESTION": "BLACK QUESTION MARK ORNAMENT",
|
|
"GREY EXCLAMATION": "WHITE EXCLAMATION MARK ORNAMENT",
|
|
"GREY QUESTION": "WHITE QUESTION MARK ORNAMENT",
|
|
"BULB": "ELECTRIC LIGHT BULB",
|
|
"LINK": "LINK SYMBOL",
|
|
"MEGA": "CHEERING MEGAPHONE",
|
|
"MAG": "LEFT-POINTING MAGNIFYING GLASS",
|
|
"MAG RIGHT": "RIGHT-POINTING MAGNIFYING GLASS",
|
|
}
|
|
|
|
|
|
def _slack_emoji_name_to_char(name: str) -> str | None:
|
|
"""Best-effort conversion of a Slack emoji shortcode name to a Unicode character.
|
|
|
|
Uses Python's unicodedata reverse lookup. Covers standard emoji but not
|
|
custom workspace emoji (those stay as :name:).
|
|
"""
|
|
unicode_name = name.replace("_", " ").upper()
|
|
resolved_name = _SLACK_EMOJI_NAME_TO_UNICODE.get(unicode_name, unicode_name)
|
|
if resolved_name is None:
|
|
return None
|
|
|
|
try:
|
|
return unicodedata.lookup(resolved_name)
|
|
except KeyError:
|
|
return None
|
|
|
|
|
|
def _collect_user_ids(elements: list[JSON], ids: set[str]) -> None:
|
|
for element in elements:
|
|
if element.get("type") == "user":
|
|
uid = element.get("user_id")
|
|
if uid:
|
|
ids.add(uid)
|
|
nested = element.get("elements")
|
|
if nested:
|
|
_collect_user_ids(nested, ids)
|
|
|
|
|
|
def extract_slack_user_ids(text: str, blocks: list[JSON] | None = None) -> set[str]:
|
|
"""Collect all Slack user IDs referenced in mrkdwn text and/or rich_text blocks."""
|
|
ids: set[str] = set()
|
|
|
|
if text:
|
|
ids.update(_RE_SLACK_USER_MENTION.findall(text))
|
|
|
|
if blocks:
|
|
for block in blocks:
|
|
if block.get("type") != "rich_text":
|
|
continue
|
|
_collect_user_ids(block.get("elements", []), ids)
|
|
|
|
return ids
|
|
|
|
|
|
def strip_slack_user_mentions(text: str) -> str:
|
|
"""Remove ``<@USERID>`` mention tokens, leaving only the message's own text."""
|
|
return _RE_SLACK_USER_MENTION.sub("", text)
|
|
|
|
|
|
def content_to_slack_mrkdwn(content: str) -> str:
|
|
"""Convert markdown comment content to Slack mrkdwn text.
|
|
|
|
Backslash-escaped characters are unescaped: mrkdwn has no escape syntax, so a
|
|
markdown ``\\.`` would otherwise reach Slack as a literal backslash. They are
|
|
masked before the syntax conversions below so an escaped ``*`` is not mistaken
|
|
for emphasis.
|
|
"""
|
|
if not content:
|
|
return ""
|
|
|
|
text = content
|
|
|
|
escaped_chars: list[str] = []
|
|
|
|
def capture_escaped_char(match: re.Match) -> str:
|
|
escaped_chars.append(match.group(1))
|
|
return f"\x00ESC{len(escaped_chars) - 1}\x00"
|
|
|
|
text = _RE_MD_ESCAPED_CHAR.sub(capture_escaped_char, text)
|
|
|
|
text = _RE_MD_IMAGE.sub(r"<\2|\1>", text)
|
|
|
|
bold_italic_matches: list[str] = []
|
|
|
|
def capture_bold_italic(match: re.Match) -> str:
|
|
bold_italic_matches.append(match.group(1))
|
|
return f"\x00BI{len(bold_italic_matches) - 1}\x00"
|
|
|
|
text = _RE_MD_BOLD_ITALIC.sub(capture_bold_italic, text)
|
|
|
|
bold_matches: list[str] = []
|
|
|
|
def capture_bold(match: re.Match) -> str:
|
|
bold_matches.append(match.group(1))
|
|
return f"\x00B{len(bold_matches) - 1}\x00"
|
|
|
|
text = _RE_MD_BOLD.sub(capture_bold, text)
|
|
text = _RE_MD_ITALIC.sub(r"_\1_", text)
|
|
text = _RE_MD_STRIKE.sub(r"~\1~", text)
|
|
text = _RE_MD_LINK.sub(r"<\2|\1>", text)
|
|
|
|
for index, value in enumerate(bold_matches):
|
|
text = text.replace(f"\x00B{index}\x00", f"*{value}*")
|
|
|
|
for index, value in enumerate(bold_italic_matches):
|
|
text = text.replace(f"\x00BI{index}\x00", f"*_{value}_*")
|
|
|
|
def resolve_mention(match: re.Match) -> str:
|
|
uuid_str = match.group(1)
|
|
try:
|
|
from insights.models import User
|
|
|
|
user = User.objects.filter(uuid=uuid_str).first()
|
|
if user:
|
|
name = f"{user.first_name} {user.last_name}".strip() or user.email
|
|
return f"@{name}"
|
|
except Exception:
|
|
pass
|
|
return "@teammate"
|
|
|
|
text = _RE_MD_MENTION.sub(resolve_mention, text)
|
|
|
|
for index, value in enumerate(escaped_chars):
|
|
text = text.replace(f"\x00ESC{index}\x00", value)
|
|
|
|
return text
|
|
|
|
|
|
def slack_mrkdwn_to_content(text: str, user_names: dict[str, str] | None = None) -> str:
|
|
"""Convert Slack mrkdwn text to markdown content."""
|
|
if not text:
|
|
return ""
|
|
|
|
def _replace_user_mention(match: re.Match) -> str:
|
|
uid = match.group(1)
|
|
if user_names and uid in user_names:
|
|
return f"@{user_names[uid]}"
|
|
return ""
|
|
|
|
text = _RE_SLACK_USER_MENTION.sub(_replace_user_mention, text)
|
|
# Convert emoji shortcodes before formatting (prevents italic regex mangling underscored names)
|
|
text = _RE_SLACK_EMOJI.sub(lambda m: _slack_emoji_name_to_char(m.group(1)) or m.group(0), text)
|
|
# Convert labeled links <url|label> to [label](url)
|
|
text = _RE_SLACK_LINK_WITH_LABEL.sub(r"[\2](\1)", text)
|
|
# Convert bare links <url> to just url
|
|
text = _RE_SLACK_LINK_BARE.sub(r"\1", text)
|
|
# Convert Slack formatting to markdown
|
|
text = _RE_SLACK_BOLD_ITALIC.sub(r"***\1***", text)
|
|
text = _RE_SLACK_BOLD.sub(r"**\1**", text)
|
|
text = _RE_SLACK_ITALIC.sub(r"*\1*", text)
|
|
text = _RE_SLACK_STRIKE.sub(r"~~\1~~", text)
|
|
|
|
return text
|
|
|
|
|
|
def _normalize_single_newlines_to_markdown(text: str) -> str:
|
|
if not text:
|
|
return ""
|
|
return _RE_SINGLE_NEWLINE.sub(" \n", text)
|
|
|
|
|
|
def _escape_markdown(text: str) -> str:
|
|
return _RE_MD_ESCAPE.sub(r"\\\1", text)
|
|
|
|
|
|
def _escape_alt_text(text: str) -> str:
|
|
return _RE_ALT_ESCAPE.sub(r"\\\1", text)
|
|
|
|
|
|
def _style_to_marks(style: JSON | None) -> list[JSON]:
|
|
if not style:
|
|
return []
|
|
|
|
marks: list[JSON] = []
|
|
if style.get("bold"):
|
|
marks.append({"type": "bold"})
|
|
if style.get("italic"):
|
|
marks.append({"type": "italic"})
|
|
if style.get("underline"):
|
|
marks.append({"type": "underline"})
|
|
if style.get("strike"):
|
|
marks.append({"type": "strike"})
|
|
if style.get("code"):
|
|
marks.append({"type": "code"})
|
|
return marks
|
|
|
|
|
|
def _marks_to_slack_style(marks: Iterable[JSON]) -> JSON:
|
|
style: JSON = {}
|
|
for mark in marks:
|
|
mark_type = mark.get("type")
|
|
if mark_type == "bold":
|
|
style["bold"] = True
|
|
elif mark_type == "italic":
|
|
style["italic"] = True
|
|
elif mark_type == "underline":
|
|
style["underline"] = True
|
|
elif mark_type == "strike":
|
|
style["strike"] = True
|
|
elif mark_type == "code":
|
|
style["code"] = True
|
|
return style
|
|
|
|
|
|
def _append_text_with_breaks(nodes: list[JSON], text: str, marks: list[JSON]) -> None:
|
|
if not text:
|
|
return
|
|
|
|
parts = text.split("\n")
|
|
for index, part in enumerate(parts):
|
|
if part:
|
|
node: JSON = {"type": "text", "text": part}
|
|
if marks:
|
|
node["marks"] = marks
|
|
nodes.append(node)
|
|
if index < len(parts) - 1:
|
|
nodes.append({"type": "hardBreak"})
|
|
|
|
|
|
def _parse_rich_text_inline_elements(elements: list[JSON], user_names: dict[str, str] | None = None) -> list[JSON]:
|
|
nodes: list[JSON] = []
|
|
|
|
for element in elements:
|
|
element_type = element.get("type")
|
|
|
|
if element_type == "text":
|
|
_append_text_with_breaks(nodes, element.get("text", ""), _style_to_marks(element.get("style")))
|
|
continue
|
|
|
|
if element_type == "link":
|
|
url = element.get("url")
|
|
if not url:
|
|
continue
|
|
marks = _style_to_marks(element.get("style"))
|
|
marks.append({"type": "link", "attrs": {"href": url}})
|
|
_append_text_with_breaks(nodes, element.get("text") or url, marks)
|
|
continue
|
|
|
|
if element_type == "emoji":
|
|
char = _slack_unicode_to_char(element.get("unicode", ""))
|
|
if not char:
|
|
char = _slack_emoji_name_to_char(element.get("name", ""))
|
|
nodes.append({"type": "text", "text": char or f":{element.get('name', '')}:"})
|
|
continue
|
|
|
|
if element_type == "user":
|
|
uid = element.get("user_id", "")
|
|
name = user_names.get(uid) if user_names else None
|
|
display = f"@{name}" if name else f"<@{uid}>"
|
|
nodes.append({"type": "text", "text": display})
|
|
continue
|
|
|
|
if element_type == "channel":
|
|
label = element.get("name") or element.get("channel_id", "")
|
|
nodes.append({"type": "text", "text": f"#{label}"})
|
|
continue
|
|
|
|
if element_type == "broadcast":
|
|
nodes.append({"type": "text", "text": f"@{element.get('range', 'channel')}"})
|
|
continue
|
|
|
|
if element.get("text"):
|
|
_append_text_with_breaks(nodes, element.get("text", ""), _style_to_marks(element.get("style")))
|
|
|
|
return nodes
|
|
|
|
|
|
def _preformatted_elements_to_text(elements: list[JSON], user_names: dict[str, str] | None = None) -> str:
|
|
"""Flatten a Slack preformatted block's inline elements to literal text.
|
|
|
|
Code blocks hold plain text (no marks/links), so each element is reduced to its
|
|
textual value rather than dropped: link -> label or url, emoji -> char or :name:,
|
|
user -> resolved name or raw mention, channel -> #name.
|
|
"""
|
|
parts: list[str] = []
|
|
for el in elements:
|
|
el_type = el.get("type")
|
|
if el_type == "link":
|
|
parts.append(el.get("text") or el.get("url", ""))
|
|
elif el_type == "emoji":
|
|
char = _slack_unicode_to_char(el.get("unicode", "")) or _slack_emoji_name_to_char(el.get("name", ""))
|
|
parts.append(char or f":{el.get('name', '')}:")
|
|
elif el_type == "user":
|
|
uid = el.get("user_id", "")
|
|
name = user_names.get(uid) if user_names else None
|
|
parts.append(f"@{name}" if name else f"<@{uid}>")
|
|
elif el_type == "channel":
|
|
parts.append(f"#{el.get('name') or el.get('channel_id', '')}")
|
|
else:
|
|
parts.append(el.get("text", ""))
|
|
return "".join(parts)
|
|
|
|
|
|
def slack_blocks_to_rich_content(blocks: list[JSON] | None, user_names: dict[str, str] | None = None) -> JSON | None:
|
|
"""Parse Slack rich_text blocks into Insights SupportEditor-compatible JSON."""
|
|
if not blocks:
|
|
return None
|
|
|
|
doc_nodes: list[JSON] = []
|
|
saw_rich_text = False
|
|
|
|
for block in blocks:
|
|
if block.get("type") != "rich_text":
|
|
continue
|
|
|
|
saw_rich_text = True
|
|
for element in block.get("elements", []):
|
|
element_type = element.get("type")
|
|
|
|
if element_type == "rich_text_section":
|
|
inline_nodes = _parse_rich_text_inline_elements(element.get("elements", []), user_names)
|
|
if inline_nodes:
|
|
doc_nodes.append({"type": "paragraph", "content": inline_nodes})
|
|
continue
|
|
|
|
if element_type == "rich_text_list":
|
|
list_type = "orderedList" if element.get("style") == "ordered" else "bulletList"
|
|
items: list[JSON] = []
|
|
for list_item in element.get("elements", []):
|
|
if list_item.get("type") != "rich_text_section":
|
|
continue
|
|
inline_nodes = _parse_rich_text_inline_elements(list_item.get("elements", []), user_names)
|
|
if inline_nodes:
|
|
items.append({"type": "listItem", "content": [{"type": "paragraph", "content": inline_nodes}]})
|
|
if items:
|
|
doc_nodes.append({"type": list_type, "content": items})
|
|
continue
|
|
|
|
if element_type == "rich_text_preformatted":
|
|
code_text = _preformatted_elements_to_text(element.get("elements", []), user_names)
|
|
doc_nodes.append(
|
|
{
|
|
"type": "codeBlock",
|
|
"content": [{"type": "text", "text": code_text}] if code_text else [],
|
|
}
|
|
)
|
|
continue
|
|
|
|
if element_type == "rich_text_quote":
|
|
inline_nodes = _parse_rich_text_inline_elements(element.get("elements", []), user_names)
|
|
if inline_nodes:
|
|
inline_nodes.insert(0, {"type": "text", "text": "> "})
|
|
doc_nodes.append({"type": "paragraph", "content": inline_nodes})
|
|
continue
|
|
|
|
if not saw_rich_text:
|
|
return None
|
|
|
|
return {"type": "doc", "content": doc_nodes or [{"type": "paragraph", "content": []}]}
|
|
|
|
|
|
def _serialize_text_node_to_markdown(node: JSON) -> str:
|
|
text = node.get("text", "")
|
|
marks = node.get("marks", [])
|
|
has_code_mark = any(mark.get("type") == "code" for mark in marks)
|
|
|
|
if not has_code_mark:
|
|
text = _escape_markdown(text)
|
|
|
|
link_mark = next((mark for mark in marks if mark.get("type") == "link"), None)
|
|
|
|
for mark in marks:
|
|
mark_type = mark.get("type")
|
|
if mark_type == "bold":
|
|
text = f"**{text}**"
|
|
elif mark_type == "italic":
|
|
text = f"*{text}*"
|
|
elif mark_type == "strike":
|
|
text = f"~~{text}~~"
|
|
elif mark_type == "code":
|
|
text = f"`{text}`" if "`" not in text else f"`` {text} ``"
|
|
# Underline has no standard markdown syntax - preserve in rich content only.
|
|
|
|
if link_mark and link_mark.get("attrs", {}).get("href"):
|
|
text = f"[{text}]({link_mark['attrs']['href']})"
|
|
|
|
return text
|
|
|
|
|
|
def _serialize_inline_nodes_to_markdown(nodes: list[JSON], include_images: bool = True) -> str:
|
|
chunks: list[str] = []
|
|
for node in nodes:
|
|
node_type = node.get("type")
|
|
if node_type == "text":
|
|
chunks.append(_serialize_text_node_to_markdown(node))
|
|
elif node_type == "hardBreak":
|
|
chunks.append(" \n")
|
|
elif node_type == "image" and include_images:
|
|
src = node.get("attrs", {}).get("src")
|
|
if src:
|
|
alt = _escape_alt_text(node.get("attrs", {}).get("alt", "image"))
|
|
chunks.append(f"")
|
|
return "".join(chunks)
|
|
|
|
|
|
def _list_start(node: JSON) -> int:
|
|
attrs = node.get("attrs") or {}
|
|
try:
|
|
return max(int(attrs.get("start") or 1), 1)
|
|
except (TypeError, ValueError):
|
|
return 1
|
|
|
|
|
|
def _serialize_block_node_to_markdown(node: JSON, include_images: bool = True) -> str:
|
|
"""Serialize a single non-list block node to markdown. Lists are handled by
|
|
_serialize_list_to_markdown because they carry indentation state."""
|
|
node_type = node.get("type")
|
|
|
|
if node_type == "paragraph":
|
|
return _serialize_inline_nodes_to_markdown(node.get("content", []), include_images=include_images)
|
|
|
|
if node_type == "blockquote":
|
|
quote_lines: list[str] = []
|
|
for child in node.get("content", []):
|
|
child_type = child.get("type")
|
|
if child_type in ("bulletList", "orderedList"):
|
|
child_md = _serialize_list_to_markdown(child, child_type == "orderedList", "", include_images)
|
|
else:
|
|
child_md = _serialize_block_node_to_markdown(child, include_images=include_images)
|
|
if child_md:
|
|
quote_lines.extend(f"> {line}".rstrip() for line in child_md.split("\n"))
|
|
return "\n".join(quote_lines)
|
|
|
|
if node_type == "heading":
|
|
attrs = node.get("attrs") or {}
|
|
try:
|
|
level = min(max(int(attrs.get("level") or 1), 1), 6)
|
|
except (TypeError, ValueError):
|
|
level = 1
|
|
text = _serialize_inline_nodes_to_markdown(node.get("content", []), include_images=include_images)
|
|
return f"{'#' * level} {text}" if text else ""
|
|
|
|
if node_type == "horizontalRule":
|
|
return "---"
|
|
|
|
if node_type == "codeBlock":
|
|
code_text = "".join(child.get("text", "") for child in node.get("content", []))
|
|
language = node.get("attrs", {}).get("language") or ""
|
|
return f"```{language}\n{code_text}\n```"
|
|
|
|
if node_type == "image":
|
|
src = node.get("attrs", {}).get("src")
|
|
if src and include_images:
|
|
alt = _escape_alt_text(node.get("attrs", {}).get("alt", "image"))
|
|
return f""
|
|
return ""
|
|
|
|
if node.get("content"):
|
|
return _serialize_inline_nodes_to_markdown(node.get("content", []), include_images=include_images)
|
|
|
|
return ""
|
|
|
|
|
|
def _serialize_list_to_markdown(node: JSON, ordered: bool, indent: str, include_images: bool = True) -> str:
|
|
start = _list_start(node)
|
|
lines: list[str] = []
|
|
position = 0
|
|
for item in node.get("content", []):
|
|
if item.get("type") != "listItem":
|
|
continue
|
|
marker = f"{start + position}. " if ordered else "- "
|
|
position += 1
|
|
child_indent = indent + " " * len(marker)
|
|
blocks: list[tuple[str, bool]] = []
|
|
for child in item.get("content", []):
|
|
child_type = child.get("type")
|
|
if child_type in ("bulletList", "orderedList"):
|
|
nested = _serialize_list_to_markdown(child, child_type == "orderedList", child_indent, include_images)
|
|
if nested:
|
|
blocks.append((nested, True))
|
|
else:
|
|
text = _serialize_block_node_to_markdown(child, include_images=include_images).strip()
|
|
if text:
|
|
blocks.append((text, False))
|
|
if not blocks:
|
|
lines.append((indent + marker).rstrip())
|
|
continue
|
|
(first_text, first_is_list), *rest = blocks
|
|
if first_is_list:
|
|
lines.append((indent + marker).rstrip())
|
|
lines.append(first_text)
|
|
else:
|
|
lines.append(indent + marker + first_text.replace("\n", "\n" + child_indent))
|
|
for text, is_list in rest:
|
|
if is_list:
|
|
lines.append(text)
|
|
else:
|
|
lines.append("\n".join(child_indent + line for line in text.split("\n")))
|
|
return "\n".join(lines)
|
|
|
|
|
|
def rich_content_to_markdown(rich_content: JSON | None, include_images: bool = True) -> str:
|
|
"""Serialize Insights rich content JSON to markdown text."""
|
|
if not rich_content:
|
|
return ""
|
|
|
|
root_type = rich_content.get("type")
|
|
if root_type != "doc":
|
|
return ""
|
|
|
|
blocks: list[str] = []
|
|
for node in rich_content.get("content", []):
|
|
node_type = node.get("type")
|
|
|
|
if node_type in ("bulletList", "orderedList"):
|
|
list_md = _serialize_list_to_markdown(node, node_type == "orderedList", "", include_images)
|
|
if list_md:
|
|
blocks.append(list_md)
|
|
continue
|
|
|
|
# Empty paragraphs are kept so intentional blank lines survive; other empty blocks are skipped.
|
|
if node_type == "paragraph":
|
|
blocks.append(_serialize_block_node_to_markdown(node, include_images=include_images))
|
|
continue
|
|
|
|
block_md = _serialize_block_node_to_markdown(node, include_images=include_images)
|
|
if block_md:
|
|
blocks.append(block_md)
|
|
|
|
return "\n\n".join(blocks).strip()
|
|
|
|
|
|
def _text_node_to_slack_elements(node: JSON) -> list[JSON]:
|
|
text = node.get("text", "")
|
|
if not text:
|
|
return []
|
|
|
|
marks = node.get("marks", [])
|
|
link_mark = next((mark for mark in marks if mark.get("type") == "link"), None)
|
|
style = _marks_to_slack_style(mark for mark in marks if mark.get("type") != "link")
|
|
|
|
if link_mark and link_mark.get("attrs", {}).get("href"):
|
|
element: JSON = {"type": "link", "url": link_mark["attrs"]["href"], "text": text}
|
|
else:
|
|
element = {"type": "text", "text": text}
|
|
|
|
if style:
|
|
element["style"] = style
|
|
return [element]
|
|
|
|
|
|
def extract_images_from_rich_content(rich_content: JSON | None) -> list[JSON]:
|
|
if not rich_content or rich_content.get("type") != "doc":
|
|
return []
|
|
|
|
images: list[JSON] = []
|
|
for node in rich_content.get("content", []):
|
|
node_type = node.get("type")
|
|
if node_type == "image":
|
|
src = node.get("attrs", {}).get("src")
|
|
if src:
|
|
images.append({"url": src, "alt": node.get("attrs", {}).get("alt", "image")})
|
|
continue
|
|
if node_type == "paragraph":
|
|
for child in node.get("content", []):
|
|
if child.get("type") == "image":
|
|
src = child.get("attrs", {}).get("src")
|
|
if src:
|
|
images.append({"url": src, "alt": child.get("attrs", {}).get("alt", "image")})
|
|
return images
|
|
|
|
|
|
_SLACK_BLOCK_NODE_TYPES = {"paragraph", "codeBlock", "image"}
|
|
|
|
|
|
def rich_content_to_slack_blocks(rich_content: JSON | None, include_images: bool = True) -> list[JSON] | None:
|
|
"""Serialize Insights rich content JSON into Slack rich_text blocks.
|
|
|
|
Returns None when the doc contains block nodes rich_text can't represent (lists,
|
|
headings, blockquotes, ...) so callers fall back to the complete markdown text
|
|
instead of sending blocks with content silently missing.
|
|
"""
|
|
if not rich_content or rich_content.get("type") != "doc":
|
|
return None
|
|
|
|
if any(node.get("type") not in _SLACK_BLOCK_NODE_TYPES for node in rich_content.get("content", [])):
|
|
return None
|
|
|
|
rich_text_elements: list[JSON] = []
|
|
|
|
for node in rich_content.get("content", []):
|
|
node_type = node.get("type")
|
|
|
|
if node_type == "paragraph":
|
|
section_elements: list[JSON] = []
|
|
for child in node.get("content", []):
|
|
child_type = child.get("type")
|
|
if child_type == "text":
|
|
section_elements.extend(_text_node_to_slack_elements(child))
|
|
elif child_type == "hardBreak":
|
|
section_elements.append({"type": "text", "text": "\n"})
|
|
elif child_type == "image" and include_images:
|
|
src = child.get("attrs", {}).get("src")
|
|
if src:
|
|
alt = child.get("attrs", {}).get("alt", "image")
|
|
section_elements.append({"type": "link", "url": src, "text": alt})
|
|
|
|
if section_elements:
|
|
if rich_text_elements:
|
|
rich_text_elements.append(
|
|
{"type": "rich_text_section", "elements": [{"type": "text", "text": "\n"}]}
|
|
)
|
|
rich_text_elements.append({"type": "rich_text_section", "elements": section_elements})
|
|
continue
|
|
|
|
if node_type == "codeBlock":
|
|
code_text = "".join(child.get("text", "") for child in node.get("content", []))
|
|
if code_text:
|
|
rich_text_elements.append(
|
|
{"type": "rich_text_preformatted", "elements": [{"type": "text", "text": code_text}]}
|
|
)
|
|
continue
|
|
|
|
if node_type == "image" and include_images:
|
|
src = node.get("attrs", {}).get("src")
|
|
if src:
|
|
rich_text_elements.append(
|
|
{
|
|
"type": "rich_text_section",
|
|
"elements": [{"type": "link", "url": src, "text": node.get("attrs", {}).get("alt", "image")}],
|
|
}
|
|
)
|
|
|
|
if not rich_text_elements:
|
|
return None
|
|
|
|
return [{"type": "rich_text", "elements": rich_text_elements}]
|
|
|
|
|
|
def slack_to_content_and_rich_content(
|
|
text: str, blocks: list[JSON] | None = None, user_names: dict[str, str] | None = None
|
|
) -> tuple[str, JSON | None]:
|
|
"""
|
|
Convert inbound Slack payload to markdown content and rich_content.
|
|
|
|
Priority:
|
|
1. Slack rich_text blocks (for style fidelity including underline and nested marks)
|
|
2. text/mrkdwn fallback
|
|
"""
|
|
parsed_rich_content = slack_blocks_to_rich_content(blocks, user_names)
|
|
if parsed_rich_content:
|
|
markdown_content = rich_content_to_markdown(parsed_rich_content)
|
|
return markdown_content, parsed_rich_content
|
|
|
|
markdown_content = slack_mrkdwn_to_content(text, user_names)
|
|
return _normalize_single_newlines_to_markdown(markdown_content), None
|
|
|
|
|
|
def _escape_html(text: str) -> str:
|
|
return html_mod.escape(text)
|
|
|
|
|
|
def _serialize_text_node_to_html(node: JSON) -> str:
|
|
text = node.get("text", "")
|
|
if not text:
|
|
return ""
|
|
|
|
escaped = _escape_html(text)
|
|
marks = node.get("marks", [])
|
|
|
|
link_href: str | None = None
|
|
for mark in marks:
|
|
mark_type = mark.get("type")
|
|
if mark_type == "bold":
|
|
escaped = f"<strong>{escaped}</strong>"
|
|
elif mark_type == "italic":
|
|
escaped = f"<em>{escaped}</em>"
|
|
elif mark_type == "underline":
|
|
escaped = f"<u>{escaped}</u>"
|
|
elif mark_type == "strike":
|
|
escaped = f"<s>{escaped}</s>"
|
|
elif mark_type == "code":
|
|
escaped = f"<code>{escaped}</code>"
|
|
elif mark_type == "link":
|
|
link_href = mark.get("attrs", {}).get("href")
|
|
|
|
if link_href:
|
|
escaped = f'<a href="{_escape_html(link_href)}">{escaped}</a>'
|
|
|
|
return escaped
|
|
|
|
|
|
def _serialize_inline_nodes_to_html(nodes: list[JSON]) -> str:
|
|
chunks: list[str] = []
|
|
for node in nodes:
|
|
node_type = node.get("type")
|
|
if node_type == "text":
|
|
chunks.append(_serialize_text_node_to_html(node))
|
|
elif node_type == "hardBreak":
|
|
chunks.append("<br>")
|
|
elif node_type == "image":
|
|
src = node.get("attrs", {}).get("src", "")
|
|
alt = node.get("attrs", {}).get("alt", "")
|
|
if src:
|
|
chunks.append(f'<img src="{_escape_html(src)}" alt="{_escape_html(alt)}">')
|
|
return "".join(chunks)
|
|
|
|
|
|
def _serialize_block_node_to_html(node: JSON) -> str:
|
|
"""Serialize a single non-list block node to email-safe HTML. Lists are handled by
|
|
_serialize_list_to_html."""
|
|
node_type = node.get("type")
|
|
|
|
if node_type == "paragraph":
|
|
return f"<p>{_serialize_inline_nodes_to_html(node.get('content', []))}</p>"
|
|
|
|
if node_type == "blockquote":
|
|
inner_blocks: list[str] = []
|
|
for child in node.get("content", []):
|
|
child_type = child.get("type")
|
|
if child_type in ("bulletList", "orderedList"):
|
|
inner_blocks.append(_serialize_list_to_html(child, child_type == "orderedList"))
|
|
elif child_type == "paragraph":
|
|
inner_blocks.append(_serialize_inline_nodes_to_html(child.get("content", [])))
|
|
else:
|
|
inner_blocks.append(_serialize_block_node_to_html(child))
|
|
return f"<blockquote>{'<br>'.join(inner_blocks)}</blockquote>"
|
|
|
|
if node_type == "heading":
|
|
attrs = node.get("attrs") or {}
|
|
try:
|
|
level = min(max(int(attrs.get("level") or 1), 1), 6)
|
|
except (TypeError, ValueError):
|
|
level = 1
|
|
return f"<h{level}>{_serialize_inline_nodes_to_html(node.get('content', []))}</h{level}>"
|
|
|
|
if node_type == "horizontalRule":
|
|
return "<hr>"
|
|
|
|
if node_type == "codeBlock":
|
|
return f"<pre><code>{_serialize_inline_nodes_to_html(node.get('content', []))}</code></pre>"
|
|
|
|
if node_type == "image":
|
|
src = node.get("attrs", {}).get("src", "")
|
|
if src:
|
|
alt = node.get("attrs", {}).get("alt", "")
|
|
return f'<p><img src="{_escape_html(src)}" alt="{_escape_html(alt)}"></p>'
|
|
return ""
|
|
|
|
if node.get("content"):
|
|
inner = _serialize_inline_nodes_to_html(node.get("content", []))
|
|
if inner:
|
|
return f"<p>{inner}</p>"
|
|
|
|
return ""
|
|
|
|
|
|
def _serialize_list_to_html(node: JSON, ordered: bool) -> str:
|
|
items: list[str] = []
|
|
for item in node.get("content", []):
|
|
if item.get("type") != "listItem":
|
|
continue
|
|
inner_parts: list[str] = []
|
|
for child in item.get("content", []):
|
|
child_type = child.get("type")
|
|
if child_type == "paragraph":
|
|
inner_parts.append(_serialize_inline_nodes_to_html(child.get("content", [])))
|
|
elif child_type in ("bulletList", "orderedList"):
|
|
inner_parts.append(_serialize_list_to_html(child, child_type == "orderedList"))
|
|
else:
|
|
inner_parts.append(_serialize_block_node_to_html(child))
|
|
items.append(f"<li>{''.join(inner_parts)}</li>")
|
|
if not ordered:
|
|
return f"<ul>{''.join(items)}</ul>"
|
|
start = _list_start(node)
|
|
start_attr = f' start="{start}"' if start > 1 else ""
|
|
return f"<ol{start_attr}>{''.join(items)}</ol>"
|
|
|
|
|
|
def rich_content_to_html(rich_content: JSON | None) -> str:
|
|
"""Serialize Insights rich content JSON to email-safe HTML."""
|
|
if not rich_content or rich_content.get("type") != "doc":
|
|
return ""
|
|
|
|
blocks: list[str] = []
|
|
for node in rich_content.get("content", []):
|
|
node_type = node.get("type")
|
|
|
|
if node_type in ("bulletList", "orderedList"):
|
|
blocks.append(_serialize_list_to_html(node, node_type == "orderedList"))
|
|
continue
|
|
|
|
html = _serialize_block_node_to_html(node)
|
|
if html:
|
|
blocks.append(html)
|
|
|
|
body = "\n".join(blocks)
|
|
return f"""<!DOCTYPE html>
|
|
<html>
|
|
<head><meta charset="utf-8"></head>
|
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; line-height: 1.5; color: #333;">
|
|
{body}
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
def rich_content_to_slack_payload(
|
|
rich_content: JSON | None, fallback_content: str, include_images: bool = True
|
|
) -> tuple[str, list[JSON] | None]:
|
|
"""
|
|
Convert outbound app message to Slack payload fields.
|
|
|
|
Returns:
|
|
- text (always present, used as fallback for notifications/older clients)
|
|
- blocks (Slack rich_text blocks when rich_content is available)
|
|
"""
|
|
if rich_content:
|
|
blocks = rich_content_to_slack_blocks(rich_content, include_images=include_images)
|
|
markdown_text = rich_content_to_markdown(rich_content, include_images=include_images)
|
|
source_content = markdown_text or fallback_content
|
|
return content_to_slack_mrkdwn(source_content), blocks
|
|
|
|
return content_to_slack_mrkdwn(fallback_content), None
|