ForcePilot/backend/package/yuxi/channels/adapters/nextcloudtalk/formatter.py
Kris b018ad25da feat(backend): add Nextcloud Talk channel adapter implementation
实现了完整的Nextcloud Talk聊天适配器,包含以下核心功能:
1. 基础客户端通信与认证
2. 会话路由与聊天类型解析
3. 消息分块与格式转换
4. 用户配对与权限校验
5. 轮询式消息监听
6. 配置验证与诊断工具
7. 安全策略与去重缓存
8. 指标统计与状态快照

新增配套的配对用户缓存文件与模块导出入口。
2026-05-12 00:47:06 +08:00

104 lines
3.5 KiB
Python

from __future__ import annotations
import re
import uuid
from typing import Any
from yuxi.channels.models import ChannelResponse, MessageType
from .chunking import chunk_text
def convert_markdown_tables(text: str) -> str:
lines = text.split("\n")
result: list[str] = []
i = 0
while i < len(lines):
line = lines[i]
if re.match(r"^\|.*\|$", line.strip()) and i + 1 < len(lines):
next_line = lines[i + 1]
if re.match(r"^\|[\s\-:|]+\|$", next_line.strip()):
header = _parse_table_row(line)
rows: list[list[str]] = []
i += 2
while i < len(lines) and re.match(r"^\|.*\|$", lines[i].strip()):
rows.append(_parse_table_row(lines[i]))
i += 1
result.extend(_table_to_text(header, rows))
continue
result.append(line)
i += 1
return "\n".join(result)
def _parse_table_row(line: str) -> list[str]:
return [cell.strip() for cell in line.strip().strip("|").split("|")]
def _table_to_text(header: list[str], rows: list[list[str]]) -> list[str]:
cols = len(header)
col_widths = [len(h) for h in header]
for row in rows:
for c in range(min(cols, len(row))):
col_widths[c] = max(col_widths[c], len(row[c]))
header_line = " | ".join(h.ljust(col_widths[i]) for i, h in enumerate(header))
sep_line = "-+-".join("-" * col_widths[i] for i in range(cols))
result = [header_line, sep_line]
for row in rows:
result.append(" | ".join((row[c] if c < len(row) else "").ljust(col_widths[c]) for c in range(cols)))
return result
def format_outbound(
response: ChannelResponse,
bot_display_name: str,
text_chunk_limit: int = 4000,
markdown_config: dict[str, Any] | None = None,
response_prefix: str = "",
) -> list[dict[str, Any]]:
markdown_config = markdown_config or {}
table_mode = markdown_config.get("table_mode", markdown_config.get("tableMode", "auto"))
content = response.content.strip()
if not content:
raise ValueError("Empty message content, cannot send")
if response_prefix:
content = f"{response_prefix} {content}"
if table_mode == "always":
content = convert_markdown_tables(content)
elif table_mode == "auto":
content = convert_markdown_tables(content)
chunks = chunk_text(content, limit=text_chunk_limit)
results: list[dict[str, Any]] = []
for idx, chunk in enumerate(chunks):
payload: dict[str, Any] = {
"token": response.identity.channel_chat_id,
"message": chunk,
"actorDisplayName": bot_display_name,
}
if response.reply_to_message_id:
payload["replyTo"] = response.reply_to_message_id
ref_id = response.metadata.get("reference_id")
if ref_id:
payload["referenceId"] = f"{ref_id}-{idx}" if len(chunks) > 1 else ref_id
else:
payload["referenceId"] = f"fp-{uuid.uuid4().hex}-{idx}" if len(chunks) > 1 else f"fp-{uuid.uuid4().hex}"
if response.message_type in (MessageType.IMAGE, MessageType.FILE, MessageType.AUDIO, MessageType.VIDEO):
raise ValueError("Media messages must use send_media(), not send()")
results.append(payload)
return results
def format_outbound_single(response: ChannelResponse, bot_display_name: str) -> dict[str, Any]:
payloads = format_outbound(response, bot_display_name)
return payloads[0] if payloads else {"token": response.identity.channel_chat_id, "message": ""}