ForcePilot/backend/package/yuxi/channels/adapters/msteams/connection_modes.py
Kris 939f1ba82a refactor(msteams): 整理代码结构并新增多项功能
本次提交对Microsoft Teams适配器代码进行了多维度优化与新增:
1.  调整多处导入顺序,优化代码可读性
2.  新增media_tools工具模块,提供媒体相关辅助函数
3.  新增thread_history模块,实现对话历史拉取与缓存功能
4.  新增connection_modes模块,支持webhook/websocket/polling三种连接模式
5.  扩展security.py与tool_policy.py,新增通配符配置校验与三级策略解析
6.  新增feedback会话记录功能
7.  为sent_message_cache添加自动清理任务
8.  优化normalizer模块,新增引用、编辑消息解析与线程上下文注入
9.  重构file_upload的SSRF防护逻辑,复用公共校验工具
10. 修复多处导入顺序与代码排版问题
11. 为消息发送添加断路器保护与异步去重锁
2026-05-13 16:12:31 +08:00

286 lines
10 KiB
Python

"""Microsoft Teams 连接模式多样化。
支持 Webhook (默认)、WebSocket (Bot Framework Streaming Extensions)、
Polling 三种连接模式,可通过配置项 connection_mode 切换。
"""
from __future__ import annotations
import asyncio
import json
from typing import Any
import aiohttp
from yuxi.utils.logging_config import logger
CONNECTION_MODE_WEBHOOK = "webhook"
CONNECTION_MODE_WEBSOCKET = "websocket"
CONNECTION_MODE_POLLING = "polling"
VALID_CONNECTION_MODES = {CONNECTION_MODE_WEBHOOK, CONNECTION_MODE_WEBSOCKET, CONNECTION_MODE_POLLING}
BOT_STREAMING_URL_TEMPLATE = "https://directline.botframework.com/v3/directline/conversations/{conversation_id}/stream"
BOT_ACTIVITIES_URL_TEMPLATE = "https://smba.trafficmanager.net/emea/v3/conversations/{conversation_id}/activities"
POLLING_DEFAULT_INTERVAL_S = 2.0
POLLING_MAX_INTERVAL_S = 10.0
WEBSOCKET_PING_INTERVAL_S = 30.0
WEBSOCKET_PONG_TIMEOUT_S = 10.0
class WebSocketClient:
"""Bot Framework Streaming Extensions WebSocket 客户端。
提供实时双向连接,延迟低于 Webhook 模式。
"""
def __init__(
self,
app_id: str,
app_password: str,
stream_url: str = "",
ping_interval: float = WEBSOCKET_PING_INTERVAL_S,
pong_timeout: float = WEBSOCKET_PONG_TIMEOUT_S,
):
self._app_id = app_id
self._app_password = app_password
self._stream_url = stream_url
self._ping_interval = ping_interval
self._pong_timeout = pong_timeout
self._ws: aiohttp.ClientWebSocketResponse | None = None
self._session: aiohttp.ClientSession | None = None
self._running = False
self._on_message: Any = None
def set_message_handler(self, handler: Any) -> None:
self._on_message = handler
async def connect(self, conversation_id: str = "") -> None:
if self._running:
return
self._session = aiohttp.ClientSession()
stream_url = self._stream_url or BOT_STREAMING_URL_TEMPLATE.format(conversation_id=conversation_id)
headers = {
"Authorization": f"Bearer {self._app_password}",
"Content-Type": "application/json",
}
self._ws = await self._session.ws_connect(stream_url, headers=headers, heartbeat=self._ping_interval)
self._running = True
logger.info(f"MSTeams WebSocket connected: {stream_url}")
asyncio.create_task(self._read_loop())
async def _read_loop(self) -> None:
while self._running and self._ws is not None:
try:
msg = await self._ws.receive(timeout=self._pong_timeout)
if msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
activities = data.get("activities", [])
for activity in activities:
if self._on_message:
await self._on_message(activity)
except json.JSONDecodeError:
logger.warning("MSTeams WebSocket: invalid JSON received")
elif msg.type == aiohttp.WSMsgType.CLOSED:
logger.info("MSTeams WebSocket closed by server")
break
elif msg.type == aiohttp.WSMsgType.ERROR:
logger.error(f"MSTeams WebSocket error: {self._ws.exception()}")
break
except TimeoutError:
continue
except Exception as e:
logger.error(f"MSTeams WebSocket read error: {e}")
await asyncio.sleep(1)
if self._running:
logger.info("MSTeams WebSocket disconnected, attempting reconnect...")
await asyncio.sleep(2)
if self._running:
asyncio.create_task(self._reconnect())
async def _reconnect(self) -> None:
try:
await self.close()
await asyncio.sleep(3)
await self.connect()
except Exception as e:
logger.error(f"MSTeams WebSocket reconnect failed: {e}")
async def send_activity(self, activity: dict[str, Any]) -> bool:
if not self._ws or self._ws.closed:
return False
try:
data = json.dumps(activity)
await self._ws.send_str(data)
return True
except Exception as e:
logger.error(f"MSTeams WebSocket send error: {e}")
return False
async def close(self) -> None:
self._running = False
if self._ws and not self._ws.closed:
await self._ws.close()
self._ws = None
if self._session and not self._session.closed:
await self._session.close()
self._session = None
class PollingClient:
"""Bot Framework Connector API 轮询拉取客户端。
适用于无法使用 Webhook 或 WebSocket 的部署场景。
"""
def __init__(
self,
app_id: str,
app_password: str,
service_url: str = "",
poll_interval: float = POLLING_DEFAULT_INTERVAL_S,
max_interval: float = POLLING_MAX_INTERVAL_S,
):
self._app_id = app_id
self._app_password = app_password
self._service_url = (service_url or "https://smba.trafficmanager.net/emea").rstrip("/")
self._poll_interval = poll_interval
self._max_interval = max_interval
self._session: aiohttp.ClientSession | None = None
self._running = False
self._last_watermark: str = ""
self._on_message: Any = None
def set_message_handler(self, handler: Any) -> None:
self._on_message = handler
async def connect(self) -> None:
if self._running:
return
self._session = aiohttp.ClientSession()
self._running = True
logger.info(f"MSTeams Polling started: interval={self._poll_interval}s")
asyncio.create_task(self._poll_loop())
async def _poll_loop(self) -> None:
backoff = self._poll_interval
while self._running:
try:
activities = await self._fetch_activities()
if activities:
backoff = self._poll_interval
for activity in activities:
if self._on_message:
await self._on_message(activity)
else:
backoff = min(backoff * 1.5, self._max_interval)
except Exception as e:
logger.warning(f"MSTeams Polling fetch error: {e}")
backoff = min(backoff * 2, self._max_interval)
await asyncio.sleep(backoff)
async def _fetch_activities(self) -> list[dict[str, Any]]:
if not self._session:
return []
url = BOT_ACTIVITIES_URL_TEMPLATE.format(conversation_id="all")
headers = {
"Authorization": f"Bearer {self._app_password}",
"Content-Type": "application/json",
}
async with self._session.get(url, headers=headers) as resp:
if resp.status == 200:
data = await resp.json()
activities = data.get("activities", [])
self._last_watermark = data.get("watermark", self._last_watermark)
return activities
elif resp.status == 429:
logger.warning("MSTeams Polling rate limited")
return []
else:
body = await resp.text()
logger.warning(f"MSTeams Polling HTTP {resp.status}: {body[:200]}")
return []
async def close(self) -> None:
self._running = False
if self._session and not self._session.closed:
await self._session.close()
self._session = None
class ConnectionModeManager:
def __init__(self, config: dict[str, Any]):
self._mode = config.get("connection_mode", CONNECTION_MODE_WEBHOOK)
if self._mode not in VALID_CONNECTION_MODES:
logger.warning(f"Invalid connection_mode '{self._mode}', falling back to 'webhook'")
self._mode = CONNECTION_MODE_WEBHOOK
self._ws_client: WebSocketClient | None = None
self._poll_client: PollingClient | None = None
self._message_router: Any = None
@property
def mode(self) -> str:
return self._mode
@property
def is_webhook(self) -> bool:
return self._mode == CONNECTION_MODE_WEBHOOK
@property
def is_websocket(self) -> bool:
return self._mode == CONNECTION_MODE_WEBSOCKET
@property
def is_polling(self) -> bool:
return self._mode == CONNECTION_MODE_POLLING
def set_message_router(self, router: Any) -> None:
self._message_router = router
async def start(
self,
app_id: str,
app_password: str,
service_url: str = "",
conversation_id: str = "",
) -> None:
if self._mode == CONNECTION_MODE_WEBSOCKET:
self._ws_client = WebSocketClient(app_id, app_password, stream_url=conversation_id)
if self._message_router:
self._ws_client.set_message_handler(self._message_router)
await self._ws_client.connect(conversation_id)
logger.info("MSTeams connection mode: WebSocket")
elif self._mode == CONNECTION_MODE_POLLING:
self._poll_client = PollingClient(app_id, app_password, service_url)
if self._message_router:
self._poll_client.set_message_handler(self._message_router)
await self._poll_client.connect()
logger.info("MSTeams connection mode: Polling")
else:
logger.info("MSTeams connection mode: Webhook")
async def stop(self) -> None:
if self._ws_client:
await self._ws_client.close()
self._ws_client = None
if self._poll_client:
await self._poll_client.close()
self._poll_client = None
async def send_activity(self, activity: dict[str, Any]) -> bool:
if self._mode == CONNECTION_MODE_WEBSOCKET and self._ws_client:
return await self._ws_client.send_activity(activity)
return False