462 lines
16 KiB
Python
462 lines
16 KiB
Python
|
|
"""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)
|