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