Files
MoviePilot/tests/test_skills_command.py
T
jxxghp 240a4dffe6 refactor(schemas): 统一 message/notification 命名边界,旧名收敛至兼容映射表
- notification 域:渠道能力(MessageChannel→NotificationChannel、ChannelCapability* 迁入 notification.py)
- message 域:消息收发(Notification→Message、NotificationType→MessageType、CommingMessage→IncomingMessage、NotificationHistoryItem→MessageHistoryItem、NotificationClear*→MessageClear*)
- Agent 工具契约:send_notification_message→send_message、notification_callback→message_callback
- 源码不保留旧名物理别名,旧导入经 app/runtime/compat/manifest.py SYMBOL_ALIASES 惰性解析
- API 路径与持久化键冻结不变,前端零改动
- 新增兼容守护测试与 docs/rules/07 命名边界规范
2026-08-16 19:32:20 +08:00

778 lines
30 KiB
Python

import io
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest.mock import patch
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.chain.message import MessageChain
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,
SkillMarketSource,
settings as skill_settings,
)
from app.schemas.types import NotificationChannel
def _build_skill_zip(skill_dir: str, skill_name: str) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr(
f"demo-main/{skill_dir}/SKILL.md",
(
f"---\n"
f"name: {skill_name}\n"
f"version: 1\n"
f"description: demo skill\n"
f"---\n\n"
f"# {skill_name}\n"
),
)
zf.writestr(f"demo-main/{skill_dir}/scripts/example.py", "print('ok')\n")
return buf.getvalue()
class _FakeResponse:
def __init__(self, payload=None, content: bytes = b"", status_code: int = 200):
self._payload = payload
self.content = content
self.status_code = status_code
def json(self):
return self._payload
class TestSkillsCommand(unittest.TestCase):
def tearDown(self):
skill_interaction_manager.clear()
def test_message_routes_text_reply_to_skills_interaction_before_ai(self):
chain = MessageChain()
skill_interaction_manager.create_or_replace(
user_id="10001",
channel=NotificationChannel.Wechat,
source="wechat-test",
username="tester",
)
with patch.object(chain, "_record_user_message"), patch(
"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(
channel=NotificationChannel.Wechat,
source="wechat-test",
userid="10001",
username="tester",
text="2",
)
handle_text.assert_called_once()
handle_ai.assert_not_called()
def test_skills_text_exit_skips_notification_history(self):
chain = SkillInteractionHandler(messenger=MessageChain())
skill_interaction_manager.create_or_replace(
user_id="10001",
channel=NotificationChannel.Telegram,
source="telegram-test",
username="tester",
)
with patch.object(chain._messenger, "post_message") as post_message:
handled = chain.handle_text_interaction(
channel=NotificationChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
text="退出",
)
self.assertTrue(handled)
notification = post_message.call_args.args[0]
self.assertEqual(notification.title, "技能交互已结束")
self.assertFalse(notification.save_history)
self.assertIsNone(skill_interaction_manager.get_by_user("10001"))
def test_callback_routes_to_skills_chain(self):
chain = MessageChain()
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=NotificationChannel.Telegram,
source="telegram-test",
username="tester",
)
with patch(
"app.chain.message.SkillInteractionHandler.handle_callback_interaction",
return_value=True,
) as handle_callback:
chain._handle_callback(
callback_data=f"skills:{request.request_id}:market",
context=InteractionContext(
channel=NotificationChannel.Telegram,
source="telegram-test",
user_id="10001",
username="tester",
),
)
handle_callback.assert_called_once()
def test_skillhelper_install_and_remove_market_skill(self):
helper = SkillHelper()
skill = SkillInfo(
id="demo-skill",
name="demo-skill",
description="demo",
source_type="market",
source_label="市场 · acme/demo",
repo_url="https://github.com/acme/demo",
repo_name="acme/demo",
skill_path="skills/demo-skill",
)
zip_bytes = _build_skill_zip("skills/demo-skill", "demo-skill")
with tempfile.TemporaryDirectory() as tempdir:
user_root = Path(tempdir) / "user-skills"
bundled_root = Path(tempdir) / "bundled-skills"
user_root.mkdir(parents=True, exist_ok=True)
bundled_root.mkdir(parents=True, exist_ok=True)
with patch.object(
SkillHelper, "get_user_skills_dir", return_value=user_root
), patch.object(
SkillHelper, "get_bundled_skills_dir", return_value=bundled_root
), patch.object(
helper, "_download_repo_archive", return_value=zip_bytes
):
success, message = helper.install_market_skill(skill)
self.assertTrue(success, message)
self.assertTrue((user_root / "demo-skill" / "SKILL.md").exists())
self.assertTrue(
(user_root / "demo-skill" / ".moviepilot-skill-source.json").exists()
)
local_skills = helper.list_local_skills()
self.assertEqual(len(local_skills), 1)
self.assertEqual(local_skills[0].source_type, "market")
self.assertTrue(local_skills[0].removable)
removed, remove_message = helper.remove_local_skill("demo-skill")
self.assertTrue(removed, remove_message)
self.assertFalse((user_root / "demo-skill").exists())
bundled_skill_dir = bundled_root / "builtin-skill"
bundled_skill_dir.mkdir(parents=True, exist_ok=True)
(bundled_skill_dir / "SKILL.md").write_text(
"---\nname: builtin-skill\ndescription: builtin\n---\n",
encoding="utf-8",
)
installed_builtin = user_root / "builtin-skill"
installed_builtin.mkdir(parents=True, exist_ok=True)
(installed_builtin / "SKILL.md").write_text(
"---\nname: builtin-skill\ndescription: builtin\n---\n",
encoding="utf-8",
)
removed, remove_message = helper.remove_local_skill("builtin-skill")
self.assertFalse(removed)
self.assertIn("内置技能", remove_message)
def test_skillhelper_lists_clawhub_registry_skills(self):
helper = SkillHelper()
response = _FakeResponse(
payload={
"status": "success",
"value": {
"hasMore": False,
"nextCursor": None,
"page": [
{
"ownerHandle": "openclaw",
"skill": {
"slug": "weather-forecast",
"displayName": "Weather Forecast",
"summary": "Forecast weather from ClawHub",
},
}
],
},
}
)
with patch.object(
helper,
"_discover_clawhub_runtime_env",
return_value={"convex_url": "https://wry-manatee-359.convex.cloud"},
), patch.object(helper, "_request_convex_query", return_value=response):
skills = helper._list_market_source_skills("https://clawhub.ai")
self.assertEqual(len(skills), 1)
self.assertEqual(skills[0].id, "weather-forecast")
self.assertEqual(skills[0].name, "Weather Forecast")
self.assertEqual(skills[0].source_type, "registry")
self.assertEqual(skills[0].registry_name, "ClawHub")
self.assertEqual(skills[0].source_label, "社区注册表 · ClawHub")
self.assertIn("/openclaw/weather-forecast", skills[0].path)
def test_skillhelper_filters_market_skills_by_query(self):
helper = SkillHelper()
skills = [
SkillInfo(
id="weather-forecast",
name="Weather Forecast",
description="Forecast weather from ClawHub",
source_label="社区注册表 · ClawHub",
),
SkillInfo(
id="github-tools",
name="GitHub Tools",
description="Manage pull requests",
source_label="官方仓库 · openai/skills",
),
]
filtered = helper.filter_market_skills(skills=skills, query="weather clawhub")
self.assertEqual(len(filtered), 1)
self.assertEqual(filtered[0].id, "weather-forecast")
def test_skillhelper_falls_back_to_rest_registry_listing_when_runtime_missing(self):
helper = SkillHelper()
response = _FakeResponse(
payload={
"items": [
{
"slug": "weather-forecast",
"name": "Weather Forecast",
"summary": "Forecast weather from ClawHub",
"owner": {"handle": "openclaw"},
}
]
}
)
with patch.object(
helper, "_discover_clawhub_runtime_env", return_value=None
), patch.object(helper, "_request_registry", return_value=response):
skills = helper._list_market_source_skills("https://clawhub.ai")
self.assertEqual(len(skills), 1)
self.assertEqual(skills[0].id, "weather-forecast")
self.assertEqual(skills[0].source_type, "registry")
self.assertEqual(skills[0].registry_name, "ClawHub")
self.assertEqual(skills[0].source_label, "社区注册表 · ClawHub")
self.assertIn("/openclaw/weather-forecast", skills[0].path)
def test_skillhelper_installs_registry_skill(self):
helper = SkillHelper()
skill = SkillInfo(
id="registry-demo",
name="Registry Demo",
description="registry demo",
source_type="registry",
source_label="注册表 · ClawHub",
registry_url="https://clawhub.ai",
registry_name="ClawHub",
registry_slug="registry-demo",
download_url="https://clawhub.ai/api/v1/download?slug=registry-demo",
)
zip_bytes = _build_skill_zip("package", "registry-demo")
with tempfile.TemporaryDirectory() as tempdir:
user_root = Path(tempdir) / "user-skills"
bundled_root = Path(tempdir) / "bundled-skills"
user_root.mkdir(parents=True, exist_ok=True)
bundled_root.mkdir(parents=True, exist_ok=True)
with patch.object(
SkillHelper, "get_user_skills_dir", return_value=user_root
), patch.object(
SkillHelper, "get_bundled_skills_dir", return_value=bundled_root
), patch.object(
helper, "_request_registry", return_value=_FakeResponse(content=zip_bytes)
):
success, message = helper.install_market_skill(skill)
self.assertTrue(success, message)
self.assertTrue((user_root / "registry-demo" / "SKILL.md").exists())
self.assertTrue(
(
user_root
/ "registry-demo"
/ ".moviepilot-skill-source.json"
).exists()
)
local_skills = helper.list_local_skills()
self.assertEqual(len(local_skills), 1)
self.assertEqual(local_skills[0].source_type, "registry")
self.assertEqual(local_skills[0].registry_name, "ClawHub")
self.assertEqual(local_skills[0].source_label, "社区注册表 · ClawHub")
def test_skillhelper_lists_market_sources_and_marks_custom_entries(self):
helper = SkillHelper()
with patch.object(
helper,
"get_market_sources",
return_value=[
"https://clawhub.ai",
"https://github.com/openai/skills",
"https://github.com/acme/custom-skills",
],
), patch.object(
helper,
"get_default_market_sources",
return_value=[
"https://clawhub.ai",
"https://github.com/openai/skills",
],
):
sources = helper.list_market_source_entries()
self.assertEqual(len(sources), 3)
self.assertTrue(sources[0].builtin)
self.assertTrue(sources[1].builtin)
self.assertFalse(sources[2].builtin)
self.assertTrue(sources[2].removable)
self.assertEqual(sources[2].label, "仓库来源 · acme/custom-skills")
def test_skillhelper_add_custom_market_source_updates_setting(self):
helper = SkillHelper()
with patch.object(
helper,
"get_market_sources",
return_value=["https://github.com/openai/skills"],
), patch.object(
type(skill_settings),
"update_setting",
return_value=(True, ""),
) as update_setting:
success, message = helper.add_custom_market_source("acme/custom-skills")
self.assertTrue(success)
self.assertIn("acme/custom-skills", message)
update_setting.assert_called_once_with(
key="SKILL_MARKET",
value="https://github.com/openai/skills,https://github.com/acme/custom-skills",
)
def test_skillhelper_remove_custom_market_source_blocks_builtin(self):
helper = SkillHelper()
with patch.object(
helper,
"get_default_market_sources",
return_value=["https://github.com/openai/skills"],
):
success, message = helper.remove_custom_market_source(
"https://github.com/openai/skills"
)
self.assertFalse(success)
self.assertIn("内置默认源", message)
def test_skills_chain_market_view_marks_clawhub_as_community_source(self):
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=NotificationChannel.Telegram,
source="telegram-test",
username="tester",
)
request.view = "market"
with patch.object(
chain.skillhelper,
"list_market_skills",
return_value=[
SkillInfo(
id="weather-forecast",
name="Weather Forecast",
description="Forecast weather from ClawHub",
source_type="registry",
source_label="社区注册表 · ClawHub",
registry_name="ClawHub",
registry_url="https://clawhub.ai",
registry_slug="weather-forecast",
)
],
):
title, text, _buttons = chain._build_market_view(request=request)
self.assertEqual(title, "技能市场")
self.assertIn("社区注册表 · ClawHub", text)
self.assertIn("社区源,安装前请自行甄别安全性", text)
self.assertIn("ClawHub 属于社区注册表", text)
def test_skills_chain_market_view_filters_by_search_query(self):
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=NotificationChannel.Telegram,
source="telegram-test",
username="tester",
)
request.view = "market"
request.market_query = "weather"
with patch.object(
chain.skillhelper,
"list_market_skills",
return_value=[
SkillInfo(
id="weather-forecast",
name="Weather Forecast",
description="Forecast weather from ClawHub",
source_type="registry",
source_label="社区注册表 · ClawHub",
registry_name="ClawHub",
registry_url="https://clawhub.ai",
registry_slug="weather-forecast",
),
SkillInfo(
id="github-tools",
name="GitHub Tools",
description="Manage pull requests",
source_type="market",
source_label="官方仓库 · openai/skills",
repo_name="openai/skills",
),
],
):
title, text, buttons = chain._build_market_view(request=request)
self.assertEqual(title, "技能市场")
self.assertIn("当前搜索:weather", text)
self.assertIn("weather-forecast", text)
self.assertNotIn("github-tools", text)
self.assertTrue(buttons)
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 = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=NotificationChannel.Telegram,
source="telegram-test",
username="tester",
)
with patch.object(chain.skillhelper, "list_local_skills", return_value=[]), patch.object(
chain.skillhelper, "list_market_skills", return_value=[]
), patch.object(
chain.skillhelper,
"list_market_source_entries",
return_value=[
SkillMarketSource(
source="https://clawhub.ai",
label="社区注册表 · ClawHub",
builtin=True,
removable=False,
),
SkillMarketSource(
source="https://github.com/openai/skills",
label="官方仓库 · openai/skills",
builtin=True,
removable=False,
),
SkillMarketSource(
source="https://github.com/acme/custom-skills",
label="仓库来源 · acme/custom-skills",
builtin=False,
removable=True,
),
],
):
title, text, _buttons = chain._build_root_view(request=request)
self.assertEqual(title, "技能管理")
self.assertIn("社区注册表 · ClawHub", text)
self.assertIn("官方仓库 · openai/skills", text)
self.assertIn("仓库来源 · acme/custom-skills", text)
self.assertIn("3. 管理技能源", text)
def test_skills_chain_installed_view_builds_remove_buttons(self):
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=NotificationChannel.WebAgent,
source="web-agent",
username="tester",
)
with patch.object(
chain.skillhelper,
"list_local_skills",
return_value=[
SkillInfo(
id="builtin",
name="Builtin",
description="Built in skill",
source_type="builtin",
source_label="内置",
removable=False,
),
SkillInfo(
id="custom",
name="Custom",
description="Custom skill",
source_type="local",
source_label="本地",
removable=True,
),
],
):
title, text, buttons = chain._build_installed_view(request=request)
self.assertEqual(title, "已安装技能")
self.assertIn("builtin", text)
self.assertIn("custom", text)
self.assertTrue(buttons)
self.assertIn(
{"text": "删除 2", "callback_data": f"skills:{request.request_id}:remove:2"},
[button for row in buttons for button in row],
)
def test_skills_chain_callback_enters_search_input_mode(self):
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=NotificationChannel.Telegram,
source="telegram-test",
username="tester",
)
with patch.object(chain, "_render_interaction") as render:
handled = chain.handle_callback_interaction(
callback_data=f"skills:{request.request_id}:search",
channel=NotificationChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
)
self.assertTrue(handled)
self.assertEqual(request.view, "market")
self.assertEqual(request.awaiting_input, "market-search")
render.assert_called_once()
def test_skills_chain_text_search_updates_market_query(self):
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=NotificationChannel.Telegram,
source="telegram-test",
username="tester",
)
request.view = "market"
with patch.object(chain, "_render_interaction") as render:
handled = chain.handle_text_interaction(
channel=NotificationChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
text="搜索 weather",
)
self.assertTrue(handled)
self.assertEqual(request.market_query, "weather")
self.assertEqual(request.market_page, 0)
self.assertIsNone(request.awaiting_input)
render.assert_called_once()
def test_skills_chain_followup_text_applies_search_when_awaiting_input(self):
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=NotificationChannel.Telegram,
source="telegram-test",
username="tester",
)
request.view = "market"
request.awaiting_input = "market-search"
with patch.object(chain, "_render_interaction") as render:
handled = chain.handle_text_interaction(
channel=NotificationChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
text="calendar",
)
self.assertTrue(handled)
self.assertEqual(request.market_query, "calendar")
self.assertIsNone(request.awaiting_input)
render.assert_called_once()
def test_skills_chain_callback_enters_source_add_mode(self):
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=NotificationChannel.Telegram,
source="telegram-test",
username="tester",
)
with patch.object(chain, "_render_interaction") as render:
handled = chain.handle_callback_interaction(
callback_data=f"skills:{request.request_id}:source-add",
channel=NotificationChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
)
self.assertTrue(handled)
self.assertEqual(request.view, "sources")
self.assertEqual(request.awaiting_input, "source-add")
render.assert_called_once()
def test_skills_chain_followup_text_adds_custom_market_source(self):
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=NotificationChannel.Telegram,
source="telegram-test",
username="tester",
)
request.view = "sources"
request.awaiting_input = "source-add"
with patch.object(
chain.skillhelper,
"add_custom_market_source",
return_value=(True, "已添加技能源:仓库来源 · acme/custom-skills"),
) as add_source, patch.object(chain, "_render_interaction") as render, patch.object(
chain._messenger, "post_message"
) as post_message:
handled = chain.handle_text_interaction(
channel=NotificationChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
text="acme/custom-skills",
)
self.assertTrue(handled)
self.assertIsNone(request.awaiting_input)
add_source.assert_called_once_with("acme/custom-skills")
post_message.assert_called_once()
render.assert_called_once()
def test_skills_chain_text_removes_custom_market_source_by_index(self):
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=NotificationChannel.Telegram,
source="telegram-test",
username="tester",
)
with patch.object(
chain,
"_remove_market_source",
return_value=(True, "已删除技能源:仓库来源 · acme/custom-skills"),
) as remove_source, patch.object(chain, "_render_interaction") as render, patch.object(
chain._messenger, "post_message"
) as post_message:
handled = chain.handle_text_interaction(
channel=NotificationChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
text="删除源 3",
)
self.assertTrue(handled)
self.assertEqual(request.view, "sources")
remove_source.assert_called_once_with(page_index=3)
post_message.assert_called_once()
render.assert_called_once()
def test_skills_chain_source_view_lists_custom_sources(self):
chain = SkillInteractionHandler(messenger=MessageChain())
request = skill_interaction_manager.create_or_replace(
user_id="10001",
channel=NotificationChannel.Telegram,
source="telegram-test",
username="tester",
)
request.view = "sources"
with patch.object(
chain.skillhelper,
"list_market_source_entries",
return_value=[
SkillMarketSource(
source="https://clawhub.ai",
label="社区注册表 · ClawHub",
builtin=True,
removable=False,
),
SkillMarketSource(
source="https://github.com/acme/custom-skills",
label="仓库来源 · acme/custom-skills",
builtin=False,
removable=True,
),
],
):
title, text, buttons = chain._build_sources_view(request=request)
self.assertEqual(title, "技能源管理")
self.assertIn("社区注册表 · ClawHub", text)
self.assertIn("仓库来源 · acme/custom-skills", text)
self.assertIn("删除自定义源", text)
self.assertTrue(buttons)
self.assertEqual(
buttons[1][0]["callback_data"],
f"skills:{request.request_id}:source-remove:2",
)
def test_skills_chain_updates_buttons_via_edit_message(self):
chain = SkillInteractionHandler(messenger=MessageChain())
buttons = [[{"text": "安装 1", "callback_data": "skills:req:install:1"}]]
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=NotificationChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
title="技能市场",
text="请选择技能",
buttons=buttons,
original_message_id=123,
original_chat_id="456",
)
edit_message.assert_called_once_with(
channel=NotificationChannel.Telegram,
source="telegram-test",
message_id=123,
chat_id="456",
title="技能市场",
text="请选择技能",
buttons=buttons,
)
post_message.assert_not_called()