实现了 Teams 机器人所需的全功能组件,包括: - 基础命令解析与帮助卡片生成 - 租户验证与访问控制 - 自定义 UA 与媒体工具 - 消息分块、批注处理与会话管理 - 防抖、缓存与配置路由能力 - 投票、配对、审计与运行时状态管理 - TTS 语音合成与卡片构建工具 - 群组管理与权限控制逻辑
197 lines
5.8 KiB
Python
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
|