ForcePilot/backend/package/yuxi/channel/extensions/msteams/monitor.py
Kris 94444ced96 feat(channel): 添加 Microsoft Teams 渠道扩展
新增 Microsoft Teams 渠道扩展,支持在 Yuxi 平台中集成 Microsoft Teams 协作平台。

包含以下功能模块:
- sdk: Bot Framework SDK 封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- auth: JWT 认证
- jwks: JWKS 密钥管理
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- state: 状态管理
- runtime: 运行时管理
- actions: 动作处理
- adaptive_card: 自适应卡片
- task_modules: 任务模块
- message_extension: 消息扩展
- proactive: Proactive Messaging
- graph: Microsoft Graph API 集成
- graph_teams: Teams 操作
- graph_members: 成员管理
- graph_messages: 消息获取
- graph_thread: 线程管理
- graph_users: 用户管理
- graph_upload: 文件上传
- files: 文件处理
- file_consent: 文件授权
- conversations: 会话存储
- mentions: @提及处理
- threading: 线程管理
- reactions: 表情反应
- polls: 投票功能
- meetings: 会议集成
- feedback: 反馈处理
- sso: 单点登录
- deep_links: 深层链接
- incoming_webhook: 入站 Webhook
- localization: 本地化
- user_agent: 用户代理
- sent_message_cache: 消息缓存
- types: 类型定义
2026-05-21 11:28:42 +08:00

232 lines
8.2 KiB
Python

