ForcePilot/backend/package/yuxi/channels/adapters/msteams/formatter.py
Kris bd60c15df0 feat(msteams): 新增完整的 Microsoft Teams 适配器模块
实现了 Teams 机器人所需的全功能组件,包括:
- 基础命令解析与帮助卡片生成
- 租户验证与访问控制
- 自定义 UA 与媒体工具
- 消息分块、批注处理与会话管理
- 防抖、缓存与配置路由能力
- 投票、配对、审计与运行时状态管理
- TTS 语音合成与卡片构建工具
- 群组管理与权限控制逻辑
2026-05-12 00:46:44 +08:00

169 lines
5.2 KiB
Python

"""Microsoft Teams ChannelResponse → Activity 格式构建。
将统一的 ChannelResponse 转换为 Bot Framework Activity 字典格式,
用于通过 REST API 发送消息到 Teams。
"""
from __future__ import annotations
import re
from typing import Any
from yuxi.channels.models import ChannelResponse, MessageType
from .mentions import parse_outbound_mentions, apply_mentions_to_activity
from .send import is_silent_reply_text, strip_silent_token
def format_outbound(response: ChannelResponse, text_chunk_limit: int = 4000) -> dict[str, Any]:
text = response.content[:text_chunk_limit]
silent = is_silent_reply_text(text)
if silent:
text = strip_silent_token(text)
table_mode = (response.metadata or {}).get("table_mode", "markdown")
if table_mode != "markdown":
text = _convert_tables(text, table_mode)
activity: dict[str, Any] = {
"type": "message",
"text": text,
"textFormat": "markdown",
}
if silent:
activity.setdefault("channelData", {})["notification"] = {"alert": "false"}
conversation_id = response.identity.channel_chat_id
if conversation_id:
activity["conversation"] = {"id": conversation_id}
if response.reply_to_message_id:
activity["replyToId"] = response.reply_to_message_id
adaptive_card = (response.metadata or {}).get("adaptive_card")
if adaptive_card:
activity["attachments"] = [
{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": adaptive_card,
}
]
elif response.attachments:
activity["attachments"] = _format_attachments(response)
if response.message_type == MessageType.IMAGE and response.attachments:
activity["attachmentLayout"] = "list"
_maybe_add_ai_generated_entity(activity, response)
parsed_text, mention_entities = parse_outbound_mentions(text)
if mention_entities:
activity["text"] = parsed_text
activity = apply_mentions_to_activity(activity, mention_entities)
return activity
def _format_attachments(response: ChannelResponse) -> list[dict[str, Any]]:
attachments = []
for att in response.attachments:
if att.url and att.url.startswith("data:"):
attachments.append(
{
"contentType": att.mime_type or "application/octet-stream",
"contentUrl": att.url,
"name": att.filename or "file",
}
)
elif att.url:
attachments.append(
{
"contentType": att.mime_type or "application/octet-stream",
"contentUrl": att.url,
"name": att.filename or "file",
}
)
return attachments
def format_adaptive_card_response(
conversation_id: str,
card: dict[str, Any],
reply_to_id: str | None = None,
) -> dict[str, Any]:
activity: dict[str, Any] = {
"type": "message",
"conversation": {"id": conversation_id},
"attachments": [
{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": card,
}
],
"textFormat": "markdown",
}
if reply_to_id:
activity["replyToId"] = reply_to_id
return activity
AI_GENERATED_ENTITY = {
"type": "https://schema.org/Message",
"@context": "https://schema.org",
"@type": "Message",
"additionalType": ["AIGeneratedContent"],
}
def _maybe_add_ai_generated_entity(activity: dict[str, Any], response: ChannelResponse) -> None:
ai_generated = (response.metadata or {}).get("ai_generated", True)
if not ai_generated:
return
entities: list[dict] = activity.setdefault("entities", [])
entities.append(AI_GENERATED_ENTITY)
TABLE_MODE_OPTIONS = {"markdown", "plain", "list"}
def _convert_tables(text: str, mode: str) -> str:
if mode not in TABLE_MODE_OPTIONS:
return text
table_pattern = re.compile(r"(\|.*?\|\n\|[-:|\s]+\|\n(?:\|.*\|\n?)+)", re.MULTILINE)
def _replace(match: re.Match) -> str:
table_block = match.group(1)
if mode == "plain":
return _table_to_plain(table_block)
if mode == "list":
return _table_to_list(table_block)
return table_block
return table_pattern.sub(_replace, text)
def _table_to_plain(table_text: str) -> str:
lines = [line.strip() for line in table_text.strip().split("\n") if "---" not in line]
result_lines = []
for line in lines:
cells = [cell.strip() for cell in line.strip("|").split("|")]
result_lines.append(" | ".join(cells))
return "\n".join(result_lines) + "\n"
def _table_to_list(table_text: str) -> str:
lines = [line.strip() for line in table_text.strip().split("\n") if "---" not in line]
if not lines:
return table_text
header = [cell.strip() for cell in lines[0].strip("|").split("|")]
result_lines = []
for row in lines[1:]:
cells = [cell.strip() for cell in row.strip("|").split("|")]
for h, c in zip(header, cells):
result_lines.append(f"- **{h}**: {c}")
result_lines.append("")
return "\n".join(result_lines)