ForcePilot/backend/package/yuxi/channels/adapters/msteams/normalizer.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

396 lines
13 KiB
Python

"""Microsoft Teams Activity → ChannelMessage 标准化转换。
将 Bot Framework Service 推送的 Activity JSON 归一化为统一的 ChannelMessage 模型。
"""
from __future__ import annotations
import re
from datetime import UTC, datetime
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 "",
}
thread_meta = extract_thread_metadata(activity)
metadata.update(thread_meta)
quote_info = extract_quote_info(activity)
metadata.update(quote_info)
edit_info = detect_edited_message(activity)
metadata.update(edit_info)
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", "")
conversation_type = conversation.get("conversationType", "personal")
base_conv_id = extract_base_conversation_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"),
)
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_quote_info(activity: dict[str, Any]) -> dict[str, str]:
result: dict[str, str] = {}
reply_to_id = activity.get("replyToId", "")
if reply_to_id:
result["quoted_message_id"] = reply_to_id
channel_data = activity.get("channelData", {}) or {}
quote_target = channel_data.get("quoteMessageId", "")
if quote_target:
result["quote_target_id"] = quote_target
text = activity.get("text", "") or ""
if "<blockquote>" in text.lower():
result["has_blockquote"] = "true"
return result
def detect_edited_message(activity: dict[str, Any]) -> dict[str, str]:
result: dict[str, str] = {}
channel_data = activity.get("channelData", {}) or {}
edit_time = channel_data.get("editedTimestamp", "")
if not edit_time:
edit_time = activity.get("editedTimestamp", "")
if edit_time:
result["is_edited"] = "true"
result["edited_timestamp"] = str(edit_time)
return result
def extract_thread_metadata(activity: dict[str, Any]) -> dict[str, str]:
metadata: dict[str, str] = {}
channel_data = activity.get("channelData", {}) or {}
team_id = (channel_data.get("team") or {}).get("id", "")
channel_id = (channel_data.get("channel") or {}).get("id", "")
conversation = activity.get("conversation", {}) or {}
conversation_id = conversation.get("id", "")
thread_root_id = extract_thread_root_id(conversation_id)
if team_id:
metadata["team_id"] = team_id
if channel_id:
metadata["channel_id"] = channel_id
if thread_root_id:
metadata["thread_root_id"] = thread_root_id
reply_to_id = activity.get("replyToId", "")
if reply_to_id:
metadata["reply_to_id"] = reply_to_id
base_conv_id = extract_base_conversation_id(conversation_id)
metadata["base_conversation_id"] = base_conv_id
return metadata
async def inject_thread_context(
channel_message: ChannelMessage,
graph_client: Any = None,
session_key: str = "",
) -> ChannelMessage:
metadata = channel_message.metadata or {}
team_id = metadata.get("team_id", "")
channel_id = metadata.get("channel_id", "")
thread_root_id = metadata.get("thread_root_id", "") or metadata.get("reply_to_id", "")
if not graph_client or not team_id or not channel_id or not thread_root_id:
return channel_message
try:
from .graph import format_thread_context
parent = await graph_client.fetch_parent_message(team_id, channel_id, thread_root_id)
if not parent:
return channel_message
replies = await graph_client.fetch_thread_replies(team_id, channel_id, thread_root_id, limit=20)
context_msgs = [parent] + replies
thread_context = format_thread_context(context_msgs)
if thread_context and channel_message.content:
channel_message.content = f"{thread_context}\n\n[Current message]\n{channel_message.content}"
metadata["thread_context_injected"] = True
channel_message.metadata = metadata
except Exception:
pass
return channel_message
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()