ForcePilot/backend/package/yuxi/channels/adapters/msteams/invoke_handler.py

170 lines
5.7 KiB
Python
Raw Normal View History

"""Microsoft Teams invoke Activity 路由分发。
处理 adaptiveCard/action invoke 的投票确认输入等交互
以及 messageReaction 事件的路由
"""
from __future__ import annotations
from typing import Any
from yuxi.channels.models import ChannelIdentity, ChannelMessage, ChannelType, EventType, MessageType
def normalize_reaction(activity: dict[str, Any]) -> ChannelMessage:
"""标准化 messageReaction Activity → ChannelMessage。
处理 reactionsAdded reactionsRemoved 两种方向
"""
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", "")
added = activity.get("reactionsAdded", [])
removed = activity.get("reactionsRemoved", [])
reactions_data = {
"added": [{"type": r.get("type", "")} for r in added],
"removed": [{"type": r.get("type", "")} for r in removed],
}
direction = "added" if added else "removed" if removed else "unknown"
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.SYSTEM_EVENT,
message_type=MessageType.TEXT,
chat_type="direct",
content=f"reaction_{direction}",
reply_to_message_id=activity.get("replyToId"),
metadata={
"reaction_direction": direction,
"reactions": reactions_data,
"raw_activity": activity,
"tenant_id": (channel_data.get("tenant") or {}).get("id", ""),
"from_name": from_info.get("name", ""),
},
)
def normalize_card_action(activity: dict[str, Any]) -> ChannelMessage:
"""标准化 adaptiveCard/action invoke → ChannelMessage。
提取 action 类型和提交数据支持投票/确认/输入/通用 Submit
"""
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", "")
value = activity.get("value", {}) or {}
invoke_name = activity.get("name", "")
action_type = _classify_invoke_action(invoke_name, value)
poll_id = _extract_poll_id(value) if action_type == "vote" else ""
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=_format_action_content(action_type, value),
metadata={
"invoke_name": invoke_name,
"action_type": action_type,
"invoke_value": value,
"poll_id": poll_id,
"tenant_id": (channel_data.get("tenant") or {}).get("id", ""),
"from_name": from_info.get("name", ""),
"raw_activity": activity,
},
)
def _classify_invoke_action(invoke_name: str, value: dict[str, Any]) -> str:
if invoke_name == "adaptiveCard/action":
data = value.get("data", {}) or {}
if isinstance(data, dict):
if "voteOption" in value:
return "vote"
if data.get("action") == "confirm":
return "confirm"
if data.get("action") == "cancel":
return "cancel"
if data.get("action") == "submit":
return "input"
if "action" in data:
return data["action"]
return "card_action"
if invoke_name == "message/submitAction":
return "feedback"
if invoke_name == "fileConsent/invoke":
return "file_consent"
return invoke_name
def _extract_poll_id(value: dict[str, Any]) -> str:
"""7 路径 pollId 提取:覆盖 Teams 不同版本文档差异。"""
data = value.get("data", {}) or {}
sources = [
lambda: data.get("poll_id"),
lambda: value.get("poll_id"),
lambda: value.get("pollId"),
lambda: data.get("pollId"),
lambda: (data.get("data", {}) or {}).get("poll_id") if isinstance(data.get("data"), dict) else None,
lambda: str(data.get("poll_id", "")) or str(value.get("poll_id", "")),
lambda: (
(value.get("action", {}) or {}).get("data", {}).get("poll_id")
if isinstance(value.get("action"), dict)
else None
),
]
for source in sources:
try:
result = source()
if result:
return str(result)
except Exception:
continue
return value.get("poll_id", "") or str(data.get("poll_id", ""))
def _format_action_content(action_type: str, value: dict[str, Any]) -> str:
if action_type == "vote":
return str(value.get("voteOption", ""))
if action_type == "feedback":
fb_value = value.get("feedbackValue", "")
return str(fb_value)
if action_type in ("confirm", "cancel", "input"):
data = value.get("data", {}) or {}
return str(data)
return str(value)
def is_signin_invoke(activity: dict[str, Any]) -> bool:
name = activity.get("name", "")
return name in ("signin/tokenExchange", "signin/verifyState")