106 lines
3.3 KiB
Python
106 lines
3.3 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import re
|
||
|
|
from html import escape
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from yuxi.channels.models import ChannelResponse
|
||
|
|
|
||
|
|
_ESCAPE_CHARS = {"&": "&", "<": "<", ">": ">"}
|
||
|
|
|
||
|
|
_CODE_BLOCK_RE = re.compile(r"```(?:\w+)?\n.+?```", re.DOTALL)
|
||
|
|
_INLINE_CODE_RE = re.compile(r"`([^`\n]+?)`")
|
||
|
|
|
||
|
|
_PLACEHOLDER_PREFIX = "\x00MDPLACEHOLDER"
|
||
|
|
|
||
|
|
TRUNCATION_MARKER = "\n...(内容过长已截断)"
|
||
|
|
|
||
|
|
|
||
|
|
def markdown_to_html(text: str) -> str:
|
||
|
|
code_blocks: dict[str, str] = {}
|
||
|
|
text = _protect_code_spans(text, _CODE_BLOCK_RE, code_blocks)
|
||
|
|
text = _protect_code_spans(text, _INLINE_CODE_RE, code_blocks)
|
||
|
|
|
||
|
|
text = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", text)
|
||
|
|
text = re.sub(r"(?<!\*)\*([^*\n]+?)\*(?!\*)", r"<i>\1</i>", text)
|
||
|
|
text = re.sub(r"(?<!_)_([^_\n]+?)_(?!_)", r"<i>\1</i>", text)
|
||
|
|
text = re.sub(r"~~(.+?)~~", r"<s>\1</s>", text)
|
||
|
|
text = re.sub(r"__(\w.*?\w)__", r"<u>\1</u>", text)
|
||
|
|
text = re.sub(r"\[(.+?)\]\((.+?)\)", r'<a href="\2">\1</a>', text)
|
||
|
|
text = re.sub(r"\|\|(.+?)\|\|", r"<tg-spoiler>\1</tg-spoiler>", text)
|
||
|
|
|
||
|
|
text = _restore_code_spans(text, code_blocks)
|
||
|
|
text = _sanitize_html(text)
|
||
|
|
return text
|
||
|
|
|
||
|
|
|
||
|
|
def strip_html_tags(text: str) -> str:
|
||
|
|
return re.sub(r"<[^>]+>", "", text)
|
||
|
|
|
||
|
|
|
||
|
|
def truncate_text(text: str, limit: int, marker: str | None = None) -> str:
|
||
|
|
m = marker if marker is not None else TRUNCATION_MARKER
|
||
|
|
if len(text) <= limit:
|
||
|
|
return text
|
||
|
|
return text[: limit - len(m)] + m
|
||
|
|
|
||
|
|
|
||
|
|
def _protect_code_spans(text: str, pattern: re.Pattern, storage: dict[str, str]) -> str:
|
||
|
|
result = []
|
||
|
|
idx = 0
|
||
|
|
for m in pattern.finditer(text):
|
||
|
|
result.append(text[idx : m.start()])
|
||
|
|
placeholder = f"{_PLACEHOLDER_PREFIX}{len(storage)}"
|
||
|
|
storage[placeholder] = m.group(0)
|
||
|
|
result.append(placeholder)
|
||
|
|
idx = m.end()
|
||
|
|
result.append(text[idx:])
|
||
|
|
return "".join(result)
|
||
|
|
|
||
|
|
|
||
|
|
def _restore_code_spans(text: str, storage: dict[str, str]) -> str:
|
||
|
|
result = text
|
||
|
|
for placeholder, code_text in storage.items():
|
||
|
|
if placeholder.startswith(f"{_PLACEHOLDER_PREFIX}") and "```" in code_text:
|
||
|
|
inner = code_text[3:].rstrip("`").lstrip("\n")
|
||
|
|
lang_end = inner.find("\n")
|
||
|
|
inner = inner[lang_end + 1 :] if lang_end >= 0 else inner
|
||
|
|
safe = escape(inner, quote=False)
|
||
|
|
result = result.replace(placeholder, f"<pre>{safe}</pre>")
|
||
|
|
elif placeholder in result:
|
||
|
|
inner = code_text.strip("`")
|
||
|
|
safe = escape(inner, quote=False)
|
||
|
|
result = result.replace(placeholder, f"<code>{safe}</code>")
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def _sanitize_html(text: str) -> str:
|
||
|
|
result = []
|
||
|
|
depth = 0
|
||
|
|
for ch in text:
|
||
|
|
if ch == "<":
|
||
|
|
depth += 1
|
||
|
|
elif ch == ">":
|
||
|
|
depth = max(0, depth - 1)
|
||
|
|
elif depth == 0:
|
||
|
|
if ch in _ESCAPE_CHARS:
|
||
|
|
result.append(_ESCAPE_CHARS[ch])
|
||
|
|
continue
|
||
|
|
result.append(ch)
|
||
|
|
return "".join(result)
|
||
|
|
|
||
|
|
|
||
|
|
def build_basic_outbound(
|
||
|
|
response: ChannelResponse,
|
||
|
|
*,
|
||
|
|
text_field: str = "text",
|
||
|
|
text: str | None = None,
|
||
|
|
extra: dict[str, Any] | None = None,
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
payload: dict[str, Any] = {text_field: text or response.content}
|
||
|
|
if response.reply_to_message_id:
|
||
|
|
payload["reply_to_message_id"] = response.reply_to_message_id
|
||
|
|
if extra:
|
||
|
|
payload.update(extra)
|
||
|
|
return payload
|