ForcePilot/backend/package/yuxi/channels/adapters/msteams/formatter.py
Kris 939f1ba82a refactor(msteams): 整理代码结构并新增多项功能
本次提交对Microsoft Teams适配器代码进行了多维度优化与新增:
1.  调整多处导入顺序,优化代码可读性
2.  新增media_tools工具模块,提供媒体相关辅助函数
3.  新增thread_history模块,实现对话历史拉取与缓存功能
4.  新增connection_modes模块,支持webhook/websocket/polling三种连接模式
5.  扩展security.py与tool_policy.py,新增通配符配置校验与三级策略解析
6.  新增feedback会话记录功能
7.  为sent_message_cache添加自动清理任务
8.  优化normalizer模块,新增引用、编辑消息解析与线程上下文注入
9.  重构file_upload的SSRF防护逻辑,复用公共校验工具
10. 修复多处导入顺序与代码排版问题
11. 为消息发送添加断路器保护与异步去重锁
2026-05-13 16:12:31 +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 apply_mentions_to_activity, parse_outbound_mentions
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)