实现了 Teams 机器人所需的全功能组件,包括: - 基础命令解析与帮助卡片生成 - 租户验证与访问控制 - 自定义 UA 与媒体工具 - 消息分块、批注处理与会话管理 - 防抖、缓存与配置路由能力 - 投票、配对、审计与运行时状态管理 - TTS 语音合成与卡片构建工具 - 群组管理与权限控制逻辑
954 lines
36 KiB
Python
954 lines
36 KiB
Python
"""Microsoft Teams 渠道适配器 — Phase 4 Tier 2 实现。
|
||
|
||
基于 Bot Framework REST API + Microsoft Graph API,
|
||
实现 BaseChannelAdapter 全部接口,接入 Phase 1 多渠道框架。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import logging
|
||
import os
|
||
import time
|
||
import uuid
|
||
from collections import OrderedDict
|
||
from typing import Any
|
||
from urllib.parse import urlparse
|
||
|
||
import aiohttp
|
||
import jwt
|
||
from jwt import PyJWKClient
|
||
|
||
from yuxi.channels.base import BaseChannelAdapter
|
||
from yuxi.channels.capabilities import ChannelCapabilities
|
||
from yuxi.channels.meta import ChannelMeta
|
||
from yuxi.channels.exceptions import (
|
||
ChannelAuthenticationError,
|
||
)
|
||
from yuxi.channels.models import (
|
||
ChannelIdentity,
|
||
ChannelMessage,
|
||
ChannelResponse,
|
||
ChannelStatus,
|
||
ChannelType,
|
||
DeliveryResult,
|
||
EventType,
|
||
HealthStatus,
|
||
MessageType,
|
||
)
|
||
from yuxi.channels.registry import register_builtin_adapter
|
||
from yuxi.utils.datetime_utils import utc_now_naive
|
||
|
||
from .audit import GraphPermissionAuditor
|
||
from .chunking import chunk_text
|
||
from .commands import extract_command
|
||
from .credentials import DelegatedAuthStore
|
||
from .debounce import DebounceManager
|
||
from .feedback import build_feedback_channel_data
|
||
from .formatter import format_outbound
|
||
from .normalizer import normalize_inbound, normalize_conversation_update, normalize_invoke
|
||
from .invoke_handler import normalize_reaction
|
||
from .polls import PollStore
|
||
from .probe import MSTeamsProbe
|
||
from .proactive import (
|
||
ConversationStore,
|
||
proactive_send as _proactive_send,
|
||
)
|
||
from .security import SecurityPolicy
|
||
from .send import MessageSender, send_adaptive_card as _send_adaptive_card, send_media as _send_stream_media
|
||
from .sent_message_cache import SentMessageCache
|
||
from .sso import SSOHandler
|
||
from .streaming import StreamManager
|
||
from .tenant import TenantValidator
|
||
from .welcome import build_welcome_response
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_MAX_MESSAGE_TRACKER_SIZE = 1000
|
||
_DEDUP_WINDOW_SECONDS = 300
|
||
_MAX_WEBHOOK_BODY_BYTES = 256 * 1024
|
||
_WEBHOOK_HANDLE_TIMEOUT_SECONDS = 30
|
||
_WEBHOOK_SIGNATURE_TIMEOUT_SECONDS = 10
|
||
|
||
BOT_FRAMEWORK_DOMAINS = {
|
||
"smba.trafficmanager.net",
|
||
"api.botframework.com",
|
||
"api.botframework.us",
|
||
}
|
||
|
||
BOT_OPENID_CONFIG_URL = "https://login.botframework.com/v1/.well-known/openidconfiguration"
|
||
|
||
_EMOJI_TO_REACTION = {
|
||
"👍": "like",
|
||
"❤️": "heart",
|
||
"😂": "laugh",
|
||
"😲": "surprised",
|
||
"😢": "sad",
|
||
"😡": "angry",
|
||
}
|
||
|
||
|
||
@register_builtin_adapter
|
||
class MSTeamsAdapter(BaseChannelAdapter):
|
||
"""Microsoft Teams 渠道适配器。
|
||
|
||
通过 Bot Framework Webhook + Graph API 接入,管理 Bot 适配器生命周期,
|
||
实现 Activity 与 ChannelMessage 的双向标准化转换。
|
||
"""
|
||
|
||
channel_id = "msteams"
|
||
channel_type = ChannelType.MS_TEAMS
|
||
|
||
text_chunk_limit = 4000
|
||
supports_markdown = True
|
||
supports_streaming = True
|
||
streaming_modes = ["off", "block", "progress"]
|
||
max_media_size_mb = 100
|
||
|
||
capabilities = ChannelCapabilities(
|
||
chat_types=["direct", "group", "channel", "thread"],
|
||
polls=True,
|
||
reactions=True,
|
||
reply=True,
|
||
threads=True,
|
||
media=True,
|
||
group_management=True,
|
||
supports_markdown=True,
|
||
supports_streaming=True,
|
||
streaming_modes=["off", "block", "progress"],
|
||
text_chunk_limit=4000,
|
||
max_media_size_mb=100,
|
||
delivery_mode="direct",
|
||
)
|
||
meta = ChannelMeta(id="msteams", label="Microsoft Teams", aliases=["teams"])
|
||
|
||
webhook_path = "/webhook/msteams"
|
||
|
||
_DEFAULT_EXTRA_WEBHOOK_PATHS = ["/api/messages/msteams"]
|
||
|
||
def __init__(self, config: dict[str, Any] | None = None):
|
||
super().__init__(config)
|
||
self._status = ChannelStatus.DISCONNECTED
|
||
self._app_id: str = ""
|
||
self._app_password: str = ""
|
||
self._tenant_validator: TenantValidator | None = None
|
||
self._sender: MessageSender | None = None
|
||
self._probe: MSTeamsProbe | None = None
|
||
self._message_tracker: OrderedDict[str, str] = OrderedDict()
|
||
self._streaming_mode: str = "block"
|
||
self._stream_mgr = StreamManager()
|
||
self._dedup_ids: dict[str, float] = {}
|
||
self._dedup_ttl = self.config.get("dedup_ttl", _DEDUP_WINDOW_SECONDS)
|
||
self._http_session: aiohttp.ClientSession | None = None
|
||
self._jwks_client: PyJWKClient | None = None
|
||
self._security_policy = SecurityPolicy(self.config)
|
||
self._reply_style: str = self.config.get("reply_style", "thread")
|
||
self._poll_store = PollStore()
|
||
self._feedback_enabled = self.config.get("feedback_enabled", True)
|
||
self._feedback_reflection = self.config.get("feedback_reflection", False)
|
||
self._welcome_enabled = self.config.get("welcome_card", True)
|
||
self._group_welcome_enabled = self.config.get("group_welcome_card", True)
|
||
self._sso_handler = SSOHandler(
|
||
connection_name=self.config.get("sso", {}).get("connection_name", ""),
|
||
enabled=self.config.get("sso", {}).get("enabled", False),
|
||
)
|
||
self._debounce_mgr = DebounceManager()
|
||
self._conv_store = ConversationStore()
|
||
self._delegated_auth_store = DelegatedAuthStore()
|
||
self._sent_cache = SentMessageCache()
|
||
|
||
async def _get_http_session(self) -> aiohttp.ClientSession:
|
||
if self._http_session is None or self._http_session.closed:
|
||
self._http_session = aiohttp.ClientSession()
|
||
return self._http_session
|
||
|
||
async def _get_jwks_client(self) -> PyJWKClient:
|
||
if self._jwks_client is None:
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.get(BOT_OPENID_CONFIG_URL) as resp:
|
||
if resp.status == 200:
|
||
config = await resp.json()
|
||
jwks_uri = config.get("jwks_uri", "")
|
||
else:
|
||
jwks_uri = ""
|
||
if jwks_uri:
|
||
self._jwks_client = PyJWKClient(jwks_uri)
|
||
else:
|
||
self._jwks_client = PyJWKClient("https://login.botframework.com/v1/.well-known/keys")
|
||
return self._jwks_client
|
||
|
||
def _require_connected(self) -> DeliveryResult | None:
|
||
if self._status != ChannelStatus.CONNECTED or not self._sender:
|
||
return DeliveryResult(success=False, error="Not connected")
|
||
return None
|
||
|
||
async def connect(self) -> None:
|
||
if self._status == ChannelStatus.CONNECTED:
|
||
return
|
||
|
||
self._app_id = self._resolve_app_id()
|
||
self._app_password = self._resolve_app_password()
|
||
if not self._app_id or not self._app_password:
|
||
raise ChannelAuthenticationError("Teams app_id/app_password not configured")
|
||
|
||
self._status = ChannelStatus.CONNECTING
|
||
logger.info(f"MSTeams connecting (app_id={self._app_id[:8]}...)")
|
||
|
||
tenants = self.config.get("allowed_tenants", [])
|
||
self._tenant_validator = TenantValidator(set(tenants) if tenants else set())
|
||
|
||
self._sender = MessageSender(
|
||
app_id=self._app_id,
|
||
app_password=self._app_password,
|
||
service_url=self.config.get("service_url", "https://smba.trafficmanager.net/emea"),
|
||
max_ops_per_second=self.config.get("rate_limit_ops_per_second", 5),
|
||
)
|
||
|
||
self._probe = MSTeamsProbe(
|
||
app_id=self._app_id,
|
||
app_password=self._app_password,
|
||
tenant_id=self._resolve_tenant_id(),
|
||
sender=self._sender,
|
||
)
|
||
|
||
valid = await self._probe.validate_credentials()
|
||
if not valid:
|
||
self._status = ChannelStatus.ERROR
|
||
raise ChannelAuthenticationError("Failed to validate Teams credentials")
|
||
|
||
self._streaming_mode = self.config.get("streaming_mode", "block")
|
||
self._status = ChannelStatus.CONNECTED
|
||
logger.info(f"MSTeams bot '{self._app_id[:8]}...' connected")
|
||
|
||
async def disconnect(self) -> None:
|
||
if self._status == ChannelStatus.DISCONNECTED:
|
||
return
|
||
|
||
logger.info(f"MSTeams adapter disconnecting (app_id={self._app_id[:8]}...)")
|
||
|
||
if self._sender:
|
||
await self._sender.close()
|
||
self._sender = None
|
||
|
||
if self._probe:
|
||
await self._probe.close()
|
||
self._probe = None
|
||
|
||
if self._http_session and not self._http_session.closed:
|
||
await self._http_session.close()
|
||
self._http_session = None
|
||
|
||
self._jwks_client = None
|
||
self._stream_mgr.clear()
|
||
self._dedup_ids.clear()
|
||
self._message_tracker.clear()
|
||
self._status = ChannelStatus.DISCONNECTED
|
||
|
||
async def send(self, response: ChannelResponse) -> DeliveryResult:
|
||
not_connected = self._require_connected()
|
||
if not_connected:
|
||
return not_connected
|
||
|
||
conversation_id = self._resolve_conversation_id(response)
|
||
chunks = chunk_text(response.content, self.text_chunk_limit)
|
||
if not chunks:
|
||
return DeliveryResult(success=False, error="Empty content")
|
||
|
||
activity = format_outbound(response, self.text_chunk_limit)
|
||
activity["text"] = chunks[0]
|
||
|
||
if self._feedback_enabled:
|
||
fb_channel_data = build_feedback_channel_data(
|
||
feedback_enabled=self._feedback_enabled,
|
||
feedback_reflection=self._feedback_reflection,
|
||
)
|
||
if fb_channel_data:
|
||
existing_cd = activity.get("channelData", {}) or {}
|
||
activity["channelData"] = {**existing_cd, **fb_channel_data}
|
||
|
||
result = await self._sender.send_activity(conversation_id, activity)
|
||
if not result.success or not result.message_id:
|
||
return result
|
||
|
||
self._sent_cache.record(result.message_id, conversation_id)
|
||
|
||
if len(chunks) <= 1:
|
||
return result
|
||
|
||
accumulated_text = chunks[0]
|
||
for chunk in chunks[1:]:
|
||
accumulated_text += chunk
|
||
if len(accumulated_text) > self.text_chunk_limit:
|
||
accumulated_text = accumulated_text[: self.text_chunk_limit]
|
||
edit_activity = {
|
||
"type": "message",
|
||
"text": accumulated_text,
|
||
"textFormat": "markdown",
|
||
}
|
||
await self._sender.update_activity(conversation_id, result.message_id, edit_activity)
|
||
|
||
return result
|
||
|
||
async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult:
|
||
not_connected = self._require_connected()
|
||
if not_connected:
|
||
return not_connected
|
||
|
||
content_type_map = {
|
||
"image": "image/png",
|
||
"video": "video/mp4",
|
||
"audio": "audio/wav",
|
||
"file": "application/octet-stream",
|
||
}
|
||
mime_type = content_type_map.get(media_type, "application/octet-stream")
|
||
|
||
if isinstance(data, bytes):
|
||
token = self._sender.token if self._sender else None
|
||
if token and len(data) > 1024 * 1024:
|
||
from .graph import GraphClient
|
||
|
||
client = GraphClient(token)
|
||
try:
|
||
filename = f"media_{media_type}_{int(time.time())}"
|
||
upload_result = await client.upload_file(data, filename)
|
||
web_url = upload_result.get("webUrl", "")
|
||
if web_url:
|
||
return await _send_stream_media(self._sender, chat_id, web_url, mime_type)
|
||
except Exception as e:
|
||
logger.error(f"MSTeams media upload via Graph failed: {e}")
|
||
return DeliveryResult(success=False, error=f"Graph upload failed: {e}")
|
||
finally:
|
||
await client.close()
|
||
|
||
content_url = f"data:{mime_type};base64,{base64.b64encode(data).decode()}"
|
||
elif isinstance(data, str):
|
||
content_url = data
|
||
else:
|
||
return DeliveryResult(success=False, error=f"Unsupported data type: {type(data)}")
|
||
|
||
return await _send_stream_media(self._sender, chat_id, content_url, mime_type)
|
||
|
||
async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult:
|
||
not_connected = self._require_connected()
|
||
if not_connected:
|
||
return not_connected
|
||
|
||
activity = {
|
||
"type": "message",
|
||
"text": content[: self.text_chunk_limit],
|
||
"textFormat": "markdown",
|
||
}
|
||
return await self._sender.update_activity(chat_id, msg_id, activity)
|
||
|
||
async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
|
||
not_connected = self._require_connected()
|
||
if not_connected:
|
||
return not_connected
|
||
return await self._sender.delete_activity(chat_id, msg_id)
|
||
|
||
async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult:
|
||
not_connected = self._require_connected()
|
||
if not_connected:
|
||
return not_connected
|
||
|
||
reaction_type = _EMOJI_TO_REACTION.get(emoji, "like")
|
||
activity = {
|
||
"type": "messageReaction",
|
||
"reactionsAdded": [{"type": reaction_type}],
|
||
"replyToId": msg_id,
|
||
}
|
||
return await self._sender.send_activity(chat_id, activity)
|
||
|
||
async def remove_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult:
|
||
not_connected = self._require_connected()
|
||
if not_connected:
|
||
return not_connected
|
||
|
||
reaction_type = _EMOJI_TO_REACTION.get(emoji, "like")
|
||
activity = {
|
||
"type": "messageReaction",
|
||
"reactionsRemoved": [{"type": reaction_type}],
|
||
"replyToId": msg_id,
|
||
}
|
||
return await self._sender.send_activity(chat_id, activity)
|
||
|
||
async def send_typing(self, chat_id: str) -> DeliveryResult:
|
||
not_connected = self._require_connected()
|
||
if not_connected:
|
||
return not_connected
|
||
|
||
activity = {"type": "typing"}
|
||
return await self._sender.send_activity(chat_id, activity)
|
||
|
||
async def send_poll(
|
||
self,
|
||
chat_id: str,
|
||
title: str,
|
||
options: list[str],
|
||
creator_id: str = "",
|
||
multi_select: bool = False,
|
||
max_selections: int = 1,
|
||
) -> DeliveryResult:
|
||
not_connected = self._require_connected()
|
||
if not_connected:
|
||
return not_connected
|
||
|
||
from .cards import build_vote_card
|
||
|
||
poll_id = f"poll_{uuid.uuid4().hex[:12]}"
|
||
self._poll_store.create_poll(
|
||
poll_id=poll_id,
|
||
title=title,
|
||
options=options,
|
||
creator_id=creator_id,
|
||
multi_select=multi_select,
|
||
max_selections=max_selections,
|
||
)
|
||
|
||
card = build_vote_card(
|
||
title=title,
|
||
options=options,
|
||
callback_data={"poll_id": poll_id, "action": "vote"},
|
||
max_selections=max_selections if multi_select else None,
|
||
)
|
||
|
||
result = await _send_adaptive_card(self._sender, chat_id, card)
|
||
if result.success:
|
||
result.message_id = poll_id
|
||
return result
|
||
|
||
async def send_adaptive_card(
|
||
self, chat_id: str, card: dict[str, Any], reply_to_id: str | None = None
|
||
) -> DeliveryResult:
|
||
not_connected = self._require_connected()
|
||
if not_connected:
|
||
return not_connected
|
||
return await _send_adaptive_card(self._sender, chat_id, card, reply_to_id)
|
||
|
||
async def pin_message(self, team_id: str, channel_id: str, message_id: str) -> DeliveryResult:
|
||
not_connected = self._require_connected()
|
||
if not_connected:
|
||
return not_connected
|
||
|
||
from .graph import GraphClient
|
||
from .pins import pin_message as _pin_message
|
||
|
||
token = self._sender.token if self._sender else None
|
||
if not token:
|
||
return DeliveryResult(success=False, error="No Graph token available")
|
||
|
||
client = GraphClient(token)
|
||
try:
|
||
result = await _pin_message(client, team_id, channel_id, message_id)
|
||
if "error" in result:
|
||
return DeliveryResult(success=False, error=str(result.get("error", "Unknown error")))
|
||
return DeliveryResult(success=True, message_id=message_id)
|
||
finally:
|
||
await client.close()
|
||
|
||
async def unpin_message(self, team_id: str, channel_id: str, message_id: str) -> DeliveryResult:
|
||
not_connected = self._require_connected()
|
||
if not_connected:
|
||
return not_connected
|
||
|
||
from .graph import GraphClient
|
||
from .pins import unpin_message as _unpin_message
|
||
|
||
token = self._sender.token if self._sender else None
|
||
if not token:
|
||
return DeliveryResult(success=False, error="No Graph token available")
|
||
|
||
client = GraphClient(token)
|
||
try:
|
||
result = await _unpin_message(client, team_id, channel_id, message_id)
|
||
if "error" in result:
|
||
return DeliveryResult(success=False, error=str(result.get("error", "Unknown error")))
|
||
return DeliveryResult(success=True, message_id=message_id)
|
||
finally:
|
||
await client.close()
|
||
|
||
async def get_pinned_messages(self, team_id: str, channel_id: str) -> list[dict[str, Any]]:
|
||
not_connected = self._require_connected()
|
||
if not_connected:
|
||
return []
|
||
|
||
from .graph import GraphClient
|
||
from .pins import get_pinned_messages as _get_pinned
|
||
|
||
token = self._sender.token if self._sender else None
|
||
if not token:
|
||
return []
|
||
|
||
client = GraphClient(token)
|
||
try:
|
||
return await _get_pinned(client, team_id, channel_id)
|
||
finally:
|
||
await client.close()
|
||
|
||
async def proactive_send(self, channel_chat_id: str, text: str) -> DeliveryResult:
|
||
not_connected = self._require_connected()
|
||
if not_connected:
|
||
return not_connected
|
||
return await _proactive_send(self._sender, self._conv_store, channel_chat_id, text)
|
||
|
||
async def use_delegated_token(self, user_id: str) -> bool:
|
||
token_entry = self._delegated_auth_store.get_token(user_id)
|
||
if not token_entry:
|
||
return False
|
||
|
||
if self._delegated_auth_store.is_expired(user_id):
|
||
refreshed = await self._delegated_auth_store.refresh_token(user_id, self._app_id, self._app_password)
|
||
if not refreshed:
|
||
return False
|
||
token_entry = self._delegated_auth_store.get_token(user_id)
|
||
if not token_entry:
|
||
return False
|
||
|
||
if self._sender:
|
||
await self._sender.set_delegated_token(token_entry.get("access_token"))
|
||
return True
|
||
|
||
async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult:
|
||
not_connected = self._require_connected()
|
||
if not_connected:
|
||
return not_connected
|
||
|
||
if not msg_id:
|
||
result = await self.send(
|
||
ChannelResponse(
|
||
identity=ChannelIdentity(
|
||
channel_id=self.channel_id,
|
||
channel_type=self.channel_type,
|
||
channel_user_id="",
|
||
channel_chat_id=chat_id,
|
||
),
|
||
content=chunk,
|
||
metadata={"streaming": True},
|
||
)
|
||
)
|
||
if result.message_id:
|
||
self._stream_mgr.register_message(chat_id, result.message_id, chunk)
|
||
return result
|
||
|
||
self._stream_mgr.append_text(chat_id, chunk)
|
||
|
||
if not self._stream_mgr.should_update(chat_id) and not finished:
|
||
return DeliveryResult(success=True, message_id=msg_id)
|
||
|
||
self._stream_mgr.mark_update(chat_id)
|
||
|
||
result = await self._stream_mgr.send_update(
|
||
self._sender, chat_id, finished=finished, chunk_limit=self.text_chunk_limit
|
||
)
|
||
if result:
|
||
return result
|
||
|
||
return DeliveryResult(success=False, error="No pending stream message")
|
||
|
||
def normalize_inbound(self, raw: dict[str, Any]) -> ChannelMessage:
|
||
activity_type = raw.get("type", "message")
|
||
|
||
if activity_type == "message":
|
||
msg = normalize_inbound(raw)
|
||
return self._classify_message(msg)
|
||
|
||
if activity_type == "conversationUpdate":
|
||
return normalize_conversation_update(raw)
|
||
|
||
if activity_type == "invoke":
|
||
return normalize_invoke(raw)
|
||
|
||
from_info = raw.get("from", {}) or {}
|
||
conversation = raw.get("conversation", {}) or {}
|
||
|
||
return ChannelMessage(
|
||
identity=ChannelIdentity(
|
||
channel_id=self.channel_id,
|
||
channel_type=self.channel_type,
|
||
channel_user_id=from_info.get("id", ""),
|
||
channel_chat_id=conversation.get("id", ""),
|
||
channel_message_id=raw.get("id"),
|
||
),
|
||
event_type=EventType.MESSAGE_RECEIVED,
|
||
content="",
|
||
metadata={"raw_activity": raw, "activity_type": activity_type},
|
||
)
|
||
|
||
def _classify_message(self, msg: ChannelMessage) -> ChannelMessage:
|
||
content = msg.content
|
||
command, args = extract_command(content)
|
||
if command:
|
||
msg.message_type = MessageType.COMMAND
|
||
msg.metadata["command"] = command
|
||
msg.metadata["command_args"] = args
|
||
return msg
|
||
|
||
def format_outbound(self, response: ChannelResponse) -> dict[str, Any]:
|
||
activity = format_outbound(response, self.text_chunk_limit)
|
||
if self._reply_style == "thread" and response.reply_to_message_id:
|
||
activity["replyToId"] = response.reply_to_message_id
|
||
return activity
|
||
|
||
@property
|
||
def dm_policy(self) -> str:
|
||
return self._security_policy.dm_policy
|
||
|
||
@property
|
||
def group_policy(self) -> str:
|
||
return self._security_policy.group_policy
|
||
|
||
@property
|
||
def poll_store(self) -> PollStore:
|
||
return self._poll_store
|
||
|
||
@property
|
||
def feedback_enabled(self) -> bool:
|
||
return self._feedback_enabled
|
||
|
||
async def health_check(self) -> HealthStatus:
|
||
if self._status != ChannelStatus.CONNECTED or not self._probe:
|
||
return HealthStatus(status="unhealthy", last_error="Not connected")
|
||
|
||
try:
|
||
result = await self._probe.probe()
|
||
result.last_connected_at = utc_now_naive()
|
||
return result
|
||
except Exception as e:
|
||
return HealthStatus(status="unhealthy", last_error=str(e))
|
||
|
||
async def handle_webhook(self, body: dict[str, Any]) -> ChannelMessage | None | int:
|
||
"""由 webhook_router 调用的 Webhook 入口。
|
||
|
||
接收 Bot Framework Service 推送的 Activity JSON,
|
||
返回标准化 ChannelMessage、None(过滤掉不处理的事件)或 HTTP 状态码。
|
||
"""
|
||
import sys
|
||
|
||
body_size = sys.getsizeof(body)
|
||
max_size = self.config.get("max_webhook_body_bytes", _MAX_WEBHOOK_BODY_BYTES)
|
||
if body_size > max_size:
|
||
logger.warning(f"MSTeams: webhook body too large ({body_size} > {max_size})")
|
||
return 413
|
||
|
||
channel_data = body.get("channelData") or {}
|
||
|
||
if self._tenant_validator and not self._tenant_validator.validate(channel_data):
|
||
logger.warning("MSTeams: rejected activity from unauthorized tenant")
|
||
return None
|
||
|
||
activity_type = body.get("type", "")
|
||
|
||
if activity_type == "message":
|
||
msg_id = body.get("id", "")
|
||
if self._is_duplicate(msg_id):
|
||
return None
|
||
|
||
if msg_id and self._sent_cache.was_sent(msg_id):
|
||
logger.debug(f"MSTeams: message {msg_id} was sent by us, skipping")
|
||
return None
|
||
|
||
channel_msg = normalize_inbound(body)
|
||
|
||
chat_type = channel_msg.chat_type or "direct"
|
||
user_id = channel_msg.identity.channel_user_id
|
||
user_name = (channel_msg.metadata or {}).get("from_name", "")
|
||
conversation = body.get("conversation", {}) or {}
|
||
conversation_id = conversation.get("id", "")
|
||
|
||
if chat_type in ("direct",):
|
||
if not self._security_policy.check_dm(user_id, user_name):
|
||
logger.debug(f"MSTeams: DM rejected by security policy for user={user_id}")
|
||
return None
|
||
else:
|
||
if not self._security_policy.check_group(user_id, user_name, conversation_id):
|
||
logger.debug(f"MSTeams: Group rejected by security policy for user={user_id}")
|
||
return None
|
||
|
||
is_mentioned = bool(channel_msg.mentions and channel_msg.mentions.is_bot_mentioned)
|
||
if not self._security_policy.check_require_mention(is_mentioned, chat_type, self.config):
|
||
logger.debug("MSTeams: message filtered by require_mention")
|
||
return None
|
||
|
||
from_info = body.get("from", {}) or {}
|
||
sender_id = from_info.get("id", "") or user_id
|
||
debounce_key = self._debounce_mgr.make_key(self._app_id, conversation_id, sender_id)
|
||
debounce_result = self._debounce_mgr.merge(
|
||
debounce_key,
|
||
channel_msg.content,
|
||
{"from_name": user_name, "conversation_id": conversation_id},
|
||
)
|
||
if debounce_result is None:
|
||
logger.debug(f"MSTeams: message merged by debounce for key={debounce_key[:40]}...")
|
||
return None
|
||
|
||
channel_msg = self._classify_message(channel_msg)
|
||
self._track_message(channel_msg)
|
||
return channel_msg
|
||
|
||
if activity_type == "conversationUpdate":
|
||
channel_msg = normalize_conversation_update(body)
|
||
if channel_msg.event_type == EventType.BOT_ADDED:
|
||
conversation = body.get("conversation", {}) or {}
|
||
conversation_type = conversation.get("conversationType", "personal")
|
||
is_personal = conversation_type == "personal"
|
||
|
||
welcome_response = build_welcome_response(
|
||
conversation_type=conversation_type,
|
||
bot_name=self.config.get("bot_name", "ForcePilot"),
|
||
prompt_starters=self.config.get("prompt_starters"),
|
||
)
|
||
channel_msg.metadata["welcome_response"] = welcome_response
|
||
channel_msg.metadata["welcome_enabled"] = (
|
||
self._welcome_enabled if is_personal else self._group_welcome_enabled
|
||
)
|
||
return channel_msg
|
||
return None
|
||
|
||
if activity_type == "messageReaction":
|
||
from_info = body.get("from", {}) or {}
|
||
user_id = from_info.get("aadObjectId", "") or from_info.get("id", "")
|
||
user_name = from_info.get("name", "")
|
||
conversation = body.get("conversation", {}) or {}
|
||
conversation_type = conversation.get("conversationType", "personal")
|
||
conversation_id = conversation.get("id", "")
|
||
|
||
if conversation_type == "personal":
|
||
if not self._security_policy.check_dm(user_id, user_name):
|
||
logger.debug(f"MSTeams: reaction DM rejected by security policy for user={user_id}")
|
||
return None
|
||
else:
|
||
if not self._security_policy.check_group(user_id, user_name, conversation_id):
|
||
logger.debug(f"MSTeams: reaction group rejected by security policy for user={user_id}")
|
||
return None
|
||
|
||
return normalize_reaction(body)
|
||
|
||
if activity_type == "invoke":
|
||
sso_result = await self._sso_handler.handle_signin(body)
|
||
if sso_result is not None:
|
||
logger.info(f"MSTeams SSO: handled {body.get('name', '')} invoke")
|
||
return None
|
||
return normalize_invoke(body)
|
||
|
||
return None
|
||
|
||
async def get_user_info(self, channel_user_id: str) -> dict[str, Any]:
|
||
not_connected = self._require_connected()
|
||
if not_connected:
|
||
return {}
|
||
|
||
token = self._sender.token if self._sender else None
|
||
if not token:
|
||
return {}
|
||
|
||
from .graph import GraphClient
|
||
|
||
client = GraphClient(token)
|
||
try:
|
||
return await client.get_user(channel_user_id)
|
||
finally:
|
||
await client.close()
|
||
|
||
async def download_media(self, file_id: str) -> bytes:
|
||
not_connected = self._require_connected()
|
||
if not_connected:
|
||
return b""
|
||
|
||
token = self._sender.token if self._sender else None
|
||
if not token:
|
||
return b""
|
||
|
||
try:
|
||
session = await self._get_http_session()
|
||
headers = {"Authorization": f"Bearer {token}"}
|
||
async with session.get(file_id, headers=headers) as resp:
|
||
if resp.status == 200:
|
||
return await resp.read()
|
||
logger.warning(f"MSTeams media download failed: HTTP {resp.status}")
|
||
return b""
|
||
except Exception as e:
|
||
logger.error(f"MSTeams media download error: {e}")
|
||
return b""
|
||
|
||
async def verify_webhook_signature(self, headers: dict, body: bytes) -> bool:
|
||
auth_header = headers.get("Authorization", "") or headers.get("authorization", "")
|
||
if not auth_header:
|
||
return False
|
||
|
||
service_url = headers.get("X-Ms-Service-Url", "") or headers.get("x-ms-service-url", "")
|
||
|
||
if service_url:
|
||
hostname = urlparse(service_url).hostname or ""
|
||
if hostname:
|
||
domain = ".".join(hostname.rsplit(".", 2)[-3:]) if hostname.count(".") >= 2 else hostname
|
||
if domain not in BOT_FRAMEWORK_DOMAINS:
|
||
logger.warning(f"MSTeams: rejected webhook from unknown domain '{hostname}'")
|
||
return False
|
||
|
||
if auth_header.startswith("Bearer "):
|
||
return await self._verify_bearer_token(auth_header[7:])
|
||
|
||
logger.warning("MSTeams: webhook request missing Bearer token")
|
||
return False
|
||
|
||
async def _verify_bearer_token(self, token: str) -> bool:
|
||
try:
|
||
unverified = jwt.decode(token, options={"verify_signature": False})
|
||
audience = unverified.get("aud", "")
|
||
issuer = unverified.get("iss", "")
|
||
|
||
app_api_audience = f"api://{self._app_id}"
|
||
|
||
valid_audiences = {
|
||
"https://api.botframework.com",
|
||
self._app_id,
|
||
app_api_audience,
|
||
}
|
||
|
||
if audience not in valid_audiences:
|
||
logger.warning(f"MSTeams: unexpected token audience '{audience}'")
|
||
return False
|
||
|
||
tenant_id = self._resolve_tenant_id()
|
||
valid_issuers = [
|
||
"https://api.botframework.com",
|
||
f"https://sts.windows.net/{tenant_id}/",
|
||
f"https://login.microsoftonline.com/{tenant_id}/v2.0",
|
||
]
|
||
|
||
if issuer and not any(issuer.startswith(vi) for vi in valid_issuers):
|
||
logger.warning(f"MSTeams: unexpected token issuer '{issuer}'")
|
||
return False
|
||
|
||
try:
|
||
jwks_client = await self._get_jwks_client()
|
||
signing_key = jwks_client.get_signing_key_from_jwt(token)
|
||
jwt.decode(
|
||
token,
|
||
key=signing_key.key,
|
||
algorithms=["RS256"],
|
||
audience=valid_audiences,
|
||
options={"require": ["exp", "iss", "aud"]},
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"MSTeams: JWT signature verification failed: {e}")
|
||
return False
|
||
|
||
return True
|
||
except Exception as e:
|
||
logger.warning(f"MSTeams: JWT decode failed: {e}")
|
||
return False
|
||
|
||
def get_webhook_paths(self) -> list[str]:
|
||
"""返回此适配器监听的所有 Webhook 路径。
|
||
|
||
主路径 + 配置的额外路径,供 Gateway 注册路由时使用。
|
||
"""
|
||
paths = [self.webhook_path]
|
||
extra = self.config.get("webhook_extra_paths", self._DEFAULT_EXTRA_WEBHOOK_PATHS)
|
||
if isinstance(extra, str):
|
||
extra = [extra]
|
||
for p in extra:
|
||
if p and p not in paths:
|
||
paths.append(p)
|
||
return paths
|
||
|
||
def _resolve_app_id(self) -> str:
|
||
return self.config.get("app_id", "") or os.getenv("TEAMS_APP_ID", "")
|
||
|
||
def _resolve_app_password(self) -> str:
|
||
return self.config.get("app_password", "") or os.getenv("TEAMS_APP_PASSWORD", "")
|
||
|
||
def _resolve_tenant_id(self) -> str:
|
||
return self.config.get("tenant_id", "") or os.getenv("TEAMS_TENANT_ID", "")
|
||
|
||
def _track_message(self, channel_msg: ChannelMessage) -> None:
|
||
chat_id = channel_msg.identity.channel_chat_id
|
||
msg_id = channel_msg.identity.channel_message_id
|
||
if msg_id:
|
||
if chat_id in self._message_tracker:
|
||
self._message_tracker.move_to_end(chat_id)
|
||
self._message_tracker[chat_id] = msg_id
|
||
while len(self._message_tracker) > _MAX_MESSAGE_TRACKER_SIZE:
|
||
self._message_tracker.popitem(last=False)
|
||
self._mark_seen(msg_id)
|
||
|
||
def _is_duplicate(self, msg_id: str) -> bool:
|
||
if not msg_id:
|
||
return False
|
||
now = time.monotonic()
|
||
expired = [mid for mid, ts in self._dedup_ids.items() if now - ts > self._dedup_ttl]
|
||
for mid in expired:
|
||
self._dedup_ids.pop(mid, None)
|
||
return msg_id in self._dedup_ids
|
||
|
||
def _mark_seen(self, msg_id: str) -> None:
|
||
if msg_id:
|
||
self._dedup_ids[msg_id] = time.monotonic()
|
||
|
||
def _resolve_conversation_id(self, response: ChannelResponse) -> str:
|
||
return response.identity.channel_chat_id
|
||
|
||
async def pre_connect(self) -> dict:
|
||
app_id = self._resolve_app_id()
|
||
app_password = self._resolve_app_password()
|
||
if not app_id or not app_password:
|
||
return {"status": "error", "message": "Missing app_id or app_password"}
|
||
|
||
probe = MSTeamsProbe(app_id, app_password)
|
||
try:
|
||
valid = await probe.validate_credentials()
|
||
finally:
|
||
await probe.close()
|
||
|
||
if not valid:
|
||
return {"status": "error", "message": "Credential validation failed"}
|
||
|
||
audit_result: dict = {}
|
||
try:
|
||
token = await probe._get_bot_token()
|
||
if token:
|
||
auditor = GraphPermissionAuditor(token, app_id)
|
||
perms = await auditor.audit()
|
||
missing = auditor.get_missing_permissions(perms)
|
||
audit_result = {"permissions": perms, "missing_permissions": missing}
|
||
except Exception as e:
|
||
logger.warning(f"MSTeams: Graph permission audit skipped: {e}")
|
||
audit_result = {"error": str(e)}
|
||
|
||
return {
|
||
"status": "ok",
|
||
"app_id": app_id[:8] + "...",
|
||
**audit_result,
|
||
}
|
||
|
||
async def _detect_edit_support(self) -> bool:
|
||
if not self._sender:
|
||
return False
|
||
|
||
edit_mode = self.config.get("streaming", {}).get("edit_support", "auto")
|
||
if edit_mode == "disabled":
|
||
return False
|
||
if edit_mode == "force":
|
||
return True
|
||
|
||
try:
|
||
token = self._sender.token
|
||
if not token:
|
||
return False
|
||
|
||
session = await self._get_http_session()
|
||
headers = {"Authorization": f"Bearer {token}"}
|
||
async with session.get(f"{self._sender._service_url}/v3/version", headers=headers) as resp:
|
||
if resp.status != 200:
|
||
return False
|
||
version_result = await resp.json()
|
||
|
||
version_str = version_result.get("version", "0.0")
|
||
parts = version_str.lstrip("v").split(".")
|
||
major = int(parts[0]) if parts else 0
|
||
minor = int(parts[1]) if len(parts) > 1 else 0
|
||
return (major > 0) or (major == 0 and minor >= 13)
|
||
except Exception:
|
||
return False
|