"""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"]*>.*?", "", text).strip() result = result.replace("
", "\n").replace("
", "\n").replace("
", "\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"]*>.*?", "", html_content) text = re.sub(r"", "\n", text, flags=re.IGNORECASE) text = re.sub(r"<[^>]+>", "", text) text = re.sub(r"\n{3,}", "\n\n", text) return text.strip()