ForcePilot/backend/package/yuxi/channels/adapters/msteams/proactive.py
Kris bd60c15df0 feat(msteams): 新增完整的 Microsoft Teams 适配器模块
实现了 Teams 机器人所需的全功能组件,包括:
- 基础命令解析与帮助卡片生成
- 租户验证与访问控制
- 自定义 UA 与媒体工具
- 消息分块、批注处理与会话管理
- 防抖、缓存与配置路由能力
- 投票、配对、审计与运行时状态管理
- TTS 语音合成与卡片构建工具
- 群组管理与权限控制逻辑
2026-05-12 00:46:44 +08:00

197 lines
5.8 KiB
Python

"""Microsoft Teams Proactive Send + Context Revoked 回退。
Conversation Reference 持久化存储,支持主动消息发送。
当 Live Context 过期时自动切换 Proactive Send。
"""
from __future__ import annotations
import abc
import json
import time
from pathlib import Path
from typing import Any, TYPE_CHECKING
from yuxi.channels.models import DeliveryResult
from yuxi.utils.logging_config import logger
if TYPE_CHECKING:
from .send import MessageSender
CONV_STORE_FILENAME = "msteams-conversations.json"
CONV_STORE_MAX_ENTRIES = 5000
class MSTeamsConversationStore(abc.ABC):
"""Conversation Reference 存储接口抽象。
支持 FS 和 Memory 两种实现,可通过接口注入切换。
"""
@abc.abstractmethod
def store(self, channel_chat_id: str, conversation_ref: dict[str, Any]) -> None: ...
@abc.abstractmethod
def get(self, channel_chat_id: str) -> dict[str, Any] | None: ...
@abc.abstractmethod
def remove(self, channel_chat_id: str) -> None: ...
@abc.abstractmethod
def list_keys(self) -> list[str]: ...
@property
@abc.abstractmethod
def count(self) -> int: ...
class ConversationStore(MSTeamsConversationStore):
def __init__(self, storage_dir: str | None = None):
self._storage_dir = Path(storage_dir or str(Path.home() / ".yuxi" / "msteams"))
self._storage_dir.mkdir(parents=True, exist_ok=True)
self._entries: dict[str, dict[str, Any]] = {}
self._load()
@property
def file_path(self) -> Path:
return self._storage_dir / CONV_STORE_FILENAME
def _load(self) -> None:
if not self.file_path.exists():
return
try:
data = json.loads(self.file_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return
self._entries = data.get("entries", {})
def _save(self) -> None:
try:
self.file_path.write_text(
json.dumps({"entries": self._entries}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except OSError as e:
logger.error(f"MSTeams conv_store: failed to save: {e}")
def store(
self,
channel_chat_id: str,
conversation_ref: dict[str, Any],
) -> None:
self._entries[channel_chat_id] = {
"ref": conversation_ref,
"stored_at": time.time(),
}
if len(self._entries) > CONV_STORE_MAX_ENTRIES:
oldest = min(
self._entries.keys(),
key=lambda k: self._entries[k].get("stored_at", 0),
default="",
)
if oldest:
self._entries.pop(oldest, None)
self._save()
def get(self, channel_chat_id: str) -> dict[str, Any] | None:
entry = self._entries.get(channel_chat_id)
if entry:
return entry["ref"]
return None
def remove(self, channel_chat_id: str) -> None:
self._entries.pop(channel_chat_id, None)
self._save()
def list_keys(self) -> list[str]:
return list(self._entries.keys())
@property
def count(self) -> int:
return len(self._entries)
class MemoryConversationStore(MSTeamsConversationStore):
"""内存实现的 Conversation Reference 存储,适用于测试或无状态场景。"""
def __init__(self):
self._entries: dict[str, dict[str, Any]] = {}
def store(self, channel_chat_id: str, conversation_ref: dict[str, Any]) -> None:
self._entries[channel_chat_id] = {
"ref": conversation_ref,
"stored_at": time.time(),
}
def get(self, channel_chat_id: str) -> dict[str, Any] | None:
entry = self._entries.get(channel_chat_id)
if entry:
return entry["ref"]
return None
def remove(self, channel_chat_id: str) -> None:
self._entries.pop(channel_chat_id, None)
def list_keys(self) -> list[str]:
return list(self._entries.keys())
@property
def count(self) -> int:
return len(self._entries)
def extract_conversation_ref(activity: dict[str, Any]) -> dict[str, Any]:
conversation = activity.get("conversation", {}) or {}
channel_data = activity.get("channelData") or {}
tenant_info = channel_data.get("tenant", {}) or {}
return {
"activityId": activity.get("id", ""),
"user": activity.get("from", {}),
"bot": activity.get("recipient", {}),
"conversation": conversation,
"channelId": activity.get("channelId", "msteams"),
"serviceUrl": activity.get("serviceUrl", ""),
"locale": activity.get("locale", ""),
"graphChatId": conversation.get("id", ""),
"threadId": "",
"timezone": channel_data.get("timezone", ""),
"tenantId": tenant_info.get("id", ""),
}
async def proactive_send(
sender: MessageSender,
conv_store: ConversationStore,
channel_chat_id: str,
text: str,
) -> DeliveryResult:
conv_ref = conv_store.get(channel_chat_id)
if not conv_ref:
logger.warning(f"MSTeams proactive: no conversation ref for {channel_chat_id}")
return DeliveryResult(success=False, error="No conversation reference")
activity: dict[str, Any] = {
"type": "message",
"text": text[:4000],
"textFormat": "markdown",
}
return await sender.send_activity(channel_chat_id, activity)
async def send_with_revoked_fallback(
sender: MessageSender,
conv_store: ConversationStore,
channel_chat_id: str,
text: str,
) -> DeliveryResult:
result = await sender.send_activity(
channel_chat_id,
{"type": "message", "text": text[:4000], "textFormat": "markdown"},
)
if result.success:
return result
if "403" in (result.error or ""):
conv_store.remove(channel_chat_id)
return result