这是一个批量整理提交,包含以下主要改动: 1. 删除多处冗余的空行和未使用的导入 2. 修复文件末尾缺少换行符的问题 3. 调整部分模块的导入顺序与代码排版 4. 修复部分配置默认值与策略逻辑 5. 新增多个功能模块与辅助工具 6. 完善异常处理与日志记录 7. 修复速率限制、消息缓存、权限校验等逻辑bug 8. 废弃部分旧有API与配置项并添加警告提示
227 lines
6.7 KiB
Python
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
|