ForcePilot/backend/package/yuxi/channels/adapters/msteams/adapter.py
Kris bd60c15df0 feat(msteams): 新增完整的 Microsoft Teams 适配器模块
实现了 Teams 机器人所需的全功能组件,包括:
- 基础命令解析与帮助卡片生成
- 租户验证与访问控制
- 自定义 UA 与媒体工具
- 消息分块、批注处理与会话管理
- 防抖、缓存与配置路由能力
- 投票、配对、审计与运行时状态管理
- TTS 语音合成与卡片构建工具
- 群组管理与权限控制逻辑
2026-05-12 00:46:44 +08:00

954 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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