本次提交对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. 为消息发送添加断路器保护与异步去重锁
286 lines
10 KiB
Python
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
|