实现了 Teams 机器人所需的全功能组件,包括: - 基础命令解析与帮助卡片生成 - 租户验证与访问控制 - 自定义 UA 与媒体工具 - 消息分块、批注处理与会话管理 - 防抖、缓存与配置路由能力 - 投票、配对、审计与运行时状态管理 - TTS 语音合成与卡片构建工具 - 群组管理与权限控制逻辑
169 lines
5.2 KiB
Python
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)
|