Files
MoviePilot/tests/test_skills_command.py
Aqr-K fc0eed08cc fix: preserve directed messaging and channel interaction contracts (#7148)
* fix: retain plugin lifecycle ownership and recover installation state

* fix: preserve directed messaging and channel interaction contracts

* test: cover plugin rollback and uninstall lifecycle boundaries

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-10-23 07:49:53 +08:00

917 lines
30 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
验证技能管理的消息交互、市场来源、安装和移除。
"""
import io
import tempfile
import zipfile
from pathlib import Path
from unittest.mock import patch
import pytest
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.agent.skills.registry import (
SkillHelper,
SkillInfo,
SkillMarketSource,
)
# 可选依赖占位必须先装配,再导入读取运行配置的应用入口。
from app.application.configuration import get_runtime_settings # noqa: E402
from app.application.messaging.interaction import InteractionContext
from app.application.messaging.skill import SkillInteractionHandler, skill_interaction_manager
from app.chain.message import MessageChain
from app.schemas.types import NotificationChannel
def _build_skill_zip(skill_dir: str, skill_name: str) -> bytes:
"""
构造包含技能前言和示例脚本的内存 ZIP,避免下载真实资源。
"""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr(
f"demo-main/{skill_dir}/SKILL.md",
(f"---\nname: {skill_name}\nversion: 1\ndescription: demo skill\n---\n\n# {skill_name}\n"),
)
zf.writestr(f"demo-main/{skill_dir}/scripts/example.py", "print('ok')\n")
return buf.getvalue()
class _FakeResponse:
"""
模拟技能市场接口的 JSON 或文件响应。
"""
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):
"""
返回测试预置的 JSON 载荷。
"""
return self._payload
@pytest.fixture
def _clear_skill_interactions():
"""
隔离测试之间共享的会话、交互状态和技能市场缓存。
"""
SkillHelper._list_market_source_skills.cache_clear()
try:
yield
finally:
skill_interaction_manager.clear()
SkillHelper._list_market_source_skills.cache_clear()
@pytest.mark.usefixtures("_clear_skill_interactions")
def test_message_routes_text_reply_to_skills_interaction_before_ai() -> None:
"""
文本回复优先交给已有技能交互会话处理。
"""
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()
@pytest.mark.usefixtures("_clear_skill_interactions")
def test_skills_text_exit_skips_notification_history() -> None:
"""
退出技能交互的提示不写入普通通知历史。
"""
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="退出",
)
assert handled
notification = post_message.call_args.args[0]
assert notification.title == "技能交互已结束"
assert not notification.save_history
assert skill_interaction_manager.get_by_user("10001") is None
@pytest.mark.usefixtures("_clear_skill_interactions")
def test_callback_routes_to_skills_chain() -> None:
"""
技能按钮回调路由至技能交互处理器。
"""
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()
@pytest.mark.usefixtures("_clear_skill_interactions")
def test_skillhelper_install_and_remove_market_skill() -> None:
"""
市场技能可安装到临时目录并按管理入口移除。
"""
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)
assert success, message
assert (user_root / "demo-skill" / "SKILL.md").exists()
assert (user_root / "demo-skill" / ".moviepilot-skill-source.json").exists()
local_skills = helper.list_local_skills()
assert len(local_skills) == 1
assert local_skills[0].source_type == "market"
assert local_skills[0].removable
removed, remove_message = helper.remove_local_skill("demo-skill")
assert removed, remove_message
assert not (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")
assert not removed
assert "内置技能" in remove_message
@pytest.mark.usefixtures("_clear_skill_interactions")
def test_skillhelper_lists_clawhub_registry_skills() -> None:
"""
从 ClawHub 注册服务读取技能列表与来源信息。
"""
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")
assert len(skills) == 1
assert skills[0].id == "weather-forecast"
assert skills[0].name == "Weather Forecast"
assert skills[0].source_type == "registry"
assert skills[0].registry_name == "ClawHub"
assert skills[0].source_label == "社区注册表 · ClawHub"
assert "/openclaw/weather-forecast" in skills[0].path
@pytest.mark.usefixtures("_clear_skill_interactions")
def test_skillhelper_filters_market_skills_by_query() -> None:
"""
市场技能列表按查询文本筛选。
"""
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")
assert len(filtered) == 1
assert filtered[0].id == "weather-forecast"
@pytest.mark.usefixtures("_clear_skill_interactions")
def test_skillhelper_falls_back_to_rest_registry_listing_when_runtime_missing() -> None:
"""
技能运行时不可用时回退到注册服务 REST 列表。
"""
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) as request_registry,
):
skills = helper._list_market_source_skills("https://clawhub.ai")
request_registry.assert_called_once()
assert len(skills) == 1
assert skills[0].id == "weather-forecast"
assert skills[0].source_type == "registry"
assert skills[0].registry_name == "ClawHub"
assert skills[0].source_label == "社区注册表 · ClawHub"
assert "/openclaw/weather-forecast" in skills[0].path
@pytest.mark.usefixtures("_clear_skill_interactions")
def test_skillhelper_installs_registry_skill() -> None:
"""
通过注册服务的下载信息安装技能。
"""
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)
assert success, message
assert (user_root / "registry-demo" / "SKILL.md").exists()
assert (user_root / "registry-demo" / ".moviepilot-skill-source.json").exists()
local_skills = helper.list_local_skills()
assert len(local_skills) == 1
assert local_skills[0].source_type == "registry"
assert local_skills[0].registry_name == "ClawHub"
assert local_skills[0].source_label == "社区注册表 · ClawHub"
@pytest.mark.usefixtures("_clear_skill_interactions")
def test_skillhelper_lists_market_sources_and_marks_custom_entries() -> None:
"""
来源列表区分内置与自定义市场来源。
"""
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()
assert len(sources) == 3
assert sources[0].builtin
assert sources[1].builtin
assert not sources[2].builtin
assert sources[2].removable
assert sources[2].label == "仓库来源 · acme/custom-skills"
@pytest.mark.usefixtures("_clear_skill_interactions")
def test_skillhelper_add_custom_market_source_updates_setting() -> None:
"""
新增自定义市场来源后持久化对应配置。
"""
helper = SkillHelper()
with (
patch.object(
helper,
"get_market_sources",
return_value=["https://github.com/openai/skills"],
),
patch.object(
get_runtime_settings(),
"update",
return_value=(True, ""),
) as update_setting,
):
success, message = helper.add_custom_market_source("acme/custom-skills")
assert success
assert "acme/custom-skills" in message
update_setting.assert_called_once_with(
key="SKILL_MARKET",
value="https://github.com/openai/skills,https://github.com/acme/custom-skills",
)
@pytest.mark.usefixtures("_clear_skill_interactions")
def test_skillhelper_remove_custom_market_source_blocks_builtin() -> None:
"""
移除自定义来源入口拒绝删除内置来源。
"""
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")
assert not success
assert "内置默认源" in message
@pytest.mark.usefixtures("_clear_skill_interactions")
def test_skills_chain_market_view_marks_clawhub_as_community_source() -> None:
"""
市场视图将 ClawHub 标记为社区来源。
"""
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)
assert title == "技能市场"
assert "社区注册表 · ClawHub" in text
assert "社区源,安装前请自行甄别安全性" in text
assert "ClawHub 属于社区注册表" in text
@pytest.mark.usefixtures("_clear_skill_interactions")
def test_skills_chain_market_view_filters_by_search_query() -> None:
"""
市场交互视图只展示匹配当前搜索条件的技能。
"""
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)
assert title == "技能市场"
assert "当前搜索:weather" in text
assert "weather-forecast" in text
assert "github-tools" not in text
assert buttons
assert buttons[0][0]["callback_data"] == f"skills:{request.request_id}:clear-search"
@pytest.mark.usefixtures("_clear_skill_interactions")
def test_skills_chain_root_view_uses_friendly_source_labels() -> None:
"""
技能首页展示易读的来源名称。
"""
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)
assert title == "技能管理"
assert "社区注册表 · ClawHub" in text
assert "官方仓库 · openai/skills" in text
assert "仓库来源 · acme/custom-skills" in text
assert "3. 管理技能源" in text
@pytest.mark.usefixtures("_clear_skill_interactions")
def test_skills_chain_installed_view_builds_remove_buttons() -> None:
"""
已安装技能视图为可移除项目生成操作按钮。
"""
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)
assert title == "已安装技能"
assert "builtin" in text
assert "custom" in text
assert buttons
assert {"text": "删除 2", "callback_data": f"skills:{request.request_id}:remove:2:{request.selection_id}"} in [
button for row in buttons for button in row
]
@pytest.mark.usefixtures("_clear_skill_interactions")
def test_skills_chain_callback_enters_search_input_mode() -> None:
"""
搜索按钮将技能会话切换为等待关键词输入。
"""
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",
)
assert handled
assert request.view == "market"
assert request.awaiting_input == "market-search"
render.assert_called_once()
@pytest.mark.usefixtures("_clear_skill_interactions")
def test_skills_chain_text_search_updates_market_query() -> None:
"""
文本搜索命令更新会话中的市场查询条件。
"""
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",
)
assert handled
assert request.market_query == "weather"
assert request.market_page == 0
assert request.awaiting_input is None
render.assert_called_once()
@pytest.mark.usefixtures("_clear_skill_interactions")
def test_skills_chain_followup_text_applies_search_when_awaiting_input() -> None:
"""
等待输入时将后续文本作为技能搜索条件。
"""
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",
)
assert handled
assert request.market_query == "calendar"
assert request.awaiting_input is None
render.assert_called_once()
@pytest.mark.usefixtures("_clear_skill_interactions")
def test_skills_chain_callback_enters_source_add_mode() -> None:
"""
添加来源按钮将会话切换为等待来源输入。
"""
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",
)
assert handled
assert request.view == "sources"
assert request.awaiting_input == "source-add"
render.assert_called_once()
@pytest.mark.usefixtures("_clear_skill_interactions")
def test_skills_chain_followup_text_adds_custom_market_source() -> None:
"""
等待来源输入时将后续文本保存为自定义来源。
"""
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",
)
assert handled
assert request.awaiting_input is None
add_source.assert_called_once_with("acme/custom-skills")
post_message.assert_called_once()
render.assert_called_once()
@pytest.mark.usefixtures("_clear_skill_interactions")
def test_skills_chain_text_removes_custom_market_source_by_index() -> None:
"""
文本命令按展示序号移除指定自定义来源。
"""
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",
)
assert handled
assert request.view == "sources"
remove_source.assert_called_once_with(page_index=3)
post_message.assert_called_once()
render.assert_called_once()
@pytest.mark.usefixtures("_clear_skill_interactions")
def test_skills_chain_source_view_lists_custom_sources() -> None:
"""
来源管理视图展示已配置的自定义来源。
"""
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)
assert title == "技能源管理"
assert "社区注册表 · ClawHub" in text
assert "仓库来源 · acme/custom-skills" in text
assert "删除自定义源" in text
assert buttons
assert buttons[1][0]["callback_data"] == f"skills:{request.request_id}:source-remove:2"
@pytest.mark.usefixtures("_clear_skill_interactions")
def test_skills_chain_updates_buttons_via_edit_message() -> None:
"""
渠道支持编辑时更新原消息中的技能操作按钮。
"""
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()