ForcePilot/backend/package/yuxi/channels/adapters/msteams/send.py
Kris 1f78c44b03 refactor: 整理并清理项目中的冗余代码与格式问题
这是一个批量整理提交,包含以下主要改动:
1.  删除多处冗余的空行和未使用的导入
2.  修复文件末尾缺少换行符的问题
3.  调整部分模块的导入顺序与代码排版
4.  修复部分配置默认值与策略逻辑
5.  新增多个功能模块与辅助工具
6.  完善异常处理与日志记录
7.  修复速率限制、消息缓存、权限校验等逻辑bug
8.  废弃部分旧有API与配置项并添加警告提示
2026-05-12 14:51:53 +08:00

564 lines
21 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 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)