新增了完整的Signal渠道适配器实现,包含RPC客户端、守护进程管理、安全策略、消息处理、安装配置工具等全套功能,支持通过signal-cli与Signal网络进行通信,包含账户管理、消息收发、反应处理、媒体分析、健康检查等能力。
293 lines
8.4 KiB
Python
293 lines
8.4 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
@dataclass
|
|
class StyleRange:
|
|
start: int
|
|
length: int
|
|
style: str
|
|
|
|
|
|
BOLD = "BOLD"
|
|
ITALIC = "ITALIC"
|
|
STRIKETHROUGH = "STRIKETHROUGH"
|
|
MONOSPACE = "MONOSPACE"
|
|
SPOILER = "SPOILER"
|
|
|
|
_MARKDOWN_PATTERNS: list[tuple[str, str]] = [
|
|
(r"\*\*(.+?)\*\*", BOLD),
|
|
(r"__(.+?)__", BOLD),
|
|
(r"\*(.+?)\*", ITALIC),
|
|
(r"_(.+?)_", ITALIC),
|
|
(r"~~(.+?)~~", STRIKETHROUGH),
|
|
(r"`(.+?)`", MONOSPACE),
|
|
(r"\|\|(.+?)\|\|", SPOILER),
|
|
]
|
|
|
|
_HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE)
|
|
_BLOCKQUOTE_PATTERN = re.compile(r"^>\s?(.+)$", re.MULTILINE)
|
|
_TABLE_SEP_PATTERN = re.compile(r"^\|?[-:|\s]+\|?$")
|
|
_LINK_PATTERN = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
|
_URL_PATTERN = re.compile(r"https?://\S+")
|
|
|
|
|
|
@dataclass
|
|
class FormattedText:
|
|
body: str
|
|
styles: list[StyleRange] = field(default_factory=list)
|
|
|
|
|
|
def markdown_to_signal_styles(
|
|
text: str,
|
|
table_mode: str = "bullets",
|
|
heading_style: str = "bold",
|
|
blockquote_prefix: str = "> ",
|
|
) -> FormattedText:
|
|
result = text
|
|
styles: list[StyleRange] = []
|
|
|
|
result = _convert_tables(result, table_mode)
|
|
|
|
result, link_ranges = _process_links(result)
|
|
|
|
for pattern, style_name in _MARKDOWN_PATTERNS:
|
|
result, styles = _apply_markdown_pattern(result, pattern, style_name, styles, link_ranges)
|
|
|
|
result = _convert_headings(result, heading_style)
|
|
result = _convert_blockquotes(result, blockquote_prefix)
|
|
|
|
styles = _merge_adjacent_styles(sorted(styles, key=lambda s: s.start))
|
|
|
|
return FormattedText(body=result, styles=styles)
|
|
|
|
|
|
def _convert_tables(text: str, table_mode: str) -> str:
|
|
if table_mode != "bullets":
|
|
return text
|
|
|
|
lines = text.split("\n")
|
|
result: list[str] = []
|
|
in_table = False
|
|
table_rows: list[list[str]] = []
|
|
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if stripped.startswith("|") and stripped.endswith("|"):
|
|
if _TABLE_SEP_PATTERN.match(stripped):
|
|
continue
|
|
cells = [c.strip() for c in stripped[1:-1].split("|")]
|
|
table_rows.append(cells)
|
|
in_table = True
|
|
continue
|
|
else:
|
|
if in_table and table_rows:
|
|
result.extend(_table_rows_to_bullets(table_rows))
|
|
table_rows = []
|
|
in_table = False
|
|
result.append(line)
|
|
|
|
if table_rows:
|
|
result.extend(_table_rows_to_bullets(table_rows))
|
|
|
|
return "\n".join(result)
|
|
|
|
|
|
def _table_rows_to_bullets(rows: list[list[str]]) -> list[str]:
|
|
if not rows:
|
|
return []
|
|
bullets: list[str] = []
|
|
is_header = True
|
|
for row in rows:
|
|
if is_header and len(rows) > 1:
|
|
bullets.append(" • ".join(row))
|
|
is_header = False
|
|
else:
|
|
for cell in row:
|
|
bullets.append(f"• {cell}")
|
|
return bullets
|
|
|
|
|
|
def _process_links(text: str) -> tuple[str, list[tuple[int, int]]]:
|
|
link_replacements: list[tuple[int, int, str, str]] = []
|
|
for m in _LINK_PATTERN.finditer(text):
|
|
label = m.group(1)
|
|
url = m.group(2)
|
|
start, end = m.span()
|
|
if label == url or _url_text_equivalent(label, url):
|
|
replacement = url
|
|
else:
|
|
replacement = f"{label} ({url})"
|
|
link_replacements.append((start, end, replacement, url))
|
|
|
|
result = text
|
|
offset = 0
|
|
link_ranges: list[tuple[int, int]] = []
|
|
for start, end, replacement, _url in sorted(link_replacements, key=lambda x: x[0]):
|
|
old_len = end - start
|
|
new_len = len(replacement)
|
|
actual_start = start + offset
|
|
result = result[:actual_start] + replacement + result[actual_start + old_len :]
|
|
link_ranges.append((actual_start, actual_start + new_len))
|
|
offset += new_len - old_len
|
|
|
|
return result, link_ranges
|
|
|
|
|
|
def _url_text_equivalent(label: str, url: str) -> bool:
|
|
clean_label = label.strip().rstrip("/").lower()
|
|
clean_url = url.strip().rstrip("/").lower()
|
|
if clean_label == clean_url:
|
|
return True
|
|
if clean_url.startswith("https://"):
|
|
if clean_label == clean_url.removeprefix("https://"):
|
|
return True
|
|
if clean_url.startswith("http://"):
|
|
if clean_label == clean_url.removeprefix("http://"):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _apply_markdown_pattern(
|
|
text: str,
|
|
pattern: str,
|
|
style_name: str,
|
|
existing_styles: list[StyleRange],
|
|
link_ranges: list[tuple[int, int]],
|
|
) -> tuple[str, list[StyleRange]]:
|
|
compiled = re.compile(pattern)
|
|
replacements: list[tuple[int, int, str]] = []
|
|
new_styles: list[StyleRange] = []
|
|
|
|
for m in compiled.finditer(text):
|
|
inner = m.group(1)
|
|
start, end = m.span()
|
|
if _overlaps_any(start, end, link_ranges):
|
|
continue
|
|
replacement = inner
|
|
replacements.append((start, end, replacement))
|
|
new_styles.append(StyleRange(start=start, length=len(inner), style=style_name))
|
|
|
|
result = text
|
|
offset = 0
|
|
final_styles = list(existing_styles)
|
|
|
|
replacements.sort(key=lambda x: x[0])
|
|
for start, end, replacement in replacements:
|
|
actual_start = start + offset
|
|
old_len = end - start
|
|
new_len = len(replacement)
|
|
result = result[:actual_start] + replacement + result[actual_start + old_len :]
|
|
|
|
for ns in new_styles:
|
|
if ns.start == start:
|
|
ns.start = actual_start
|
|
offset += new_len - old_len
|
|
|
|
for ns in new_styles:
|
|
if ns.start + ns.length <= len(result):
|
|
final_styles.append(ns)
|
|
|
|
for existing in final_styles:
|
|
if existing in new_styles:
|
|
continue
|
|
for rep_start, rep_end, _ in replacements:
|
|
if existing.start >= rep_start and existing.start < rep_end:
|
|
existing.start += len(replacement) - (rep_end - rep_start)
|
|
|
|
return result, final_styles
|
|
|
|
|
|
def _overlaps_any(start: int, end: int, ranges: list[tuple[int, int]]) -> bool:
|
|
for r_start, r_end in ranges:
|
|
if start < r_end and end > r_start:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _convert_headings(text: str, heading_style: str) -> str:
|
|
if heading_style == "bold":
|
|
|
|
def _replace(m: re.Match) -> str:
|
|
content = m.group(2)
|
|
return f"**{content}**"
|
|
|
|
return _HEADING_PATTERN.sub(_replace, text)
|
|
return text
|
|
|
|
|
|
def _convert_blockquotes(text: str, blockquote_prefix: str) -> str:
|
|
lines = text.split("\n")
|
|
result = []
|
|
in_blockquote = False
|
|
prefix = blockquote_prefix or "> "
|
|
|
|
for line in lines:
|
|
m = _BLOCKQUOTE_PATTERN.match(line)
|
|
if m:
|
|
result.append(f"{prefix}{m.group(1)}")
|
|
in_blockquote = True
|
|
else:
|
|
if in_blockquote and line.strip() == "":
|
|
in_blockquote = False
|
|
result.append(line)
|
|
|
|
return "\n".join(result)
|
|
|
|
|
|
def _merge_adjacent_styles(styles: list[StyleRange]) -> list[StyleRange]:
|
|
if not styles:
|
|
return []
|
|
merged: list[StyleRange] = []
|
|
for style in styles:
|
|
if merged and merged[-1].style == style.style and merged[-1].start + merged[-1].length == style.start:
|
|
merged[-1].length += style.length
|
|
else:
|
|
merged.append(StyleRange(start=style.start, length=style.length, style=style.style))
|
|
return merged
|
|
|
|
|
|
def split_text(text: str, limit: int = 4000, chunk_mode: str = "newline") -> list[str]:
|
|
if len(text) <= limit:
|
|
return [text]
|
|
|
|
if chunk_mode == "length":
|
|
return [text[i : i + limit] for i in range(0, len(text), limit)]
|
|
|
|
chunks: list[str] = []
|
|
paragraphs = text.split("\n\n")
|
|
current = ""
|
|
|
|
for para in paragraphs:
|
|
if len(current) + len(para) + 2 <= limit:
|
|
current = f"{current}\n\n{para}" if current else para
|
|
else:
|
|
if current:
|
|
chunks.append(current)
|
|
if len(para) > limit:
|
|
for i in range(0, len(para), limit):
|
|
chunks.append(para[i : i + limit])
|
|
current = ""
|
|
else:
|
|
current = para
|
|
|
|
if current:
|
|
chunks.append(current)
|
|
return chunks or [text]
|
|
|
|
|
|
def clamp_styles_to_length(styles: list[StyleRange], body: str) -> list[StyleRange]:
|
|
body_len = len(body)
|
|
result: list[StyleRange] = []
|
|
for s in styles:
|
|
if s.start >= body_len:
|
|
continue
|
|
end = s.start + s.length
|
|
if end > body_len:
|
|
s.length = body_len - s.start
|
|
if s.length > 0:
|
|
result.append(s)
|
|
return result
|