"""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