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

- 新增 application/messaging 交互层:router.py 统一会话优先级与回调分发,
  site/subscribe/skill/media/plugin 各交互状态与视图从 Chain 迁出
- MessageChain 改为通过 InteractionRouter 派发文本会话与按钮回调,
  新增结构化 callback_data 通道(兼容 CALLBACK: 文本前缀)
- Transfer 失败重试/AI 接管回调归入 TransferChain
- MediaInteractionChain 拆出为 app/chain/interaction.py(旧路径保留兼容别名)
- WebAgent Endpoint 去重,统一使用 agent.py 回调协议函数
- 删除 app/chain/skills.py(交互逻辑并入 SkillInteractionHandler)
- 同步更新架构文档与测试,全量 4476 通过
This commit is contained in:
jxxghp
2026-08-15 16:36:39 +08:00
parent 5a1808592a
commit dd38c16400
30 changed files with 4894 additions and 4230 deletions
+20 -16
View File
@@ -10,10 +10,11 @@ from app.agent.tools.impl.ask_user_choice import (
UserChoiceOptionInput,
)
from app.agent.tools.impl.send_message import SendMessageTool
from app.application.messaging.interaction import (
from app.application.messaging.agent import (
AgentInteractionOption,
agent_interaction_manager,
)
from app.application.messaging.interaction import InteractionContext
from app.chain.message import MessageChain
from app.runtime.config import settings
from app.schemas.types import MessageChannel
@@ -201,13 +202,15 @@ class TestAgentInteraction(unittest.TestCase):
side_effect=lambda coro, _loop: (coro.close(), Mock())[1],
):
handled = chain._handle_callback(
text=f"CALLBACK:agent_interaction:choice:{request.request_id}:1",
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
original_message_id=123,
original_chat_id="456",
callback_data=f"agent_interaction:choice:{request.request_id}:1",
context=InteractionContext(
channel=MessageChannel.Telegram,
source="telegram-test",
user_id="10001",
username="tester",
original_message_id=123,
original_chat_id="456",
),
)
self.assertTrue(handled)
@@ -246,11 +249,13 @@ class TestAgentInteraction(unittest.TestCase):
chain.messagehelper, "put"
), patch.object(chain.messageoper, "add"):
chain._handle_callback(
text=f"CALLBACK:agent_choice:{request.request_id}:1",
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
callback_data=f"agent_choice:{request.request_id}:1",
context=InteractionContext(
channel=MessageChannel.Telegram,
source="telegram-test",
user_id="10001",
username="tester",
),
)
handle_ai_message.assert_called_once()
@@ -269,9 +274,8 @@ class TestAgentInteraction(unittest.TestCase):
chain,
"_handle_ai_message",
return_value=True,
) as handle_ai_message, patch.object(
chain,
"_handle_plugin_input_interaction",
) as handle_ai_message, patch(
"app.chain.message.PluginInputInteractionHandler.handle_text",
) as handle_plugin_interaction, patch.object(
chain,
"_mark_message_processing_started",
+14 -10
View File
@@ -12,7 +12,9 @@ from app.runtime.config import settings
from app.db import SessionFactory
from app.db.oper.message import MessageOper
from app.db.models.message import Message
from app.application.messaging.interaction import AgentInteractionOption, agent_interaction_manager, media_interaction_manager
from app.application.messaging.agent import AgentInteractionOption, agent_interaction_manager
from app.application.messaging.interaction import InteractionContext
from app.application.messaging.media import media_interaction_manager
from app.schemas.types import MessageChannel, NotificationType
@@ -39,7 +41,7 @@ def test_explicit_ai_message_bypasses_pending_media_interaction():
try:
with patch.object(chain, "_record_user_message"), patch(
"app.chain.message.MediaInteractionChain.handle_text_interaction",
"app.chain.interaction.MediaInteractionChain.handle_text_interaction",
return_value=True,
) as handle_media_interaction, patch.object(
chain, "_handle_ai_message", return_value=True
@@ -264,14 +266,16 @@ def test_agent_choice_callback_is_not_recorded_to_message_history():
side_effect=lambda coro, _loop: (coro.close(), Mock())[1],
):
chain._handle_callback(
text=f"CALLBACK:agent_interaction:choice:{request.request_id}:1",
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
is_channel_admin=False,
original_message_id=123,
original_chat_id="456",
callback_data=f"agent_interaction:choice:{request.request_id}:1",
context=InteractionContext(
channel=MessageChannel.Telegram,
source="telegram-test",
user_id="10001",
username="tester",
original_message_id=123,
original_chat_id="456",
is_channel_admin=False,
),
)
finally:
agent_interaction_manager.clear()
+193
View File
@@ -0,0 +1,193 @@
"""InteractionRouter 单元测试:会话选择、回调派发顺序和未消费回退语义。"""
import unittest
from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import MagicMock
from app.testing.bootstrap import ensure_optional_stub
ensure_optional_stub("qbittorrentapi", TorrentFilesList=list)
ensure_optional_stub("transmission_rpc", File=object)
ensure_optional_stub("psutil")
ensure_optional_stub("aioshutil")
ensure_optional_stub("pyquery", PyQuery=object)
from app.application.messaging.interaction import InteractionContext, InteractionDispatch
from app.application.messaging.router import (
CallbackRoute,
InteractionRouter,
SessionRoute,
has_pending_interaction,
)
from app.application.messaging.site import site_interaction_manager
from app.application.messaging.skill import skill_interaction_manager
from app.schemas.types import MessageChannel
def _context(user_id="10001") -> InteractionContext:
"""构造最小交互上下文。"""
return InteractionContext(
channel=MessageChannel.Telegram,
source="telegram-test",
user_id=user_id,
username="tester",
)
def _session_route(name: str, pending=None, consumed=True) -> tuple[SessionRoute, MagicMock]:
"""构造带可控返回值的会话路由,同时返回 handler 便于断言。"""
handler = MagicMock(return_value=consumed)
route = SessionRoute(
name=name,
get_pending=lambda _user_id, _pending=pending: _pending,
handle_text=handler,
)
return route, handler
def _callback_route(name: str, matched=True, handled=True) -> CallbackRoute:
"""构造带可控匹配和处理结果的回调路由。"""
dispatcher = MagicMock(return_value=InteractionDispatch(handled=handled))
return CallbackRoute(
name=name,
matches=lambda _data, _matched=matched: _matched,
dispatch=dispatcher,
)
class TestInteractionRouterSessions(unittest.TestCase):
def test_latest_session_prefers_newest_created_at(self):
"""多个待处理会话时选择创建时间最近的一条。"""
now = datetime.now()
old_route, _ = _session_route(
"sites", pending=SimpleNamespace(created_at=now - timedelta(minutes=10))
)
new_route, _ = _session_route(
"media", pending=SimpleNamespace(created_at=now)
)
router = InteractionRouter(
session_routes=[old_route, new_route], callback_routes=[]
)
self.assertEqual(router.latest_session("10001"), new_route)
def test_latest_session_missing_timestamp_treated_as_oldest(self):
"""缺少时间戳的会话不应抢占有时间戳的会话。"""
plain_route, _ = _session_route("sites", pending=SimpleNamespace())
stamped_route, _ = _session_route(
"media", pending=SimpleNamespace(created_at=datetime.now())
)
router = InteractionRouter(
session_routes=[plain_route, stamped_route], callback_routes=[]
)
self.assertEqual(router.latest_session("10001"), stamped_route)
def test_dispatch_active_text_consumed_by_latest_session(self):
"""文本应只派发给最近会话并返回其消费结果。"""
old_route, old_handler = _session_route(
"sites", pending=SimpleNamespace(created_at=None)
)
new_route, new_handler = _session_route(
"media", pending=SimpleNamespace(created_at=datetime.now())
)
router = InteractionRouter(
session_routes=[old_route, new_route], callback_routes=[]
)
self.assertTrue(router.dispatch_active_text(_context(), "输入内容"))
new_handler.assert_called_once()
old_handler.assert_not_called()
def test_dispatch_active_text_returns_false_without_session(self):
"""没有待处理会话时不消费文本。"""
router = InteractionRouter(
session_routes=[_session_route("sites", pending=None)[0]], callback_routes=[]
)
self.assertFalse(router.dispatch_active_text(_context(), "输入内容"))
def test_has_pending_checks_all_routes(self):
"""任意路由存在待处理会话即视为有待处理交互。"""
router = InteractionRouter(
session_routes=[
_session_route("sites", pending=None)[0],
_session_route("media", pending=SimpleNamespace())[0],
],
callback_routes=[],
)
self.assertTrue(router.has_pending("10001"))
empty_router = InteractionRouter(
session_routes=[_session_route("sites", pending=None)[0]], callback_routes=[]
)
self.assertFalse(empty_router.has_pending("10001"))
class TestInteractionRouterCallbacks(unittest.TestCase):
def test_dispatch_callback_respects_registration_order(self):
"""回调按注册顺序匹配,首个匹配并消费的路由生效。"""
first = _callback_route("transfer", matched=True, handled=True)
second = _callback_route("skill", matched=True, handled=True)
router = InteractionRouter(
session_routes=[], callback_routes=[first, second]
)
result = router.dispatch_callback(_context(), "any")
self.assertTrue(result.handled)
first.dispatch.assert_called_once()
second.dispatch.assert_not_called()
def test_dispatch_callback_continues_when_matched_route_not_handled(self):
"""匹配但未消费的路由不拦截后续路由。"""
unmatched = _callback_route("transfer", matched=False, handled=True)
skipped = _callback_route("skill", matched=True, handled=False)
consumer = _callback_route("site", matched=True, handled=True)
router = InteractionRouter(
session_routes=[], callback_routes=[unmatched, skipped, consumer]
)
result = router.dispatch_callback(_context(), "any")
self.assertTrue(result.handled)
unmatched.dispatch.assert_not_called()
skipped.dispatch.assert_called_once()
consumer.dispatch.assert_called_once()
def test_dispatch_callback_unhandled_when_no_route_matches(self):
"""所有路由均不匹配时返回未处理。"""
router = InteractionRouter(
session_routes=[],
callback_routes=[_callback_route("transfer", matched=False)],
)
result = router.dispatch_callback(_context(), "unknown")
self.assertFalse(result.handled)
self.assertFalse(result.defer_processing_finish)
class TestHasPendingInteraction(unittest.TestCase):
def tearDown(self):
site_interaction_manager.clear()
skill_interaction_manager.clear()
def test_has_pending_interaction_detects_real_sessions(self):
"""WebAgent 判断应覆盖真实交互会话管理器。"""
self.assertFalse(has_pending_interaction("10001"))
site_interaction_manager.create_or_replace(
user_id="10001",
command="/sites",
channel=MessageChannel.Telegram,
source="telegram-test",
username="tester",
)
self.assertTrue(has_pending_interaction("10001"))
self.assertFalse(has_pending_interaction("10002"))
if __name__ == "__main__":
unittest.main()
+30 -19
View File
@@ -3,11 +3,17 @@ from unittest.mock import patch
import pytest
from app.chain.message import MediaInteractionChain, MessageChain
from app.chain.message import MessageChain
from app.chain.interaction import MediaInteractionChain
from app.runtime.events import EventManager
from app.domain.context import Context, MediaInfo, TorrentInfo
from app.domain.meta.metabase import MetaBase
from app.application.messaging.interaction import media_interaction_manager, plugin_input_interaction_manager
from app.application.messaging.interaction import InteractionContext
from app.application.messaging.media import media_interaction_manager
from app.application.messaging.plugin import (
PluginInputInteractionHandler,
plugin_input_interaction_manager,
)
from app.schemas import CommingMessage, TransferDirectoryConf
from app.schemas.types import EventType, MediaSource, MediaType, MessageChannel
@@ -175,7 +181,7 @@ def test_message_routes_text_reply_to_media_interaction_before_ai():
assert request is not None
with patch.object(chain, "_record_user_message"), patch(
"app.chain.message.MediaInteractionChain.handle_text_interaction",
"app.chain.interaction.MediaInteractionChain.handle_text_interaction",
return_value=True,
) as handle_text, patch.object(chain, "_handle_ai_message") as handle_ai:
chain.handle_message(
@@ -270,8 +276,9 @@ def test_handle_message_keeps_legacy_positional_images_argument():
chain = MessageChain()
images = [CommingMessage.MessageImage(ref="tg://file_id/photo-1")]
with patch.object(
chain, "_handle_plugin_input_interaction", return_value=False
with patch(
"app.chain.message.PluginInputInteractionHandler.handle_text",
return_value=False,
), patch.object(
chain, "_mark_message_processing_started", return_value=None
), patch.object(
@@ -319,7 +326,7 @@ def test_plugin_input_session_captures_plain_text_before_media_interaction():
)
with patch.object(chain, "_record_user_message"), patch(
"app.chain.message.MediaInteractionChain.handle_text_interaction",
"app.chain.interaction.MediaInteractionChain.handle_text_interaction",
return_value=True,
) as handle_media, patch.object(chain.eventmanager, "send_event") as send_event:
chain.handle_message(
@@ -518,11 +525,13 @@ def test_plugin_input_session_ignores_none_text_messages():
)
image = CommingMessage.MessageImage(ref="https://example.invalid/image.jpg")
handled = chain._handle_plugin_input_interaction(
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
handled = PluginInputInteractionHandler(messenger=chain).handle_text(
context=InteractionContext(
channel=MessageChannel.Telegram,
source="telegram-test",
user_id="10001",
username="tester",
),
text=None,
images=[image],
)
@@ -1547,7 +1556,7 @@ def test_noai_prefix_starts_traditional_search_when_global_ai_enabled():
"app.chain.media.MediaChain.search",
return_value=(meta, medias),
) as search_media, patch(
"app.chain.message.MediaInteractionChain.post_medias_message"
"app.chain.interaction.MediaInteractionChain.post_medias_message"
) as post_medias_message, patch.object(
chain, "_handle_ai_message"
) as handle_ai:
@@ -1591,7 +1600,7 @@ def test_noai_prefix_preserves_traditional_interaction_priority_after_search():
), patch(
"app.chain.message.settings.AI_AGENT_GLOBAL", True
), patch(
"app.chain.message.MediaInteractionChain.handle_text_interaction",
"app.chain.interaction.MediaInteractionChain.handle_text_interaction",
return_value=True,
) as handle_text, patch.object(chain, "_handle_ai_message") as handle_ai:
chain.handle_message(
@@ -1622,15 +1631,17 @@ def test_callback_routes_to_media_interaction_chain():
)
with patch(
"app.chain.message.MediaInteractionChain.handle_callback_interaction",
"app.chain.interaction.MediaInteractionChain.handle_callback_interaction",
return_value=True,
) as handle_callback:
chain._handle_callback(
text=f"CALLBACK:media:{request.request_id}:page-next",
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
callback_data=f"media:{request.request_id}:page-next",
context=InteractionContext(
channel=MessageChannel.Telegram,
source="telegram-test",
user_id="10001",
username="tester",
),
)
handle_callback.assert_called_once()
+46 -42
View File
@@ -14,7 +14,9 @@ ensure_optional_stub("aioshutil")
ensure_optional_stub("pyquery", PyQuery=object)
from app.chain.message import MessageChain
from app.chain.skills import SkillsChain, skills_interaction_manager
from app.application.messaging.interaction import InteractionContext
from app.application.messaging.skill import SkillInteractionHandler
from app.application.messaging.skill import skill_interaction_manager
from app.agent.skills.registry import (
SkillHelper,
SkillInfo,
@@ -54,11 +56,11 @@ class _FakeResponse:
class TestSkillsCommand(unittest.TestCase):
def tearDown(self):
skills_interaction_manager.clear()
skill_interaction_manager.clear()
def test_message_routes_text_reply_to_skills_interaction_before_ai(self):
chain = MessageChain()
skills_interaction_manager.create_or_replace(
skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Wechat,
source="wechat-test",
@@ -66,7 +68,7 @@ class TestSkillsCommand(unittest.TestCase):
)
with patch.object(chain, "_record_user_message"), patch(
"app.chain.message.SkillsChain.handle_text_interaction",
"app.chain.message.SkillInteractionHandler.handle_text_interaction",
return_value=True,
) as handle_text, patch.object(chain, "_handle_ai_message") as handle_ai:
chain.handle_message(
@@ -81,15 +83,15 @@ class TestSkillsCommand(unittest.TestCase):
handle_ai.assert_not_called()
def test_skills_text_exit_skips_notification_history(self):
chain = SkillsChain()
skills_interaction_manager.create_or_replace(
chain = SkillInteractionHandler(messenger=MessageChain())
skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
username="tester",
)
with patch.object(chain, "post_message") as post_message:
with patch.object(chain._messenger, "post_message") as post_message:
handled = chain.handle_text_interaction(
channel=MessageChannel.Telegram,
source="telegram-test",
@@ -102,11 +104,11 @@ class TestSkillsCommand(unittest.TestCase):
notification = post_message.call_args.args[0]
self.assertEqual(notification.title, "技能交互已结束")
self.assertFalse(notification.save_history)
self.assertIsNone(skills_interaction_manager.get_by_user("10001"))
self.assertIsNone(skill_interaction_manager.get_by_user("10001"))
def test_callback_routes_to_skills_chain(self):
chain = MessageChain()
request = skills_interaction_manager.create_or_replace(
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
@@ -114,15 +116,17 @@ class TestSkillsCommand(unittest.TestCase):
)
with patch(
"app.chain.message.SkillsChain.handle_callback_interaction",
"app.chain.message.SkillInteractionHandler.handle_callback_interaction",
return_value=True,
) as handle_callback:
chain._handle_callback(
text=f"CALLBACK:skills:{request.request_id}:market",
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
callback_data=f"skills:{request.request_id}:market",
context=InteractionContext(
channel=MessageChannel.Telegram,
source="telegram-test",
user_id="10001",
username="tester",
),
)
handle_callback.assert_called_once()
@@ -383,8 +387,8 @@ class TestSkillsCommand(unittest.TestCase):
self.assertIn("内置默认源", message)
def test_skills_chain_market_view_marks_clawhub_as_community_source(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
@@ -416,8 +420,8 @@ class TestSkillsCommand(unittest.TestCase):
self.assertIn("ClawHub 属于社区注册表", text)
def test_skills_chain_market_view_filters_by_search_query(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
@@ -460,8 +464,8 @@ class TestSkillsCommand(unittest.TestCase):
self.assertEqual(buttons[0][0]["callback_data"], f"skills:{request.request_id}:clear-search")
def test_skills_chain_root_view_uses_friendly_source_labels(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
@@ -503,8 +507,8 @@ class TestSkillsCommand(unittest.TestCase):
self.assertIn("3. 管理技能源", text)
def test_skills_chain_installed_view_builds_remove_buttons(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.WebAgent,
source="web-agent",
@@ -545,8 +549,8 @@ class TestSkillsCommand(unittest.TestCase):
)
def test_skills_chain_callback_enters_search_input_mode(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
@@ -568,8 +572,8 @@ class TestSkillsCommand(unittest.TestCase):
render.assert_called_once()
def test_skills_chain_text_search_updates_market_query(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
@@ -593,8 +597,8 @@ class TestSkillsCommand(unittest.TestCase):
render.assert_called_once()
def test_skills_chain_followup_text_applies_search_when_awaiting_input(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
@@ -618,8 +622,8 @@ class TestSkillsCommand(unittest.TestCase):
render.assert_called_once()
def test_skills_chain_callback_enters_source_add_mode(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
@@ -641,8 +645,8 @@ class TestSkillsCommand(unittest.TestCase):
render.assert_called_once()
def test_skills_chain_followup_text_adds_custom_market_source(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
@@ -656,7 +660,7 @@ class TestSkillsCommand(unittest.TestCase):
"add_custom_market_source",
return_value=(True, "已添加技能源:仓库来源 · acme/custom-skills"),
) as add_source, patch.object(chain, "_render_interaction") as render, patch.object(
chain, "post_message"
chain._messenger, "post_message"
) as post_message:
handled = chain.handle_text_interaction(
channel=MessageChannel.Telegram,
@@ -673,8 +677,8 @@ class TestSkillsCommand(unittest.TestCase):
render.assert_called_once()
def test_skills_chain_text_removes_custom_market_source_by_index(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
@@ -686,7 +690,7 @@ class TestSkillsCommand(unittest.TestCase):
"_remove_market_source",
return_value=(True, "已删除技能源:仓库来源 · acme/custom-skills"),
) as remove_source, patch.object(chain, "_render_interaction") as render, patch.object(
chain, "post_message"
chain._messenger, "post_message"
) as post_message:
handled = chain.handle_text_interaction(
channel=MessageChannel.Telegram,
@@ -703,8 +707,8 @@ class TestSkillsCommand(unittest.TestCase):
render.assert_called_once()
def test_skills_chain_source_view_lists_custom_sources(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
@@ -743,11 +747,11 @@ class TestSkillsCommand(unittest.TestCase):
)
def test_skills_chain_updates_buttons_via_edit_message(self):
chain = SkillsChain()
chain = SkillInteractionHandler(messenger=MessageChain())
buttons = [[{"text": "安装 1", "callback_data": "skills:req:install:1"}]]
with patch.object(chain, "edit_message", return_value=True) as edit_message, patch.object(
chain, "post_message"
with patch.object(chain._messenger, "edit_message", return_value=True) as edit_message, patch.object(
chain._messenger, "post_message"
) as post_message:
chain._update_or_post_message(
channel=MessageChannel.Telegram,
+19 -14
View File
@@ -11,21 +11,22 @@ ensure_optional_stub("aioshutil")
ensure_optional_stub("pyquery", PyQuery=object)
from app.chain.message import MessageChain
from app.application.messaging.interaction import InteractionContext
from app.chain.site import SiteChain, site_interaction_manager
from app.chain.skills import skills_interaction_manager
from app.application.messaging.skill import skill_interaction_manager
from app.chain.subscribe import SubscribeChain, subscribe_interaction_manager
from app.schemas.types import MessageChannel
class TestSlashCommandInteractions(unittest.TestCase):
def tearDown(self):
skills_interaction_manager.clear()
skill_interaction_manager.clear()
site_interaction_manager.clear()
subscribe_interaction_manager.clear()
def test_message_routes_text_reply_to_latest_sites_interaction(self):
chain = MessageChain()
skills_interaction_manager.create_or_replace(
skill_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Wechat,
source="wechat-test",
@@ -43,7 +44,7 @@ class TestSlashCommandInteractions(unittest.TestCase):
"app.chain.message.SiteChain.handle_text_interaction",
return_value=True,
) as handle_site, patch(
"app.chain.message.SkillsChain.handle_text_interaction"
"app.chain.message.SkillInteractionHandler.handle_text_interaction"
) as handle_skills:
chain.handle_message(
channel=MessageChannel.Wechat,
@@ -105,11 +106,13 @@ class TestSlashCommandInteractions(unittest.TestCase):
return_value=True,
) as handle_callback:
chain._handle_callback(
text=f"CALLBACK:sites:{request.request_id}:refresh",
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
callback_data=f"sites:{request.request_id}:refresh",
context=InteractionContext(
channel=MessageChannel.Telegram,
source="telegram-test",
user_id="10001",
username="tester",
),
)
handle_callback.assert_called_once()
@@ -129,11 +132,13 @@ class TestSlashCommandInteractions(unittest.TestCase):
return_value=True,
) as handle_callback:
chain._handle_callback(
text=f"CALLBACK:subscribes:{request.request_id}:refresh",
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
callback_data=f"subscribes:{request.request_id}:refresh",
context=InteractionContext(
channel=MessageChannel.Telegram,
source="telegram-test",
user_id="10001",
username="tester",
),
)
handle_callback.assert_called_once()
+4 -2
View File
@@ -399,6 +399,8 @@ def _load_subscribe_chain_class():
SUBSCRIBE_CHAIN_MODULE, SubscribeChain = _load_subscribe_chain_class()
# 进度格式化已迁移到交互处理器,经由隔离加载的模块获取
SubscribeInteractionHandler = SUBSCRIBE_CHAIN_MODULE.SubscribeInteractionHandler
def _patch_media_recognize(module, result):
@@ -473,7 +475,7 @@ class SubscribeChainTest(TestCase):
"""订阅列表展示必须把 S0 当作合法季号,而不是回退到第 1 季。"""
subscribe = self._build_subscribe(season=0, total_episode=5, lack_episode=2)
progress = SubscribeChain._format_subscribe_progress(subscribe)
progress = SubscribeInteractionHandler._format_subscribe_progress(subscribe)
self.assertEqual(progress, "第0季 [3/5]")
@@ -481,7 +483,7 @@ class SubscribeChainTest(TestCase):
"""S0 没有总集数时仍显示特别季季号。"""
subscribe = self._build_subscribe(season=0, total_episode=None, lack_episode=None)
progress = SubscribeChain._format_subscribe_progress(subscribe)
progress = SubscribeInteractionHandler._format_subscribe_progress(subscribe)
self.assertEqual(progress, "第0季")
+41 -16
View File
@@ -13,6 +13,7 @@ sys.modules.setdefault("psutil", ModuleType("psutil"))
from app.chain.message import MessageChain
from app.chain.transfer import TransferChain
from app.application.messaging.interaction import InteractionContext
from app.runtime.config import settings
from app.schemas.types import MessageChannel
@@ -49,21 +50,45 @@ class TestTransferFailedRetryButtons(unittest.TestCase):
redo.assert_called_once_with(12)
post_message.assert_not_called()
def test_transfer_retry_callback_retries_history(self):
def test_message_chain_routes_transfer_callback_to_transfer_chain(self):
"""MessageChain 收到整理失败按钮回调时委托 TransferChain 处理。"""
chain = MessageChain()
with patch("app.chain.message.TransferChain") as transfer_cls:
transfer_cls.return_value.redo_transfer_history.return_value = (True, "")
transfer_cls.return_value.handle_failed_transfer_callback.return_value = True
chain._handle_callback(
callback_data="transfer_retry_12",
context=InteractionContext(
channel=MessageChannel.Telegram,
source="telegram-test",
user_id="10001",
username="tester",
),
)
transfer_cls.return_value.handle_failed_transfer_callback.assert_called_once_with(
callback_data="transfer_retry_12",
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
)
def test_transfer_retry_callback_retries_history(self):
chain = TransferChain()
with patch.object(chain, "redo_transfer_history", return_value=(True, "")) as redo:
with patch.object(chain, "post_message") as post_message:
chain._handle_callback(
text="CALLBACK:transfer_retry_12",
handled = chain.handle_failed_transfer_callback(
callback_data="transfer_retry_12",
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
)
transfer_cls.return_value.redo_transfer_history.assert_called_once_with(12)
self.assertTrue(handled)
redo.assert_called_once_with(12)
self.assertEqual(post_message.call_count, 2)
self.assertEqual(
post_message.call_args_list[0].args[0].title,
@@ -75,7 +100,7 @@ class TestTransferFailedRetryButtons(unittest.TestCase):
)
def test_transfer_ai_retry_callback_schedules_agent_takeover(self):
chain = MessageChain()
chain = TransferChain()
history = SimpleNamespace(
id=34,
status=False,
@@ -107,15 +132,15 @@ class TestTransferFailedRetryButtons(unittest.TestCase):
with patch.object(settings, "AI_AGENT_ENABLE", True):
with patch(
"app.chain.message.TransferHistoryOper"
"app.chain.transfer.TransferHistoryOper"
) as history_oper_cls, patch(
"app.chain.message.asyncio.run_coroutine_threadsafe",
"app.chain.transfer.asyncio.run_coroutine_threadsafe",
side_effect=_close_pending_coro,
) as run_task:
history_oper_cls.return_value.get.return_value = history
with patch.object(chain, "post_message") as post_message:
chain._handle_callback(
text="CALLBACK:transfer_ai_retry_34",
chain.handle_failed_transfer_callback(
callback_data="transfer_ai_retry_34",
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
@@ -130,7 +155,7 @@ class TestTransferFailedRetryButtons(unittest.TestCase):
)
def test_transfer_ai_retry_callback_uses_successful_move_dest_as_source(self):
chain = MessageChain()
chain = TransferChain()
captured = {}
history = SimpleNamespace(
id=35,
@@ -177,20 +202,20 @@ class TestTransferFailedRetryButtons(unittest.TestCase):
with patch.object(settings, "AI_AGENT_ENABLE", True):
with patch(
"app.chain.message.TransferHistoryOper"
"app.chain.transfer.TransferHistoryOper"
) as history_oper_cls, patch(
"app.chain.message.agent_manager.run_background_prompt",
"app.chain.transfer.agent_manager.run_background_prompt",
side_effect=fake_run_background_prompt,
), patch(
"app.chain.message.asyncio.run_coroutine_threadsafe",
"app.chain.transfer.asyncio.run_coroutine_threadsafe",
side_effect=_run_pending_coro,
):
history_oper_cls.return_value.get.return_value = history
with patch.object(chain, "post_message"), patch.object(
chain, "async_post_message", side_effect=fake_async_post_message
):
chain._handle_callback(
text="CALLBACK:transfer_ai_retry_35",
chain.handle_failed_transfer_callback(
callback_data="transfer_ai_retry_35",
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
+5 -4
View File
@@ -34,7 +34,8 @@ from app.runtime.events import Event
from app.db.oper.agentchat import AgentChatOper
from app.db.models.agentchat import AgentChat
from app.application.messaging.agent import build_web_agent_message_update_event
from app.application.messaging.interaction import AgentInteractionOption, agent_interaction_manager, skills_interaction_manager
from app.application.messaging.agent import AgentInteractionOption, agent_interaction_manager
from app.application.messaging.skill import skill_interaction_manager
from app.chain.message import MessageChain
from app.schemas.message import ChannelCapability, ChannelCapabilityManager
from app.schemas.types import EventType, MessageChannel, NotificationType
@@ -340,9 +341,9 @@ def test_build_web_agent_display_message_from_events_marks_done():
def test_has_web_agent_traditional_interaction_detects_pending_skills():
"""WebAgent 应能识别命令后的传统交互上下文。"""
skills_interaction_manager.clear()
skill_interaction_manager.clear()
try:
skills_interaction_manager.create_or_replace(
skill_interaction_manager.create_or_replace(
user_id="1",
channel=MessageChannel.WebAgent,
source="web-agent",
@@ -352,7 +353,7 @@ def test_has_web_agent_traditional_interaction_detects_pending_skills():
assert _has_web_agent_traditional_interaction("1") is True
assert _has_web_agent_traditional_interaction("2") is False
finally:
skills_interaction_manager.clear()
skill_interaction_manager.clear()
def test_web_agent_admin_context_uses_current_user_id():