"""Microsoft Teams 出站消息发送模块。 通过 Bot Framework REST API 发送各类消息到 Teams,包括文本/Markdown、 Adaptive Card、媒体文件和流式编辑等。 """ from __future__ import annotations import asyncio import time from collections.abc import Callable, Awaitable from dataclasses import dataclass, field from enum import Enum from typing import Any import aiohttp from yuxi.channels.models import DeliveryResult from yuxi.utils.logging_config import logger BOT_SERVICE_URL = "https://smba.trafficmanager.net/emea" TOKEN_URL = "https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token" TOKEN_GRACE_PERIOD_S = 60 DEFAULT_MAX_OPS_PER_SECOND = 5 MAX_RETRIES = 3 RETRY_BASE_DELAY_S = 1.0 class MSTeamsErrorCategory(Enum): AUTH = "auth" THROTTLED = "throttled" TRANSIENT = "transient" PERMANENT = "permanent" REVOKED = "revoked" @dataclass class MSTeamsErrorInfo: category: MSTeamsErrorCategory status: int message: str hint: str = "" context: dict[str, Any] = field(default_factory=dict) def classify_http_error(status: int, error_body: str = "") -> str: if status == 429: return f"rate_limited:{error_body[:100]}" if status == 401: detail = error_body[:200] if error_body else "unauthorized" return f"auth_failed:401:token_expired_or_invalid:{detail}" if status == 403: detail = error_body[:200] if error_body else "forbidden" return f"auth_failed:403:insufficient_permissions:{detail}" if 500 <= status < 600: return f"server_error:{status}:{error_body[:100]}" return f"http_{status}:{error_body[:100]}" def classify_msteams_send_error( status: int, error_body: str = "", context: dict[str, Any] | None = None ) -> MSTeamsErrorInfo: ctx = context or {} if status == 401: return MSTeamsErrorInfo( category=MSTeamsErrorCategory.AUTH, status=401, message=error_body[:200] if error_body else "Token expired or invalid", hint="请检查 App ID 和 App Password 配置是否正确,或尝试重新生成 Bot 凭据", context=ctx, ) if status == 403: reason = error_body[:200] if error_body else "" if "ServiceError" in reason and "Unknown" in reason: return MSTeamsErrorInfo( category=MSTeamsErrorCategory.REVOKED, status=403, message="Conversation context revoked", hint="Bot 在该会话中被移除,上下文已失效", context=ctx, ) return MSTeamsErrorInfo( category=MSTeamsErrorCategory.AUTH, status=403, message=reason or "Insufficient permissions", hint="Bot 缺少必要权限,请在 Azure AD 中检查 API 权限设置", context=ctx, ) if status == 429: return MSTeamsErrorInfo( category=MSTeamsErrorCategory.THROTTLED, status=429, message=error_body[:200] if error_body else "Rate limited", hint="请求频率过高,已自动重试。请检查发送速率配置或联系 Teams 管理员提升限额", context=ctx, ) if 500 <= status < 600: return MSTeamsErrorInfo( category=MSTeamsErrorCategory.TRANSIENT, status=status, message=error_body[:200] if error_body else f"Server error {status}", hint="Teams 服务暂时不可用,已自动重试。如持续出现请联系 Teams 支持", context=ctx, ) if 400 <= status < 500: return MSTeamsErrorInfo( category=MSTeamsErrorCategory.PERMANENT, status=status, message=error_body[:200] if error_body else f"Client error {status}", hint="请求参数有误,请检查消息内容和格式", context=ctx, ) return MSTeamsErrorInfo( category=MSTeamsErrorCategory.PERMANENT, status=status, message=error_body[:100] if error_body else f"HTTP {status}", hint="发生未预期的错误", context=ctx, ) def format_send_error_hint(error_info: MSTeamsErrorInfo) -> str: parts = [ f"[{error_info.category.value.upper()}] HTTP {error_info.status}", ] if error_info.message: parts.append(f"详情: {error_info.message[:200]}") if error_info.hint: parts.append(f"建议: {error_info.hint}") return " | ".join(parts) def _parse_retry_after(headers: dict) -> float: raw = headers.get("Retry-After", "") or headers.get("retry-after", "") if not raw: return 5.0 try: return float(raw) except ValueError: return 5.0 class MessageSender: def __init__( self, app_id: str, app_password: str, service_url: str = BOT_SERVICE_URL, max_ops_per_second: float = DEFAULT_MAX_OPS_PER_SECOND, ): self._app_id = app_id self._app_password = app_password self._service_url = service_url.rstrip("/") self._token: str | None = None self._token_expires_at: float = 0 self._session: aiohttp.ClientSession | None = None self._rate_semaphore = asyncio.Semaphore(1) self._min_interval = 1.0 / max_ops_per_second if max_ops_per_second > 0 else 0 self._last_send: float = 0 self._token_lock = asyncio.Lock() self._delegated_token: str | None = None self._delegated_token_lock = asyncio.Lock() self._on_retry_callbacks: list[Callable[[str, int, dict[str, Any] | None], Awaitable[None]]] = [] @property def token(self) -> str | None: return self._token @property def delegated_token(self) -> str | None: return self._delegated_token async def set_delegated_token(self, token: str | None) -> None: async with self._delegated_token_lock: self._delegated_token = token async def _ensure_session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: self._session = aiohttp.ClientSession() return self._session async def close(self) -> None: if self._session and not self._session.closed: await self._session.close() self._session = None async def _ensure_token(self) -> bool: if self._token and time.monotonic() < self._token_expires_at - TOKEN_GRACE_PERIOD_S: return True return await self._refresh_token() async def _refresh_token(self) -> bool: async with self._token_lock: if self._token and time.monotonic() < self._token_expires_at - TOKEN_GRACE_PERIOD_S: return True data = { "client_id": self._app_id, "client_secret": self._app_password, "grant_type": "client_credentials", "scope": "https://api.botframework.com/.default", } try: session = await self._ensure_session() async with session.post(TOKEN_URL, data=data) as resp: if resp.status == 200: result = await resp.json() self._token = result.get("access_token") expires_in = result.get("expires_in", 3600) self._token_expires_at = time.monotonic() + expires_in return self._token is not None except Exception as e: logger.error(f"MSTeams token refresh error: {e}") return False async def _acquire_rate_limit(self) -> None: if self._min_interval <= 0: return async with self._rate_semaphore: elapsed = time.monotonic() - self._last_send wait = max(0, self._min_interval - elapsed) if wait > 0: await asyncio.sleep(wait) self._last_send = time.monotonic() def _resolve_auth_header(self, prefer_delegated: bool = False) -> str: effective_token = self._delegated_token if prefer_delegated and self._delegated_token else self._token return f"Bearer {effective_token}" if effective_token else "" def register_on_retry( self, callback: Callable[[str, int, dict[str, Any] | None], Awaitable[None]], ) -> None: self._on_retry_callbacks.append(callback) def clear_on_retry_callbacks(self) -> None: self._on_retry_callbacks.clear() async def _notify_retry(self, error_category: str, attempt: int, context: dict[str, Any] | None = None) -> None: if not self._on_retry_callbacks: return for callback in self._on_retry_callbacks: try: await callback(error_category, attempt, context) except Exception: pass async def send_activity( self, conversation_id: str, activity: dict[str, Any], activity_id: str | None = None, ) -> DeliveryResult: activity["from"] = {"id": self._app_id} if not activity.get("type"): activity["type"] = "message" last_error: str | None = None for attempt in range(MAX_RETRIES + 1): if not await self._ensure_token(): return DeliveryResult(success=False, error="Failed to obtain access token") await self._acquire_rate_limit() url = f"{self._service_url}/v3/conversations/{conversation_id}/activities" if activity_id: url = f"{url}/{activity_id}" headers = { "Authorization": self._resolve_auth_header(), "Content-Type": "application/json", } try: session = await self._ensure_session() method = session.put if activity_id else session.post async with method(url, headers=headers, json=activity) as resp: if resp.status in (200, 201): result = await resp.json() return DeliveryResult(success=True, message_id=result.get("id", "")) error_text = await resp.text() error_label = classify_http_error(resp.status, error_text) logger.warning( f"MSTeams send failed (attempt {attempt + 1}): HTTP {resp.status} - {error_text[:200]}" ) last_error = error_label if resp.status in (401, 403) and attempt < MAX_RETRIES: if self._delegated_token and self._token: await self.set_delegated_token(None) self._token = None await self._notify_retry("auth", attempt + 1, {"status": resp.status}) continue self._token = None await self._notify_retry("auth", attempt + 1, {"status": resp.status}) continue if resp.status == 429 and attempt < MAX_RETRIES: delay = _parse_retry_after(dict(resp.headers)) await self._notify_retry("throttled", attempt + 1, {"retry_after": delay}) await asyncio.sleep(delay) continue if 500 <= resp.status < 600 and attempt < MAX_RETRIES: delay = RETRY_BASE_DELAY_S * (2**attempt) await self._notify_retry("transient", attempt + 1, {"status": resp.status, "delay": delay}) await asyncio.sleep(delay) continue return DeliveryResult(success=False, error=error_label) except Exception as e: logger.error(f"MSTeams send error (attempt {attempt + 1}): {e}") last_error = str(e) if attempt < MAX_RETRIES: delay = RETRY_BASE_DELAY_S * (2**attempt) await self._notify_retry("exception", attempt + 1, {"error": str(e)[:100], "delay": delay}) await asyncio.sleep(delay) continue return DeliveryResult(success=False, error=str(e)) return DeliveryResult(success=False, error=last_error or "Max retries exceeded") async def update_activity( self, conversation_id: str, activity_id: str, activity: dict[str, Any], ) -> DeliveryResult: return await self.send_activity(conversation_id, activity, activity_id) async def delete_activity( self, conversation_id: str, activity_id: str, ) -> DeliveryResult: for attempt in range(MAX_RETRIES + 1): if not await self._ensure_token(): return DeliveryResult(success=False, error="Failed to obtain access token") await self._acquire_rate_limit() url = f"{self._service_url}/v3/conversations/{conversation_id}/activities/{activity_id}" headers = {"Authorization": f"Bearer {self._token}"} try: session = await self._ensure_session() async with session.delete(url, headers=headers) as resp: if resp.status in (200, 204): return DeliveryResult(success=True, message_id=activity_id) if resp.status in (401, 403) and attempt < MAX_RETRIES: self._token = None continue error_text = await resp.text() return DeliveryResult(success=False, error=classify_http_error(resp.status, error_text)) except Exception as e: logger.error(f"MSTeams delete error (attempt {attempt + 1}): {e}") if attempt < MAX_RETRIES: await asyncio.sleep(RETRY_BASE_DELAY_S * (2**attempt)) continue return DeliveryResult(success=False, error=str(e)) return DeliveryResult(success=False, error="Max retries exceeded") async def send_message( sender: MessageSender, conversation_id: str, text: str, reply_to_id: str | None = None, text_chunk_limit: int = 4000, ) -> DeliveryResult: activity: dict[str, Any] = { "type": "message", "text": text[:text_chunk_limit], "textFormat": "markdown", } if reply_to_id: activity["replyToId"] = reply_to_id return await sender.send_activity(conversation_id, activity) async def send_adaptive_card( sender: MessageSender, conversation_id: str, card: dict[str, Any], reply_to_id: str | None = None, ) -> DeliveryResult: activity: dict[str, Any] = { "type": "message", "attachments": [ { "contentType": "application/vnd.microsoft.card.adaptive", "content": card, } ], } if reply_to_id: activity["replyToId"] = reply_to_id return await sender.send_activity(conversation_id, activity) async def send_media( sender: MessageSender, conversation_id: str, content_url: str, content_type: str, filename: str = "file", reply_to_id: str | None = None, ) -> DeliveryResult: activity: dict[str, Any] = { "type": "message", "attachments": [ { "contentType": content_type, "contentUrl": content_url, "name": filename, } ], } if reply_to_id: activity["replyToId"] = reply_to_id return await sender.send_activity(conversation_id, activity) STREAM_UPDATE_MIN_INTERVAL_S = 0.5 SILENT_REPLY_TOKEN = "[SILENT]" def is_silent_reply_text(text: str) -> bool: """检测消息是否为静默回复(不触发通知)。""" return SILENT_REPLY_TOKEN in text def strip_silent_token(text: str) -> str: """移除静默回复标记。""" return text.replace(SILENT_REPLY_TOKEN, "").strip() async def send_stream_chunk( sender: MessageSender, conversation_id: str, activity_id: str, text: str, text_chunk_limit: int = 4000, ) -> DeliveryResult: activity: dict[str, Any] = { "type": "message", "text": text[:text_chunk_limit], "textFormat": "markdown", } return await sender.update_activity(conversation_id, activity_id, activity)