ForcePilot/backend/package/yuxi/channels/adapters/signal/format.py
Kris 8dc86766f1 feat(channels/signal): 新增Signal渠道适配器完整实现
新增了完整的Signal渠道适配器实现,包含RPC客户端、守护进程管理、安全策略、消息处理、安装配置工具等全套功能,支持通过signal-cli与Signal网络进行通信,包含账户管理、消息收发、反应处理、媒体分析、健康检查等能力。
2026-05-12 00:48:25 +08:00

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