mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 08:57:09 +08:00
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:
@@ -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` |
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
交互路由层:统一选择活动文本会话,并按固定顺序派发按钮回调。
|
||||
|
||||
文本会话候选覆盖 Site、Subscribe、Skill、Media 四类,
|
||||
按会话创建时间选择最近激活的一条,避免旧会话抢占新会话的输入。
|
||||
"""
|
||||
|
||||
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,
|
||||
)
|
||||
)
|
||||
@@ -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:
|
||||
"""
|
||||
清除当前市场搜索状态,恢复全量市场列表。
|
||||
"""
|
||||
@@ -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
+214
-2090
File diff suppressed because it is too large
Load Diff
+16
-505
@@ -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(
|
||||
"""委托交互处理器处理文本输入。"""
|
||||
return self._interaction_handler().handle_text_interaction(
|
||||
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,
|
||||
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
@@ -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(
|
||||
"""委托交互处理器处理文本输入。"""
|
||||
return self._interaction_handler().handle_text_interaction(
|
||||
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,
|
||||
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):
|
||||
|
||||
@@ -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
@@ -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": {},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
callback_data=f"agent_interaction:choice:{request.request_id}:1",
|
||||
context=InteractionContext(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
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",
|
||||
callback_data=f"agent_choice:{request.request_id}:1",
|
||||
context=InteractionContext(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
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",
|
||||
|
||||
@@ -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",
|
||||
callback_data=f"agent_interaction:choice:{request.request_id}:1",
|
||||
context=InteractionContext(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
user_id="10001",
|
||||
username="tester",
|
||||
is_channel_admin=False,
|
||||
original_message_id=123,
|
||||
original_chat_id="456",
|
||||
is_channel_admin=False,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
agent_interaction_manager.clear()
|
||||
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
handled = PluginInputInteractionHandler(messenger=chain).handle_text(
|
||||
context=InteractionContext(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
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",
|
||||
callback_data=f"media:{request.request_id}:page-next",
|
||||
context=InteractionContext(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
user_id="10001",
|
||||
username="tester",
|
||||
),
|
||||
)
|
||||
|
||||
handle_callback.assert_called_once()
|
||||
|
||||
@@ -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",
|
||||
callback_data=f"skills:{request.request_id}:market",
|
||||
context=InteractionContext(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
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,
|
||||
|
||||
@@ -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",
|
||||
callback_data=f"sites:{request.request_id}:refresh",
|
||||
context=InteractionContext(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
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",
|
||||
callback_data=f"subscribes:{request.request_id}:refresh",
|
||||
context=InteractionContext(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
user_id="10001",
|
||||
username="tester",
|
||||
),
|
||||
)
|
||||
|
||||
handle_callback.assert_called_once()
|
||||
|
||||
@@ -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季")
|
||||
|
||||
|
||||
@@ -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, "")
|
||||
with patch.object(chain, "post_message") as post_message:
|
||||
transfer_cls.return_value.handle_failed_transfer_callback.return_value = True
|
||||
chain._handle_callback(
|
||||
text="CALLBACK:transfer_retry_12",
|
||||
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",
|
||||
)
|
||||
|
||||
transfer_cls.return_value.redo_transfer_history.assert_called_once_with(12)
|
||||
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:
|
||||
handled = chain.handle_failed_transfer_callback(
|
||||
callback_data="transfer_retry_12",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
username="tester",
|
||||
)
|
||||
|
||||
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",
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user