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