mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +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:
@@ -10,10 +10,11 @@ from app.agent.tools.impl.ask_user_choice import (
|
||||
UserChoiceOptionInput,
|
||||
)
|
||||
from app.agent.tools.impl.send_message import SendMessageTool
|
||||
from app.application.messaging.interaction import (
|
||||
from app.application.messaging.agent import (
|
||||
AgentInteractionOption,
|
||||
agent_interaction_manager,
|
||||
)
|
||||
from app.application.messaging.interaction import InteractionContext
|
||||
from app.chain.message import MessageChain
|
||||
from app.runtime.config import settings
|
||||
from app.schemas.types import MessageChannel
|
||||
@@ -201,13 +202,15 @@ class TestAgentInteraction(unittest.TestCase):
|
||||
side_effect=lambda coro, _loop: (coro.close(), Mock())[1],
|
||||
):
|
||||
handled = chain._handle_callback(
|
||||
text=f"CALLBACK:agent_interaction:choice:{request.request_id}:1",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
username="tester",
|
||||
original_message_id=123,
|
||||
original_chat_id="456",
|
||||
callback_data=f"agent_interaction:choice:{request.request_id}:1",
|
||||
context=InteractionContext(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
user_id="10001",
|
||||
username="tester",
|
||||
original_message_id=123,
|
||||
original_chat_id="456",
|
||||
),
|
||||
)
|
||||
|
||||
self.assertTrue(handled)
|
||||
@@ -246,11 +249,13 @@ class TestAgentInteraction(unittest.TestCase):
|
||||
chain.messagehelper, "put"
|
||||
), patch.object(chain.messageoper, "add"):
|
||||
chain._handle_callback(
|
||||
text=f"CALLBACK:agent_choice:{request.request_id}:1",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
username="tester",
|
||||
callback_data=f"agent_choice:{request.request_id}:1",
|
||||
context=InteractionContext(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
user_id="10001",
|
||||
username="tester",
|
||||
),
|
||||
)
|
||||
|
||||
handle_ai_message.assert_called_once()
|
||||
@@ -269,9 +274,8 @@ class TestAgentInteraction(unittest.TestCase):
|
||||
chain,
|
||||
"_handle_ai_message",
|
||||
return_value=True,
|
||||
) as handle_ai_message, patch.object(
|
||||
chain,
|
||||
"_handle_plugin_input_interaction",
|
||||
) as handle_ai_message, patch(
|
||||
"app.chain.message.PluginInputInteractionHandler.handle_text",
|
||||
) as handle_plugin_interaction, patch.object(
|
||||
chain,
|
||||
"_mark_message_processing_started",
|
||||
|
||||
@@ -12,7 +12,9 @@ from app.runtime.config import settings
|
||||
from app.db import SessionFactory
|
||||
from app.db.oper.message import MessageOper
|
||||
from app.db.models.message import Message
|
||||
from app.application.messaging.interaction import AgentInteractionOption, agent_interaction_manager, media_interaction_manager
|
||||
from app.application.messaging.agent import AgentInteractionOption, agent_interaction_manager
|
||||
from app.application.messaging.interaction import InteractionContext
|
||||
from app.application.messaging.media import media_interaction_manager
|
||||
from app.schemas.types import MessageChannel, NotificationType
|
||||
|
||||
|
||||
@@ -39,7 +41,7 @@ def test_explicit_ai_message_bypasses_pending_media_interaction():
|
||||
|
||||
try:
|
||||
with patch.object(chain, "_record_user_message"), patch(
|
||||
"app.chain.message.MediaInteractionChain.handle_text_interaction",
|
||||
"app.chain.interaction.MediaInteractionChain.handle_text_interaction",
|
||||
return_value=True,
|
||||
) as handle_media_interaction, patch.object(
|
||||
chain, "_handle_ai_message", return_value=True
|
||||
@@ -264,14 +266,16 @@ def test_agent_choice_callback_is_not_recorded_to_message_history():
|
||||
side_effect=lambda coro, _loop: (coro.close(), Mock())[1],
|
||||
):
|
||||
chain._handle_callback(
|
||||
text=f"CALLBACK:agent_interaction:choice:{request.request_id}:1",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
username="tester",
|
||||
is_channel_admin=False,
|
||||
original_message_id=123,
|
||||
original_chat_id="456",
|
||||
callback_data=f"agent_interaction:choice:{request.request_id}:1",
|
||||
context=InteractionContext(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
user_id="10001",
|
||||
username="tester",
|
||||
original_message_id=123,
|
||||
original_chat_id="456",
|
||||
is_channel_admin=False,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
agent_interaction_manager.clear()
|
||||
|
||||
@@ -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(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
username="tester",
|
||||
handled = PluginInputInteractionHandler(messenger=chain).handle_text(
|
||||
context=InteractionContext(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
user_id="10001",
|
||||
username="tester",
|
||||
),
|
||||
text=None,
|
||||
images=[image],
|
||||
)
|
||||
@@ -1547,7 +1556,7 @@ def test_noai_prefix_starts_traditional_search_when_global_ai_enabled():
|
||||
"app.chain.media.MediaChain.search",
|
||||
return_value=(meta, medias),
|
||||
) as search_media, patch(
|
||||
"app.chain.message.MediaInteractionChain.post_medias_message"
|
||||
"app.chain.interaction.MediaInteractionChain.post_medias_message"
|
||||
) as post_medias_message, patch.object(
|
||||
chain, "_handle_ai_message"
|
||||
) as handle_ai:
|
||||
@@ -1591,7 +1600,7 @@ def test_noai_prefix_preserves_traditional_interaction_priority_after_search():
|
||||
), patch(
|
||||
"app.chain.message.settings.AI_AGENT_GLOBAL", True
|
||||
), patch(
|
||||
"app.chain.message.MediaInteractionChain.handle_text_interaction",
|
||||
"app.chain.interaction.MediaInteractionChain.handle_text_interaction",
|
||||
return_value=True,
|
||||
) as handle_text, patch.object(chain, "_handle_ai_message") as handle_ai:
|
||||
chain.handle_message(
|
||||
@@ -1622,15 +1631,17 @@ def test_callback_routes_to_media_interaction_chain():
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.chain.message.MediaInteractionChain.handle_callback_interaction",
|
||||
"app.chain.interaction.MediaInteractionChain.handle_callback_interaction",
|
||||
return_value=True,
|
||||
) as handle_callback:
|
||||
chain._handle_callback(
|
||||
text=f"CALLBACK:media:{request.request_id}:page-next",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
username="tester",
|
||||
callback_data=f"media:{request.request_id}:page-next",
|
||||
context=InteractionContext(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
user_id="10001",
|
||||
username="tester",
|
||||
),
|
||||
)
|
||||
|
||||
handle_callback.assert_called_once()
|
||||
|
||||
@@ -14,7 +14,9 @@ ensure_optional_stub("aioshutil")
|
||||
ensure_optional_stub("pyquery", PyQuery=object)
|
||||
|
||||
from app.chain.message import MessageChain
|
||||
from app.chain.skills import SkillsChain, skills_interaction_manager
|
||||
from app.application.messaging.interaction import InteractionContext
|
||||
from app.application.messaging.skill import SkillInteractionHandler
|
||||
from app.application.messaging.skill import skill_interaction_manager
|
||||
from app.agent.skills.registry import (
|
||||
SkillHelper,
|
||||
SkillInfo,
|
||||
@@ -54,11 +56,11 @@ class _FakeResponse:
|
||||
|
||||
class TestSkillsCommand(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
skills_interaction_manager.clear()
|
||||
skill_interaction_manager.clear()
|
||||
|
||||
def test_message_routes_text_reply_to_skills_interaction_before_ai(self):
|
||||
chain = MessageChain()
|
||||
skills_interaction_manager.create_or_replace(
|
||||
skill_interaction_manager.create_or_replace(
|
||||
user_id="10001",
|
||||
channel=MessageChannel.Wechat,
|
||||
source="wechat-test",
|
||||
@@ -66,7 +68,7 @@ class TestSkillsCommand(unittest.TestCase):
|
||||
)
|
||||
|
||||
with patch.object(chain, "_record_user_message"), patch(
|
||||
"app.chain.message.SkillsChain.handle_text_interaction",
|
||||
"app.chain.message.SkillInteractionHandler.handle_text_interaction",
|
||||
return_value=True,
|
||||
) as handle_text, patch.object(chain, "_handle_ai_message") as handle_ai:
|
||||
chain.handle_message(
|
||||
@@ -81,15 +83,15 @@ class TestSkillsCommand(unittest.TestCase):
|
||||
handle_ai.assert_not_called()
|
||||
|
||||
def test_skills_text_exit_skips_notification_history(self):
|
||||
chain = SkillsChain()
|
||||
skills_interaction_manager.create_or_replace(
|
||||
chain = SkillInteractionHandler(messenger=MessageChain())
|
||||
skill_interaction_manager.create_or_replace(
|
||||
user_id="10001",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
username="tester",
|
||||
)
|
||||
|
||||
with patch.object(chain, "post_message") as post_message:
|
||||
with patch.object(chain._messenger, "post_message") as post_message:
|
||||
handled = chain.handle_text_interaction(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
@@ -102,11 +104,11 @@ class TestSkillsCommand(unittest.TestCase):
|
||||
notification = post_message.call_args.args[0]
|
||||
self.assertEqual(notification.title, "技能交互已结束")
|
||||
self.assertFalse(notification.save_history)
|
||||
self.assertIsNone(skills_interaction_manager.get_by_user("10001"))
|
||||
self.assertIsNone(skill_interaction_manager.get_by_user("10001"))
|
||||
|
||||
def test_callback_routes_to_skills_chain(self):
|
||||
chain = MessageChain()
|
||||
request = skills_interaction_manager.create_or_replace(
|
||||
request = skill_interaction_manager.create_or_replace(
|
||||
user_id="10001",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
@@ -114,15 +116,17 @@ class TestSkillsCommand(unittest.TestCase):
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.chain.message.SkillsChain.handle_callback_interaction",
|
||||
"app.chain.message.SkillInteractionHandler.handle_callback_interaction",
|
||||
return_value=True,
|
||||
) as handle_callback:
|
||||
chain._handle_callback(
|
||||
text=f"CALLBACK:skills:{request.request_id}:market",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
username="tester",
|
||||
callback_data=f"skills:{request.request_id}:market",
|
||||
context=InteractionContext(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
user_id="10001",
|
||||
username="tester",
|
||||
),
|
||||
)
|
||||
|
||||
handle_callback.assert_called_once()
|
||||
@@ -383,8 +387,8 @@ class TestSkillsCommand(unittest.TestCase):
|
||||
self.assertIn("内置默认源", message)
|
||||
|
||||
def test_skills_chain_market_view_marks_clawhub_as_community_source(self):
|
||||
chain = SkillsChain()
|
||||
request = skills_interaction_manager.create_or_replace(
|
||||
chain = SkillInteractionHandler(messenger=MessageChain())
|
||||
request = skill_interaction_manager.create_or_replace(
|
||||
user_id="10001",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
@@ -416,8 +420,8 @@ class TestSkillsCommand(unittest.TestCase):
|
||||
self.assertIn("ClawHub 属于社区注册表", text)
|
||||
|
||||
def test_skills_chain_market_view_filters_by_search_query(self):
|
||||
chain = SkillsChain()
|
||||
request = skills_interaction_manager.create_or_replace(
|
||||
chain = SkillInteractionHandler(messenger=MessageChain())
|
||||
request = skill_interaction_manager.create_or_replace(
|
||||
user_id="10001",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
@@ -460,8 +464,8 @@ class TestSkillsCommand(unittest.TestCase):
|
||||
self.assertEqual(buttons[0][0]["callback_data"], f"skills:{request.request_id}:clear-search")
|
||||
|
||||
def test_skills_chain_root_view_uses_friendly_source_labels(self):
|
||||
chain = SkillsChain()
|
||||
request = skills_interaction_manager.create_or_replace(
|
||||
chain = SkillInteractionHandler(messenger=MessageChain())
|
||||
request = skill_interaction_manager.create_or_replace(
|
||||
user_id="10001",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
@@ -503,8 +507,8 @@ class TestSkillsCommand(unittest.TestCase):
|
||||
self.assertIn("3. 管理技能源", text)
|
||||
|
||||
def test_skills_chain_installed_view_builds_remove_buttons(self):
|
||||
chain = SkillsChain()
|
||||
request = skills_interaction_manager.create_or_replace(
|
||||
chain = SkillInteractionHandler(messenger=MessageChain())
|
||||
request = skill_interaction_manager.create_or_replace(
|
||||
user_id="10001",
|
||||
channel=MessageChannel.WebAgent,
|
||||
source="web-agent",
|
||||
@@ -545,8 +549,8 @@ class TestSkillsCommand(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_skills_chain_callback_enters_search_input_mode(self):
|
||||
chain = SkillsChain()
|
||||
request = skills_interaction_manager.create_or_replace(
|
||||
chain = SkillInteractionHandler(messenger=MessageChain())
|
||||
request = skill_interaction_manager.create_or_replace(
|
||||
user_id="10001",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
@@ -568,8 +572,8 @@ class TestSkillsCommand(unittest.TestCase):
|
||||
render.assert_called_once()
|
||||
|
||||
def test_skills_chain_text_search_updates_market_query(self):
|
||||
chain = SkillsChain()
|
||||
request = skills_interaction_manager.create_or_replace(
|
||||
chain = SkillInteractionHandler(messenger=MessageChain())
|
||||
request = skill_interaction_manager.create_or_replace(
|
||||
user_id="10001",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
@@ -593,8 +597,8 @@ class TestSkillsCommand(unittest.TestCase):
|
||||
render.assert_called_once()
|
||||
|
||||
def test_skills_chain_followup_text_applies_search_when_awaiting_input(self):
|
||||
chain = SkillsChain()
|
||||
request = skills_interaction_manager.create_or_replace(
|
||||
chain = SkillInteractionHandler(messenger=MessageChain())
|
||||
request = skill_interaction_manager.create_or_replace(
|
||||
user_id="10001",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
@@ -618,8 +622,8 @@ class TestSkillsCommand(unittest.TestCase):
|
||||
render.assert_called_once()
|
||||
|
||||
def test_skills_chain_callback_enters_source_add_mode(self):
|
||||
chain = SkillsChain()
|
||||
request = skills_interaction_manager.create_or_replace(
|
||||
chain = SkillInteractionHandler(messenger=MessageChain())
|
||||
request = skill_interaction_manager.create_or_replace(
|
||||
user_id="10001",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
@@ -641,8 +645,8 @@ class TestSkillsCommand(unittest.TestCase):
|
||||
render.assert_called_once()
|
||||
|
||||
def test_skills_chain_followup_text_adds_custom_market_source(self):
|
||||
chain = SkillsChain()
|
||||
request = skills_interaction_manager.create_or_replace(
|
||||
chain = SkillInteractionHandler(messenger=MessageChain())
|
||||
request = skill_interaction_manager.create_or_replace(
|
||||
user_id="10001",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
@@ -656,7 +660,7 @@ class TestSkillsCommand(unittest.TestCase):
|
||||
"add_custom_market_source",
|
||||
return_value=(True, "已添加技能源:仓库来源 · acme/custom-skills"),
|
||||
) as add_source, patch.object(chain, "_render_interaction") as render, patch.object(
|
||||
chain, "post_message"
|
||||
chain._messenger, "post_message"
|
||||
) as post_message:
|
||||
handled = chain.handle_text_interaction(
|
||||
channel=MessageChannel.Telegram,
|
||||
@@ -673,8 +677,8 @@ class TestSkillsCommand(unittest.TestCase):
|
||||
render.assert_called_once()
|
||||
|
||||
def test_skills_chain_text_removes_custom_market_source_by_index(self):
|
||||
chain = SkillsChain()
|
||||
request = skills_interaction_manager.create_or_replace(
|
||||
chain = SkillInteractionHandler(messenger=MessageChain())
|
||||
request = skill_interaction_manager.create_or_replace(
|
||||
user_id="10001",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
@@ -686,7 +690,7 @@ class TestSkillsCommand(unittest.TestCase):
|
||||
"_remove_market_source",
|
||||
return_value=(True, "已删除技能源:仓库来源 · acme/custom-skills"),
|
||||
) as remove_source, patch.object(chain, "_render_interaction") as render, patch.object(
|
||||
chain, "post_message"
|
||||
chain._messenger, "post_message"
|
||||
) as post_message:
|
||||
handled = chain.handle_text_interaction(
|
||||
channel=MessageChannel.Telegram,
|
||||
@@ -703,8 +707,8 @@ class TestSkillsCommand(unittest.TestCase):
|
||||
render.assert_called_once()
|
||||
|
||||
def test_skills_chain_source_view_lists_custom_sources(self):
|
||||
chain = SkillsChain()
|
||||
request = skills_interaction_manager.create_or_replace(
|
||||
chain = SkillInteractionHandler(messenger=MessageChain())
|
||||
request = skill_interaction_manager.create_or_replace(
|
||||
user_id="10001",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
@@ -743,11 +747,11 @@ class TestSkillsCommand(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_skills_chain_updates_buttons_via_edit_message(self):
|
||||
chain = SkillsChain()
|
||||
chain = SkillInteractionHandler(messenger=MessageChain())
|
||||
buttons = [[{"text": "安装 1", "callback_data": "skills:req:install:1"}]]
|
||||
|
||||
with patch.object(chain, "edit_message", return_value=True) as edit_message, patch.object(
|
||||
chain, "post_message"
|
||||
with patch.object(chain._messenger, "edit_message", return_value=True) as edit_message, patch.object(
|
||||
chain._messenger, "post_message"
|
||||
) as post_message:
|
||||
chain._update_or_post_message(
|
||||
channel=MessageChannel.Telegram,
|
||||
|
||||
@@ -11,21 +11,22 @@ ensure_optional_stub("aioshutil")
|
||||
ensure_optional_stub("pyquery", PyQuery=object)
|
||||
|
||||
from app.chain.message import MessageChain
|
||||
from app.application.messaging.interaction import InteractionContext
|
||||
from app.chain.site import SiteChain, site_interaction_manager
|
||||
from app.chain.skills import skills_interaction_manager
|
||||
from app.application.messaging.skill import skill_interaction_manager
|
||||
from app.chain.subscribe import SubscribeChain, subscribe_interaction_manager
|
||||
from app.schemas.types import MessageChannel
|
||||
|
||||
|
||||
class TestSlashCommandInteractions(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
skills_interaction_manager.clear()
|
||||
skill_interaction_manager.clear()
|
||||
site_interaction_manager.clear()
|
||||
subscribe_interaction_manager.clear()
|
||||
|
||||
def test_message_routes_text_reply_to_latest_sites_interaction(self):
|
||||
chain = MessageChain()
|
||||
skills_interaction_manager.create_or_replace(
|
||||
skill_interaction_manager.create_or_replace(
|
||||
user_id="10001",
|
||||
channel=MessageChannel.Wechat,
|
||||
source="wechat-test",
|
||||
@@ -43,7 +44,7 @@ class TestSlashCommandInteractions(unittest.TestCase):
|
||||
"app.chain.message.SiteChain.handle_text_interaction",
|
||||
return_value=True,
|
||||
) as handle_site, patch(
|
||||
"app.chain.message.SkillsChain.handle_text_interaction"
|
||||
"app.chain.message.SkillInteractionHandler.handle_text_interaction"
|
||||
) as handle_skills:
|
||||
chain.handle_message(
|
||||
channel=MessageChannel.Wechat,
|
||||
@@ -105,11 +106,13 @@ class TestSlashCommandInteractions(unittest.TestCase):
|
||||
return_value=True,
|
||||
) as handle_callback:
|
||||
chain._handle_callback(
|
||||
text=f"CALLBACK:sites:{request.request_id}:refresh",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
username="tester",
|
||||
callback_data=f"sites:{request.request_id}:refresh",
|
||||
context=InteractionContext(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
user_id="10001",
|
||||
username="tester",
|
||||
),
|
||||
)
|
||||
|
||||
handle_callback.assert_called_once()
|
||||
@@ -129,11 +132,13 @@ class TestSlashCommandInteractions(unittest.TestCase):
|
||||
return_value=True,
|
||||
) as handle_callback:
|
||||
chain._handle_callback(
|
||||
text=f"CALLBACK:subscribes:{request.request_id}:refresh",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
username="tester",
|
||||
callback_data=f"subscribes:{request.request_id}:refresh",
|
||||
context=InteractionContext(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
user_id="10001",
|
||||
username="tester",
|
||||
),
|
||||
)
|
||||
|
||||
handle_callback.assert_called_once()
|
||||
|
||||
@@ -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, "")
|
||||
transfer_cls.return_value.handle_failed_transfer_callback.return_value = True
|
||||
chain._handle_callback(
|
||||
callback_data="transfer_retry_12",
|
||||
context=InteractionContext(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
user_id="10001",
|
||||
username="tester",
|
||||
),
|
||||
)
|
||||
|
||||
transfer_cls.return_value.handle_failed_transfer_callback.assert_called_once_with(
|
||||
callback_data="transfer_retry_12",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
username="tester",
|
||||
)
|
||||
|
||||
def test_transfer_retry_callback_retries_history(self):
|
||||
chain = TransferChain()
|
||||
|
||||
with patch.object(chain, "redo_transfer_history", return_value=(True, "")) as redo:
|
||||
with patch.object(chain, "post_message") as post_message:
|
||||
chain._handle_callback(
|
||||
text="CALLBACK:transfer_retry_12",
|
||||
handled = chain.handle_failed_transfer_callback(
|
||||
callback_data="transfer_retry_12",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
username="tester",
|
||||
)
|
||||
|
||||
transfer_cls.return_value.redo_transfer_history.assert_called_once_with(12)
|
||||
self.assertTrue(handled)
|
||||
redo.assert_called_once_with(12)
|
||||
self.assertEqual(post_message.call_count, 2)
|
||||
self.assertEqual(
|
||||
post_message.call_args_list[0].args[0].title,
|
||||
@@ -75,7 +100,7 @@ class TestTransferFailedRetryButtons(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_transfer_ai_retry_callback_schedules_agent_takeover(self):
|
||||
chain = MessageChain()
|
||||
chain = TransferChain()
|
||||
history = SimpleNamespace(
|
||||
id=34,
|
||||
status=False,
|
||||
@@ -107,15 +132,15 @@ class TestTransferFailedRetryButtons(unittest.TestCase):
|
||||
|
||||
with patch.object(settings, "AI_AGENT_ENABLE", True):
|
||||
with patch(
|
||||
"app.chain.message.TransferHistoryOper"
|
||||
"app.chain.transfer.TransferHistoryOper"
|
||||
) as history_oper_cls, patch(
|
||||
"app.chain.message.asyncio.run_coroutine_threadsafe",
|
||||
"app.chain.transfer.asyncio.run_coroutine_threadsafe",
|
||||
side_effect=_close_pending_coro,
|
||||
) as run_task:
|
||||
history_oper_cls.return_value.get.return_value = history
|
||||
with patch.object(chain, "post_message") as post_message:
|
||||
chain._handle_callback(
|
||||
text="CALLBACK:transfer_ai_retry_34",
|
||||
chain.handle_failed_transfer_callback(
|
||||
callback_data="transfer_ai_retry_34",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
@@ -130,7 +155,7 @@ class TestTransferFailedRetryButtons(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_transfer_ai_retry_callback_uses_successful_move_dest_as_source(self):
|
||||
chain = MessageChain()
|
||||
chain = TransferChain()
|
||||
captured = {}
|
||||
history = SimpleNamespace(
|
||||
id=35,
|
||||
@@ -177,20 +202,20 @@ class TestTransferFailedRetryButtons(unittest.TestCase):
|
||||
|
||||
with patch.object(settings, "AI_AGENT_ENABLE", True):
|
||||
with patch(
|
||||
"app.chain.message.TransferHistoryOper"
|
||||
"app.chain.transfer.TransferHistoryOper"
|
||||
) as history_oper_cls, patch(
|
||||
"app.chain.message.agent_manager.run_background_prompt",
|
||||
"app.chain.transfer.agent_manager.run_background_prompt",
|
||||
side_effect=fake_run_background_prompt,
|
||||
), patch(
|
||||
"app.chain.message.asyncio.run_coroutine_threadsafe",
|
||||
"app.chain.transfer.asyncio.run_coroutine_threadsafe",
|
||||
side_effect=_run_pending_coro,
|
||||
):
|
||||
history_oper_cls.return_value.get.return_value = history
|
||||
with patch.object(chain, "post_message"), patch.object(
|
||||
chain, "async_post_message", side_effect=fake_async_post_message
|
||||
):
|
||||
chain._handle_callback(
|
||||
text="CALLBACK:transfer_ai_retry_35",
|
||||
chain.handle_failed_transfer_callback(
|
||||
callback_data="transfer_ai_retry_35",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
|
||||
@@ -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