这是一个批量整理提交,包含以下主要改动: 1. 删除多处冗余的空行和未使用的导入 2. 修复文件末尾缺少换行符的问题 3. 调整部分模块的导入顺序与代码排版 4. 修复部分配置默认值与策略逻辑 5. 新增多个功能模块与辅助工具 6. 完善异常处理与日志记录 7. 修复速率限制、消息缓存、权限校验等逻辑bug 8. 废弃部分旧有API与配置项并添加警告提示
564 lines
21 KiB
Python
564 lines
21 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 send_proactive(self, conv_ref: dict[str, Any], activity: dict[str, Any]) -> DeliveryResult:
|
||
service_url = conv_ref.get("serviceUrl", "")
|
||
conversation_id = conv_ref.get("conversation", {}).get("id", "")
|
||
if not service_url or not conversation_id:
|
||
return DeliveryResult(success=False, error="Invalid conversation reference")
|
||
|
||
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"{service_url}/v3/conversations/{conversation_id}/activities"
|
||
headers = {
|
||
"Authorization": self._resolve_auth_header(),
|
||
"Content-Type": "application/json",
|
||
}
|
||
|
||
try:
|
||
session = await self._ensure_session()
|
||
async with session.post(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 proactive 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 proactive 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": self._resolve_auth_header()}
|
||
|
||
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)
|
||
|
||
error_text = await resp.text()
|
||
error_label = classify_http_error(resp.status, error_text)
|
||
logger.warning(
|
||
f"MSTeams delete failed (attempt {attempt + 1}): HTTP {resp.status} - {error_text[:200]}"
|
||
)
|
||
|
||
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 delete error (attempt {attempt + 1}): {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="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)
|