from __future__ import annotations
import logging
import re
from yuxi.channel.message.models import GroupContext, MessageType, PeerInfo, UnifiedMessage
from yuxi.channel.routing.models import PeerKind
from .format import clean_inbound_text
from .sdk import was_bot_mentioned
from .types import BotFrameworkActivity, MSTeamsActivityType
logger = logging.getLogger(__name__)
AI_TAG_RE = re.compile(r"\[AI\]\s*", re.IGNORECASE)
def _resolve_chat_type(activity: BotFrameworkActivity) -> str:
ct = activity.conversation_type
if ct == "personal":
return "direct"
if ct == "channel":
return "channel" if not activity.reply_to_id else "thread"
if ct == "groupChat":
return "group"
return "direct" if not activity.is_group else "group"
def _resolve_message_type(activity: BotFrameworkActivity) -> MessageType:
if activity.attachments:
first = activity.attachments[0]
content_type = first.get("contentType", "")
if "image" in content_type:
return MessageType.IMAGE
if "video" in content_type:
return MessageType.VIDEO
if "audio" in content_type:
return MessageType.VOICE
return MessageType.FILE
if activity.type == MSTeamsActivityType.MESSAGE_REACTION:
return MessageType.EVENT
return MessageType.TEXT
def _resolve_peer_kind(activity: BotFrameworkActivity) -> PeerKind:
ct = _resolve_chat_type(activity)
if ct in ("direct",):
return PeerKind.DIRECT
return PeerKind.GROUP
def _build_group_context(activity: BotFrameworkActivity) -> GroupContext | None:
chat_type = _resolve_chat_type(activity)
if chat_type == "direct":
return None
conv = activity.conversation
cd = activity.channel_data
return GroupContext(
id=activity.conversation_id,
kind=chat_type,
name=conv.name if conv else None,
team_id=cd.team_id if cd else None,
thread_id=activity.reply_to_id if activity.is_channel_thread else None,
native_channel_id=cd.channel_id if cd else None,
)
def _build_debounce_key(activity: BotFrameworkActivity) -> str:
return f"msteams:{activity.from_id}:{activity.conversation_id}"
def is_echo_activity(activity: BotFrameworkActivity, bot_app_id: str) -> bool:
sender_id = activity.from_id
bot_id = f"28:{bot_app_id}"
return sender_id == bot_id or sender_id == bot_app_id
def _build_event_message(
activity: BotFrameworkActivity,
account_id: str,
event_type: str,
payload: dict | list | None = None,
) -> UnifiedMessage:
return UnifiedMessage(
msg_id=f"msteams:{event_type}:{activity.id}",
channel_type="msteams",
account_id=account_id,
content="",
sender=PeerInfo(id=activity.from_id, kind=_resolve_peer_kind(activity)),
message_type=MessageType.EVENT,
group=_build_group_context(activity),
raw_payload=activity.raw,
metadata={"event_type": event_type, "payload": payload},
conversation_label=f"msteams:{activity.conversation_id}",
native_channel_id=activity.conversation_id,
)
def activity_to_unified_message(
activity: BotFrameworkActivity,
bot_app_id: str,
account_id: str,
) -> UnifiedMessage | None:
if activity.type == MSTeamsActivityType.TYPING:
return None
if activity.type == MSTeamsActivityType.MESSAGE_REACTION:
from .reactions import parse_message_reaction
reaction_data = parse_message_reaction(activity.raw)
if reaction_data:
return UnifiedMessage(
msg_id=f"msteams:reaction:{activity.id}",
channel_type="msteams",
account_id=account_id,
content="",
sender=PeerInfo(id=activity.from_id, kind=_resolve_peer_kind(activity)),
message_type=MessageType.EVENT,
group=_build_group_context(activity),
raw_payload=activity.raw,
metadata={
"event_type": "message_reaction",
"reply_to_id": reaction_data.get("reply_to_id"),
"reactions_added": reaction_data.get("reactions_added", []),
"reactions_removed": reaction_data.get("reactions_removed", []),
},
conversation_label=f"msteams:{activity.conversation_id}",
native_channel_id=activity.conversation_id,
)
return None
if activity.type == MSTeamsActivityType.CONVERSATION_UPDATE:
members_added = activity.raw.get("membersAdded", [])
members_removed = activity.raw.get("membersRemoved", [])
bot_id = f"28:{bot_app_id}"
bot_was_added = any(m.get("id") in (bot_id, bot_app_id) for m in members_added)
bot_was_removed = any(m.get("id") in (bot_id, bot_app_id) for m in members_removed)
if bot_was_added:
return _build_event_message(activity, account_id, "bot_added", members_added)
if bot_was_removed:
return _build_event_message(activity, account_id, "bot_removed", members_removed)
event_type = activity.raw.get("eventType") or activity.raw.get("channelData", {}).get("eventType", "")
if event_type:
return _build_event_message(activity, account_id, event_type, activity.raw.get("membersAdded", []))
return None
if activity.type == MSTeamsActivityType.INSTALLATION_UPDATE:
action = activity.raw.get("action", "")
return _build_event_message(activity, account_id, "installation_update", {"action": action})
if activity.type == MSTeamsActivityType.MESSAGE_UPDATE:
return UnifiedMessage(
msg_id=f"msteams:msgupdate:{activity.id}",
channel_type="msteams",
account_id=account_id,
content=clean_inbound_text(activity.text or ""),
sender=PeerInfo(id=activity.from_id, kind=_resolve_peer_kind(activity)),
message_type=MessageType.EVENT,
group=_build_group_context(activity),
raw_payload=activity.raw,
metadata={"event_type": "message_update", "original_id": activity.id},
conversation_label=f"msteams:{activity.conversation_id}",
native_channel_id=activity.conversation_id,
)
if activity.type == MSTeamsActivityType.MESSAGE_DELETE:
return _build_event_message(activity, account_id, "message_delete")
if activity.type == MSTeamsActivityType.INVOKE:
return None
if is_echo_activity(activity, bot_app_id):
return None
text = clean_inbound_text(activity.text or "")
text = AI_TAG_RE.sub("", text)
if not text and not activity.attachments:
return None
chat_type = _resolve_chat_type(activity)
peer_kind = _resolve_peer_kind(activity)
sender = PeerInfo(
id=activity.from_id,
kind=peer_kind,
display_name=activity.from_name or activity.from_id,
)
media_urls_list = [att.get("contentUrl", "") for att in activity.attachments if att.get("contentUrl")]
mentioned = was_bot_mentioned(activity, bot_app_id)
thread_id = activity.reply_to_id if activity.is_channel_thread else None
metadata: dict = {
"service_url": activity.service_url,
"tenant_id": activity.tenant_id,
"debounce_key": _build_debounce_key(activity),
"chat_type": chat_type,
"locale": activity.locale,
}
if text.startswith("/"):
command, *args = text[1:].split(maxsplit=1)
metadata["command"] = command
metadata["command_args"] = args[0] if args else ""
return UnifiedMessage(
msg_id=f"msteams:{activity.id}",
channel_type="msteams",
account_id=account_id,
content=text,
sender=sender,
message_type=_resolve_message_type(activity),
group=_build_group_context(activity),
reply_to_id=activity.reply_to_id,
was_mentioned=mentioned,
media_urls=media_urls_list if media_urls_list else [],
raw_payload=activity.raw,
metadata=metadata,
conversation_label=f"msteams:{activity.conversation_id}",
native_channel_id=activity.conversation_id,
native_direct_user_id=activity.from_id,
thread_parent_id=thread_id,
)