refactor(messaging): 拆分用户交互模块到 application/messaging 层

- 新增 application/messaging 交互层:router.py 统一会话优先级与回调分发,
  site/subscribe/skill/media/plugin 各交互状态与视图从 Chain 迁出
- MessageChain 改为通过 InteractionRouter 派发文本会话与按钮回调,
  新增结构化 callback_data 通道(兼容 CALLBACK: 文本前缀)
- Transfer 失败重试/AI 接管回调归入 TransferChain
- MediaInteractionChain 拆出为 app/chain/interaction.py(旧路径保留兼容别名)
- WebAgent Endpoint 去重,统一使用 agent.py 回调协议函数
- 删除 app/chain/skills.py(交互逻辑并入 SkillInteractionHandler)
- 同步更新架构文档与测试,全量 4476 通过
This commit is contained in:
jxxghp
2026-08-15 16:36:39 +08:00
parent 5a1808592a
commit dd38c16400
30 changed files with 4894 additions and 4230 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ The historical `app/core`, `app/helper`, and `app/utils` directories are compati
| `app/adapters/system/` | 操作系统、文件、进程、标准流、包/资源安装、显示和 Rust 加速适配 | 业务规则、进程重启决策 | `host.py`, `stdio.py`, `package.py`, `resource.py`, `rust.py` |
| `app/adapters/external/` | CookieCloud、插件市场、OCR、IP 归属和 MoviePilot Server 等命名外部生态 | 通用 HTTP/DNS/文件机制或可复用领域语义 | `market.py`, `server.py`, `cookiecloud.py`, `ocr.py`, `location.py` |
| `app/application/` | 读取配置/持久化状态的聚焦应用服务和服务族规则 | 多领域 Chain 编排、底层通用机制、通用传输协议 | `recognition.py`, `filter.py`, `notification.py`, `mediaserver.py`, `rss.py`, `site/sites.*` |
| `app/application/messaging/` | 消息渲染/路由、交互和 Agent 到消息桥接 | 认证策略、通用 HTTP、服务发现、仅端点使用的 Web Push 行为 | `message.py`, `interaction.py`, `agent.py` |
| `app/application/messaging/` | 消息渲染/路由、交互和 Agent 到消息桥接`interaction.py` 通用交互契约和视图工具;`router.py` 统一交互优先级和回调分发;`site.py`/`subscribe.py`/`skill.py` 对应命令的会话、输入解析和视图;`media.py` 媒体交互状态(业务工作流仍由 `MediaInteractionChain` 执行);`plugin.py` 插件输入接管和插件按钮回调;`agent.py` Agent 选择状态、回调协议和 WebAgent 消息桥接;`message.py` 通知渲染、模板和队列。不作为推荐给插件直接使用的公开 SDK | 认证策略、通用 HTTP、服务发现、仅端点使用的 Web Push 行为 | `message.py`, `interaction.py`, `router.py`, `agent.py` |
| `app/application/security/` | 认证、授权、Cookie、Passkey、OTP/二次认证、路径/URL 安全、SSRF 和签名策略 | 通用 URL 解析、进程运行策略、普通业务校验 | `access.py`, `auth.py`, `cookie.py`, `passkey.py`, `otp.py`, `twofactor.py`, `url.py` |
| `app/chain/` | Reusable use-case orchestration across modules, services, Oper classes, events, and caches | Transport schemas, backend-specific protocol details, generic primitives | `media.py`, `download.py`, `subscribe.py`, `transfer.py` |
| `app/startup/` | Composition root: inject providers/adapters, order initialization and shutdown, decide restart/lifecycle policy | Reusable business rules or adapter implementation details | `lifecycle.py`, `domain_initializer.py`, `cache_initializer.py`, `modules_initializer.py` |
+4 -3
View File
@@ -6,9 +6,10 @@ from pydantic import BaseModel, Field, model_validator
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.application.messaging.interaction import (
from app.application.messaging.agent import (
AgentInteractionOption,
agent_interaction_manager,
build_agent_choice_callback,
)
from app.runtime.log import logger
from app.schemas import Notification, NotificationType
@@ -180,8 +181,8 @@ class AskUserChoiceTool(MoviePilotTool):
current_row.append(
{
"text": self._truncate_button_text(option.label, max_text_length),
"callback_data": (
f"agent_interaction:choice:{request.request_id}:{index}"
"callback_data": build_agent_choice_callback(
request.request_id, index
),
}
)
+13 -85
View File
@@ -25,9 +25,6 @@ from app.agent.orchestrator import MoviePilotAgent, ReplyMode, agent_manager
from app.agent.llm.capability import AgentCapabilityManager
from app.agent.mcp import agent_mcp_manager
from app.chain.message import MessageChain
from app.chain.site import site_interaction_manager
from app.chain.skills import skills_interaction_manager
from app.chain.subscribe import subscribe_interaction_manager
from app.command import Command
from app.runtime.config import global_vars, settings
from app.runtime.events import Event, EventManager
@@ -38,7 +35,13 @@ from app.db.models.agentchat import AgentChat
from app.db.oper.user import UserOper
from app.api.deps import get_current_active_user
from app.application.messaging.agent import attach_web_agent_edit_queue, detach_web_agent_edit_queue
from app.application.messaging.interaction import agent_interaction_manager, media_interaction_manager
from app.application.messaging.agent import agent_interaction_manager
from app.application.messaging.agent import (
build_agent_choice_button_rows,
normalize_web_agent_button_rows,
parse_agent_choice_callback,
)
from app.application.messaging.router import has_pending_interaction
from app.runtime.localization import LocaleHelper
from app.runtime.log import logger
from app.schemas.types import EventType, MessageChannel
@@ -979,52 +982,6 @@ def _merge_web_agent_prompt_with_transcript(prompt: str, transcript: Optional[st
return "\n".join(merged_parts).strip()
def _parse_web_agent_choice_callback(callback_data: str) -> Optional[tuple[str, int]]:
"""
解析 Web Agent 按钮选择回调数据。
:param callback_data: Agent 按钮携带的回调数据
:return: 请求 ID 与选项序号,格式无效时返回 None
"""
if not callback_data.startswith("agent_interaction:choice:"):
return None
try:
_, _, request_id, option_index = callback_data.split(":", 3)
except ValueError:
return None
if not request_id or not option_index.isdigit():
return None
return request_id, int(option_index)
def _normalize_web_agent_choice_button_rows(buttons: Optional[list[list[dict]]]) -> list[list[dict]]:
"""
将消息渠道按钮二维结构转换为 Web 前端可渲染的按钮行。
:param buttons: Notification 中的按钮行
:return: Web 选择卡片按钮行
"""
normalized_rows = []
for row in buttons or []:
normalized_row = []
for button in row or []:
text = str(button.get("text") or "").strip()
callback_data = str(button.get("callback_data") or "").strip()
if not text or not callback_data:
continue
description = str(button.get("description") or "").strip()
normalized_row.append(
{
"label": text,
"callback_data": callback_data,
**({"description": description} if description else {}),
}
)
if normalized_row:
normalized_rows.append(normalized_row)
return normalized_rows
def _build_web_agent_choice_event(notification: schemas.Notification) -> Optional[dict]:
"""
将带按钮通知转换为 Web Agent 选择卡片事件。
@@ -1032,13 +989,13 @@ def _build_web_agent_choice_event(notification: schemas.Notification) -> Optiona
:param notification: Agent 工具发出的按钮通知
:return: 选择卡片事件,按钮为空时返回 None
"""
button_rows = _normalize_web_agent_choice_button_rows(notification.buttons)
button_rows = normalize_web_agent_button_rows(notification.buttons)
buttons = [button for row in button_rows for button in row]
if not buttons:
return None
choice_id = None
parsed = _parse_web_agent_choice_callback(buttons[0]["callback_data"])
parsed = parse_agent_choice_callback(buttons[0]["callback_data"])
if parsed:
choice_id = parsed[0]
@@ -1054,27 +1011,6 @@ def _build_web_agent_choice_event(notification: schemas.Notification) -> Optiona
}
def _build_web_agent_choice_buttons_from_request(
request,
) -> tuple[list[dict], list[list[dict]]]:
"""
根据待处理交互请求重建可持久化的按钮列表与按钮行。
:param request: 等待用户选择的交互请求
:return: 平铺按钮列表与按行分组的按钮结构
"""
buttons = [
{
"label": option.label,
"callback_data": f"agent_interaction:choice:{request.request_id}:{index}",
"description": option.description or option.label,
}
for index, option in enumerate(request.options, start=1)
]
button_rows = [[button] for button in buttons]
return buttons, button_rows
def _resolve_web_agent_choice_payload(callback_data: str, user_id: str) -> Optional[dict]:
"""
解析并消费 Web Agent 按钮选择,生成前端反馈与下一条用户消息。
@@ -1083,7 +1019,7 @@ def _resolve_web_agent_choice_payload(callback_data: str, user_id: str) -> Optio
:param user_id: 当前登录用户 ID
:return: 可返回给前端的数据,选择无效时返回 None
"""
parsed = _parse_web_agent_choice_callback(callback_data)
parsed = parse_agent_choice_callback(callback_data)
if not parsed:
return None
@@ -1097,7 +1033,7 @@ def _resolve_web_agent_choice_payload(callback_data: str, user_id: str) -> Optio
return None
request, option = resolved
buttons, button_rows = _build_web_agent_choice_buttons_from_request(request)
buttons, button_rows = build_agent_choice_button_rows(request)
selected_description = option.description or option.label
return {
"message": option.value,
@@ -1223,15 +1159,7 @@ def _has_web_agent_traditional_interaction(user_id: str) -> bool:
:param user_id: 当前登录用户 ID
:return: 存在传统交互上下文时返回 True
"""
return any(
manager.get_by_user(user_id)
for manager in (
site_interaction_manager,
subscribe_interaction_manager,
skills_interaction_manager,
media_interaction_manager,
)
)
return has_pending_interaction(user_id)
def _extract_web_agent_notification_from_event_data(
@@ -1695,7 +1623,7 @@ async def web_agent_callback(
:param current_user: 当前登录用户
:return: 下一条需要发送给 Agent 的用户消息与卡片反馈
"""
if not _parse_web_agent_choice_callback(payload.callback_data):
if not parse_agent_choice_callback(payload.callback_data):
denied_message = _ensure_web_agent_command_allowed(current_user)
if denied_message:
return schemas.Response(success=False, message=denied_message)
+169 -1
View File
@@ -1,9 +1,177 @@
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from queue import Queue
from threading import Lock
from typing import Callable, Iterable, Optional, Union
from typing import Callable, Dict, Iterable, List, Optional, Tuple, Union
from app.schemas.types import MessageChannel
# Agent 选择按钮回调前缀(新旧两种格式都必须继续兼容)
AGENT_CHOICE_PREFIX = "agent_interaction:choice:"
LEGACY_AGENT_CHOICE_PREFIX = "agent_choice:"
def build_agent_choice_callback(request_id: str, option_index: int) -> str:
"""构造 Agent 选择按钮回调数据。"""
return f"{AGENT_CHOICE_PREFIX}{request_id}:{option_index}"
def parse_agent_choice_callback(
callback_data: str,
) -> Optional[Tuple[str, int]]:
"""解析新旧两种 Agent 选择回调,格式无效时返回 None。"""
if callback_data.startswith(AGENT_CHOICE_PREFIX):
try:
_, _, request_id, option_index = callback_data.split(":", 3)
except ValueError:
return None
elif callback_data.startswith(LEGACY_AGENT_CHOICE_PREFIX):
# 兼容旧格式,避免已发送的按钮失效
try:
_, request_id, option_index = callback_data.split(":", 2)
except ValueError:
return None
else:
return None
if not request_id or not option_index.isdigit():
return None
return request_id, int(option_index)
def build_agent_choice_button_rows(
request: "PendingAgentInteraction",
) -> Tuple[List[dict], List[List[dict]]]:
"""根据待选择请求构造 WebAgent 和消息渠道共用的按钮。"""
buttons = [
{
"label": option.label,
"callback_data": build_agent_choice_callback(request.request_id, index),
"description": option.description or option.label,
}
for index, option in enumerate(request.options, start=1)
]
button_rows = [[button] for button in buttons]
return buttons, button_rows
@dataclass(frozen=True)
class AgentInteractionOption:
"""
Agent 交互选项
"""
label: str
value: str
description: Optional[str] = None
@dataclass
class PendingAgentInteraction:
"""
待处理的 Agent 客户端交互请求
"""
request_id: str
session_id: str
user_id: str
channel: Optional[str]
source: Optional[str]
username: Optional[str]
title: Optional[str]
prompt: str
options: List[AgentInteractionOption]
created_at: datetime = field(default_factory=datetime.now)
class AgentInteractionManager:
"""
管理 Agent 发起的客户端交互请求
"""
_ttl = timedelta(hours=24)
def __init__(self):
"""初始化待处理的 Agent 交互请求表。"""
self._pending_interactions: Dict[str, PendingAgentInteraction] = {}
self._lock = Lock()
def _cleanup_locked(self) -> None:
"""在持锁状态下移除过期 Agent 交互。"""
expire_before = datetime.now() - self._ttl
expired_ids = [
request_id
for request_id, request in self._pending_interactions.items()
if request.created_at < expire_before
]
for request_id in expired_ids:
self._pending_interactions.pop(request_id, None)
def create_request(
self,
session_id: str,
user_id: str,
channel: Optional[str],
source: Optional[str],
username: Optional[str],
title: Optional[str],
prompt: str,
options: List[AgentInteractionOption],
) -> PendingAgentInteraction:
"""
创建一条待用户确认的 Agent 交互请求
"""
with self._lock:
self._cleanup_locked()
request_id = uuid.uuid4().hex[:12]
while request_id in self._pending_interactions:
request_id = uuid.uuid4().hex[:12]
request = PendingAgentInteraction(
request_id=request_id,
session_id=session_id,
user_id=str(user_id),
channel=channel,
source=source,
username=username,
title=title,
prompt=prompt,
options=options,
)
self._pending_interactions[request_id] = request
return request
def resolve(
self,
request_id: str,
option_index: int,
user_id: Optional[str] = None,
) -> Optional[tuple[PendingAgentInteraction, AgentInteractionOption]]:
"""
消费一条 Agent 交互请求并返回选中的选项
"""
with self._lock:
self._cleanup_locked()
request = self._pending_interactions.get(request_id)
if not request:
return None
if user_id is not None and str(request.user_id) != str(user_id):
return None
if option_index < 1 or option_index > len(request.options):
return None
option = request.options[option_index - 1]
self._pending_interactions.pop(request_id, None)
return request, option
def clear(self) -> None:
"""
清空所有 Agent 交互请求
"""
with self._lock:
self._pending_interactions.clear()
agent_interaction_manager = AgentInteractionManager()
_WEB_AGENT_EDIT_QUEUES: dict[str, list[Queue[dict]]] = {}
_WEB_AGENT_EDIT_LOCK = Lock()
+30 -744
View File
@@ -3,10 +3,8 @@ import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from threading import Lock
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
from typing import Any, Dict, List, Optional, Protocol, Sequence, Tuple, Union
from app.domain.context import MediaInfo
from app.domain.meta.metabase import MetaBase
from app.schemas import Notification
from app.schemas.message import ChannelCapabilityManager
from app.schemas.types import MessageChannel
@@ -118,6 +116,35 @@ class SlashInteractionManager:
self._by_user.clear()
@dataclass(frozen=True, slots=True)
class InteractionContext:
"""描述一次与渠道无关的用户交互上下文。"""
channel: MessageChannel
source: Optional[str]
user_id: Union[str, int]
username: Optional[str]
original_message_id: Optional[Union[str, int]] = None
original_chat_id: Optional[str] = None
is_channel_admin: Optional[bool] = None
@dataclass(frozen=True, slots=True)
class InteractionDispatch:
"""描述交互路由是否命中以及是否延迟结束处理状态。"""
handled: bool
defer_processing_finish: bool = False
class MessageGateway(Protocol):
"""声明交互控制器使用的消息发送和编辑能力。"""
def post_message(self, message: Notification): ...
def edit_message(self, **kwargs) -> bool: ...
def supports_interaction_buttons(channel: Optional[MessageChannel]) -> bool:
"""
渠道同时支持按钮和回调时优先使用按钮交互
@@ -266,744 +293,3 @@ def format_markdown_table(
for row in rows
]
return "\n".join([header_line, separator_line, *data_lines])
@dataclass
class PendingMediaInteraction:
"""
记录一次搜索/下载/订阅交互的当前上下文
"""
request_id: str
user_id: str
channel: Optional[MessageChannel]
source: Optional[str]
username: Optional[str]
action: str
keyword: str
phase: str = "media"
page: int = 0
title: str = ""
meta: Optional[MetaBase] = None
current_media: Optional[MediaInfo] = None
items: List[Any] = field(default_factory=list)
download_dirs: List[Any] = field(default_factory=list)
pending_download_mode: Optional[str] = None
pending_download_context: Optional[Any] = None
pending_no_exists: Optional[Dict[Any, Any]] = None
pending_torrent_page: int = 0
created_at: datetime = field(default_factory=datetime.now)
class MediaInteractionManager:
"""
管理用户当前激活的媒体交互状态
每个用户只保留一个有效会话避免旧按钮与新一轮搜索混用
"""
_ttl = timedelta(hours=24)
def __init__(self):
"""初始化按请求和用户索引的媒体会话表。"""
self._by_id: Dict[str, PendingMediaInteraction] = {}
self._by_user: Dict[str, str] = {}
self._lock = Lock()
def _cleanup_locked(self) -> None:
"""
清理超时会话避免内存中残留旧交互状态
"""
expire_before = datetime.now() - self._ttl
expired = [
request_id
for request_id, request in self._by_id.items()
if request.created_at < expire_before
]
for request_id in expired:
request = self._by_id.pop(request_id, None)
if request:
self._by_user.pop(str(request.user_id), None)
def create_or_replace(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel],
source: Optional[str],
username: Optional[str],
action: str,
keyword: str,
title: str = "",
meta: Optional[MetaBase] = None,
items: Optional[List[Any]] = None,
) -> PendingMediaInteraction:
"""
为用户创建新的交互状态并替换旧会话
"""
with self._lock:
self._cleanup_locked()
user_key = str(user_id)
old_request_id = self._by_user.get(user_key)
if old_request_id:
self._by_id.pop(old_request_id, None)
request = PendingMediaInteraction(
request_id=uuid.uuid4().hex[:12],
user_id=user_key,
channel=channel,
source=source,
username=username,
action=action,
keyword=keyword,
title=title,
meta=meta,
items=list(items or []),
)
self._by_id[request.request_id] = request
self._by_user[user_key] = request.request_id
return request
def get_by_user(
self, user_id: Union[str, int]
) -> Optional[PendingMediaInteraction]:
"""
按用户读取当前会话供文本回复和旧按钮兼容使用
"""
with self._lock:
self._cleanup_locked()
request_id = self._by_user.get(str(user_id))
if not request_id:
return None
return self._by_id.get(request_id)
def get_by_id(
self, request_id: str, user_id: Union[str, int]
) -> Optional[PendingMediaInteraction]:
"""
按请求 ID 读取会话并校验用户归属
"""
with self._lock:
self._cleanup_locked()
request = self._by_id.get(request_id)
if not request or str(request.user_id) != str(user_id):
return None
return request
def remove(self, request_id: str) -> None:
"""
主动结束一条会话
"""
with self._lock:
request = self._by_id.pop(request_id, None)
if request:
self._by_user.pop(str(request.user_id), None)
def clear(self) -> None:
"""
清空所有交互状态主要用于测试
"""
with self._lock:
self._by_id.clear()
self._by_user.clear()
media_interaction_manager = MediaInteractionManager()
@dataclass
class PendingPluginInputInteraction:
"""
记录插件临时接管用户下一条文本输入的会话
"""
request_id: str
user_id: str
plugin_id: str
channel: Optional[MessageChannel]
source: Optional[str]
username: Optional[str]
chat_id: Optional[str] = None
prompt_id: Optional[str] = None
payload: Optional[Any] = None
timeout_seconds: int = 120
created_at: datetime = field(default_factory=datetime.now)
# Optional reply binding for channels that can report reply_to_message_id.
prompt_message_id: Optional[str] = None
@property
def expires_at(self) -> datetime:
"""返回输入会话的绝对过期时间。"""
return self.created_at + timedelta(seconds=max(1, self.timeout_seconds))
class PluginInputInteractionManager:
"""
管理插件输入会话
会话按用户和渠道绑定同一用户在同一渠道只保留一个待输入会话
"""
EXPIRED_GRACE_SECONDS = 300
def __init__(self):
"""初始化活动输入会话、用户渠道索引和过期墓碑。"""
self._by_id: Dict[str, PendingPluginInputInteraction] = {}
self._by_user_channel: Dict[Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]], str] = {}
self._expired_by_user_channel: Dict[
Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]],
PendingPluginInputInteraction,
] = {}
self._lock = Lock()
@staticmethod
def _user_channel_source_key(
user_id: Union[str, int],
channel: Optional[MessageChannel],
source: Optional[str] = None,
chat_id: Optional[Union[str, int]] = None,
) -> Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]]:
"""归一化用户、渠道、来源和会话 ID 的联合索引键。"""
return str(user_id), channel, source, str(chat_id) if chat_id not in (None, "") else None
@classmethod
def _keys_overlap(
cls,
left: Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]],
right: Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]],
) -> bool:
"""判断两个输入会话键是否会争用同一条用户回复。"""
left_user, left_channel, left_source, left_chat_id = left
right_user, right_channel, right_source, right_chat_id = right
if left_user != right_user:
return False
if left_chat_id and right_chat_id and left_chat_id != right_chat_id:
return False
if (left_channel is None and left_source is None) or (right_channel is None and right_source is None):
return left_channel == right_channel and left_source == right_source
channel_overlap = left_channel == right_channel or left_channel is None or right_channel is None
source_overlap = left_source == right_source or left_source is None or right_source is None
return channel_overlap and source_overlap
def _cleanup_locked(self) -> None:
"""在持锁状态下淘汰过期会话并维护短期过期墓碑。"""
now = datetime.now()
expired_tombstones = [
key
for key, request in self._expired_by_user_channel.items()
if request.expires_at + timedelta(seconds=self.EXPIRED_GRACE_SECONDS) < now
]
for key in expired_tombstones:
self._expired_by_user_channel.pop(key, None)
expired = [
request_id
for request_id, request in self._by_id.items()
if request.expires_at < now
]
for request_id in expired:
request = self._by_id.pop(request_id, None)
if request:
key = self._user_channel_source_key(
request.user_id,
request.channel,
request.source,
request.chat_id,
)
self._by_user_channel.pop(key, None)
self._expired_by_user_channel[key] = request
def create_or_replace(
self,
user_id: Union[str, int],
plugin_id: str,
channel: Optional[MessageChannel],
source: Optional[str],
username: Optional[str],
chat_id: Optional[Union[str, int]] = None,
prompt_id: Optional[str] = None,
timeout_seconds: int = 120,
payload: Optional[Any] = None,
*,
prompt_message_id: Optional[Union[str, int]] = None,
) -> PendingPluginInputInteraction:
"""创建插件输入会话并替换键范围重叠的旧会话。"""
with self._lock:
self._cleanup_locked()
key = self._user_channel_source_key(user_id, channel, source, chat_id)
old_request_ids = [
request_id
for stored_key, request_id in self._by_user_channel.items()
if self._keys_overlap(stored_key, key)
]
for old_request_id in old_request_ids:
self._by_id.pop(old_request_id, None)
self._by_user_channel = {
stored_key: request_id
for stored_key, request_id in self._by_user_channel.items()
if request_id not in old_request_ids
}
self._expired_by_user_channel = {
stored_key: request
for stored_key, request in self._expired_by_user_channel.items()
if not self._keys_overlap(stored_key, key)
}
normalized_chat_id = str(chat_id) if chat_id not in (None, "") else None
normalized_prompt_message_id = (
str(prompt_message_id)
if channel == MessageChannel.Telegram and normalized_chat_id and prompt_message_id not in (None, "")
else None
)
request = PendingPluginInputInteraction(
request_id=uuid.uuid4().hex[:12],
user_id=str(user_id),
plugin_id=plugin_id,
channel=channel,
source=source,
username=username,
chat_id=normalized_chat_id,
prompt_id=prompt_id,
prompt_message_id=normalized_prompt_message_id,
timeout_seconds=timeout_seconds,
payload=payload,
)
self._by_id[request.request_id] = request
self._by_user_channel[key] = request.request_id
return request
def get_by_user(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel] = None,
source: Optional[str] = None,
chat_id: Optional[Union[str, int]] = None,
) -> Optional[PendingPluginInputInteraction]:
"""按用户和渠道上下文查询活动输入会话。"""
with self._lock:
self._cleanup_locked()
request_id = self._find_request_id_locked(user_id, channel, source, chat_id)
if request_id:
return self._by_id.get(request_id)
return None
def pop_by_user(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel] = None,
source: Optional[str] = None,
chat_id: Optional[Union[str, int]] = None,
) -> Optional[PendingPluginInputInteraction]:
"""取出并删除活动或刚过期的输入会话。"""
with self._lock:
self._cleanup_locked()
key, request_id = self._find_key_and_request_id_locked(user_id, channel, source, chat_id)
if request_id:
self._by_user_channel.pop(key, None)
return self._by_id.pop(request_id, None)
expired_key, request = self._find_expired_key_and_request_locked(user_id, channel, source, chat_id)
if expired_key:
self._expired_by_user_channel.pop(expired_key, None)
return request
def consume_by_user(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel] = None,
source: Optional[str] = None,
chat_id: Optional[Union[str, int]] = None,
*,
reply_to_message_id: Optional[Union[str, int]] = None,
bypass_reply_check: bool = False,
) -> Tuple[Optional[PendingPluginInputInteraction], Optional[str]]:
"""消费匹配回复的输入会话,并返回 active 或 expired 状态。"""
with self._lock:
key, request_id = self._find_key_and_request_id_locked(user_id, channel, source, chat_id)
if request_id:
request = self._by_id.get(request_id)
if not request:
self._by_user_channel.pop(key, None)
elif request.expires_at < datetime.now():
self._by_user_channel.pop(key, None)
self._by_id.pop(request_id, None)
if request.prompt_message_id:
return None, None
return request, "expired"
elif not self._reply_matches_prompt(
request,
chat_id,
reply_to_message_id,
ignore_reply_to_message_id=bypass_reply_check,
):
return None, None
else:
self._by_user_channel.pop(key, None)
self._by_id.pop(request_id, None)
return request, "active"
self._cleanup_locked()
key, request = self._find_expired_key_and_request_locked(user_id, channel, source, chat_id)
if request:
self._expired_by_user_channel.pop(key, None)
if request.prompt_message_id:
return None, None
return request, "expired"
self._cleanup_locked()
return None, None
@staticmethod
def _reply_matches_prompt(
request: PendingPluginInputInteraction,
chat_id: Optional[Union[str, int]],
reply_to_message_id: Optional[Union[str, int]],
*,
ignore_reply_to_message_id: bool = False,
) -> bool:
"""校验消息回复关系是否绑定到原始提示。"""
if not request.prompt_message_id:
return True
if not request.chat_id or chat_id in (None, ""):
return False
if str(chat_id) != str(request.chat_id):
return False
if ignore_reply_to_message_id:
return True
if reply_to_message_id in (None, ""):
return False
return str(reply_to_message_id) == str(request.prompt_message_id)
def _find_request_id_locked(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel],
source: Optional[str],
chat_id: Optional[Union[str, int]] = None,
) -> Optional[str]:
"""在持锁状态下查找活动请求 ID。"""
_, request_id = self._find_key_and_request_id_locked(user_id, channel, source, chat_id)
return request_id
def _find_key_and_request_id_locked(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel],
source: Optional[str],
chat_id: Optional[Union[str, int]] = None,
) -> Tuple[Optional[Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]]], Optional[str]]:
"""返回首个候选键及其活动请求 ID。"""
for key in self._candidate_keys(user_id, channel, source, chat_id):
request_id = self._by_user_channel.get(key)
if request_id:
return key, request_id
return None, None
def _find_expired_key_and_request_locked(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel],
source: Optional[str],
chat_id: Optional[Union[str, int]] = None,
) -> Tuple[Optional[Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]]],
Optional[PendingPluginInputInteraction]]:
"""返回仍在宽限期内的过期会话及其索引键。"""
now = datetime.now()
for key in self._candidate_keys(user_id, channel, source, chat_id):
request = self._expired_by_user_channel.get(key)
if not request:
continue
if request.expires_at + timedelta(seconds=self.EXPIRED_GRACE_SECONDS) < now:
self._expired_by_user_channel.pop(key, None)
continue
return key, request
return None, None
def _candidate_keys(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel],
source: Optional[str],
chat_id: Optional[Union[str, int]] = None,
) -> List[Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]]]:
"""按精确到宽松顺序生成输入会话候选键。"""
chat_key = str(chat_id) if chat_id not in (None, "") else None
candidates = [
self._user_channel_source_key(user_id, channel, source, chat_key),
]
if source is not None:
candidates.append(self._user_channel_source_key(user_id, channel, None, chat_key))
if channel is not None and source is not None:
candidates.append(self._user_channel_source_key(user_id, None, source, chat_key))
if channel is None and source is None:
wildcard_key = self._user_channel_source_key(user_id, None, None, chat_key)
candidates.append(wildcard_key)
if chat_key is not None:
candidates.append(self._user_channel_source_key(user_id, channel, source, None))
if source is not None:
candidates.append(self._user_channel_source_key(user_id, channel, None, None))
if channel is not None and source is not None:
candidates.append(self._user_channel_source_key(user_id, None, source, None))
if channel is None and source is None:
candidates.append(self._user_channel_source_key(user_id, None, None, None))
return candidates
def remove(self, request_id: str) -> None:
"""删除指定插件输入会话及其联合索引。"""
with self._lock:
request = self._by_id.pop(request_id, None)
if request:
self._by_user_channel.pop(
self._user_channel_source_key(request.user_id, request.channel, request.source, request.chat_id),
None,
)
def clear(self) -> None:
"""清空活动和过期的插件输入会话。"""
with self._lock:
self._by_id.clear()
self._by_user_channel.clear()
self._expired_by_user_channel.clear()
plugin_input_interaction_manager = PluginInputInteractionManager()
@dataclass(frozen=True)
class AgentInteractionOption:
"""
Agent 交互选项
"""
label: str
value: str
description: Optional[str] = None
@dataclass
class PendingAgentInteraction:
"""
待处理的 Agent 客户端交互请求
"""
request_id: str
session_id: str
user_id: str
channel: Optional[str]
source: Optional[str]
username: Optional[str]
title: Optional[str]
prompt: str
options: List[AgentInteractionOption]
created_at: datetime = field(default_factory=datetime.now)
class AgentInteractionManager:
"""
管理 Agent 发起的客户端交互请求
"""
_ttl = timedelta(hours=24)
def __init__(self):
"""初始化待处理的 Agent 交互请求表。"""
self._pending_interactions: Dict[str, PendingAgentInteraction] = {}
self._lock = Lock()
def _cleanup_locked(self) -> None:
"""在持锁状态下移除过期 Agent 交互。"""
expire_before = datetime.now() - self._ttl
expired_ids = [
request_id
for request_id, request in self._pending_interactions.items()
if request.created_at < expire_before
]
for request_id in expired_ids:
self._pending_interactions.pop(request_id, None)
def create_request(
self,
session_id: str,
user_id: str,
channel: Optional[str],
source: Optional[str],
username: Optional[str],
title: Optional[str],
prompt: str,
options: List[AgentInteractionOption],
) -> PendingAgentInteraction:
"""
创建一条待用户确认的 Agent 交互请求
"""
with self._lock:
self._cleanup_locked()
request_id = uuid.uuid4().hex[:12]
while request_id in self._pending_interactions:
request_id = uuid.uuid4().hex[:12]
request = PendingAgentInteraction(
request_id=request_id,
session_id=session_id,
user_id=str(user_id),
channel=channel,
source=source,
username=username,
title=title,
prompt=prompt,
options=options,
)
self._pending_interactions[request_id] = request
return request
def resolve(
self,
request_id: str,
option_index: int,
user_id: Optional[str] = None,
) -> Optional[tuple[PendingAgentInteraction, AgentInteractionOption]]:
"""
消费一条 Agent 交互请求并返回选中的选项
"""
with self._lock:
self._cleanup_locked()
request = self._pending_interactions.get(request_id)
if not request:
return None
if user_id is not None and str(request.user_id) != str(user_id):
return None
if option_index < 1 or option_index > len(request.options):
return None
option = request.options[option_index - 1]
self._pending_interactions.pop(request_id, None)
return request, option
def clear(self) -> None:
"""
清空所有 Agent 交互请求
"""
with self._lock:
self._pending_interactions.clear()
agent_interaction_manager = AgentInteractionManager()
@dataclass
class PendingSkillsInteraction:
"""
记录一次 /skills 会话的上下文便于按钮和文本回复共用同一状态
"""
request_id: str
user_id: str
channel: Optional[MessageChannel]
source: Optional[str]
username: Optional[str]
view: str = "root"
local_page: int = 0
market_page: int = 0
market_query: str = ""
awaiting_input: Optional[str] = None
created_at: datetime = field(default_factory=datetime.now)
class SkillsInteractionManager:
"""
管理用户当前的技能交互状态
每个用户同一时间只保留一个有效会话避免旧按钮继续生效
"""
_ttl = timedelta(hours=24)
def __init__(self):
"""初始化按请求和用户索引的技能交互会话表。"""
self._by_id: Dict[str, PendingSkillsInteraction] = {}
self._by_user: Dict[str, str] = {}
self._lock = Lock()
def _cleanup_locked(self):
"""
清理超时会话避免按钮回调无限积累
"""
expire_before = datetime.now() - self._ttl
expired = [
request_id
for request_id, request in self._by_id.items()
if request.created_at < expire_before
]
for request_id in expired:
request = self._by_id.pop(request_id, None)
if request:
self._by_user.pop(str(request.user_id), None)
def create_or_replace(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel],
source: Optional[str],
username: Optional[str],
) -> PendingSkillsInteraction:
"""
为用户创建新会话并替换掉旧的技能交互状态
"""
with self._lock:
self._cleanup_locked()
user_key = str(user_id)
old_request_id = self._by_user.get(user_key)
if old_request_id:
self._by_id.pop(old_request_id, None)
request_id = uuid.uuid4().hex[:12]
request = PendingSkillsInteraction(
request_id=request_id,
user_id=user_key,
channel=channel,
source=source,
username=username,
)
self._by_id[request_id] = request
self._by_user[user_key] = request_id
return request
def get_by_user(
self, user_id: Union[str, int]
) -> Optional[PendingSkillsInteraction]:
"""
按用户获取当前有效会话供纯文本回复路由使用
"""
with self._lock:
self._cleanup_locked()
request_id = self._by_user.get(str(user_id))
if not request_id:
return None
return self._by_id.get(request_id)
def get_by_id(
self, request_id: str, user_id: Union[str, int]
) -> Optional[PendingSkillsInteraction]:
"""
按请求 ID 获取会话并校验会话归属用户
"""
with self._lock:
self._cleanup_locked()
request = self._by_id.get(request_id)
if not request or str(request.user_id) != str(user_id):
return None
return request
def remove(self, request_id: str) -> None:
"""
主动结束会话释放用户和请求 ID 的双向索引
"""
with self._lock:
request = self._by_id.pop(request_id, None)
if request:
self._by_user.pop(str(request.user_id), None)
def clear(self):
"""
清空所有会话主要用于测试场景
"""
with self._lock:
self._by_id.clear()
self._by_user.clear()
skills_interaction_manager = SkillsInteractionManager()
+151
View File
@@ -0,0 +1,151 @@
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from threading import Lock
from typing import Any, Dict, List, Optional, Union
from app.domain.context import MediaInfo
from app.domain.meta.metabase import MetaBase
from app.schemas.types import MessageChannel
@dataclass
class PendingMediaInteraction:
"""
记录一次搜索/下载/订阅交互的当前上下文
"""
request_id: str
user_id: str
channel: Optional[MessageChannel]
source: Optional[str]
username: Optional[str]
action: str
keyword: str
phase: str = "media"
page: int = 0
title: str = ""
meta: Optional[MetaBase] = None
current_media: Optional[MediaInfo] = None
items: List[Any] = field(default_factory=list)
download_dirs: List[Any] = field(default_factory=list)
pending_download_mode: Optional[str] = None
pending_download_context: Optional[Any] = None
pending_no_exists: Optional[Dict[Any, Any]] = None
pending_torrent_page: int = 0
created_at: datetime = field(default_factory=datetime.now)
class MediaInteractionManager:
"""
管理用户当前激活的媒体交互状态
每个用户只保留一个有效会话避免旧按钮与新一轮搜索混用
"""
_ttl = timedelta(hours=24)
def __init__(self):
"""初始化按请求和用户索引的媒体会话表。"""
self._by_id: Dict[str, PendingMediaInteraction] = {}
self._by_user: Dict[str, str] = {}
self._lock = Lock()
def _cleanup_locked(self) -> None:
"""
清理超时会话避免内存中残留旧交互状态
"""
expire_before = datetime.now() - self._ttl
expired = [
request_id
for request_id, request in self._by_id.items()
if request.created_at < expire_before
]
for request_id in expired:
request = self._by_id.pop(request_id, None)
if request:
self._by_user.pop(str(request.user_id), None)
def create_or_replace(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel],
source: Optional[str],
username: Optional[str],
action: str,
keyword: str,
title: str = "",
meta: Optional[MetaBase] = None,
items: Optional[List[Any]] = None,
) -> PendingMediaInteraction:
"""
为用户创建新的交互状态并替换旧会话
"""
with self._lock:
self._cleanup_locked()
user_key = str(user_id)
old_request_id = self._by_user.get(user_key)
if old_request_id:
self._by_id.pop(old_request_id, None)
request = PendingMediaInteraction(
request_id=uuid.uuid4().hex[:12],
user_id=user_key,
channel=channel,
source=source,
username=username,
action=action,
keyword=keyword,
title=title,
meta=meta,
items=list(items or []),
)
self._by_id[request.request_id] = request
self._by_user[user_key] = request.request_id
return request
def get_by_user(
self, user_id: Union[str, int]
) -> Optional[PendingMediaInteraction]:
"""
按用户读取当前会话供文本回复和旧按钮兼容使用
"""
with self._lock:
self._cleanup_locked()
request_id = self._by_user.get(str(user_id))
if not request_id:
return None
return self._by_id.get(request_id)
def get_by_id(
self, request_id: str, user_id: Union[str, int]
) -> Optional[PendingMediaInteraction]:
"""
按请求 ID 读取会话并校验用户归属
"""
with self._lock:
self._cleanup_locked()
request = self._by_id.get(request_id)
if not request or str(request.user_id) != str(user_id):
return None
return request
def remove(self, request_id: str) -> None:
"""
主动结束一条会话
"""
with self._lock:
request = self._by_id.pop(request_id, None)
if request:
self._by_user.pop(str(request.user_id), None)
def clear(self) -> None:
"""
清空所有交互状态主要用于测试
"""
with self._lock:
self._by_id.clear()
self._by_user.clear()
media_interaction_manager = MediaInteractionManager()
+504
View File
@@ -0,0 +1,504 @@
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from threading import Lock
from typing import Any, Dict, List, Optional, Tuple, Union
from app.application.messaging.interaction import InteractionContext, MessageGateway
from app.runtime.events import EventManager
from app.schemas import Notification
from app.schemas.types import EventType, MessageChannel
@dataclass
class PendingPluginInputInteraction:
"""
记录插件临时接管用户下一条文本输入的会话
"""
request_id: str
user_id: str
plugin_id: str
channel: Optional[MessageChannel]
source: Optional[str]
username: Optional[str]
chat_id: Optional[str] = None
prompt_id: Optional[str] = None
payload: Optional[Any] = None
timeout_seconds: int = 120
created_at: datetime = field(default_factory=datetime.now)
# Optional reply binding for channels that can report reply_to_message_id.
prompt_message_id: Optional[str] = None
@property
def expires_at(self) -> datetime:
"""返回输入会话的绝对过期时间。"""
return self.created_at + timedelta(seconds=max(1, self.timeout_seconds))
class PluginInputInteractionManager:
"""
管理插件输入会话
会话按用户和渠道绑定同一用户在同一渠道只保留一个待输入会话
"""
EXPIRED_GRACE_SECONDS = 300
def __init__(self):
"""初始化活动输入会话、用户渠道索引和过期墓碑。"""
self._by_id: Dict[str, PendingPluginInputInteraction] = {}
self._by_user_channel: Dict[Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]], str] = {}
self._expired_by_user_channel: Dict[
Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]],
PendingPluginInputInteraction,
] = {}
self._lock = Lock()
@staticmethod
def _user_channel_source_key(
user_id: Union[str, int],
channel: Optional[MessageChannel],
source: Optional[str] = None,
chat_id: Optional[Union[str, int]] = None,
) -> Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]]:
"""归一化用户、渠道、来源和会话 ID 的联合索引键。"""
return str(user_id), channel, source, str(chat_id) if chat_id not in (None, "") else None
@classmethod
def _keys_overlap(
cls,
left: Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]],
right: Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]],
) -> bool:
"""判断两个输入会话键是否会争用同一条用户回复。"""
left_user, left_channel, left_source, left_chat_id = left
right_user, right_channel, right_source, right_chat_id = right
if left_user != right_user:
return False
if left_chat_id and right_chat_id and left_chat_id != right_chat_id:
return False
if (left_channel is None and left_source is None) or (right_channel is None and right_source is None):
return left_channel == right_channel and left_source == right_source
channel_overlap = left_channel == right_channel or left_channel is None or right_channel is None
source_overlap = left_source == right_source or left_source is None or right_source is None
return channel_overlap and source_overlap
def _cleanup_locked(self) -> None:
"""在持锁状态下淘汰过期会话并维护短期过期墓碑。"""
now = datetime.now()
expired_tombstones = [
key
for key, request in self._expired_by_user_channel.items()
if request.expires_at + timedelta(seconds=self.EXPIRED_GRACE_SECONDS) < now
]
for key in expired_tombstones:
self._expired_by_user_channel.pop(key, None)
expired = [
request_id
for request_id, request in self._by_id.items()
if request.expires_at < now
]
for request_id in expired:
request = self._by_id.pop(request_id, None)
if request:
key = self._user_channel_source_key(
request.user_id,
request.channel,
request.source,
request.chat_id,
)
self._by_user_channel.pop(key, None)
self._expired_by_user_channel[key] = request
def create_or_replace(
self,
user_id: Union[str, int],
plugin_id: str,
channel: Optional[MessageChannel],
source: Optional[str],
username: Optional[str],
chat_id: Optional[Union[str, int]] = None,
prompt_id: Optional[str] = None,
timeout_seconds: int = 120,
payload: Optional[Any] = None,
*,
prompt_message_id: Optional[Union[str, int]] = None,
) -> PendingPluginInputInteraction:
"""创建插件输入会话并替换键范围重叠的旧会话。"""
with self._lock:
self._cleanup_locked()
key = self._user_channel_source_key(user_id, channel, source, chat_id)
old_request_ids = [
request_id
for stored_key, request_id in self._by_user_channel.items()
if self._keys_overlap(stored_key, key)
]
for old_request_id in old_request_ids:
self._by_id.pop(old_request_id, None)
self._by_user_channel = {
stored_key: request_id
for stored_key, request_id in self._by_user_channel.items()
if request_id not in old_request_ids
}
self._expired_by_user_channel = {
stored_key: request
for stored_key, request in self._expired_by_user_channel.items()
if not self._keys_overlap(stored_key, key)
}
normalized_chat_id = str(chat_id) if chat_id not in (None, "") else None
normalized_prompt_message_id = (
str(prompt_message_id)
if channel == MessageChannel.Telegram and normalized_chat_id and prompt_message_id not in (None, "")
else None
)
request = PendingPluginInputInteraction(
request_id=uuid.uuid4().hex[:12],
user_id=str(user_id),
plugin_id=plugin_id,
channel=channel,
source=source,
username=username,
chat_id=normalized_chat_id,
prompt_id=prompt_id,
prompt_message_id=normalized_prompt_message_id,
timeout_seconds=timeout_seconds,
payload=payload,
)
self._by_id[request.request_id] = request
self._by_user_channel[key] = request.request_id
return request
def get_by_user(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel] = None,
source: Optional[str] = None,
chat_id: Optional[Union[str, int]] = None,
) -> Optional[PendingPluginInputInteraction]:
"""按用户和渠道上下文查询活动输入会话。"""
with self._lock:
self._cleanup_locked()
request_id = self._find_request_id_locked(user_id, channel, source, chat_id)
if request_id:
return self._by_id.get(request_id)
return None
def pop_by_user(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel] = None,
source: Optional[str] = None,
chat_id: Optional[Union[str, int]] = None,
) -> Optional[PendingPluginInputInteraction]:
"""取出并删除活动或刚过期的输入会话。"""
with self._lock:
self._cleanup_locked()
key, request_id = self._find_key_and_request_id_locked(user_id, channel, source, chat_id)
if request_id:
self._by_user_channel.pop(key, None)
return self._by_id.pop(request_id, None)
expired_key, request = self._find_expired_key_and_request_locked(user_id, channel, source, chat_id)
if expired_key:
self._expired_by_user_channel.pop(expired_key, None)
return request
def consume_by_user(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel] = None,
source: Optional[str] = None,
chat_id: Optional[Union[str, int]] = None,
*,
reply_to_message_id: Optional[Union[str, int]] = None,
bypass_reply_check: bool = False,
) -> Tuple[Optional[PendingPluginInputInteraction], Optional[str]]:
"""消费匹配回复的输入会话,并返回 active 或 expired 状态。"""
with self._lock:
key, request_id = self._find_key_and_request_id_locked(user_id, channel, source, chat_id)
if request_id:
request = self._by_id.get(request_id)
if not request:
self._by_user_channel.pop(key, None)
elif request.expires_at < datetime.now():
self._by_user_channel.pop(key, None)
self._by_id.pop(request_id, None)
if request.prompt_message_id:
return None, None
return request, "expired"
elif not self._reply_matches_prompt(
request,
chat_id,
reply_to_message_id,
ignore_reply_to_message_id=bypass_reply_check,
):
return None, None
else:
self._by_user_channel.pop(key, None)
self._by_id.pop(request_id, None)
return request, "active"
self._cleanup_locked()
key, request = self._find_expired_key_and_request_locked(user_id, channel, source, chat_id)
if request:
self._expired_by_user_channel.pop(key, None)
if request.prompt_message_id:
return None, None
return request, "expired"
self._cleanup_locked()
return None, None
@staticmethod
def _reply_matches_prompt(
request: PendingPluginInputInteraction,
chat_id: Optional[Union[str, int]],
reply_to_message_id: Optional[Union[str, int]],
*,
ignore_reply_to_message_id: bool = False,
) -> bool:
"""校验消息回复关系是否绑定到原始提示。"""
if not request.prompt_message_id:
return True
if not request.chat_id or chat_id in (None, ""):
return False
if str(chat_id) != str(request.chat_id):
return False
if ignore_reply_to_message_id:
return True
if reply_to_message_id in (None, ""):
return False
return str(reply_to_message_id) == str(request.prompt_message_id)
def _find_request_id_locked(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel],
source: Optional[str],
chat_id: Optional[Union[str, int]] = None,
) -> Optional[str]:
"""在持锁状态下查找活动请求 ID。"""
_, request_id = self._find_key_and_request_id_locked(user_id, channel, source, chat_id)
return request_id
def _find_key_and_request_id_locked(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel],
source: Optional[str],
chat_id: Optional[Union[str, int]] = None,
) -> Tuple[Optional[Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]]], Optional[str]]:
"""返回首个候选键及其活动请求 ID。"""
for key in self._candidate_keys(user_id, channel, source, chat_id):
request_id = self._by_user_channel.get(key)
if request_id:
return key, request_id
return None, None
def _find_expired_key_and_request_locked(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel],
source: Optional[str],
chat_id: Optional[Union[str, int]] = None,
) -> Tuple[Optional[Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]]],
Optional[PendingPluginInputInteraction]]:
"""返回仍在宽限期内的过期会话及其索引键。"""
now = datetime.now()
for key in self._candidate_keys(user_id, channel, source, chat_id):
request = self._expired_by_user_channel.get(key)
if not request:
continue
if request.expires_at + timedelta(seconds=self.EXPIRED_GRACE_SECONDS) < now:
self._expired_by_user_channel.pop(key, None)
continue
return key, request
return None, None
def _candidate_keys(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel],
source: Optional[str],
chat_id: Optional[Union[str, int]] = None,
) -> List[Tuple[str, Optional[MessageChannel], Optional[str], Optional[str]]]:
"""按精确到宽松顺序生成输入会话候选键。"""
chat_key = str(chat_id) if chat_id not in (None, "") else None
candidates = [
self._user_channel_source_key(user_id, channel, source, chat_key),
]
if source is not None:
candidates.append(self._user_channel_source_key(user_id, channel, None, chat_key))
if channel is not None and source is not None:
candidates.append(self._user_channel_source_key(user_id, None, source, chat_key))
if channel is None and source is None:
wildcard_key = self._user_channel_source_key(user_id, None, None, chat_key)
candidates.append(wildcard_key)
if chat_key is not None:
candidates.append(self._user_channel_source_key(user_id, channel, source, None))
if source is not None:
candidates.append(self._user_channel_source_key(user_id, channel, None, None))
if channel is not None and source is not None:
candidates.append(self._user_channel_source_key(user_id, None, source, None))
if channel is None and source is None:
candidates.append(self._user_channel_source_key(user_id, None, None, None))
return candidates
def remove(self, request_id: str) -> None:
"""删除指定插件输入会话及其联合索引。"""
with self._lock:
request = self._by_id.pop(request_id, None)
if request:
self._by_user_channel.pop(
self._user_channel_source_key(request.user_id, request.channel, request.source, request.chat_id),
None,
)
def clear(self) -> None:
"""清空活动和过期的插件输入会话。"""
with self._lock:
self._by_id.clear()
self._by_user_channel.clear()
self._expired_by_user_channel.clear()
plugin_input_interaction_manager = PluginInputInteractionManager()
class PluginInputInteractionHandler:
"""消费插件申请接管的下一条用户文本输入。"""
def __init__(self, messenger: MessageGateway):
"""保存消息投递接口。"""
self._messenger = messenger
def handle_text(
self,
*,
context: InteractionContext,
text: str,
reply_to_message_id: Optional[Union[str, int]] = None,
images=None,
audio_refs=None,
files=None,
has_audio_input: bool = False,
) -> bool:
"""消费插件输入会话,并派发 MessageAction 事件。"""
if not text or not text.strip() or images or audio_refs or files or has_audio_input:
return False
if text.startswith("CALLBACK:"):
return False
channel = context.channel
source = context.source
userid = context.user_id
username = context.username
original_chat_id = context.original_chat_id
is_cancel_text = text.strip().lower() in {"取消", "退出", "q", "quit", "exit"}
request, status = plugin_input_interaction_manager.consume_by_user(
userid,
channel,
source,
original_chat_id,
reply_to_message_id=reply_to_message_id,
bypass_reply_check=is_cancel_text,
)
if not request:
return False
if status == "expired":
# 调用时解析单例,避免模块级绑定在单例注册表被重置后与宿主脱钩
EventManager().send_event(
EventType.MessageAction,
{
"plugin_id": request.plugin_id,
"__mp_target_plugin_id": request.plugin_id,
"text": f"plugin_input_expired|{request.request_id}",
"userid": userid,
"channel": channel,
"source": source,
"username": username,
"chat_id": original_chat_id,
"reply_to_message_id": reply_to_message_id,
"prompt_id": request.prompt_id,
"input_session_id": request.request_id,
"expired": True,
"payload": request.payload,
},
)
self._messenger.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="插件输入已超时,请重新发起操作。",
save_history=False,
)
)
return not text.strip().startswith("/")
if is_cancel_text:
EventManager().send_event(
EventType.MessageAction,
{
"plugin_id": request.plugin_id,
"__mp_target_plugin_id": request.plugin_id,
"text": f"plugin_input_cancel|{request.request_id}",
"userid": userid,
"channel": channel,
"source": source,
"username": username,
"chat_id": original_chat_id,
"reply_to_message_id": reply_to_message_id,
"prompt_id": request.prompt_id,
"input_session_id": request.request_id,
"cancelled": True,
"payload": request.payload,
},
)
self._messenger.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="已取消插件输入",
save_history=False,
)
)
return True
EventManager().send_event(
EventType.MessageAction,
{
"plugin_id": request.plugin_id,
"__mp_target_plugin_id": request.plugin_id,
"text": f"plugin_input|{request.request_id}",
"input_text": text,
"userid": userid,
"channel": channel,
"source": source,
"username": username,
"chat_id": original_chat_id,
"reply_to_message_id": reply_to_message_id,
"prompt_id": request.prompt_id,
"input_session_id": request.request_id,
"payload": request.payload,
},
)
return True
@staticmethod
def parse_callback(callback_data: str) -> Optional[Tuple[str, str]]:
"""解析插件按钮回调,格式错误时返回 None。"""
if not callback_data.startswith("[PLUGIN]"):
return None
# 用 partition 避免缺少分隔符的回调抛异常
plugin_id, separator, content = callback_data.partition("|")
if not separator:
return None
return plugin_id.replace("[PLUGIN]", "", 1), content
+121
View File
@@ -0,0 +1,121 @@
"""
交互路由层统一选择活动文本会话并按固定顺序派发按钮回调
文本会话候选覆盖 SiteSubscribeSkillMedia 四类
按会话创建时间选择最近激活的一条避免旧会话抢占新会话的输入
"""
from dataclasses import dataclass
from typing import Any, Callable, Optional, Sequence, Union
from app.application.messaging.interaction import (
InteractionContext,
InteractionDispatch,
)
from app.application.messaging.media import media_interaction_manager
from app.application.messaging.site import site_interaction_manager
from app.application.messaging.skill import skill_interaction_manager
from app.application.messaging.subscribe import subscribe_interaction_manager
@dataclass(frozen=True, slots=True)
class SessionRoute:
"""描述一种可继续接收文本的交互会话。"""
# 会话名称,与对应 Slash 命令一致
name: str
# 返回用户当前待处理会话对象(含 created_at),无会话返回 None
get_pending: Callable[[Union[str, int]], Optional[Any]]
# 将一条文本派发给该会话,返回是否已消费
handle_text: Callable[[InteractionContext, str], bool]
@dataclass(frozen=True, slots=True)
class CallbackRoute:
"""描述一种按钮回调的匹配和处理方式。"""
# 路由名称,用于日志和排查
name: str
# 按回调内容判断是否归本路由处理
matches: Callable[[str], bool]
# 执行回调处理并返回派发结果
dispatch: Callable[[str, InteractionContext], InteractionDispatch]
class InteractionRouter:
"""统一选择活动文本会话并按顺序派发按钮回调。"""
def __init__(
self,
session_routes: Sequence[SessionRoute],
callback_routes: Sequence[CallbackRoute],
):
"""按注册顺序保存会话路由和回调路由,回调顺序即优先级。"""
self._session_routes = list(session_routes)
self._callback_routes = list(callback_routes)
def latest_session(self, user_id: Union[str, int]) -> Optional[SessionRoute]:
"""返回最近创建的待处理文本会话路由,无会话返回 None。"""
best_route: Optional[SessionRoute] = None
best_created_at = None
for route in self._session_routes:
pending = route.get_pending(user_id)
if pending is None:
continue
created_at = getattr(pending, "created_at", None)
# 缺少时间戳的会话视为最早,保证有时间戳的新会话优先
if best_route is None or (
created_at is not None
and (best_created_at is None or created_at > best_created_at)
):
best_route = route
best_created_at = created_at
return best_route
def has_pending(self, user_id: Union[str, int]) -> bool:
"""判断用户是否存在任意待处理文本会话。"""
return any(
route.get_pending(user_id) is not None
for route in self._session_routes
)
def dispatch_active_text(
self,
context: InteractionContext,
text: str,
) -> bool:
"""把文本派发给最近活动的会话,返回是否被消费。"""
route = self.latest_session(context.user_id)
if route is None:
return False
return route.handle_text(context, text)
def dispatch_callback(
self,
context: InteractionContext,
callback_data: str,
) -> InteractionDispatch:
"""按注册顺序匹配并派发按钮回调,均不匹配时返回未处理。
匹配到的路由未消费回调handled=False时继续尝试后续路由
与既有顺序式派发的行为保持一致
"""
for route in self._callback_routes:
if route.matches(callback_data):
result = route.dispatch(callback_data, context)
if result.handled:
return result
return InteractionDispatch(handled=False)
def has_pending_interaction(user_id: Union[str, int]) -> bool:
"""供 WebAgent 判断用户是否处于传统交互会话。"""
return any(
manager.get_by_user(user_id) is not None
for manager in (
site_interaction_manager,
subscribe_interaction_manager,
skill_interaction_manager,
media_interaction_manager,
)
)
+587
View File
@@ -0,0 +1,587 @@
import re
from typing import Callable, List, Optional, Tuple, Union
from app.db.models.site import Site
from app.db.oper.site import SiteOper
from app.domain import site as site_rules
from app.application.messaging.interaction import (
MessageGateway,
SlashInteractionManager,
build_navigation_buttons,
format_markdown_table,
page_items,
supports_interaction_buttons,
supports_markdown,
update_or_post_message,
)
from app.runtime.log import logger
from app.schemas import Notification
from app.schemas.types import MessageChannel
site_interaction_manager = SlashInteractionManager()
class SiteInteractionHandler:
"""
管理 /sites 交互会话输入解析和站点列表渲染
"""
_button_page_size = 6
_text_page_size = 10
def __init__(
self,
messenger: MessageGateway,
cookie_updater: Callable[..., Tuple[bool, str]],
):
"""
注入消息投递接口和站点 Cookie 更新动作
"""
self._messenger = messenger
self._cookie_updater = cookie_updater
def remote_list(
self,
arg_str: str = "",
channel: MessageChannel = None,
userid: Union[str, int] = None,
source: Optional[str] = None,
):
"""
/sites 统一入口
"""
request = site_interaction_manager.create_or_replace(
user_id=userid,
command="/sites",
channel=channel,
source=source,
username=None,
)
normalized_arg = (arg_str or "").strip()
if normalized_arg and self.handle_text_interaction(
channel=channel,
source=source,
userid=userid,
username="",
text=normalized_arg,
):
return
self._render_site_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username="",
)
@staticmethod
def parse_callback(callback_data: str) -> Optional[Tuple[str, str]]:
"""
解析 /sites 按钮回调
"""
if not callback_data.startswith("sites:"):
return None
parts = callback_data.split(":")
if len(parts) < 3:
return None
return parts[1], parts[2]
def handle_callback_interaction(
self,
callback_data: str,
channel: MessageChannel,
source: str,
userid: Union[str, int],
username: str,
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
) -> bool:
"""
处理 /sites 按钮交互
"""
parsed = self.parse_callback(callback_data)
if not parsed:
return False
request_id, action = parsed
request = site_interaction_manager.get_by_id(request_id, userid)
if not request:
self._messenger.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="站点交互已失效,请重新发送 /sites",
)
)
return True
request.channel = channel
request.source = source
request.username = username
if action == "close":
site_interaction_manager.remove(request.request_id)
update_or_post_message(
chain=self._messenger,
channel=channel,
source=source,
userid=userid,
username=username,
title="站点管理",
text="站点交互已结束",
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
return True
if action == "page-prev":
request.page = max(0, request.page - 1)
request.awaiting_input = None
elif action == "page-next":
request.page += 1
request.awaiting_input = None
elif action in {"cookie", "enable", "disable"}:
request.awaiting_input = action
elif action == "refresh":
request.awaiting_input = None
self._render_site_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
return True
def handle_text_interaction(
self,
channel: MessageChannel,
source: str,
userid: Union[str, int],
username: str,
text: str,
) -> bool:
"""
处理 /sites 文本补充输入
"""
request = site_interaction_manager.get_by_user(userid)
if not request:
return False
request.channel = channel
request.source = source
request.username = username
normalized = (text or "").strip()
lowered = normalized.lower()
if lowered in {"退出", "关闭", "q", "quit", "exit"}:
site_interaction_manager.remove(request.request_id)
self._messenger.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="站点交互已结束",
save_history=False,
)
)
return True
if lowered in {"取消", "cancel", "返回", "back"}:
request.awaiting_input = None
self._render_site_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if lowered in {"刷新", "refresh", "列表", "list"}:
request.awaiting_input = None
self._render_site_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if lowered in {"p", "prev", "上一页"}:
request.awaiting_input = None
request.page = max(0, request.page - 1)
self._render_site_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if lowered in {"n", "next", "下一页"}:
request.awaiting_input = None
request.page += 1
self._render_site_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
cookie_match = re.match(
r"^(?:cookie|更新cookie|更新\s*cookie)\s+(.+)$",
normalized,
re.IGNORECASE,
)
enable_match = re.match(r"^(?:启用|enable)\s+(.+)$", normalized, re.IGNORECASE)
disable_match = re.match(
r"^(?:禁用|disable)\s+(.+)$", normalized, re.IGNORECASE
)
if request.awaiting_input == "cookie":
success, message = self._update_site_cookie_from_input(normalized)
request.awaiting_input = None
self._messenger.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=message,
)
)
self._render_site_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if request.awaiting_input == "enable":
success, message = self._set_sites_enabled(normalized, enabled=True)
request.awaiting_input = None
self._messenger.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=message,
)
)
self._render_site_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if request.awaiting_input == "disable":
success, message = self._set_sites_enabled(normalized, enabled=False)
request.awaiting_input = None
self._messenger.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=message,
)
)
self._render_site_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if cookie_match:
success, message = self._update_site_cookie_from_input(cookie_match.group(1))
self._messenger.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=message,
)
)
self._render_site_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if enable_match:
success, message = self._set_sites_enabled(enable_match.group(1), enabled=True)
self._messenger.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=message,
)
)
self._render_site_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if disable_match:
success, message = self._set_sites_enabled(
disable_match.group(1), enabled=False
)
self._messenger.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=message,
)
)
self._render_site_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
self._messenger.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=self._site_usage_hint(request.awaiting_input),
)
)
return True
def _render_site_interaction(
self,
request,
channel: MessageChannel,
source: Optional[str],
userid: Union[str, int],
username: Optional[str],
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
) -> None:
"""
渲染 /sites 当前页面
"""
site_list = SiteOper().list()
page_size = self._button_page_size if supports_interaction_buttons(channel) else self._text_page_size
page_sites, page, total_pages = page_items(site_list, request.page, page_size)
request.page = page
if site_list:
body = self._format_site_list(page_sites, channel=channel)
footer = [
f"{page + 1}/{total_pages} 页,共 {len(site_list)} 个站点",
self._site_prompt(request.awaiting_input),
self._site_usage_hint(request.awaiting_input),
]
text = "\n\n".join([body, *[line for line in footer if line]])
else:
text = "当前没有任何站点。\n\n输入 `退出` 结束交互。"
buttons = None
if supports_interaction_buttons(channel):
buttons = build_navigation_buttons("sites", request, page, total_pages)
buttons.extend(
[
[
{
"text": "更新 Cookie",
"callback_data": f"sites:{request.request_id}:cookie",
},
{
"text": "禁用站点",
"callback_data": f"sites:{request.request_id}:disable",
},
{
"text": "启用站点",
"callback_data": f"sites:{request.request_id}:enable",
},
],
[
{
"text": "刷新列表",
"callback_data": f"sites:{request.request_id}:refresh",
},
{
"text": "关闭",
"callback_data": f"sites:{request.request_id}:close",
},
],
]
)
update_or_post_message(
chain=self._messenger,
channel=channel,
source=source,
userid=userid,
username=username,
title="站点管理",
text=text,
buttons=buttons,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
@staticmethod
def _format_site_list(
site_list: List[Site], channel: Optional[MessageChannel]
) -> str:
"""
根据渠道能力格式化站点列表
"""
if supports_markdown(channel):
rows = [
[
site.id,
site.name,
"启用" if site.is_active else "禁用",
"已配置" if site.cookie else "未配置",
"" if site.render else "",
site.domain or site_rules.extract_domain(site.url or ""),
]
for site in site_list
]
return format_markdown_table(
headers=["ID", "站点", "状态", "Cookie", "渲染", "域名"],
rows=rows,
)
lines = []
for site in site_list:
lines.append(
f"{site.id}. {site.name} | 状态:{'启用' if site.is_active else '禁用'}"
f" | Cookie{'已配置' if site.cookie else '未配置'}"
f" | 渲染:{'' if site.render else ''}"
f" | 域名:{site.domain or site_rules.extract_domain(site.url or '')}"
)
return "\n".join(lines)
@staticmethod
def _site_prompt(awaiting_input: Optional[str]) -> str:
"""
返回当前输入模式提示
"""
if awaiting_input == "cookie":
return "当前操作:更新站点 Cookie,请输入:<id> <username> <password> [2fa_code/secret]"
if awaiting_input == "enable":
return "当前操作:启用站点,请输入站点 ID,多个 ID 用空格分隔。"
if awaiting_input == "disable":
return "当前操作:禁用站点,请输入站点 ID,多个 ID 用空格分隔。"
return ""
@staticmethod
def _site_usage_hint(awaiting_input: Optional[str]) -> str:
"""
返回 /sites 的文本操作提示
"""
if awaiting_input == "cookie":
return "输入站点 ID、用户名、密码和可选 2FA;输入 `取消` 返回列表,输入 `退出` 结束交互。"
if awaiting_input in {"enable", "disable"}:
return "输入一个或多个站点 ID;输入 `取消` 返回列表,输入 `退出` 结束交互。"
return (
"可输入:`cookie <id> <username> <password> [2fa]`、`启用 <id...>`、`禁用 <id...>`、"
"`n`、`p`、`刷新`、`退出`。"
)
@staticmethod
def _parse_site_ids(arg_str: str) -> List[int]:
"""
从输入中提取站点 ID
"""
return [int(item) for item in re.findall(r"\d+", arg_str or "")]
def _set_sites_enabled(self, arg_str: str, enabled: bool) -> Tuple[bool, str]:
"""
批量启用或禁用站点
"""
site_ids = self._parse_site_ids(arg_str)
if not site_ids:
return False, "请输入至少一个有效的站点 ID"
siteoper = SiteOper()
changed = []
missing = []
for site_id in site_ids:
site = siteoper.get(site_id)
if not site:
missing.append(str(site_id))
continue
siteoper.update(site_id, {"is_active": enabled})
changed.append(site.name)
action = "启用" if enabled else "禁用"
if not changed and missing:
return False, f"未找到站点:{', '.join(missing)}"
message = f"{action} {len(changed)} 个站点"
if changed:
message += f"{', '.join(changed)}"
if missing:
message += f";未找到:{', '.join(missing)}"
return True, message
def _update_site_cookie_from_input(self, arg_str: str) -> Tuple[bool, str]:
"""
根据输入更新单个站点 Cookie
"""
args = str(arg_str or "").split()
if len(args) not in {3, 4} or not args[0].isdigit():
return (
False,
"格式错误,请输入:cookie <id> <username> <password> [2fa_code/secret]",
)
site_id = int(args[0])
site_info = SiteOper().get(site_id)
if not site_info:
return False, f"站点编号 {site_id} 不存在"
status, msg = self._cookie_updater(
site_info=site_info,
username=args[1],
password=args[2],
two_step_code=args[3] if len(args) == 4 else None,
)
if not status:
logger.error(msg)
return False, f"{site_info.name}】Cookie&UA 更新失败:{msg}"
return True, f"{site_info.name}】Cookie&UA 更新成功"
@@ -1,19 +1,147 @@
import re
from typing import List, Optional, Tuple, Union
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from threading import Lock
from typing import Dict, List, Optional, Tuple, Union
from app.chain import ChainBase
from app.agent.skills.registry import SkillHelper, SkillInfo
from app.application.messaging.interaction import (
MessageGateway,
build_navigation_buttons,
page_items,
supports_interaction_buttons,
update_or_post_message, skills_interaction_manager, PendingSkillsInteraction,
update_or_post_message,
)
from app.agent.skills.registry import SkillHelper, SkillInfo
from app.schemas import Notification
from app.schemas.types import MessageChannel
class SkillsChain(ChainBase):
@dataclass
class PendingSkillInteraction:
"""
记录一次 /skills 会话的上下文便于按钮和文本回复共用同一状态
"""
request_id: str
user_id: str
channel: Optional[MessageChannel]
source: Optional[str]
username: Optional[str]
view: str = "root"
local_page: int = 0
market_page: int = 0
market_query: str = ""
awaiting_input: Optional[str] = None
created_at: datetime = field(default_factory=datetime.now)
class SkillInteractionManager:
"""
管理用户当前的技能交互状态
每个用户同一时间只保留一个有效会话避免旧按钮继续生效
"""
_ttl = timedelta(hours=24)
def __init__(self):
"""初始化按请求和用户索引的技能交互会话表。"""
self._by_id: Dict[str, PendingSkillInteraction] = {}
self._by_user: Dict[str, str] = {}
self._lock = Lock()
def _cleanup_locked(self):
"""
清理超时会话避免按钮回调无限积累
"""
expire_before = datetime.now() - self._ttl
expired = [
request_id
for request_id, request in self._by_id.items()
if request.created_at < expire_before
]
for request_id in expired:
request = self._by_id.pop(request_id, None)
if request:
self._by_user.pop(str(request.user_id), None)
def create_or_replace(
self,
user_id: Union[str, int],
channel: Optional[MessageChannel],
source: Optional[str],
username: Optional[str],
) -> PendingSkillInteraction:
"""
为用户创建新会话并替换掉旧的技能交互状态
"""
with self._lock:
self._cleanup_locked()
user_key = str(user_id)
old_request_id = self._by_user.get(user_key)
if old_request_id:
self._by_id.pop(old_request_id, None)
request_id = uuid.uuid4().hex[:12]
request = PendingSkillInteraction(
request_id=request_id,
user_id=user_key,
channel=channel,
source=source,
username=username,
)
self._by_id[request_id] = request
self._by_user[user_key] = request_id
return request
def get_by_user(
self, user_id: Union[str, int]
) -> Optional[PendingSkillInteraction]:
"""
按用户获取当前有效会话供纯文本回复路由使用
"""
with self._lock:
self._cleanup_locked()
request_id = self._by_user.get(str(user_id))
if not request_id:
return None
return self._by_id.get(request_id)
def get_by_id(
self, request_id: str, user_id: Union[str, int]
) -> Optional[PendingSkillInteraction]:
"""
按请求 ID 获取会话并校验会话归属用户
"""
with self._lock:
self._cleanup_locked()
request = self._by_id.get(request_id)
if not request or str(request.user_id) != str(user_id):
return None
return request
def remove(self, request_id: str) -> None:
"""
主动结束会话释放用户和请求 ID 的双向索引
"""
with self._lock:
request = self._by_id.pop(request_id, None)
if request:
self._by_user.pop(str(request.user_id), None)
def clear(self):
"""
清空所有会话主要用于测试场景
"""
with self._lock:
self._by_id.clear()
self._by_user.clear()
skill_interaction_manager = SkillInteractionManager()
class SkillInteractionHandler:
"""
处理 /skills 指令按钮回调和文本式技能管理交互
"""
@@ -21,9 +149,14 @@ class SkillsChain(ChainBase):
_button_page_size = 6
_text_page_size = 8
def __init__(self):
super().__init__()
self.skillhelper = SkillHelper()
def __init__(
self,
messenger: MessageGateway,
skill_helper: Optional[SkillHelper] = None,
):
"""注入消息接口和技能管理能力。"""
self._messenger = messenger
self.skillhelper = skill_helper or SkillHelper()
def remote_manage(
self,
@@ -35,7 +168,7 @@ class SkillsChain(ChainBase):
"""
/skills 入口创建新会话并渲染首屏菜单
"""
request = skills_interaction_manager.create_or_replace(
request = skill_interaction_manager.create_or_replace(
user_id=userid,
channel=channel,
source=source,
@@ -95,9 +228,9 @@ class SkillsChain(ChainBase):
return False
request_id, action, index = parsed
request = skills_interaction_manager.get_by_id(request_id, userid)
request = skill_interaction_manager.get_by_id(request_id, userid)
if not request:
self.post_message(
self._messenger.post_message(
Notification(
channel=channel,
source=source,
@@ -113,7 +246,7 @@ class SkillsChain(ChainBase):
request.username = username
if action == "close":
skills_interaction_manager.remove(request.request_id)
skill_interaction_manager.remove(request.request_id)
self._update_or_post_message(
channel=channel,
source=source,
@@ -177,7 +310,7 @@ class SkillsChain(ChainBase):
request.awaiting_input = None
success, message = self._install_market_skill(request, index)
if success:
self.post_message(
self._messenger.post_message(
Notification(
channel=channel,
source=source,
@@ -187,7 +320,7 @@ class SkillsChain(ChainBase):
)
)
else:
self.post_message(
self._messenger.post_message(
Notification(
channel=channel,
source=source,
@@ -199,7 +332,7 @@ class SkillsChain(ChainBase):
elif action == "remove" and index:
request.awaiting_input = None
success, message = self._remove_local_skill(request, index)
self.post_message(
self._messenger.post_message(
Notification(
channel=channel,
source=source,
@@ -215,7 +348,7 @@ class SkillsChain(ChainBase):
request.view = "sources"
request.awaiting_input = None
success, message = self._remove_market_source(index)
self.post_message(
self._messenger.post_message(
Notification(
channel=channel,
source=source,
@@ -247,7 +380,7 @@ class SkillsChain(ChainBase):
"""
处理不支持按钮渠道上的文本指令也兼容用户直接回复文字操作
"""
request = skills_interaction_manager.get_by_user(userid)
request = skill_interaction_manager.get_by_user(userid)
if not request:
return False
@@ -258,8 +391,8 @@ class SkillsChain(ChainBase):
normalized = (text or "").strip()
lowered = normalized.lower()
if lowered in {"退出", "关闭", "q", "quit", "exit"}:
skills_interaction_manager.remove(request.request_id)
self.post_message(
skill_interaction_manager.remove(request.request_id)
self._messenger.post_message(
Notification(
channel=channel,
source=source,
@@ -294,7 +427,7 @@ class SkillsChain(ChainBase):
request.view = "sources"
request.awaiting_input = None
_, message = self.skillhelper.add_custom_market_source(add_source)
self.post_message(
self._messenger.post_message(
Notification(
channel=channel,
source=source,
@@ -318,7 +451,7 @@ class SkillsChain(ChainBase):
_, message = self._remove_market_source(
page_index=int(remove_source_match.group(1))
)
self.post_message(
self._messenger.post_message(
Notification(
channel=channel,
source=source,
@@ -391,7 +524,7 @@ class SkillsChain(ChainBase):
self._extract_market_search_query(normalized),
)
else:
self.post_message(
self._messenger.post_message(
Notification(
channel=channel,
source=source,
@@ -416,7 +549,7 @@ class SkillsChain(ChainBase):
else:
_, message = self.skillhelper.add_custom_market_source(normalized)
request.awaiting_input = None
self.post_message(
self._messenger.post_message(
Notification(
channel=channel,
source=source,
@@ -481,7 +614,7 @@ class SkillsChain(ChainBase):
request=request,
page_index=int(install_match.group(1)),
)
self.post_message(
self._messenger.post_message(
Notification(
channel=channel,
source=source,
@@ -504,7 +637,7 @@ class SkillsChain(ChainBase):
request=request,
page_index=int(remove_match.group(1)),
)
self.post_message(
self._messenger.post_message(
Notification(
channel=channel,
source=source,
@@ -522,7 +655,7 @@ class SkillsChain(ChainBase):
)
return True
self.post_message(
self._messenger.post_message(
Notification(
channel=channel,
source=source,
@@ -535,7 +668,7 @@ class SkillsChain(ChainBase):
def _install_market_skill(
self,
request: PendingSkillsInteraction,
request: PendingSkillInteraction,
page_index: int,
) -> Tuple[bool, str]:
"""
@@ -554,7 +687,7 @@ class SkillsChain(ChainBase):
def _remove_local_skill(
self,
request: PendingSkillsInteraction,
request: PendingSkillInteraction,
page_index: int,
) -> Tuple[bool, str]:
"""
@@ -588,7 +721,7 @@ class SkillsChain(ChainBase):
def _render_interaction(
self,
request: PendingSkillsInteraction,
request: PendingSkillInteraction,
channel: MessageChannel,
source: Optional[str],
userid: Union[str, int],
@@ -633,7 +766,7 @@ class SkillsChain(ChainBase):
def _build_root_view(
self,
request: PendingSkillsInteraction,
request: PendingSkillInteraction,
force_market_refresh: bool = False,
) -> Tuple[str, str, Optional[List[List[dict]]]]:
"""
@@ -684,7 +817,7 @@ class SkillsChain(ChainBase):
def _build_installed_view(
self,
request: PendingSkillsInteraction
request: PendingSkillInteraction
) -> Tuple[str, str, Optional[List[List[dict]]]]:
"""
构建已安装技能视图列出来源和可删除状态
@@ -744,7 +877,7 @@ class SkillsChain(ChainBase):
def _build_market_view(
self,
request: PendingSkillsInteraction,
request: PendingSkillInteraction,
force_market_refresh: bool = False,
) -> Tuple[str, str, Optional[List[List[dict]]]]:
"""
@@ -845,7 +978,7 @@ class SkillsChain(ChainBase):
def _build_sources_view(
self,
request: PendingSkillsInteraction,
request: PendingSkillInteraction,
) -> Tuple[str, str, Optional[List[List[dict]]]]:
"""
构建技能源管理视图提供自定义 GitHub 源的增删入口
@@ -954,7 +1087,7 @@ class SkillsChain(ChainBase):
@staticmethod
def _navigation_buttons(
request: PendingSkillsInteraction,
request: PendingSkillInteraction,
page: int,
total_pages: int,
) -> List[List[dict]]:
@@ -984,7 +1117,7 @@ class SkillsChain(ChainBase):
优先编辑原消息编辑失败时再回退为发送新消息
"""
update_or_post_message(
chain=self,
chain=self._messenger,
channel=channel,
source=source,
userid=userid,
@@ -1011,7 +1144,7 @@ class SkillsChain(ChainBase):
def _get_market_skills(
self,
request: PendingSkillsInteraction,
request: PendingSkillInteraction,
force_market_refresh: bool = False,
) -> List[SkillInfo]:
"""
@@ -1057,7 +1190,7 @@ class SkillsChain(ChainBase):
@staticmethod
def _apply_market_search(
request: PendingSkillsInteraction,
request: PendingSkillInteraction,
query: str,
) -> None:
"""
@@ -1069,7 +1202,7 @@ class SkillsChain(ChainBase):
request.awaiting_input = None
@staticmethod
def _clear_market_search(request: PendingSkillsInteraction) -> None:
def _clear_market_search(request: PendingSkillInteraction) -> None:
"""
清除当前市场搜索状态恢复全量市场列表
"""
+725
View File
@@ -0,0 +1,725 @@
import re
from typing import List, Optional, Protocol, Tuple, Union
from app.adapters.external.server import MoviePilotServerHelper
from app.application.messaging.interaction import (
MessageGateway,
SlashInteractionManager,
build_navigation_buttons,
format_markdown_table,
page_items,
supports_interaction_buttons,
supports_markdown,
update_or_post_message,
)
from app.db.models.subscribe import Subscribe
from app.db.oper.subscribe import SubscribeOper
from app.schemas import Notification
from app.schemas.types import MessageChannel, MediaType
subscribe_interaction_manager = SlashInteractionManager()
class SubscribeInteractionActions(Protocol):
"""
声明订阅交互需要调用的业务动作
"""
def refresh(self):
"""执行订阅刷新。"""
...
def check(self):
"""执行订阅元数据检查。"""
...
def search(self, **kwargs):
"""执行订阅搜索。"""
...
class SubscribeInteractionHandler:
"""
管理 /subscribes 交互会话按钮文本输入和列表视图
"""
_button_page_size = 6
_text_page_size = 10
def __init__(
self,
messenger: MessageGateway,
actions: SubscribeInteractionActions,
):
"""
注入消息投递接口和订阅业务动作
"""
self._messenger = messenger
self._actions = actions
def remote_list(
self,
arg_str: str = "",
channel: MessageChannel = None,
userid: Union[str, int] = None,
source: Optional[str] = None,
):
"""
/subscribes 统一入口
"""
request = subscribe_interaction_manager.create_or_replace(
user_id=userid,
command="/subscribes",
channel=channel,
source=source,
username=None,
)
normalized_arg = (arg_str or "").strip()
if normalized_arg and self.handle_text_interaction(
channel=channel,
source=source,
userid=userid,
username="",
text=normalized_arg,
):
return
self._render_subscribe_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username="",
)
@staticmethod
def parse_callback(callback_data: str) -> Optional[Tuple[str, str]]:
"""
解析 /subscribes 按钮回调
"""
if not callback_data.startswith("subscribes:"):
return None
parts = callback_data.split(":")
if len(parts) < 3:
return None
return parts[1], parts[2]
def handle_callback_interaction(
self,
callback_data: str,
channel: MessageChannel,
source: str,
userid: Union[str, int],
username: str,
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
) -> bool:
"""
处理 /subscribes 按钮交互
"""
parsed = self.parse_callback(callback_data)
if not parsed:
return False
request_id, action = parsed
request = subscribe_interaction_manager.get_by_id(request_id, userid)
if not request:
self._messenger.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="订阅交互已失效,请重新发送 /subscribes",
)
)
return True
request.channel = channel
request.source = source
request.username = username
if action == "close":
subscribe_interaction_manager.remove(request.request_id)
update_or_post_message(
chain=self._messenger,
channel=channel,
source=source,
userid=userid,
username=username,
title="订阅管理",
text="订阅交互已结束",
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
return True
if action == "page-prev":
request.page = max(0, request.page - 1)
request.awaiting_input = None
elif action == "page-next":
request.page += 1
request.awaiting_input = None
elif action in {"search", "delete"}:
request.awaiting_input = action
elif action == "refresh":
request.awaiting_input = None
self._run_refresh_action(channel, source, userid, username)
elif action == "refresh-list":
request.awaiting_input = None
elif action == "metadata":
request.awaiting_input = None
self._run_metadata_refresh_action(channel, source, userid, username)
self._render_subscribe_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
return True
def handle_text_interaction(
self,
channel: MessageChannel,
source: str,
userid: Union[str, int],
username: str,
text: str,
) -> bool:
"""
处理 /subscribes 文本补充输入
"""
request = subscribe_interaction_manager.get_by_user(userid)
if not request:
return False
request.channel = channel
request.source = source
request.username = username
normalized = (text or "").strip()
lowered = normalized.lower()
if lowered in {"退出", "关闭", "q", "quit", "exit"}:
subscribe_interaction_manager.remove(request.request_id)
self._messenger.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="订阅交互已结束",
save_history=False,
)
)
return True
if lowered in {"取消", "cancel", "返回", "back"}:
request.awaiting_input = None
self._render_subscribe_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if lowered in {"刷新列表", "列表", "list"}:
request.awaiting_input = None
self._render_subscribe_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if lowered in {"刷新", "refresh"}:
request.awaiting_input = None
self._run_refresh_action(channel, source, userid, username)
self._render_subscribe_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if lowered in {"元数据", "刷新元数据", "metadata"}:
request.awaiting_input = None
self._run_metadata_refresh_action(channel, source, userid, username)
self._render_subscribe_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if lowered in {"p", "prev", "上一页"}:
request.awaiting_input = None
request.page = max(0, request.page - 1)
self._render_subscribe_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if lowered in {"n", "next", "下一页"}:
request.awaiting_input = None
request.page += 1
self._render_subscribe_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
search_match = re.match(r"^(?:搜索|search)\s+(.+)$", normalized, re.IGNORECASE)
delete_match = re.match(r"^(?:删除|delete)\s+(.+)$", normalized, re.IGNORECASE)
if request.awaiting_input == "search":
success, message = self._run_search_action(
normalized, channel, source, userid, username
)
request.awaiting_input = None
self._messenger.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=message,
)
)
self._render_subscribe_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if request.awaiting_input == "delete":
success, message = self._delete_subscribes(normalized)
request.awaiting_input = None
self._messenger.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=message,
)
)
self._render_subscribe_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if search_match:
success, message = self._run_search_action(
search_match.group(1), channel, source, userid, username
)
self._messenger.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=message,
)
)
self._render_subscribe_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if delete_match:
success, message = self._delete_subscribes(delete_match.group(1))
self._messenger.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=message,
)
)
self._render_subscribe_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
self._messenger.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=self._subscribe_usage_hint(request.awaiting_input),
)
)
return True
def _render_subscribe_interaction(
self,
request,
channel: MessageChannel,
source: Optional[str],
userid: Union[str, int],
username: Optional[str],
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
) -> None:
"""
渲染 /subscribes 当前页面
"""
subscribes = SubscribeOper().list()
page_size = (
self._button_page_size
if supports_interaction_buttons(channel)
else self._text_page_size
)
page_subscribes, page, total_pages = page_items(
subscribes, request.page, page_size
)
request.page = page
if subscribes:
body = self._format_subscribe_list(page_subscribes, channel=channel)
footer = [
f"{page + 1}/{total_pages} 页,共 {len(subscribes)} 个订阅",
self._subscribe_prompt(request.awaiting_input),
self._subscribe_usage_hint(request.awaiting_input),
]
text = "\n\n".join([body, *[line for line in footer if line]])
else:
text = "当前没有任何订阅。\n\n输入 `退出` 结束交互。"
buttons = None
if supports_interaction_buttons(channel):
buttons = build_navigation_buttons(
"subscribes", request, page, total_pages
)
buttons.extend(
[
[
{
"text": "搜索订阅",
"callback_data": f"subscribes:{request.request_id}:search",
},
{
"text": "删除订阅",
"callback_data": f"subscribes:{request.request_id}:delete",
},
{
"text": "刷新订阅",
"callback_data": f"subscribes:{request.request_id}:refresh",
},
],
[
{
"text": "刷新元数据",
"callback_data": f"subscribes:{request.request_id}:metadata",
},
{
"text": "刷新列表",
"callback_data": f"subscribes:{request.request_id}:refresh-list",
},
{
"text": "关闭",
"callback_data": f"subscribes:{request.request_id}:close",
},
],
]
)
update_or_post_message(
chain=self._messenger,
channel=channel,
source=source,
userid=userid,
username=username,
title="订阅管理",
text=text,
buttons=buttons,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
def _format_subscribe_list(
self, subscribes: List[Subscribe], channel: Optional[MessageChannel]
) -> str:
"""
根据渠道能力格式化订阅列表
"""
if supports_markdown(channel):
rows = [
[
subscribe.id,
subscribe.name,
subscribe.type,
subscribe.year or "-",
self._format_subscribe_progress(subscribe),
self._format_subscribe_state(subscribe.state),
]
for subscribe in subscribes
]
return format_markdown_table(
headers=["ID", "名称", "类型", "年份", "季/进度", "状态"],
rows=rows,
)
lines = []
for subscribe in subscribes:
lines.append(
f"{subscribe.id}. {subscribe.name}{subscribe.year or '-'}"
f" | {subscribe.type}"
f" | {self._format_subscribe_progress(subscribe)}"
f" | 状态:{self._format_subscribe_state(subscribe.state)}"
)
return "\n".join(lines)
@staticmethod
def _format_subscribe_state(state: Optional[str]) -> str:
"""
订阅状态显示文本
"""
mapping = {
"N": "新建",
"R": "订阅中",
"P": "待定",
"S": "暂停",
}
return mapping.get(state or "", state or "-")
@staticmethod
def _format_subscribe_progress(subscribe: Subscribe) -> str:
"""
构造订阅的季和进度说明
"""
if subscribe.type == MediaType.MOVIE.value:
return "电影"
season = subscribe.season if subscribe.season is not None else 1
if subscribe.total_episode:
lack_episode = (
subscribe.lack_episode
if subscribe.lack_episode is not None
else subscribe.total_episode
)
downloaded = max(subscribe.total_episode - lack_episode, 0)
return f"{season}季 [{downloaded}/{subscribe.total_episode}]"
return f"{season}"
@staticmethod
def _subscribe_prompt(awaiting_input: Optional[str]) -> str:
"""
返回当前输入模式提示
"""
if awaiting_input == "search":
return "当前操作:搜索订阅,请输入订阅 ID,多个 ID 用空格分隔,或输入 all 搜索全部。"
if awaiting_input == "delete":
return "当前操作:删除订阅,请输入订阅 ID,多个 ID 用空格分隔。"
return ""
@staticmethod
def _subscribe_usage_hint(awaiting_input: Optional[str]) -> str:
"""
返回 /subscribes 的文本操作提示
"""
if awaiting_input == "search":
return "输入订阅 ID 或 all;输入 `取消` 返回列表,输入 `退出` 结束交互。"
if awaiting_input == "delete":
return "输入一个或多个订阅 ID;输入 `取消` 返回列表,输入 `退出` 结束交互。"
return (
"可输入:`搜索 <id...|all>`、`删除 <id...>`、`刷新`、`刷新元数据`、`n`、`p`、`退出`。"
)
def _run_refresh_action(
self,
channel: MessageChannel,
source: str,
userid: Union[str, int],
username: str,
) -> None:
"""
执行订阅刷新
"""
self._messenger.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="开始刷新订阅...",
)
)
self._actions.refresh()
self._messenger.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="订阅刷新执行完成",
)
)
def _run_metadata_refresh_action(
self,
channel: MessageChannel,
source: str,
userid: Union[str, int],
username: str,
) -> None:
"""
执行订阅元数据刷新
"""
self._messenger.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="开始刷新订阅元数据...",
)
)
self._actions.check()
self._messenger.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="订阅元数据刷新完成",
)
)
@staticmethod
def _parse_subscribe_ids(arg_str: str) -> List[int]:
"""
从输入中提取订阅 ID
"""
return [int(item) for item in re.findall(r"\d+", arg_str or "")]
def _run_search_action(
self,
arg_str: str,
channel: MessageChannel,
source: str,
userid: Union[str, int],
username: str,
) -> Tuple[bool, str]:
"""
手动执行订阅搜索
"""
normalized = (arg_str or "").strip()
if not normalized or normalized.lower() in {"all", "全部", "所有"}:
self._messenger.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="开始搜索所有订阅...",
)
)
self._actions.search(state="N,R,P", manual=True)
return True, "所有订阅搜索完成"
subscribe_ids = self._parse_subscribe_ids(normalized)
if not subscribe_ids:
return False, "请输入订阅 ID,多个 ID 用空格分隔,或输入 all"
subscribeoper = SubscribeOper()
missing = []
searched = []
for subscribe_id in subscribe_ids:
subscribe = subscribeoper.get(subscribe_id)
if not subscribe:
missing.append(str(subscribe_id))
continue
self._messenger.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=f"开始搜索订阅【{subscribe.name}】...",
)
)
self._actions.search(sid=subscribe_id, manual=True)
searched.append(subscribe.name)
if not searched and missing:
return False, f"未找到订阅:{', '.join(missing)}"
message = f"已完成 {len(searched)} 个订阅搜索"
if searched:
message += f"{', '.join(searched)}"
if missing:
message += f";未找到:{', '.join(missing)}"
return True, message
def _delete_subscribes(self, arg_str: str) -> Tuple[bool, str]:
"""
批量删除订阅
"""
subscribe_ids = self._parse_subscribe_ids(arg_str)
if not subscribe_ids:
return False, "请输入至少一个有效的订阅 ID"
subscribeoper = SubscribeOper()
deleted = []
missing = []
for subscribe_id in subscribe_ids:
subscribe = subscribeoper.get(subscribe_id)
if not subscribe:
missing.append(str(subscribe_id))
continue
deleted.append(subscribe.name)
subscribeoper.delete(subscribe_id)
MoviePilotServerHelper.sub_done_async(
{
"media_source": subscribe.media_source,
"media_id": subscribe.media_id,
"season": subscribe.season,
}
)
if not deleted and missing:
return False, f"未找到订阅:{', '.join(missing)}"
message = f"已删除 {len(deleted)} 个订阅"
if deleted:
message += f"{', '.join(deleted)}"
if missing:
message += f";未找到:{', '.join(missing)}"
return True, message
File diff suppressed because it is too large Load Diff
+223 -2099
View File
File diff suppressed because it is too large Load Diff
+16 -505
View File
@@ -17,14 +17,9 @@ from app.adapters.network.browser import PlaywrightHelper
from app.adapters.network.cloudflare import under_challenge
from app.application.security.cookie import CookieHelper
from app.adapters.external.cookiecloud import CookieCloudHelper
from app.application.messaging.interaction import (
SlashInteractionManager,
build_navigation_buttons,
format_markdown_table,
page_items,
supports_interaction_buttons,
supports_markdown,
update_or_post_message,
from app.application.messaging.site import (
SiteInteractionHandler,
site_interaction_manager,
)
from app.application.rss import RssHelper
from app.runtime.log import logger
@@ -37,7 +32,6 @@ from app.foundation import size as size_tools
from app.foundation import url as url_tools
from app.foundation.dom import DomUtils
site_interaction_manager = SlashInteractionManager()
class SiteChain(ChainBase):
@@ -45,8 +39,6 @@ class SiteChain(ChainBase):
站点管理处理链
"""
_button_page_size = 6
_text_page_size = 10
def __init__(self):
"""初始化站点管理处理链及特殊站点测试器"""
@@ -756,6 +748,10 @@ class SiteChain(ChainBase):
return False, f"无法打开网站!"
return True, "连接成功"
def _interaction_handler(self) -> "SiteInteractionHandler":
"""构造 /sites 交互处理器,Cookie 更新动作由本链提供。"""
return SiteInteractionHandler(messenger=self, cookie_updater=self.update_cookie)
def remote_list(
self,
arg_str: str = "",
@@ -764,30 +760,10 @@ class SiteChain(ChainBase):
source: Optional[str] = None,
):
"""
/sites 统一入口
/sites 统一入口委托交互处理器
"""
request = site_interaction_manager.create_or_replace(
user_id=userid,
command="/sites",
channel=channel,
source=source,
username=None,
)
normalized_arg = (arg_str or "").strip()
if normalized_arg and self.handle_text_interaction(
channel=channel,
source=source,
userid=userid,
username="",
text=normalized_arg,
):
return
self._render_site_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username="",
return self._interaction_handler().remote_list(
arg_str=arg_str, channel=channel, userid=userid, source=source
)
@staticmethod
@@ -795,12 +771,7 @@ class SiteChain(ChainBase):
"""
解析 /sites 按钮回调
"""
if not callback_data.startswith("sites:"):
return None
parts = callback_data.split(":")
if len(parts) < 3:
return None
return parts[1], parts[2]
return SiteInteractionHandler.parse_callback(callback_data)
def handle_callback_interaction(
self,
@@ -812,59 +783,9 @@ class SiteChain(ChainBase):
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
) -> bool:
"""
处理 /sites 按钮交互
"""
parsed = self.parse_callback(callback_data)
if not parsed:
return False
request_id, action = parsed
request = site_interaction_manager.get_by_id(request_id, userid)
if not request:
self.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="站点交互已失效,请重新发送 /sites",
)
)
return True
request.channel = channel
request.source = source
request.username = username
if action == "close":
site_interaction_manager.remove(request.request_id)
update_or_post_message(
chain=self,
channel=channel,
source=source,
userid=userid,
username=username,
title="站点管理",
text="站点交互已结束",
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
return True
if action == "page-prev":
request.page = max(0, request.page - 1)
request.awaiting_input = None
elif action == "page-next":
request.page += 1
request.awaiting_input = None
elif action in {"cookie", "enable", "disable"}:
request.awaiting_input = action
elif action == "refresh":
request.awaiting_input = None
self._render_site_interaction(
request=request,
"""委托交互处理器处理按钮回调。"""
return self._interaction_handler().handle_callback_interaction(
callback_data=callback_data,
channel=channel,
source=source,
userid=userid,
@@ -872,7 +793,6 @@ class SiteChain(ChainBase):
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
return True
def handle_text_interaction(
self,
@@ -882,424 +802,15 @@ class SiteChain(ChainBase):
username: str,
text: str,
) -> bool:
"""
处理 /sites 文本补充输入
"""
request = site_interaction_manager.get_by_user(userid)
if not request:
return False
request.channel = channel
request.source = source
request.username = username
normalized = (text or "").strip()
lowered = normalized.lower()
if lowered in {"退出", "关闭", "q", "quit", "exit"}:
site_interaction_manager.remove(request.request_id)
self.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="站点交互已结束",
save_history=False,
)
)
return True
if lowered in {"取消", "cancel", "返回", "back"}:
request.awaiting_input = None
self._render_site_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if lowered in {"刷新", "refresh", "列表", "list"}:
request.awaiting_input = None
self._render_site_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if lowered in {"p", "prev", "上一页"}:
request.awaiting_input = None
request.page = max(0, request.page - 1)
self._render_site_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if lowered in {"n", "next", "下一页"}:
request.awaiting_input = None
request.page += 1
self._render_site_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
cookie_match = re.match(
r"^(?:cookie|更新cookie|更新\s*cookie)\s+(.+)$",
normalized,
re.IGNORECASE,
)
enable_match = re.match(r"^(?:启用|enable)\s+(.+)$", normalized, re.IGNORECASE)
disable_match = re.match(
r"^(?:禁用|disable)\s+(.+)$", normalized, re.IGNORECASE
)
if request.awaiting_input == "cookie":
success, message = self._update_site_cookie_from_input(normalized)
request.awaiting_input = None
self.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=message,
)
)
self._render_site_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if request.awaiting_input == "enable":
success, message = self._set_sites_enabled(normalized, enabled=True)
request.awaiting_input = None
self.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=message,
)
)
self._render_site_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if request.awaiting_input == "disable":
success, message = self._set_sites_enabled(normalized, enabled=False)
request.awaiting_input = None
self.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=message,
)
)
self._render_site_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if cookie_match:
success, message = self._update_site_cookie_from_input(cookie_match.group(1))
self.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=message,
)
)
self._render_site_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if enable_match:
success, message = self._set_sites_enabled(enable_match.group(1), enabled=True)
self.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=message,
)
)
self._render_site_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if disable_match:
success, message = self._set_sites_enabled(
disable_match.group(1), enabled=False
)
self.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=message,
)
)
self._render_site_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
self.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=self._site_usage_hint(request.awaiting_input),
)
)
return True
def _render_site_interaction(
self,
request,
channel: MessageChannel,
source: Optional[str],
userid: Union[str, int],
username: Optional[str],
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
) -> None:
"""
渲染 /sites 当前页面
"""
site_list = SiteOper().list()
page_size = self._button_page_size if supports_interaction_buttons(channel) else self._text_page_size
page_sites, page, total_pages = page_items(site_list, request.page, page_size)
request.page = page
if site_list:
body = self._format_site_list(page_sites, channel=channel)
footer = [
f"{page + 1}/{total_pages} 页,共 {len(site_list)} 个站点",
self._site_prompt(request.awaiting_input),
self._site_usage_hint(request.awaiting_input),
]
text = "\n\n".join([body, *[line for line in footer if line]])
else:
text = "当前没有任何站点。\n\n输入 `退出` 结束交互。"
buttons = None
if supports_interaction_buttons(channel):
buttons = build_navigation_buttons("sites", request, page, total_pages)
buttons.extend(
[
[
{
"text": "更新 Cookie",
"callback_data": f"sites:{request.request_id}:cookie",
},
{
"text": "禁用站点",
"callback_data": f"sites:{request.request_id}:disable",
},
{
"text": "启用站点",
"callback_data": f"sites:{request.request_id}:enable",
},
],
[
{
"text": "刷新列表",
"callback_data": f"sites:{request.request_id}:refresh",
},
{
"text": "关闭",
"callback_data": f"sites:{request.request_id}:close",
},
],
]
)
update_or_post_message(
chain=self,
"""委托交互处理器处理文本输入。"""
return self._interaction_handler().handle_text_interaction(
channel=channel,
source=source,
userid=userid,
username=username,
title="站点管理",
text=text,
buttons=buttons,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
@staticmethod
def _format_site_list(
site_list: List[Site], channel: Optional[MessageChannel]
) -> str:
"""
根据渠道能力格式化站点列表
"""
if supports_markdown(channel):
rows = [
[
site.id,
site.name,
"启用" if site.is_active else "禁用",
"已配置" if site.cookie else "未配置",
"" if site.render else "",
site.domain or site_rules.extract_domain(site.url or ""),
]
for site in site_list
]
return format_markdown_table(
headers=["ID", "站点", "状态", "Cookie", "渲染", "域名"],
rows=rows,
)
lines = []
for site in site_list:
lines.append(
f"{site.id}. {site.name} | 状态:{'启用' if site.is_active else '禁用'}"
f" | Cookie{'已配置' if site.cookie else '未配置'}"
f" | 渲染:{'' if site.render else ''}"
f" | 域名:{site.domain or site_rules.extract_domain(site.url or '')}"
)
return "\n".join(lines)
@staticmethod
def _site_prompt(awaiting_input: Optional[str]) -> str:
"""
返回当前输入模式提示
"""
if awaiting_input == "cookie":
return "当前操作:更新站点 Cookie,请输入:<id> <username> <password> [2fa_code/secret]"
if awaiting_input == "enable":
return "当前操作:启用站点,请输入站点 ID,多个 ID 用空格分隔。"
if awaiting_input == "disable":
return "当前操作:禁用站点,请输入站点 ID,多个 ID 用空格分隔。"
return ""
@staticmethod
def _site_usage_hint(awaiting_input: Optional[str]) -> str:
"""
返回 /sites 的文本操作提示
"""
if awaiting_input == "cookie":
return "输入站点 ID、用户名、密码和可选 2FA;输入 `取消` 返回列表,输入 `退出` 结束交互。"
if awaiting_input in {"enable", "disable"}:
return "输入一个或多个站点 ID;输入 `取消` 返回列表,输入 `退出` 结束交互。"
return (
"可输入:`cookie <id> <username> <password> [2fa]`、`启用 <id...>`、`禁用 <id...>`、"
"`n`、`p`、`刷新`、`退出`。"
)
@staticmethod
def _parse_site_ids(arg_str: str) -> List[int]:
"""
从输入中提取站点 ID
"""
return [int(item) for item in re.findall(r"\d+", arg_str or "")]
def _set_sites_enabled(self, arg_str: str, enabled: bool) -> Tuple[bool, str]:
"""
批量启用或禁用站点
"""
site_ids = self._parse_site_ids(arg_str)
if not site_ids:
return False, "请输入至少一个有效的站点 ID"
siteoper = SiteOper()
changed = []
missing = []
for site_id in site_ids:
site = siteoper.get(site_id)
if not site:
missing.append(str(site_id))
continue
siteoper.update(site_id, {"is_active": enabled})
changed.append(site.name)
action = "启用" if enabled else "禁用"
if not changed and missing:
return False, f"未找到站点:{', '.join(missing)}"
message = f"{action} {len(changed)} 个站点"
if changed:
message += f"{', '.join(changed)}"
if missing:
message += f";未找到:{', '.join(missing)}"
return True, message
def _update_site_cookie_from_input(self, arg_str: str) -> Tuple[bool, str]:
"""
根据输入更新单个站点 Cookie
"""
args = str(arg_str or "").split()
if len(args) not in {3, 4} or not args[0].isdigit():
return (
False,
"格式错误,请输入:cookie <id> <username> <password> [2fa_code/secret]",
)
site_id = int(args[0])
site_info = SiteOper().get(site_id)
if not site_info:
return False, f"站点编号 {site_id} 不存在"
status, msg = self.update_cookie(
site_info=site_info,
username=args[1],
password=args[2],
two_step_code=args[3] if len(args) == 4 else None,
)
if not status:
logger.error(msg)
return False, f"{site_info.name}】Cookie&UA 更新失败:{msg}"
return True, f"{site_info.name}】Cookie&UA 更新成功"
def remote_disable(self, arg_str: str, channel: MessageChannel,
userid: Union[str, int] = None, source: Optional[str] = None):
+16 -626
View File
@@ -32,14 +32,9 @@ from app.db.models.subscribe import Subscribe
from app.db.oper.site import SiteOper
from app.db.oper.subscribe import SubscribeOper
from app.db.oper.systemconfig import SystemConfigOper
from app.application.messaging.interaction import (
SlashInteractionManager,
build_navigation_buttons,
format_markdown_table,
page_items,
supports_interaction_buttons,
supports_markdown,
update_or_post_message,
from app.application.messaging.subscribe import (
SubscribeInteractionHandler,
subscribe_interaction_manager,
)
from app.application.mediaserver import MediaServerHelper
from app.application.subscribe import add_subscribe, async_add_subscribe
@@ -53,7 +48,6 @@ from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaS
from app.domain.media import MUSIC_SUBSCRIBABLE_TYPES
from app.schemas.media import build_media_key, normalize_media_source, resolve_media_identity
subscribe_interaction_manager = SlashInteractionManager()
def _normalize_music_total_tracks(value: Any) -> Optional[int]:
@@ -142,8 +136,6 @@ class SubscribeChain(ChainBase):
_rlock = threading.RLock()
# 避免莫名原因导致长时间持有锁
_LOCK_TIMOUT = 3600 * 2
_button_page_size = 6
_text_page_size = 10
@staticmethod
def __normalize_episode_priority(episode_priority: Optional[dict]) -> Dict[str, int]:
@@ -3251,6 +3243,10 @@ class SubscribeChain(ChainBase):
"season": subscribe.season,
})
def _interaction_handler(self) -> "SubscribeInteractionHandler":
"""构造 /subscribes 交互处理器,业务动作由本链提供。"""
return SubscribeInteractionHandler(messenger=self, actions=self)
def remote_list(
self,
arg_str: str = "",
@@ -3259,30 +3255,10 @@ class SubscribeChain(ChainBase):
source: Optional[str] = None,
):
"""
/subscribes 统一入口
/subscribes 统一入口委托交互处理器
"""
request = subscribe_interaction_manager.create_or_replace(
user_id=userid,
command="/subscribes",
channel=channel,
source=source,
username=None,
)
normalized_arg = (arg_str or "").strip()
if normalized_arg and self.handle_text_interaction(
channel=channel,
source=source,
userid=userid,
username="",
text=normalized_arg,
):
return
self._render_subscribe_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username="",
return self._interaction_handler().remote_list(
arg_str=arg_str, channel=channel, userid=userid, source=source
)
@staticmethod
@@ -3290,12 +3266,7 @@ class SubscribeChain(ChainBase):
"""
解析 /subscribes 按钮回调
"""
if not callback_data.startswith("subscribes:"):
return None
parts = callback_data.split(":")
if len(parts) < 3:
return None
return parts[1], parts[2]
return SubscribeInteractionHandler.parse_callback(callback_data)
def handle_callback_interaction(
self,
@@ -3307,65 +3278,9 @@ class SubscribeChain(ChainBase):
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
) -> bool:
"""
处理 /subscribes 按钮交互
"""
parsed = self.parse_callback(callback_data)
if not parsed:
return False
request_id, action = parsed
request = subscribe_interaction_manager.get_by_id(request_id, userid)
if not request:
self.post_message(
schemas.Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="订阅交互已失效,请重新发送 /subscribes",
)
)
return True
request.channel = channel
request.source = source
request.username = username
if action == "close":
subscribe_interaction_manager.remove(request.request_id)
update_or_post_message(
chain=self,
channel=channel,
source=source,
userid=userid,
username=username,
title="订阅管理",
text="订阅交互已结束",
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
return True
if action == "page-prev":
request.page = max(0, request.page - 1)
request.awaiting_input = None
elif action == "page-next":
request.page += 1
request.awaiting_input = None
elif action in {"search", "delete"}:
request.awaiting_input = action
elif action == "refresh":
request.awaiting_input = None
self._run_refresh_action(channel, source, userid, username)
elif action == "refresh-list":
request.awaiting_input = None
elif action == "metadata":
request.awaiting_input = None
self._run_metadata_refresh_action(channel, source, userid, username)
self._render_subscribe_interaction(
request=request,
"""委托交互处理器处理按钮回调。"""
return self._interaction_handler().handle_callback_interaction(
callback_data=callback_data,
channel=channel,
source=source,
userid=userid,
@@ -3373,7 +3288,6 @@ class SubscribeChain(ChainBase):
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
return True
def handle_text_interaction(
self,
@@ -3383,539 +3297,15 @@ class SubscribeChain(ChainBase):
username: str,
text: str,
) -> bool:
"""
处理 /subscribes 文本补充输入
"""
request = subscribe_interaction_manager.get_by_user(userid)
if not request:
return False
request.channel = channel
request.source = source
request.username = username
normalized = (text or "").strip()
lowered = normalized.lower()
if lowered in {"退出", "关闭", "q", "quit", "exit"}:
subscribe_interaction_manager.remove(request.request_id)
self.post_message(
schemas.Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="订阅交互已结束",
save_history=False,
)
)
return True
if lowered in {"取消", "cancel", "返回", "back"}:
request.awaiting_input = None
self._render_subscribe_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if lowered in {"刷新列表", "列表", "list"}:
request.awaiting_input = None
self._render_subscribe_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if lowered in {"刷新", "refresh"}:
request.awaiting_input = None
self._run_refresh_action(channel, source, userid, username)
self._render_subscribe_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if lowered in {"元数据", "刷新元数据", "metadata"}:
request.awaiting_input = None
self._run_metadata_refresh_action(channel, source, userid, username)
self._render_subscribe_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if lowered in {"p", "prev", "上一页"}:
request.awaiting_input = None
request.page = max(0, request.page - 1)
self._render_subscribe_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if lowered in {"n", "next", "下一页"}:
request.awaiting_input = None
request.page += 1
self._render_subscribe_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
search_match = re.match(r"^(?:搜索|search)\s+(.+)$", normalized, re.IGNORECASE)
delete_match = re.match(r"^(?:删除|delete)\s+(.+)$", normalized, re.IGNORECASE)
if request.awaiting_input == "search":
success, message = self._run_search_action(
normalized, channel, source, userid, username
)
request.awaiting_input = None
self.post_message(
schemas.Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=message,
)
)
self._render_subscribe_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if request.awaiting_input == "delete":
success, message = self._delete_subscribes(normalized)
request.awaiting_input = None
self.post_message(
schemas.Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=message,
)
)
self._render_subscribe_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if search_match:
success, message = self._run_search_action(
search_match.group(1), channel, source, userid, username
)
self.post_message(
schemas.Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=message,
)
)
self._render_subscribe_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if delete_match:
success, message = self._delete_subscribes(delete_match.group(1))
self.post_message(
schemas.Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=message,
)
)
self._render_subscribe_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
self.post_message(
schemas.Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=self._subscribe_usage_hint(request.awaiting_input),
)
)
return True
def _render_subscribe_interaction(
self,
request,
channel: MessageChannel,
source: Optional[str],
userid: Union[str, int],
username: Optional[str],
original_message_id: Optional[Union[str, int]] = None,
original_chat_id: Optional[str] = None,
) -> None:
"""
渲染 /subscribes 当前页面
"""
subscribes = SubscribeOper().list()
page_size = (
self._button_page_size
if supports_interaction_buttons(channel)
else self._text_page_size
)
page_subscribes, page, total_pages = page_items(
subscribes, request.page, page_size
)
request.page = page
if subscribes:
body = self._format_subscribe_list(page_subscribes, channel=channel)
footer = [
f"{page + 1}/{total_pages} 页,共 {len(subscribes)} 个订阅",
self._subscribe_prompt(request.awaiting_input),
self._subscribe_usage_hint(request.awaiting_input),
]
text = "\n\n".join([body, *[line for line in footer if line]])
else:
text = "当前没有任何订阅。\n\n输入 `退出` 结束交互。"
buttons = None
if supports_interaction_buttons(channel):
buttons = build_navigation_buttons(
"subscribes", request, page, total_pages
)
buttons.extend(
[
[
{
"text": "搜索订阅",
"callback_data": f"subscribes:{request.request_id}:search",
},
{
"text": "删除订阅",
"callback_data": f"subscribes:{request.request_id}:delete",
},
{
"text": "刷新订阅",
"callback_data": f"subscribes:{request.request_id}:refresh",
},
],
[
{
"text": "刷新元数据",
"callback_data": f"subscribes:{request.request_id}:metadata",
},
{
"text": "刷新列表",
"callback_data": f"subscribes:{request.request_id}:refresh-list",
},
{
"text": "关闭",
"callback_data": f"subscribes:{request.request_id}:close",
},
],
]
)
update_or_post_message(
chain=self,
"""委托交互处理器处理文本输入。"""
return self._interaction_handler().handle_text_interaction(
channel=channel,
source=source,
userid=userid,
username=username,
title="订阅管理",
text=text,
buttons=buttons,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
)
def _format_subscribe_list(
self, subscribes: List[Subscribe], channel: Optional[MessageChannel]
) -> str:
"""
根据渠道能力格式化订阅列表
"""
if supports_markdown(channel):
rows = [
[
subscribe.id,
subscribe.name,
subscribe.type,
subscribe.year or "-",
self._format_subscribe_progress(subscribe),
self._format_subscribe_state(subscribe.state),
]
for subscribe in subscribes
]
return format_markdown_table(
headers=["ID", "名称", "类型", "年份", "季/进度", "状态"],
rows=rows,
)
lines = []
for subscribe in subscribes:
lines.append(
f"{subscribe.id}. {subscribe.name}{subscribe.year or '-'}"
f" | {subscribe.type}"
f" | {self._format_subscribe_progress(subscribe)}"
f" | 状态:{self._format_subscribe_state(subscribe.state)}"
)
return "\n".join(lines)
@staticmethod
def _format_subscribe_state(state: Optional[str]) -> str:
"""
订阅状态显示文本
"""
mapping = {
"N": "新建",
"R": "订阅中",
"P": "待定",
"S": "暂停",
}
return mapping.get(state or "", state or "-")
@staticmethod
def _format_subscribe_progress(subscribe: Subscribe) -> str:
"""
构造订阅的季和进度说明
"""
if subscribe.type == MediaType.MOVIE.value:
return "电影"
season = subscribe.season if subscribe.season is not None else 1
if subscribe.total_episode:
lack_episode = (
subscribe.lack_episode
if subscribe.lack_episode is not None
else subscribe.total_episode
)
downloaded = max(subscribe.total_episode - lack_episode, 0)
return f"{season}季 [{downloaded}/{subscribe.total_episode}]"
return f"{season}"
@staticmethod
def _subscribe_prompt(awaiting_input: Optional[str]) -> str:
"""
返回当前输入模式提示
"""
if awaiting_input == "search":
return "当前操作:搜索订阅,请输入订阅 ID,多个 ID 用空格分隔,或输入 all 搜索全部。"
if awaiting_input == "delete":
return "当前操作:删除订阅,请输入订阅 ID,多个 ID 用空格分隔。"
return ""
@staticmethod
def _subscribe_usage_hint(awaiting_input: Optional[str]) -> str:
"""
返回 /subscribes 的文本操作提示
"""
if awaiting_input == "search":
return "输入订阅 ID 或 all;输入 `取消` 返回列表,输入 `退出` 结束交互。"
if awaiting_input == "delete":
return "输入一个或多个订阅 ID;输入 `取消` 返回列表,输入 `退出` 结束交互。"
return (
"可输入:`搜索 <id...|all>`、`删除 <id...>`、`刷新`、`刷新元数据`、`n`、`p`、`退出`。"
)
def _run_refresh_action(
self,
channel: MessageChannel,
source: str,
userid: Union[str, int],
username: str,
) -> None:
"""
执行订阅刷新
"""
self.post_message(
schemas.Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="开始刷新订阅...",
)
)
self.refresh()
self.post_message(
schemas.Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="订阅刷新执行完成",
)
)
def _run_metadata_refresh_action(
self,
channel: MessageChannel,
source: str,
userid: Union[str, int],
username: str,
) -> None:
"""
执行订阅元数据刷新
"""
self.post_message(
schemas.Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="开始刷新订阅元数据...",
)
)
self.check()
self.post_message(
schemas.Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="订阅元数据刷新完成",
)
)
@staticmethod
def _parse_subscribe_ids(arg_str: str) -> List[int]:
"""
从输入中提取订阅 ID
"""
return [int(item) for item in re.findall(r"\d+", arg_str or "")]
def _run_search_action(
self,
arg_str: str,
channel: MessageChannel,
source: str,
userid: Union[str, int],
username: str,
) -> Tuple[bool, str]:
"""
手动执行订阅搜索
"""
normalized = (arg_str or "").strip()
if not normalized or normalized.lower() in {"all", "全部", "所有"}:
self.post_message(
schemas.Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="开始搜索所有订阅...",
)
)
self.search(state="N,R,P", manual=True)
return True, "所有订阅搜索完成"
subscribe_ids = self._parse_subscribe_ids(normalized)
if not subscribe_ids:
return False, "请输入订阅 ID,多个 ID 用空格分隔,或输入 all"
subscribeoper = SubscribeOper()
missing = []
searched = []
for subscribe_id in subscribe_ids:
subscribe = subscribeoper.get(subscribe_id)
if not subscribe:
missing.append(str(subscribe_id))
continue
self.post_message(
schemas.Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=f"开始搜索订阅【{subscribe.name}】...",
)
)
self.search(sid=subscribe_id, manual=True)
searched.append(subscribe.name)
if not searched and missing:
return False, f"未找到订阅:{', '.join(missing)}"
message = f"已完成 {len(searched)} 个订阅搜索"
if searched:
message += f"{', '.join(searched)}"
if missing:
message += f";未找到:{', '.join(missing)}"
return True, message
def _delete_subscribes(self, arg_str: str) -> Tuple[bool, str]:
"""
批量删除订阅
"""
subscribe_ids = self._parse_subscribe_ids(arg_str)
if not subscribe_ids:
return False, "请输入至少一个有效的订阅 ID"
subscribeoper = SubscribeOper()
deleted = []
missing = []
for subscribe_id in subscribe_ids:
subscribe = subscribeoper.get(subscribe_id)
if not subscribe:
missing.append(str(subscribe_id))
continue
deleted.append(subscribe.name)
subscribeoper.delete(subscribe_id)
MoviePilotServerHelper.sub_done_async(
{
"media_source": subscribe.media_source,
"media_id": subscribe.media_id,
"season": subscribe.season,
}
)
if not deleted and missing:
return False, f"未找到订阅:{', '.join(missing)}"
message = f"已删除 {len(deleted)} 个订阅"
if deleted:
message += f"{', '.join(deleted)}"
if missing:
message += f";未找到:{', '.join(missing)}"
return True, message
def remote_delete(self, arg_str: str, channel: MessageChannel,
userid: Union[str, int] = None, source: Optional[str] = None):
+204
View File
@@ -11,6 +11,7 @@ from typing import List, Optional, Tuple, Union, Dict, Callable, Any
from app import schemas
from app.agent.orchestrator import ReplyMode, agent_manager, prompt_manager
from app.agent.prompt.transfer_redo import build_manual_redo_prompt
from app.chain import ChainBase
from app.chain.media import MediaChain
from app.chain.storage import StorageChain
@@ -4480,6 +4481,209 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"""
return self.__re_transfer(logid=history_id)
@staticmethod
def parse_failed_transfer_callback(
callback_data: str,
) -> Optional[tuple[str, int]]:
"""
解析整理失败通知按钮回调
"""
for prefix, action in (
("transfer_retry_", "retry"),
("transfer_ai_retry_", "ai_retry"),
):
if callback_data.startswith(prefix):
history_id = callback_data.replace(prefix, "", 1)
if history_id.isdigit():
return action, int(history_id)
return None
def handle_failed_transfer_callback(
self,
*,
callback_data: str,
channel: MessageChannel,
source: str,
userid: Union[str, int],
username: str,
) -> bool:
"""
处理整理失败通知中的重试类按钮
"""
callback = self.parse_failed_transfer_callback(callback_data)
if not callback:
return False
action, history_id = callback
if action == "retry":
self._retry_transfer_history(
history_id=history_id,
channel=channel,
source=source,
userid=userid,
username=username,
)
else:
self._take_over_transfer_history_by_ai(
history_id=history_id,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
def _retry_transfer_history(
self,
history_id: int,
channel: MessageChannel,
source: str,
userid: Union[str, int],
username: str,
) -> None:
"""
立即重新整理一条失败的整理记录
"""
self.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=f"开始重新整理记录 #{history_id} ...",
save_history=False,
)
)
state, errmsg = self.redo_transfer_history(history_id)
if state:
self.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=f"整理记录 #{history_id} 已重新整理",
link=settings.MP_DOMAIN("#/history"),
save_history=False,
)
)
return
self.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="重新整理失败",
text=errmsg,
link=settings.MP_DOMAIN("#/history"),
save_history=False,
)
)
def _take_over_transfer_history_by_ai(
self,
history_id: int,
channel: MessageChannel,
source: str,
userid: Union[str, int],
username: str,
) -> None:
"""
由智能助手接管一条失败的整理记录
"""
if not settings.AI_AGENT_ENABLE:
self.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="MoviePilot智能助手未启用,请在系统设置中启用",
save_history=False,
)
)
return
history = TransferHistoryOper().get(history_id)
if not history:
self.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="重新整理失败",
text=f"整理记录 #{history_id} 不存在",
link=settings.MP_DOMAIN("#/history"),
save_history=False,
)
)
return
redo_prompt = build_manual_redo_prompt(history)
self.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=f"已将整理记录 #{history_id} 交给智能助手处理",
text="处理完成后会在这里回复结果。",
link=settings.MP_DOMAIN("#/history"),
save_history=False,
)
)
async def _run_ai_takeover():
final_output = ""
def _capture_output(text_output: str):
nonlocal final_output
final_output = text_output or ""
try:
await agent_manager.run_background_prompt(
message=redo_prompt,
session_prefix=f"__agent_manual_redo_{history_id}",
output_callback=_capture_output,
reply_mode=ReplyMode.CAPTURE_ONLY,
allow_message_tools=False,
)
await self.async_post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="智能助手整理完成",
text=final_output.strip()
or f"整理记录 #{history_id} 已由智能助手处理完成。",
link=settings.MP_DOMAIN("#/history"),
save_history=False,
)
)
except Exception as e:
await self.async_post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="智能助手整理失败",
text=str(e),
link=settings.MP_DOMAIN("#/history"),
save_history=False,
)
)
asyncio.run_coroutine_threadsafe(_run_ai_takeover(), global_vars.loop)
def __re_transfer(
self,
logid: int,
+3 -3
View File
@@ -7,13 +7,13 @@ from app.chain import ChainBase
from app.chain.download import DownloadChain
from app.chain.message import MessageChain
from app.chain.site import SiteChain
from app.chain.skills import SkillsChain
from app.chain.subscribe import SubscribeChain
from app.chain.system import SystemChain
from app.chain.transfer import TransferChain
from app.runtime.events import Event as ManagerEvent, eventmanager, Event
from app.runtime.extensions.plugin_manager import PluginManager
from app.application.messaging.message import MessageHelper
from app.application.messaging.skill import SkillInteractionHandler
from app.runtime.thread import ThreadHelper
from app.runtime.log import logger
from app.scheduler import Scheduler
@@ -25,7 +25,7 @@ from app.foundation.collections import DictUtils
class CommandChain(ChainBase):
pass
"""命令分发专用 Chain,仅作为命令侧的消息投递网关。"""
def _finish_command_processing_status(status: Optional[dict], user_id: Optional[str] = None) -> None:
@@ -132,7 +132,7 @@ class Command(metaclass=Singleton):
"data": {},
},
"/skills": {
"func": SkillsChain().remote_manage,
"func": SkillInteractionHandler(messenger=CommandChain()).remote_manage,
"description": "管理技能",
"category": "智能体",
"data": {},
+13
View File
@@ -24,6 +24,12 @@ class SymbolAlias:
# 只登记已经删除旧物理源码、并完成 canonical 路径验证的模块。
MODULE_ALIASES: Dict[str, ModuleAlias] = {
"app.chain.media_interaction": ModuleAlias(
target="app.chain.interaction",
replacement="app.chain.interaction",
introduced="v3.0.0",
owner="chain",
),
"app.log": ModuleAlias(
target="app.sdk.logging",
replacement="app.sdk.logging",
@@ -674,6 +680,13 @@ PACKAGE_EXPORTS: Dict[str, Dict[str, SymbolAlias]] = {
# 物理模块仍存在、仅部分公开符号迁走时,由导入器在标准 Loader 执行后叠加惰性符号路由。
# canonical 源码不反向依赖兼容层,目标符号也只在旧调用方真正取用时加载。
SYMBOL_ALIASES: Dict[str, Dict[str, SymbolAlias]] = {
"app.chain.message": {
"MediaInteractionChain": SymbolAlias(
target_module="app.chain.interaction",
target_name="MediaInteractionChain",
replacement="app.chain.interaction.MediaInteractionChain",
),
},
"app.domain.media": {
name: SymbolAlias(
target_module="app.schemas.media",
+2
View File
@@ -198,6 +198,8 @@ class CommingMessage(BaseModel):
audio_refs: Optional[List[str]] = None
# 文件附件列表
files: Optional[List[MessageAttachment]] = None
# 结构化按钮回调数据(优先于 CALLBACK: 文本前缀)
callback_data: Optional[str] = None
@field_validator("images", mode="before")
@classmethod
+1 -1
View File
@@ -32,7 +32,7 @@ MoviePilot is a self-hosted media automation platform targeting Chinese-language
| `app/runtime/` | Config, events, logging, caching, concurrency, process state, extensions, and legacy compatibility |
| `app/adapters/` | Cache, network, system, generated-resource, and named external-product adapters |
| `app/runtime/extensions/` | Module, plugin, and configured-service lifecycle management |
| `app/application/messaging/` | Messaging, interaction, and Agent-to-message capabilities |
| `app/application/messaging/` | Messaging, interaction, and Agent-to-message capabilities (`interaction.py` contracts, `router.py` priority/callback dispatch, `site.py`/`subscribe.py`/`skill.py` command sessions, `media.py` media interaction state, `plugin.py` plugin input, `agent.py` agent choice bridge, `message.py` rendering and queue); not a public plugin SDK |
| `app/application/security/` | Authentication and access-control capabilities |
| `app/application/` | Focused application services |
| `app/sdk/` | Stable imports for plugins |
+1 -1
View File
@@ -62,7 +62,7 @@ to make the directory tree look symmetrical.
|---|---|
| `app/application/*.py` | Audio, directory, downloader, filter, formatting, transfer history, image, media-server, notification, recognition, RSS, storage and torrent application services |
| `app/application/site/` | Configured site catalog, authentication level and index-resource capability; the generated extension and its data bundle stay together here |
| `app/application/messaging/` | Message rendering/routing, interactions and the Agent-to-message bridge |
| `app/application/messaging/` | Message rendering/routing, interactions and the Agent-to-message bridge: `interaction.py` shared interaction contracts and view helpers; `router.py` unified interaction priority and callback dispatch; `site.py`/`subscribe.py`/`skill.py` per-command sessions, input parsing and views; `media.py` media interaction state while the business workflow stays in `MediaInteractionChain`; `plugin.py` plugin input capture and plugin button callbacks; `agent.py` agent choice state, callback protocol and WebAgent bridge; `message.py` notification rendering, templates and queue. Not a public SDK recommended for direct plugin use |
| `app/application/security/` | Authentication, authorization, cookies, passkeys, OTP/two-factor, path/URL safety, SSRF and signing policy |
Application services may use domain rules, runtime contracts, Oper classes and
+20 -16
View File
@@ -10,10 +10,11 @@ from app.agent.tools.impl.ask_user_choice import (
UserChoiceOptionInput,
)
from app.agent.tools.impl.send_message import SendMessageTool
from app.application.messaging.interaction import (
from app.application.messaging.agent import (
AgentInteractionOption,
agent_interaction_manager,
)
from app.application.messaging.interaction import InteractionContext
from app.chain.message import MessageChain
from app.runtime.config import settings
from app.schemas.types import MessageChannel
@@ -201,13 +202,15 @@ class TestAgentInteraction(unittest.TestCase):
side_effect=lambda coro, _loop: (coro.close(), Mock())[1],
):
handled = chain._handle_callback(
text=f"CALLBACK:agent_interaction:choice:{request.request_id}:1",
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
original_message_id=123,
original_chat_id="456",
callback_data=f"agent_interaction:choice:{request.request_id}:1",
context=InteractionContext(
channel=MessageChannel.Telegram,
source="telegram-test",
user_id="10001",
username="tester",
original_message_id=123,
original_chat_id="456",
),
)
self.assertTrue(handled)
@@ -246,11 +249,13 @@ class TestAgentInteraction(unittest.TestCase):
chain.messagehelper, "put"
), patch.object(chain.messageoper, "add"):
chain._handle_callback(
text=f"CALLBACK:agent_choice:{request.request_id}:1",
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
callback_data=f"agent_choice:{request.request_id}:1",
context=InteractionContext(
channel=MessageChannel.Telegram,
source="telegram-test",
user_id="10001",
username="tester",
),
)
handle_ai_message.assert_called_once()
@@ -269,9 +274,8 @@ class TestAgentInteraction(unittest.TestCase):
chain,
"_handle_ai_message",
return_value=True,
) as handle_ai_message, patch.object(
chain,
"_handle_plugin_input_interaction",
) as handle_ai_message, patch(
"app.chain.message.PluginInputInteractionHandler.handle_text",
) as handle_plugin_interaction, patch.object(
chain,
"_mark_message_processing_started",
+14 -10
View File
@@ -12,7 +12,9 @@ from app.runtime.config import settings
from app.db import SessionFactory
from app.db.oper.message import MessageOper
from app.db.models.message import Message
from app.application.messaging.interaction import AgentInteractionOption, agent_interaction_manager, media_interaction_manager
from app.application.messaging.agent import AgentInteractionOption, agent_interaction_manager
from app.application.messaging.interaction import InteractionContext
from app.application.messaging.media import media_interaction_manager
from app.schemas.types import MessageChannel, NotificationType
@@ -39,7 +41,7 @@ def test_explicit_ai_message_bypasses_pending_media_interaction():
try:
with patch.object(chain, "_record_user_message"), patch(
"app.chain.message.MediaInteractionChain.handle_text_interaction",
"app.chain.interaction.MediaInteractionChain.handle_text_interaction",
return_value=True,
) as handle_media_interaction, patch.object(
chain, "_handle_ai_message", return_value=True
@@ -264,14 +266,16 @@ def test_agent_choice_callback_is_not_recorded_to_message_history():
side_effect=lambda coro, _loop: (coro.close(), Mock())[1],
):
chain._handle_callback(
text=f"CALLBACK:agent_interaction:choice:{request.request_id}:1",
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
is_channel_admin=False,
original_message_id=123,
original_chat_id="456",
callback_data=f"agent_interaction:choice:{request.request_id}:1",
context=InteractionContext(
channel=MessageChannel.Telegram,
source="telegram-test",
user_id="10001",
username="tester",
original_message_id=123,
original_chat_id="456",
is_channel_admin=False,
),
)
finally:
agent_interaction_manager.clear()
+193
View File
@@ -0,0 +1,193 @@
"""InteractionRouter 单元测试:会话选择、回调派发顺序和未消费回退语义。"""
import unittest
from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import MagicMock
from app.testing.bootstrap import ensure_optional_stub
ensure_optional_stub("qbittorrentapi", TorrentFilesList=list)
ensure_optional_stub("transmission_rpc", File=object)
ensure_optional_stub("psutil")
ensure_optional_stub("aioshutil")
ensure_optional_stub("pyquery", PyQuery=object)
from app.application.messaging.interaction import InteractionContext, InteractionDispatch
from app.application.messaging.router import (
CallbackRoute,
InteractionRouter,
SessionRoute,
has_pending_interaction,
)
from app.application.messaging.site import site_interaction_manager
from app.application.messaging.skill import skill_interaction_manager
from app.schemas.types import MessageChannel
def _context(user_id="10001") -> InteractionContext:
"""构造最小交互上下文。"""
return InteractionContext(
channel=MessageChannel.Telegram,
source="telegram-test",
user_id=user_id,
username="tester",
)
def _session_route(name: str, pending=None, consumed=True) -> tuple[SessionRoute, MagicMock]:
"""构造带可控返回值的会话路由,同时返回 handler 便于断言。"""
handler = MagicMock(return_value=consumed)
route = SessionRoute(
name=name,
get_pending=lambda _user_id, _pending=pending: _pending,
handle_text=handler,
)
return route, handler
def _callback_route(name: str, matched=True, handled=True) -> CallbackRoute:
"""构造带可控匹配和处理结果的回调路由。"""
dispatcher = MagicMock(return_value=InteractionDispatch(handled=handled))
return CallbackRoute(
name=name,
matches=lambda _data, _matched=matched: _matched,
dispatch=dispatcher,
)
class TestInteractionRouterSessions(unittest.TestCase):
def test_latest_session_prefers_newest_created_at(self):
"""多个待处理会话时选择创建时间最近的一条。"""
now = datetime.now()
old_route, _ = _session_route(
"sites", pending=SimpleNamespace(created_at=now - timedelta(minutes=10))
)
new_route, _ = _session_route(
"media", pending=SimpleNamespace(created_at=now)
)
router = InteractionRouter(
session_routes=[old_route, new_route], callback_routes=[]
)
self.assertEqual(router.latest_session("10001"), new_route)
def test_latest_session_missing_timestamp_treated_as_oldest(self):
"""缺少时间戳的会话不应抢占有时间戳的会话。"""
plain_route, _ = _session_route("sites", pending=SimpleNamespace())
stamped_route, _ = _session_route(
"media", pending=SimpleNamespace(created_at=datetime.now())
)
router = InteractionRouter(
session_routes=[plain_route, stamped_route], callback_routes=[]
)
self.assertEqual(router.latest_session("10001"), stamped_route)
def test_dispatch_active_text_consumed_by_latest_session(self):
"""文本应只派发给最近会话并返回其消费结果。"""
old_route, old_handler = _session_route(
"sites", pending=SimpleNamespace(created_at=None)
)
new_route, new_handler = _session_route(
"media", pending=SimpleNamespace(created_at=datetime.now())
)
router = InteractionRouter(
session_routes=[old_route, new_route], callback_routes=[]
)
self.assertTrue(router.dispatch_active_text(_context(), "输入内容"))
new_handler.assert_called_once()
old_handler.assert_not_called()
def test_dispatch_active_text_returns_false_without_session(self):
"""没有待处理会话时不消费文本。"""
router = InteractionRouter(
session_routes=[_session_route("sites", pending=None)[0]], callback_routes=[]
)
self.assertFalse(router.dispatch_active_text(_context(), "输入内容"))
def test_has_pending_checks_all_routes(self):
"""任意路由存在待处理会话即视为有待处理交互。"""
router = InteractionRouter(
session_routes=[
_session_route("sites", pending=None)[0],
_session_route("media", pending=SimpleNamespace())[0],
],
callback_routes=[],
)
self.assertTrue(router.has_pending("10001"))
empty_router = InteractionRouter(
session_routes=[_session_route("sites", pending=None)[0]], callback_routes=[]
)
self.assertFalse(empty_router.has_pending("10001"))
class TestInteractionRouterCallbacks(unittest.TestCase):
def test_dispatch_callback_respects_registration_order(self):
"""回调按注册顺序匹配,首个匹配并消费的路由生效。"""
first = _callback_route("transfer", matched=True, handled=True)
second = _callback_route("skill", matched=True, handled=True)
router = InteractionRouter(
session_routes=[], callback_routes=[first, second]
)
result = router.dispatch_callback(_context(), "any")
self.assertTrue(result.handled)
first.dispatch.assert_called_once()
second.dispatch.assert_not_called()
def test_dispatch_callback_continues_when_matched_route_not_handled(self):
"""匹配但未消费的路由不拦截后续路由。"""
unmatched = _callback_route("transfer", matched=False, handled=True)
skipped = _callback_route("skill", matched=True, handled=False)
consumer = _callback_route("site", matched=True, handled=True)
router = InteractionRouter(
session_routes=[], callback_routes=[unmatched, skipped, consumer]
)
result = router.dispatch_callback(_context(), "any")
self.assertTrue(result.handled)
unmatched.dispatch.assert_not_called()
skipped.dispatch.assert_called_once()
consumer.dispatch.assert_called_once()
def test_dispatch_callback_unhandled_when_no_route_matches(self):
"""所有路由均不匹配时返回未处理。"""
router = InteractionRouter(
session_routes=[],
callback_routes=[_callback_route("transfer", matched=False)],
)
result = router.dispatch_callback(_context(), "unknown")
self.assertFalse(result.handled)
self.assertFalse(result.defer_processing_finish)
class TestHasPendingInteraction(unittest.TestCase):
def tearDown(self):
site_interaction_manager.clear()
skill_interaction_manager.clear()
def test_has_pending_interaction_detects_real_sessions(self):
"""WebAgent 判断应覆盖真实交互会话管理器。"""
self.assertFalse(has_pending_interaction("10001"))
site_interaction_manager.create_or_replace(
user_id="10001",
command="/sites",
channel=MessageChannel.Telegram,
source="telegram-test",
username="tester",
)
self.assertTrue(has_pending_interaction("10001"))
self.assertFalse(has_pending_interaction("10002"))
if __name__ == "__main__":
unittest.main()
+30 -19
View File
@@ -3,11 +3,17 @@ from unittest.mock import patch
import pytest
from app.chain.message import MediaInteractionChain, MessageChain
from app.chain.message import MessageChain
from app.chain.interaction import MediaInteractionChain
from app.runtime.events import EventManager
from app.domain.context import Context, MediaInfo, TorrentInfo
from app.domain.meta.metabase import MetaBase
from app.application.messaging.interaction import media_interaction_manager, plugin_input_interaction_manager
from app.application.messaging.interaction import InteractionContext
from app.application.messaging.media import media_interaction_manager
from app.application.messaging.plugin import (
PluginInputInteractionHandler,
plugin_input_interaction_manager,
)
from app.schemas import CommingMessage, TransferDirectoryConf
from app.schemas.types import EventType, MediaSource, MediaType, MessageChannel
@@ -175,7 +181,7 @@ def test_message_routes_text_reply_to_media_interaction_before_ai():
assert request is not None
with patch.object(chain, "_record_user_message"), patch(
"app.chain.message.MediaInteractionChain.handle_text_interaction",
"app.chain.interaction.MediaInteractionChain.handle_text_interaction",
return_value=True,
) as handle_text, patch.object(chain, "_handle_ai_message") as handle_ai:
chain.handle_message(
@@ -270,8 +276,9 @@ def test_handle_message_keeps_legacy_positional_images_argument():
chain = MessageChain()
images = [CommingMessage.MessageImage(ref="tg://file_id/photo-1")]
with patch.object(
chain, "_handle_plugin_input_interaction", return_value=False
with patch(
"app.chain.message.PluginInputInteractionHandler.handle_text",
return_value=False,
), patch.object(
chain, "_mark_message_processing_started", return_value=None
), patch.object(
@@ -319,7 +326,7 @@ def test_plugin_input_session_captures_plain_text_before_media_interaction():
)
with patch.object(chain, "_record_user_message"), patch(
"app.chain.message.MediaInteractionChain.handle_text_interaction",
"app.chain.interaction.MediaInteractionChain.handle_text_interaction",
return_value=True,
) as handle_media, patch.object(chain.eventmanager, "send_event") as send_event:
chain.handle_message(
@@ -518,11 +525,13 @@ def test_plugin_input_session_ignores_none_text_messages():
)
image = CommingMessage.MessageImage(ref="https://example.invalid/image.jpg")
handled = chain._handle_plugin_input_interaction(
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
handled = PluginInputInteractionHandler(messenger=chain).handle_text(
context=InteractionContext(
channel=MessageChannel.Telegram,
source="telegram-test",
user_id="10001",
username="tester",
),
text=None,
images=[image],
)
@@ -1547,7 +1556,7 @@ def test_noai_prefix_starts_traditional_search_when_global_ai_enabled():
"app.chain.media.MediaChain.search",
return_value=(meta, medias),
) as search_media, patch(
"app.chain.message.MediaInteractionChain.post_medias_message"
"app.chain.interaction.MediaInteractionChain.post_medias_message"
) as post_medias_message, patch.object(
chain, "_handle_ai_message"
) as handle_ai:
@@ -1591,7 +1600,7 @@ def test_noai_prefix_preserves_traditional_interaction_priority_after_search():
), patch(
"app.chain.message.settings.AI_AGENT_GLOBAL", True
), patch(
"app.chain.message.MediaInteractionChain.handle_text_interaction",
"app.chain.interaction.MediaInteractionChain.handle_text_interaction",
return_value=True,
) as handle_text, patch.object(chain, "_handle_ai_message") as handle_ai:
chain.handle_message(
@@ -1622,15 +1631,17 @@ def test_callback_routes_to_media_interaction_chain():
)
with patch(
"app.chain.message.MediaInteractionChain.handle_callback_interaction",
"app.chain.interaction.MediaInteractionChain.handle_callback_interaction",
return_value=True,
) as handle_callback:
chain._handle_callback(
text=f"CALLBACK:media:{request.request_id}:page-next",
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
callback_data=f"media:{request.request_id}:page-next",
context=InteractionContext(
channel=MessageChannel.Telegram,
source="telegram-test",
user_id="10001",
username="tester",
),
)
handle_callback.assert_called_once()
+46 -42
View File
@@ -14,7 +14,9 @@ ensure_optional_stub("aioshutil")
ensure_optional_stub("pyquery", PyQuery=object)
from app.chain.message import MessageChain
from app.chain.skills import SkillsChain, skills_interaction_manager
from app.application.messaging.interaction import InteractionContext
from app.application.messaging.skill import SkillInteractionHandler
from app.application.messaging.skill import skill_interaction_manager
from app.agent.skills.registry import (
SkillHelper,
SkillInfo,
@@ -54,11 +56,11 @@ class _FakeResponse:
class TestSkillsCommand(unittest.TestCase):
def tearDown(self):
skills_interaction_manager.clear()
skill_interaction_manager.clear()
def test_message_routes_text_reply_to_skills_interaction_before_ai(self):
chain = MessageChain()
skills_interaction_manager.create_or_replace(
skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Wechat,
source="wechat-test",
@@ -66,7 +68,7 @@ class TestSkillsCommand(unittest.TestCase):
)
with patch.object(chain, "_record_user_message"), patch(
"app.chain.message.SkillsChain.handle_text_interaction",
"app.chain.message.SkillInteractionHandler.handle_text_interaction",
return_value=True,
) as handle_text, patch.object(chain, "_handle_ai_message") as handle_ai:
chain.handle_message(
@@ -81,15 +83,15 @@ class TestSkillsCommand(unittest.TestCase):
handle_ai.assert_not_called()
def test_skills_text_exit_skips_notification_history(self):
chain = SkillsChain()
skills_interaction_manager.create_or_replace(
chain = SkillInteractionHandler(messenger=MessageChain())
skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
username="tester",
)
with patch.object(chain, "post_message") as post_message:
with patch.object(chain._messenger, "post_message") as post_message:
handled = chain.handle_text_interaction(
channel=MessageChannel.Telegram,
source="telegram-test",
@@ -102,11 +104,11 @@ class TestSkillsCommand(unittest.TestCase):
notification = post_message.call_args.args[0]
self.assertEqual(notification.title, "技能交互已结束")
self.assertFalse(notification.save_history)
self.assertIsNone(skills_interaction_manager.get_by_user("10001"))
self.assertIsNone(skill_interaction_manager.get_by_user("10001"))
def test_callback_routes_to_skills_chain(self):
chain = MessageChain()
request = skills_interaction_manager.create_or_replace(
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
@@ -114,15 +116,17 @@ class TestSkillsCommand(unittest.TestCase):
)
with patch(
"app.chain.message.SkillsChain.handle_callback_interaction",
"app.chain.message.SkillInteractionHandler.handle_callback_interaction",
return_value=True,
) as handle_callback:
chain._handle_callback(
text=f"CALLBACK:skills:{request.request_id}:market",
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
callback_data=f"skills:{request.request_id}:market",
context=InteractionContext(
channel=MessageChannel.Telegram,
source="telegram-test",
user_id="10001",
username="tester",
),
)
handle_callback.assert_called_once()
@@ -383,8 +387,8 @@ class TestSkillsCommand(unittest.TestCase):
self.assertIn("内置默认源", message)
def test_skills_chain_market_view_marks_clawhub_as_community_source(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
@@ -416,8 +420,8 @@ class TestSkillsCommand(unittest.TestCase):
self.assertIn("ClawHub 属于社区注册表", text)
def test_skills_chain_market_view_filters_by_search_query(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
@@ -460,8 +464,8 @@ class TestSkillsCommand(unittest.TestCase):
self.assertEqual(buttons[0][0]["callback_data"], f"skills:{request.request_id}:clear-search")
def test_skills_chain_root_view_uses_friendly_source_labels(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
@@ -503,8 +507,8 @@ class TestSkillsCommand(unittest.TestCase):
self.assertIn("3. 管理技能源", text)
def test_skills_chain_installed_view_builds_remove_buttons(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.WebAgent,
source="web-agent",
@@ -545,8 +549,8 @@ class TestSkillsCommand(unittest.TestCase):
)
def test_skills_chain_callback_enters_search_input_mode(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
@@ -568,8 +572,8 @@ class TestSkillsCommand(unittest.TestCase):
render.assert_called_once()
def test_skills_chain_text_search_updates_market_query(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
@@ -593,8 +597,8 @@ class TestSkillsCommand(unittest.TestCase):
render.assert_called_once()
def test_skills_chain_followup_text_applies_search_when_awaiting_input(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
@@ -618,8 +622,8 @@ class TestSkillsCommand(unittest.TestCase):
render.assert_called_once()
def test_skills_chain_callback_enters_source_add_mode(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
@@ -641,8 +645,8 @@ class TestSkillsCommand(unittest.TestCase):
render.assert_called_once()
def test_skills_chain_followup_text_adds_custom_market_source(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
@@ -656,7 +660,7 @@ class TestSkillsCommand(unittest.TestCase):
"add_custom_market_source",
return_value=(True, "已添加技能源:仓库来源 · acme/custom-skills"),
) as add_source, patch.object(chain, "_render_interaction") as render, patch.object(
chain, "post_message"
chain._messenger, "post_message"
) as post_message:
handled = chain.handle_text_interaction(
channel=MessageChannel.Telegram,
@@ -673,8 +677,8 @@ class TestSkillsCommand(unittest.TestCase):
render.assert_called_once()
def test_skills_chain_text_removes_custom_market_source_by_index(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
@@ -686,7 +690,7 @@ class TestSkillsCommand(unittest.TestCase):
"_remove_market_source",
return_value=(True, "已删除技能源:仓库来源 · acme/custom-skills"),
) as remove_source, patch.object(chain, "_render_interaction") as render, patch.object(
chain, "post_message"
chain._messenger, "post_message"
) as post_message:
handled = chain.handle_text_interaction(
channel=MessageChannel.Telegram,
@@ -703,8 +707,8 @@ class TestSkillsCommand(unittest.TestCase):
render.assert_called_once()
def test_skills_chain_source_view_lists_custom_sources(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
@@ -743,11 +747,11 @@ class TestSkillsCommand(unittest.TestCase):
)
def test_skills_chain_updates_buttons_via_edit_message(self):
chain = SkillsChain()
chain = SkillInteractionHandler(messenger=MessageChain())
buttons = [[{"text": "安装 1", "callback_data": "skills:req:install:1"}]]
with patch.object(chain, "edit_message", return_value=True) as edit_message, patch.object(
chain, "post_message"
with patch.object(chain._messenger, "edit_message", return_value=True) as edit_message, patch.object(
chain._messenger, "post_message"
) as post_message:
chain._update_or_post_message(
channel=MessageChannel.Telegram,
+19 -14
View File
@@ -11,21 +11,22 @@ ensure_optional_stub("aioshutil")
ensure_optional_stub("pyquery", PyQuery=object)
from app.chain.message import MessageChain
from app.application.messaging.interaction import InteractionContext
from app.chain.site import SiteChain, site_interaction_manager
from app.chain.skills import skills_interaction_manager
from app.application.messaging.skill import skill_interaction_manager
from app.chain.subscribe import SubscribeChain, subscribe_interaction_manager
from app.schemas.types import MessageChannel
class TestSlashCommandInteractions(unittest.TestCase):
def tearDown(self):
skills_interaction_manager.clear()
skill_interaction_manager.clear()
site_interaction_manager.clear()
subscribe_interaction_manager.clear()
def test_message_routes_text_reply_to_latest_sites_interaction(self):
chain = MessageChain()
skills_interaction_manager.create_or_replace(
skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Wechat,
source="wechat-test",
@@ -43,7 +44,7 @@ class TestSlashCommandInteractions(unittest.TestCase):
"app.chain.message.SiteChain.handle_text_interaction",
return_value=True,
) as handle_site, patch(
"app.chain.message.SkillsChain.handle_text_interaction"
"app.chain.message.SkillInteractionHandler.handle_text_interaction"
) as handle_skills:
chain.handle_message(
channel=MessageChannel.Wechat,
@@ -105,11 +106,13 @@ class TestSlashCommandInteractions(unittest.TestCase):
return_value=True,
) as handle_callback:
chain._handle_callback(
text=f"CALLBACK:sites:{request.request_id}:refresh",
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
callback_data=f"sites:{request.request_id}:refresh",
context=InteractionContext(
channel=MessageChannel.Telegram,
source="telegram-test",
user_id="10001",
username="tester",
),
)
handle_callback.assert_called_once()
@@ -129,11 +132,13 @@ class TestSlashCommandInteractions(unittest.TestCase):
return_value=True,
) as handle_callback:
chain._handle_callback(
text=f"CALLBACK:subscribes:{request.request_id}:refresh",
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
callback_data=f"subscribes:{request.request_id}:refresh",
context=InteractionContext(
channel=MessageChannel.Telegram,
source="telegram-test",
user_id="10001",
username="tester",
),
)
handle_callback.assert_called_once()
+4 -2
View File
@@ -399,6 +399,8 @@ def _load_subscribe_chain_class():
SUBSCRIBE_CHAIN_MODULE, SubscribeChain = _load_subscribe_chain_class()
# 进度格式化已迁移到交互处理器,经由隔离加载的模块获取
SubscribeInteractionHandler = SUBSCRIBE_CHAIN_MODULE.SubscribeInteractionHandler
def _patch_media_recognize(module, result):
@@ -473,7 +475,7 @@ class SubscribeChainTest(TestCase):
"""订阅列表展示必须把 S0 当作合法季号,而不是回退到第 1 季。"""
subscribe = self._build_subscribe(season=0, total_episode=5, lack_episode=2)
progress = SubscribeChain._format_subscribe_progress(subscribe)
progress = SubscribeInteractionHandler._format_subscribe_progress(subscribe)
self.assertEqual(progress, "第0季 [3/5]")
@@ -481,7 +483,7 @@ class SubscribeChainTest(TestCase):
"""S0 没有总集数时仍显示特别季季号。"""
subscribe = self._build_subscribe(season=0, total_episode=None, lack_episode=None)
progress = SubscribeChain._format_subscribe_progress(subscribe)
progress = SubscribeInteractionHandler._format_subscribe_progress(subscribe)
self.assertEqual(progress, "第0季")
+41 -16
View File
@@ -13,6 +13,7 @@ sys.modules.setdefault("psutil", ModuleType("psutil"))
from app.chain.message import MessageChain
from app.chain.transfer import TransferChain
from app.application.messaging.interaction import InteractionContext
from app.runtime.config import settings
from app.schemas.types import MessageChannel
@@ -49,21 +50,45 @@ class TestTransferFailedRetryButtons(unittest.TestCase):
redo.assert_called_once_with(12)
post_message.assert_not_called()
def test_transfer_retry_callback_retries_history(self):
def test_message_chain_routes_transfer_callback_to_transfer_chain(self):
"""MessageChain 收到整理失败按钮回调时委托 TransferChain 处理。"""
chain = MessageChain()
with patch("app.chain.message.TransferChain") as transfer_cls:
transfer_cls.return_value.redo_transfer_history.return_value = (True, "")
transfer_cls.return_value.handle_failed_transfer_callback.return_value = True
chain._handle_callback(
callback_data="transfer_retry_12",
context=InteractionContext(
channel=MessageChannel.Telegram,
source="telegram-test",
user_id="10001",
username="tester",
),
)
transfer_cls.return_value.handle_failed_transfer_callback.assert_called_once_with(
callback_data="transfer_retry_12",
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
)
def test_transfer_retry_callback_retries_history(self):
chain = TransferChain()
with patch.object(chain, "redo_transfer_history", return_value=(True, "")) as redo:
with patch.object(chain, "post_message") as post_message:
chain._handle_callback(
text="CALLBACK:transfer_retry_12",
handled = chain.handle_failed_transfer_callback(
callback_data="transfer_retry_12",
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
)
transfer_cls.return_value.redo_transfer_history.assert_called_once_with(12)
self.assertTrue(handled)
redo.assert_called_once_with(12)
self.assertEqual(post_message.call_count, 2)
self.assertEqual(
post_message.call_args_list[0].args[0].title,
@@ -75,7 +100,7 @@ class TestTransferFailedRetryButtons(unittest.TestCase):
)
def test_transfer_ai_retry_callback_schedules_agent_takeover(self):
chain = MessageChain()
chain = TransferChain()
history = SimpleNamespace(
id=34,
status=False,
@@ -107,15 +132,15 @@ class TestTransferFailedRetryButtons(unittest.TestCase):
with patch.object(settings, "AI_AGENT_ENABLE", True):
with patch(
"app.chain.message.TransferHistoryOper"
"app.chain.transfer.TransferHistoryOper"
) as history_oper_cls, patch(
"app.chain.message.asyncio.run_coroutine_threadsafe",
"app.chain.transfer.asyncio.run_coroutine_threadsafe",
side_effect=_close_pending_coro,
) as run_task:
history_oper_cls.return_value.get.return_value = history
with patch.object(chain, "post_message") as post_message:
chain._handle_callback(
text="CALLBACK:transfer_ai_retry_34",
chain.handle_failed_transfer_callback(
callback_data="transfer_ai_retry_34",
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
@@ -130,7 +155,7 @@ class TestTransferFailedRetryButtons(unittest.TestCase):
)
def test_transfer_ai_retry_callback_uses_successful_move_dest_as_source(self):
chain = MessageChain()
chain = TransferChain()
captured = {}
history = SimpleNamespace(
id=35,
@@ -177,20 +202,20 @@ class TestTransferFailedRetryButtons(unittest.TestCase):
with patch.object(settings, "AI_AGENT_ENABLE", True):
with patch(
"app.chain.message.TransferHistoryOper"
"app.chain.transfer.TransferHistoryOper"
) as history_oper_cls, patch(
"app.chain.message.agent_manager.run_background_prompt",
"app.chain.transfer.agent_manager.run_background_prompt",
side_effect=fake_run_background_prompt,
), patch(
"app.chain.message.asyncio.run_coroutine_threadsafe",
"app.chain.transfer.asyncio.run_coroutine_threadsafe",
side_effect=_run_pending_coro,
):
history_oper_cls.return_value.get.return_value = history
with patch.object(chain, "post_message"), patch.object(
chain, "async_post_message", side_effect=fake_async_post_message
):
chain._handle_callback(
text="CALLBACK:transfer_ai_retry_35",
chain.handle_failed_transfer_callback(
callback_data="transfer_ai_retry_35",
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
+5 -4
View File
@@ -34,7 +34,8 @@ from app.runtime.events import Event
from app.db.oper.agentchat import AgentChatOper
from app.db.models.agentchat import AgentChat
from app.application.messaging.agent import build_web_agent_message_update_event
from app.application.messaging.interaction import AgentInteractionOption, agent_interaction_manager, skills_interaction_manager
from app.application.messaging.agent import AgentInteractionOption, agent_interaction_manager
from app.application.messaging.skill import skill_interaction_manager
from app.chain.message import MessageChain
from app.schemas.message import ChannelCapability, ChannelCapabilityManager
from app.schemas.types import EventType, MessageChannel, NotificationType
@@ -340,9 +341,9 @@ def test_build_web_agent_display_message_from_events_marks_done():
def test_has_web_agent_traditional_interaction_detects_pending_skills():
"""WebAgent 应能识别命令后的传统交互上下文。"""
skills_interaction_manager.clear()
skill_interaction_manager.clear()
try:
skills_interaction_manager.create_or_replace(
skill_interaction_manager.create_or_replace(
user_id="1",
channel=MessageChannel.WebAgent,
source="web-agent",
@@ -352,7 +353,7 @@ def test_has_web_agent_traditional_interaction_detects_pending_skills():
assert _has_web_agent_traditional_interaction("1") is True
assert _has_web_agent_traditional_interaction("2") is False
finally:
skills_interaction_manager.clear()
skill_interaction_manager.clear()
def test_web_agent_admin_context_uses_current_user_id():