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

462 lines
16 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 出站消息发送模块。
通过 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)