实现了完整的Nextcloud Talk聊天适配器,包含以下核心功能: 1. 基础客户端通信与认证 2. 会话路由与聊天类型解析 3. 消息分块与格式转换 4. 用户配对与权限校验 5. 轮询式消息监听 6. 配置验证与诊断工具 7. 安全策略与去重缓存 8. 指标统计与状态快照 新增配套的配对用户缓存文件与模块导出入口。
104 lines
3.5 KiB
Python
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": ""}
|