ForcePilot/backend/package/yuxi/channels/adapters/msteams/adapter.py
Kris 939f1ba82a refactor(msteams): 整理代码结构并新增多项功能
本次提交对Microsoft Teams适配器代码进行了多维度优化与新增:
1.  调整多处导入顺序,优化代码可读性
2.  新增media_tools工具模块,提供媒体相关辅助函数
3.  新增thread_history模块,实现对话历史拉取与缓存功能
4.  新增connection_modes模块,支持webhook/websocket/polling三种连接模式
5.  扩展security.py与tool_policy.py,新增通配符配置校验与三级策略解析
6.  新增feedback会话记录功能
7.  为sent_message_cache添加自动清理任务
8.  优化normalizer模块,新增引用、编辑消息解析与线程上下文注入
9.  重构file_upload的SSRF防护逻辑,复用公共校验工具
10. 修复多处导入顺序与代码排版问题
11. 为消息发送添加断路器保护与异步去重锁
2026-05-13 16:12:31 +08:00

997 lines
37 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 asyncio
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.exceptions import (
ChannelAuthenticationError,
)
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
from yuxi.channels.meta import ChannelMeta
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 .invoke_handler import normalize_reaction
from .normalizer import normalize_conversation_update, normalize_inbound, normalize_invoke
from .polls import PollStore
from .proactive import (
ConversationStore,
)
from .proactive import (
proactive_send as _proactive_send,
)
from .probe import MSTeamsProbe
from .security import SecurityPolicy
from .send import MessageSender
from .send import send_adaptive_card as _send_adaptive_card
from .send import 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
MEDIA_GRAPH_UPLOAD_THRESHOLD = 512 * 1024
MEDIA_BASE64_MAX_SIZE = 1024 * 1024
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._dedup_lock = asyncio.Lock()
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()
self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60, channel_id="msteams")
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
self._sent_cache.start_cleanup_task()
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
await self._conv_store.flush()
await self._poll_store.flush()
self._sent_cache.stop_cleanup_task()
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
async def _do_send() -> DeliveryResult:
conversation_id = self._resolve_conversation_id(response)
chunks = chunk_text(response.content, self.text_chunk_limit)
if not chunks:
raise ChannelAuthenticationError("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:
raise ChannelAuthenticationError(f"Send failed: {result.error}")
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
try:
return await self._circuit_breaker.call(_do_send)
except CircuitBreakerOpenError:
return DeliveryResult(success=False, error="Circuit breaker open")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
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):
file_size = len(data)
token = self._sender.token if self._sender else None
if file_size > MEDIA_GRAPH_UPLOAD_THRESHOLD:
if not token:
return DeliveryResult(
success=False,
error=f"File too large ({file_size} bytes) and no token available for Graph upload",
)
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)
return DeliveryResult(success=False, error="Graph upload: no webUrl returned")
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()
if file_size > MEDIA_BASE64_MAX_SIZE:
return DeliveryResult(
success=False,
error=f"File too large for base64 encoding ({file_size} > {MEDIA_BASE64_MAX_SIZE})",
)
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
await 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 await self._check_and_mark(msg_id):
logger.debug(f"MSTeams: duplicate message {msg_id}, skipping")
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)
if self.config.get("thread_context_enabled", True):
try:
token = self._sender.token if self._sender else None
if token:
from .graph import GraphClient
from .normalizer import inject_thread_context
graph_client = GraphClient(token)
session_key = (channel_msg.metadata or {}).get("SessionKey", "")
channel_msg = await inject_thread_context(channel_msg, graph_client, session_key)
await graph_client.close()
except Exception:
pass
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:
jwks_client = await self._get_jwks_client()
signing_key = jwks_client.get_signing_key_from_jwt(token)
app_api_audience = f"api://{self._app_id}"
valid_audiences = {
"https://api.botframework.com",
self._app_id,
app_api_audience,
}
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",
]
jwt.decode(
token,
key=signing_key.key,
algorithms=["RS256"],
audience=valid_audiences,
issuer=None,
options={
"require": ["exp", "iss", "aud"],
"verify_signature": True,
"verify_exp": True,
"verify_aud": True,
},
)
return True
except Exception as e:
logger.warning(f"MSTeams: JWT verification 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)
async def _check_and_mark(self, msg_id: str) -> bool:
if not msg_id:
return False
async with self._dedup_lock:
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)
if msg_id in self._dedup_ids:
return True
self._dedup_ids[msg_id] = now
return False
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