ForcePilot/backend/package/yuxi/channel/extensions/slack/format.py
Kris bfc7755137 feat(channel): 添加 Slack 渠道扩展
新增 Slack 渠道扩展,支持在 Yuxi 平台中集成 Slack 团队协作平台。

包含以下功能模块:
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- monitor: 渠道状态监控
- status: 会话状态管理
- actions: 交互动作处理
- interactive: 交互式消息
- commands: 斜杠指令
- threading: 线程管理
- mentions: @提及
- constants: 常量定义
- types: 类型定义
2026-05-21 11:43:23 +08:00

203 lines
5.5 KiB
Python

import re
from yuxi.channel.extensions.slack.constants import (
SLACK_AGENT_PROMPT_RULES,
SLACK_MENTION_PATTERN,
SLACK_TEXT_LIMIT,
)
_LINK_PATTERN = re.compile(r"\[([^\]]*)\]\(([^)]*)\)")
_BOLD_PATTERN = re.compile(r"\*\*(.+?)\*\*")
_ITALIC_PATTERN = re.compile(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)")
_STRIKETHROUGH_PATTERN = re.compile(r"~~(.+?)~~")
_HEADING_PATTERN = re.compile(r"^#{1,6}\s+(.+)$", re.MULTILINE)
_FORMAT_TOKEN_PATTERN = re.compile(
r"""
\*[^*]+\* |
_[^_]+_ |
~[^~]+~ |
`[^`]+` |
<[^>]+>
""",
re.VERBOSE,
)
def _find_safe_split_position(text: str, pos: int) -> int:
for match in _FORMAT_TOKEN_PATTERN.finditer(text):
start, end = match.start(), match.end()
if start < pos < end:
before_len = pos - start
after_len = end - pos
return start if before_len <= after_len else end
return pos
def markdown_to_slack(text: str) -> str:
text = _LINK_PATTERN.sub(r"<\2|\1>", text)
text = _BOLD_PATTERN.sub(r"*\1*", text)
text = _ITALIC_PATTERN.sub(r"_\1_", text)
text = _STRIKETHROUGH_PATTERN.sub(r"~\1~", text)
text = _HEADING_PATTERN.sub(r"*\1*", text)
lines = text.split("\n")
result: list[str] = []
table_buffer: list[str] = []
in_table = False
for line in lines:
stripped = line.strip()
if stripped.startswith("|") and stripped.endswith("|"):
if not in_table:
in_table = True
table_buffer = []
table_buffer.append(stripped)
continue
else:
if in_table:
in_table = False
result.extend(_convert_table_rows(table_buffer))
table_buffer = []
result.append(line)
if in_table and table_buffer:
result.extend(_convert_table_rows(table_buffer))
return "\n".join(result)
def _convert_table_rows(rows: list[str]) -> list[str]:
if len(rows) < 2:
return rows
def _parse_cells(row: str) -> list[str]:
return [cell.strip() for cell in row.strip("|").split("|")]
header = _parse_cells(rows[0])
separator = _parse_cells(rows[1])
body_rows = rows[2:]
is_sep = all(re.fullmatch(r"[-:]{3,}", cell) or re.fullmatch(r":[-:]+:", cell) for cell in separator)
if not is_sep:
all_rows = [_parse_cells(r) for r in rows]
else:
all_rows = [_parse_cells(r) for r in body_rows]
if not header:
return [", ".join(cells) for cells in all_rows]
bullet_lines: list[str] = []
for cells in all_rows:
pairs = [f"{header[i]}: {cells[i]}" if i < len(cells) else f"{header[i]}: " for i in range(len(header))]
bullet_lines.append("" + ", ".join(pairs))
return bullet_lines
def chunk_text(text: str, limit: int = SLACK_TEXT_LIMIT) -> list[str]:
if not text:
return []
if len(text) <= limit:
return [text]
chunks: list[str] = []
paragraphs = text.split("\n\n")
for para in paragraphs:
if not para:
continue
if len(para) <= limit:
chunks.append(para)
else:
chunks.extend(_chunk_single_paragraph(para, limit))
merged: list[str] = []
for chunk in chunks:
if not merged:
merged.append(chunk)
continue
last = merged[-1]
candidate = last + "\n\n" + chunk
if len(candidate) <= limit:
merged[-1] = candidate
else:
merged.append(chunk)
return merged
def _chunk_single_paragraph(text: str, limit: int) -> list[str]:
sentences = re.split(r"(?<=[.?!])\s+", text)
chunks: list[str] = []
current = ""
for sentence in sentences:
if not sentence:
continue
if len(sentence) > limit:
if current:
chunks.append(current.rstrip())
current = ""
chunks.extend(_chunk_by_words(sentence, limit))
else:
candidate = (current + " " + sentence).strip() if current else sentence
if len(candidate) <= limit:
current = candidate
else:
chunks.append(current.rstrip())
current = sentence
if current:
chunks.append(current.rstrip())
return chunks
def _chunk_by_words(text: str, limit: int) -> list[str]:
words = text.split(" ")
chunks: list[str] = []
current = ""
for word in words:
if not word:
continue
if not current:
current = word
continue
candidate = current + " " + word
if len(candidate) <= limit:
current = candidate
else:
chunks.append(current)
current = word
if current:
chunks.append(current)
safe_chunks: list[str] = []
for chunk in chunks:
if len(chunk) <= limit:
safe_chunks.append(chunk)
else:
split_pos = _find_safe_split_position(chunk, limit)
if split_pos == 0 or split_pos == limit:
safe_chunks.append(chunk)
else:
safe_chunks.append(chunk[:split_pos])
safe_chunks.append(chunk[split_pos:])
return safe_chunks
def strip_mentions(text: str) -> str:
return re.sub(SLACK_MENTION_PATTERN, "", text).strip()
def extract_mentions(text: str) -> list[str]:
return re.findall(SLACK_MENTION_PATTERN, text)
def get_agent_prompt_rules() -> list[str]:
return SLACK_AGENT_PROMPT_RULES