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

287 lines
9.4 KiB
Python

"""Microsoft Teams Activity → ChannelMessage 标准化转换。
将 Bot Framework Service 推送的 Activity JSON 归一化为统一的 ChannelMessage 模型。
"""
from __future__ import annotations
import re
from datetime import datetime, UTC
from typing import Any
from yuxi.channels.models import (
Attachment,
ChannelIdentity,
ChannelMessage,
ChannelType,
EventType,
MentionsInfo,
MessageType,
)
from yuxi.utils.datetime_utils import utc_now_naive
from .session import determine_chat_type, resolve_channel_chat_id, resolve_thread_id
THREAD_MESSAGE_ID_SEPARATOR = ";messageid="
def extract_thread_root_id(conversation_id: str) -> str:
if THREAD_MESSAGE_ID_SEPARATOR in conversation_id:
return conversation_id.split(THREAD_MESSAGE_ID_SEPARATOR, 1)[1]
return ""
def extract_base_conversation_id(conversation_id: str) -> str:
if THREAD_MESSAGE_ID_SEPARATOR in conversation_id:
return conversation_id.split(THREAD_MESSAGE_ID_SEPARATOR, 1)[0]
return conversation_id
ATTACHMENT_TYPE_MAP = {
"image/": "image",
"video/": "video",
"audio/": "audio",
"application/pdf": "file",
"application/vnd.microsoft.card.adaptive": "card",
"application/vnd.microsoft.card.hero": "card",
}
def normalize_inbound(activity: dict[str, Any]) -> ChannelMessage:
from_info = activity.get("from", {}) or {}
conversation = activity.get("conversation", {}) or {}
channel_data = activity.get("channelData") or {}
conversation_type = conversation.get("conversationType", "personal")
chat_type = determine_chat_type(conversation_type)
aad_object_id = from_info.get("aadObjectId", "") or from_info.get("id", "")
conversation_id = conversation.get("id", "")
base_conv_id = extract_base_conversation_id(conversation_id)
thread_root_id = extract_thread_root_id(conversation_id)
channel_chat_id = resolve_channel_chat_id(conversation_type, base_conv_id, aad_object_id, channel_data)
identity = ChannelIdentity(
channel_id="msteams",
channel_type=ChannelType.MS_TEAMS,
channel_user_id=aad_object_id,
channel_chat_id=channel_chat_id,
channel_message_id=activity.get("id"),
)
attachments = _extract_attachments(activity.get("attachments", []))
recipient = activity.get("recipient", {}) or {}
bot_id = recipient.get("id", "")
mentions = _extract_mentions(activity.get("entities", []), bot_id)
content = activity.get("text", "")
raw_text = activity.get("text", "")
if activity.get("text") and activity.get("textFormat") == "markdown":
content = _strip_mention_tags(content)
command_body = None
if content.startswith("/"):
command_body = content.strip()
body_for_agent = content
if mentions and mentions.is_bot_mentioned:
body_for_agent = f"[Bot mentioned] {content}"
metadata = {
"from_name": from_info.get("name", ""),
"conversation_type": conversation_type,
"channel_id": activity.get("channelId", "msteams"),
"service_url": activity.get("serviceUrl", ""),
"tenant_id": (channel_data.get("tenant") or {}).get("id", ""),
"team_id": (channel_data.get("team") or {}).get("id", ""),
"teams_channel_id": (channel_data.get("channel") or {}).get("id", ""),
"raw_activity": activity,
"Body": content,
"BodyForAgent": body_for_agent,
"RawBody": raw_text,
"CommandBody": command_body,
"SessionKey": resolve_thread_id(
"default",
conversation_type,
aad_object_id,
channel_data,
activity.get("replyToId", ""),
),
"Provider": "msteams",
"Surface": conversation_type,
"WasMentioned": bool(mentions and mentions.is_bot_mentioned),
"ConversationMessageId": thread_root_id or "",
}
reply_to = thread_root_id or activity.get("replyToId")
return ChannelMessage(
identity=identity,
event_type=EventType.MESSAGE_RECEIVED,
message_type=MessageType.TEXT,
chat_type=chat_type,
content=content,
attachments=attachments,
mentions=mentions,
reply_to_message_id=reply_to,
metadata=metadata,
timestamp=_parse_timestamp(activity.get("timestamp")),
)
def normalize_conversation_update(activity: dict[str, Any]) -> ChannelMessage:
from_info = activity.get("from", {}) or {}
conversation = activity.get("conversation", {}) or {}
channel_data = activity.get("channelData") or {}
recipient = activity.get("recipient", {}) or {}
conversation_type = conversation.get("conversationType", "personal")
chat_type = determine_chat_type(conversation_type)
aad_object_id = from_info.get("aadObjectId", "") or from_info.get("id", "")
conversation_id = conversation.get("id", "")
channel_chat_id = resolve_channel_chat_id(conversation_type, conversation_id, aad_object_id, channel_data)
members_added = activity.get("membersAdded", [])
bot_added = any(m.get("id") == recipient.get("id") for m in members_added)
identity = ChannelIdentity(
channel_id="msteams",
channel_type=ChannelType.MS_TEAMS,
channel_user_id=aad_object_id,
channel_chat_id=channel_chat_id,
channel_message_id=activity.get("id"),
)
return ChannelMessage(
identity=identity,
event_type=EventType.BOT_ADDED if bot_added else EventType.MEMBER_JOINED,
message_type=MessageType.TEXT,
chat_type=chat_type,
content="",
metadata={
"from_name": from_info.get("name", ""),
"conversation_type": conversation_type,
"tenant_id": (channel_data.get("tenant") or {}).get("id", ""),
"team_id": (channel_data.get("team") or {}).get("id", ""),
"bot_added": bot_added,
"raw_activity": activity,
},
timestamp=_parse_timestamp(activity.get("timestamp")),
)
def normalize_invoke(activity: dict[str, Any]) -> ChannelMessage:
from_info = activity.get("from", {}) or {}
conversation = activity.get("conversation", {}) or {}
channel_data = activity.get("channelData") or {}
aad_object_id = from_info.get("aadObjectId", "") or from_info.get("id", "")
conversation_id = conversation.get("id", "")
identity = ChannelIdentity(
channel_id="msteams",
channel_type=ChannelType.MS_TEAMS,
channel_user_id=aad_object_id,
channel_chat_id=conversation_id,
channel_message_id=activity.get("id"),
)
return ChannelMessage(
identity=identity,
event_type=EventType.CARD_ACTION,
message_type=MessageType.TEXT,
chat_type="direct",
content=str(activity.get("value", "")),
metadata={
"invoke_name": activity.get("name", ""),
"tenant_id": (channel_data.get("tenant") or {}).get("id", ""),
"raw_activity": activity,
},
timestamp=_parse_timestamp(activity.get("timestamp")),
)
def _extract_attachments(raw_attachments: list[dict]) -> list[Attachment]:
result = []
for att in raw_attachments:
content_type = att.get("contentType", "")
att_type = "file"
for prefix, mapped in ATTACHMENT_TYPE_MAP.items():
if content_type.startswith(prefix):
att_type = mapped
break
result.append(
Attachment(
type=att_type,
url=att.get("contentUrl", ""),
filename=att.get("name", ""),
metadata={"content_type": content_type},
)
)
return result
def _extract_mentions(entities: list[dict], bot_id: str) -> MentionsInfo | None:
mentioned_ids = []
is_bot_mentioned = False
for entity in entities:
if entity.get("type") == "mention":
mentioned = entity.get("mentioned", {})
uid = mentioned.get("id", "")
if uid:
mentioned_ids.append(uid)
if uid == bot_id:
is_bot_mentioned = True
if not mentioned_ids:
return None
return MentionsInfo(
mentioned_user_ids=mentioned_ids,
is_bot_mentioned=is_bot_mentioned,
raw_text=None,
)
def _strip_mention_tags(text: str) -> str:
result = re.sub(r"<at[^>]*>.*?</at>", "", text).strip()
result = result.replace("<br>", "\n").replace("<br/>", "\n").replace("<br />", "\n")
return result
def _parse_timestamp(ts: str | None) -> datetime:
if not ts:
return utc_now_naive()
try:
ts_clean = ts.replace("Z", "+00:00")
return datetime.fromisoformat(ts_clean).replace(tzinfo=UTC)
except (ValueError, TypeError):
return utc_now_naive()
def extract_reply_context(activity: dict[str, Any]) -> dict[str, str]:
result: dict[str, str] = {}
reply_to_id = activity.get("replyToId", "")
if reply_to_id:
result["reply_to_message_id"] = reply_to_id
channel_data = activity.get("channelData", {}) or {}
reply_to = channel_data.get("replyToMatchedMessageId", "")
if reply_to:
result["channel_reply_id"] = reply_to
return result
def extract_html_text(html_content: str) -> str:
if not html_content:
return ""
text = re.sub(r"<at[^>]*>.*?</at>", "", html_content)
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"<[^>]+>", "", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()