ForcePilot/backend/package/yuxi/channels/adapters/msteams/proactive.py
Kris 1f78c44b03 refactor: 整理并清理项目中的冗余代码与格式问题
这是一个批量整理提交,包含以下主要改动:
1.  删除多处冗余的空行和未使用的导入
2.  修复文件末尾缺少换行符的问题
3.  调整部分模块的导入顺序与代码排版
4.  修复部分配置默认值与策略逻辑
5.  新增多个功能模块与辅助工具
6.  完善异常处理与日志记录
7.  修复速率限制、消息缓存、权限校验等逻辑bug
8.  废弃部分旧有API与配置项并添加警告提示
2026-05-12 14:51:53 +08:00

227 lines
6.7 KiB
Python

"""Microsoft Teams Proactive Send + Context Revoked 回退。
Conversation Reference 持久化存储,支持主动消息发送。
当 Live Context 过期时自动切换 Proactive Send。
"""
from __future__ import annotations
import abc
import asyncio
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):
_SAVE_DEBOUNCE_S = 5.0
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._dirty = False
self._save_task: asyncio.Task | None = None
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 _schedule_save(self) -> None:
if not self._dirty:
return
if self._save_task and not self._save_task.done():
return
try:
loop = asyncio.get_running_loop()
self._save_task = loop.create_task(self._debounced_save())
except RuntimeError:
self._save()
async def _debounced_save(self) -> None:
await asyncio.sleep(self._SAVE_DEBOUNCE_S)
self._dirty = False
self._save()
async def flush(self) -> None:
if self._dirty:
self._dirty = False
if self._save_task and not self._save_task.done():
self._save_task.cancel()
self._save()
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._dirty = True
self._schedule_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._dirty = True
self._schedule_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_proactive(conv_ref, 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