feat: add searchable skills marketplace

This commit is contained in:
jxxghp
2026-04-22 16:49:42 +08:00
parent 89bf89c02d
commit 460b386004
3 changed files with 580 additions and 21 deletions

View File

@@ -27,6 +27,8 @@ class PendingSkillsInteraction:
view: str = "root"
local_page: int = 0
market_page: int = 0
market_query: str = ""
awaiting_input: Optional[str] = None
created_at: datetime = field(default_factory=datetime.now)
@@ -162,7 +164,14 @@ class SkillsChain(ChainBase):
source=source,
username=None,
)
force = (arg_str or "").strip().lower() in {"refresh", "刷新"}
normalized_arg = (arg_str or "").strip()
force = normalized_arg.lower() in {"refresh", "刷新"}
search_query = self._extract_market_search_query(normalized_arg) or (
"" if force else normalized_arg
)
if search_query:
request.view = "market"
request.market_query = search_query
self._render_interaction(
request=request,
channel=channel,
@@ -242,13 +251,22 @@ class SkillsChain(ChainBase):
if action == "root":
request.view = "root"
request.awaiting_input = None
elif action == "installed":
request.view = "installed"
request.local_page = 0
request.awaiting_input = None
elif action == "market":
request.view = "market"
request.market_page = 0
request.awaiting_input = None
elif action == "search":
request.view = "market"
request.awaiting_input = "market-search"
elif action == "clear-search":
self._clear_market_search(request)
elif action == "refresh":
request.awaiting_input = None
self._render_interaction(
request=request,
channel=channel,
@@ -261,16 +279,19 @@ class SkillsChain(ChainBase):
)
return True
elif action == "page-next":
request.awaiting_input = None
if request.view == "installed":
request.local_page += 1
elif request.view == "market":
request.market_page += 1
elif action == "page-prev":
request.awaiting_input = None
if request.view == "installed":
request.local_page = max(0, request.local_page - 1)
elif request.view == "market":
request.market_page = max(0, request.market_page - 1)
elif action == "install" and index:
request.awaiting_input = None
success, message = self._install_market_skill(request, index)
if success:
self.post_message(
@@ -293,6 +314,7 @@ class SkillsChain(ChainBase):
)
)
elif action == "remove" and index:
request.awaiting_input = None
success, message = self._remove_local_skill(request, index)
self.post_message(
Notification(
@@ -354,6 +376,7 @@ class SkillsChain(ChainBase):
if lowered in {"返回", "back"}:
request.view = "root"
request.awaiting_input = None
self._render_interaction(
request=request,
channel=channel,
@@ -364,6 +387,7 @@ class SkillsChain(ChainBase):
return True
if lowered in {"刷新", "refresh"}:
request.awaiting_input = None
self._render_interaction(
request=request,
channel=channel,
@@ -375,6 +399,7 @@ class SkillsChain(ChainBase):
return True
if lowered in {"p", "prev", "上一页"}:
request.awaiting_input = None
if request.view == "installed":
request.local_page = max(0, request.local_page - 1)
elif request.view == "market":
@@ -389,6 +414,7 @@ class SkillsChain(ChainBase):
return True
if lowered in {"n", "next", "下一页"}:
request.awaiting_input = None
if request.view == "installed":
request.local_page += 1
elif request.view == "market":
@@ -407,6 +433,11 @@ class SkillsChain(ChainBase):
request.view = "installed"
elif lowered in {"2", "市场", "market"}:
request.view = "market"
elif self._extract_market_search_query(normalized):
self._apply_market_search(
request,
self._extract_market_search_query(normalized),
)
else:
self.post_message(
Notification(
@@ -427,9 +458,49 @@ class SkillsChain(ChainBase):
)
return True
if lowered in {"清除搜索", "取消搜索", "clear", "clear search"}:
if request.view == "market" or request.market_query:
self._clear_market_search(request)
self._render_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if request.awaiting_input == "market-search":
if lowered in {"取消", "cancel"}:
request.awaiting_input = None
else:
search_query = self._extract_market_search_query(normalized) or normalized
self._apply_market_search(request, search_query)
self._render_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
search_query = self._extract_market_search_query(normalized)
if search_query:
self._apply_market_search(request, search_query)
self._render_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
install_match = re.match(r"^(?:安装|装)\s*(\d+)$", normalized)
remove_match = re.match(r"^(?:删除|删)\s*(\d+)$", normalized)
if request.view == "market" and install_match:
request.awaiting_input = None
success, message = self._install_market_skill(
request=request,
page_index=int(install_match.group(1)),
@@ -452,6 +523,7 @@ class SkillsChain(ChainBase):
)
return True
if request.view == "installed" and remove_match:
request.awaiting_input = None
success, message = self._remove_local_skill(
request=request,
page_index=int(remove_match.group(1)),
@@ -493,7 +565,7 @@ class SkillsChain(ChainBase):
"""
按当前市场页的可见序号安装技能,避免跨页序号歧义。
"""
market_skills = [skill for skill in self.skillhelper.list_market_skills() if not skill.installed]
market_skills = self._get_market_skills(request=request)
page_items, page, _ = self._page_items(
items=market_skills,
page=request.market_page,
@@ -683,11 +755,10 @@ class SkillsChain(ChainBase):
"""
构建技能市场视图,仅展示尚未安装的技能。
"""
market_skills = [
skill
for skill in self.skillhelper.list_market_skills(force=force_market_refresh)
if not skill.installed
]
market_skills = self._get_market_skills(
request=request,
force_market_refresh=force_market_refresh,
)
page_items, page, total_pages = self._page_items(
items=market_skills,
page=request.market_page,
@@ -696,9 +767,21 @@ class SkillsChain(ChainBase):
request.market_page = page
text_lines = [f"{page + 1}/{total_pages} 页,共 {len(market_skills)} 个可安装技能"]
if request.market_query:
text_lines.append(f"当前搜索:{request.market_query}")
if request.awaiting_input == "market-search":
text_lines.extend(
[
"",
"搜索输入中:直接回复关键词即可筛选市场技能,回复 取消 结束输入。",
]
)
if not page_items:
text_lines.append("")
text_lines.append("当前没有可安装的市场技能")
if request.market_query:
text_lines.append("当前搜索没有匹配的市场技能")
else:
text_lines.append("当前没有可安装的市场技能")
else:
for index, skill in enumerate(page_items, start=1):
text_lines.extend(
@@ -722,13 +805,30 @@ class SkillsChain(ChainBase):
text_lines.extend(
[
"",
"回复 安装 <序号> 安装技能,回复 刷新 重新拉取市场,回复 n/p 翻页,回复 返回 回到菜单,回复 退出 结束交互",
"回复 搜索 <关键词> 筛选技能,回复 清除搜索 恢复全量列表,回复 安装 <序号> 安装技能,回复 刷新 重新拉取市场,回复 n/p 翻页,回复 返回 回到菜单,回复 退出 结束交互",
]
)
buttons = None
if self._supports_interactive_buttons(request.channel):
buttons = []
search_row = []
if request.market_query:
search_row.append(
{
"text": "清除搜索",
"callback_data": f"skills:{request.request_id}:clear-search",
}
)
else:
search_row.append(
{
"text": "搜索",
"callback_data": f"skills:{request.request_id}:search",
}
)
if search_row:
buttons.append(search_row)
for index, _skill in enumerate(page_items, start=1):
buttons.append(
[
@@ -872,7 +972,60 @@ class SkillsChain(ChainBase):
根据当前视图返回可执行的文本命令提示。
"""
if view == "market":
return "请输入 安装 <序号>、刷新、n、p、返回 或 退出"
return "请输入 搜索 <关键词>、清除搜索、安装 <序号>、刷新、n、p、返回 或 退出"
if view == "installed":
return "请输入 删除 <序号>、n、p、返回 或 退出"
return "请输入 1、2、刷新 或 退出"
return "请输入 1、2、搜索 <关键词>、刷新 或 退出"
def _get_market_skills(
self,
request: PendingSkillsInteraction,
force_market_refresh: bool = False,
) -> List[SkillInfo]:
"""
获取当前 /skills 会话可见的市场技能,并应用搜索词过滤。
"""
skills = [
skill
for skill in self.skillhelper.list_market_skills(force=force_market_refresh)
if not skill.installed
]
if not request.market_query:
return skills
return self.skillhelper.filter_market_skills(
skills=skills,
query=request.market_query,
)
@staticmethod
def _extract_market_search_query(text: str) -> str:
"""
从文本命令中提取市场搜索词,兼容“搜索/查找/查”前缀。
"""
normalized = (text or "").strip()
if not normalized:
return ""
match = re.match(r"^(?:搜索|查找|查)\s+(.+)$", normalized)
return match.group(1).strip() if match else ""
@staticmethod
def _apply_market_search(
request: PendingSkillsInteraction,
query: str,
) -> None:
"""
将会话切到市场搜索结果视图,并重置分页状态。
"""
request.view = "market"
request.market_query = (query or "").strip()
request.market_page = 0
request.awaiting_input = None
@staticmethod
def _clear_market_search(request: PendingSkillsInteraction) -> None:
"""
清除当前市场搜索状态,恢复全量市场列表。
"""
request.market_query = ""
request.market_page = 0
request.awaiting_input = None

View File

@@ -1,12 +1,13 @@
import io
import json
import re
import shutil
import zipfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from urllib.parse import urlencode, urlparse
from urllib.parse import urlencode, urljoin, urlparse
from app.agent.middleware.skills import _parse_skill_metadata
from app.core.cache import cached, fresh
@@ -20,6 +21,10 @@ _SOURCE_META_FILENAME = ".moviepilot-skill-source.json"
_DEFAULT_BRANCHES = ("main", "master")
_MARKET_CACHE_TTL = 60 * 30
_CLAWHUB_HOSTS = {"clawhub.ai", "www.clawhub.ai"}
_CLAWHUB_CONVEX_CLIENT = "npm-1.35.1"
_CLAWHUB_LIST_QUERY = "skills:listPublicPageV4"
_CLAWHUB_LIST_PAGE_SIZE = 100
_CLAWHUB_LIST_MAX_PAGES = 2
_OFFICIAL_SKILL_REPOS = {
"openai/skills",
"anthropics/skills",
@@ -316,6 +321,44 @@ class SkillHelper(metaclass=WeakSingleton):
),
)
@staticmethod
def filter_market_skills(
skills: List[SkillInfo],
query: str,
) -> List[SkillInfo]:
"""
按关键词过滤市场技能。
搜索范围覆盖技能 ID、名称、描述以及来源标签多词查询按 AND 语义匹配,
便于用户逐步缩小候选范围。
"""
normalized_query = (query or "").strip().lower()
if not normalized_query:
return skills
terms = [term for term in re.split(r"\s+", normalized_query) if term]
if not terms:
return skills
results: List[SkillInfo] = []
for skill in skills:
haystack = " ".join(
filter(
None,
[
skill.id,
skill.name,
skill.description,
skill.source_label,
skill.repo_name,
skill.registry_name,
],
)
).lower()
if all(term in haystack for term in terms):
results.append(skill)
return results
@cached(maxsize=24, ttl=_MARKET_CACHE_TTL, skip_empty=True)
def _list_market_source_skills(self, source: str) -> List[SkillInfo]:
"""
@@ -405,8 +448,17 @@ class SkillHelper(metaclass=WeakSingleton):
def _list_market_registry_skills(self, registry: dict) -> List[SkillInfo]:
"""
从注册表拉取技能列表,目前用于 ClawHub
从注册表拉取技能列表。
ClawHub 官方前端当前通过 Convex query 拉取公开技能列表,因此这里优先复用
同一条查询链路;若运行时信息解析失败,再回退到旧的 REST 风格接口。
"""
parsed = urlparse((registry.get("registry_url") or "").rstrip("/"))
if parsed.netloc.lower() in _CLAWHUB_HOSTS:
skills = self._list_clawhub_registry_skills(registry)
if skills:
return skills
response = self._request_registry(
url=f"{registry['api_base']}/skills",
params={"limit": 200, "sort": "installsAllTime"},
@@ -599,29 +651,94 @@ class SkillHelper(metaclass=WeakSingleton):
return [item for item in items if isinstance(item, dict)]
return []
def _list_clawhub_registry_skills(self, registry: dict) -> List[SkillInfo]:
"""
按 ClawHub 官方前端的调用方式,通过 Convex query 获取公开技能列表。
"""
runtime_env = self._discover_clawhub_runtime_env(registry["registry_url"])
convex_url = (runtime_env or {}).get("convex_url")
if not convex_url:
return []
results: Dict[str, SkillInfo] = {}
cursor = None
for _ in range(_CLAWHUB_LIST_MAX_PAGES):
args = {
"numItems": _CLAWHUB_LIST_PAGE_SIZE,
"sort": "downloads",
"dir": "desc",
"nonSuspiciousOnly": True,
}
if cursor:
args["cursor"] = cursor
response = self._request_convex_query(
deployment_url=convex_url,
path=_CLAWHUB_LIST_QUERY,
args=args,
)
if not response:
break
payload = self._load_json_response(response)
value = payload.get("value")
if not isinstance(value, dict):
break
page = value.get("page")
if not isinstance(page, list):
break
for item in page:
if not isinstance(item, dict):
continue
skill = self._build_registry_skill(item, registry)
if skill and skill.id not in results:
results[skill.id] = skill
if not value.get("hasMore") or not value.get("nextCursor"):
break
cursor = value["nextCursor"]
return list(results.values())
def _build_registry_skill(
self, item: dict, registry: dict
) -> Optional[SkillInfo]:
"""
将注册表返回的条目转换为统一的 SkillInfo。
"""
skill_data = item.get("skill") if isinstance(item.get("skill"), dict) else item
slug = (
item.get("slug")
skill_data.get("slug")
or item.get("slug")
or skill_data.get("id")
or item.get("id")
or item.get("name")
or item.get("title")
or skill_data.get("name")
or skill_data.get("displayName")
or skill_data.get("title")
)
if not slug:
return None
name = item.get("name") or item.get("title") or slug
name = (
skill_data.get("name")
or skill_data.get("displayName")
or item.get("displayName")
or skill_data.get("title")
or item.get("title")
or slug
)
description = (
item.get("description")
skill_data.get("description")
or skill_data.get("summary")
or item.get("description")
or item.get("summary")
or skill_data.get("excerpt")
or item.get("excerpt")
or ""
)
owner_handle = self._extract_registry_owner_handle(item)
owner_handle = item.get("ownerHandle") or self._extract_registry_owner_handle(item)
page_path = f"/{owner_handle}/{slug}" if owner_handle else f"/skills/{slug}"
return SkillInfo(
@@ -637,7 +754,8 @@ class SkillHelper(metaclass=WeakSingleton):
registry_url=registry["registry_url"],
registry_name=registry["registry_name"],
registry_slug=str(slug),
download_url=item.get("downloadUrl")
download_url=skill_data.get("downloadUrl")
or item.get("downloadUrl")
or self._build_registry_download_url(registry["api_base"], str(slug)),
installed=False,
removable=False,
@@ -685,6 +803,74 @@ class SkillHelper(metaclass=WeakSingleton):
return None
return response.content
@cached(maxsize=4, ttl=_MARKET_CACHE_TTL, skip_empty=True)
def _discover_clawhub_runtime_env(self, registry_url: str) -> Optional[dict]:
"""
从 ClawHub 首页的 runtime env 脚本中提取当前生效的 Convex 部署地址。
"""
response = self._request_registry(url=registry_url)
if response is None or response.status_code != 200:
return None
html = self._read_response_text(response)
runtime_asset_path = self._extract_runtime_env_asset_path(html)
if not runtime_asset_path:
return None
runtime_asset_url = urljoin(f"{registry_url.rstrip('/')}/", runtime_asset_path)
asset_response = self._request_registry(url=runtime_asset_url)
if asset_response is None or asset_response.status_code != 200:
return None
script = self._read_response_text(asset_response)
convex_url = self._extract_runtime_env_value(script, "VITE_CONVEX_URL")
convex_site_url = self._extract_runtime_env_value(script, "VITE_CONVEX_SITE_URL")
if not convex_url and not convex_site_url:
return None
return {
"convex_url": convex_url.rstrip("/") if convex_url else None,
"convex_site_url": convex_site_url.rstrip("/") if convex_site_url else None,
}
@staticmethod
def _read_response_text(response) -> str:
"""
尽量稳定地把 requests 响应或测试桩响应转换成文本。
"""
text = getattr(response, "text", None)
if isinstance(text, str):
return text
content = getattr(response, "content", b"")
if isinstance(content, bytes):
return content.decode("utf-8", errors="ignore")
if isinstance(content, str):
return content
return ""
@staticmethod
def _extract_runtime_env_asset_path(html: str) -> Optional[str]:
"""
从 ClawHub 首页 HTML 中定位 runtime env 资源路径。
"""
match = re.search(r'"/assets/runtimeEnv-[^"]+\.js"', html or "")
if not match:
return None
return match.group(0).strip('"')
@staticmethod
def _extract_runtime_env_value(script: str, key: str) -> Optional[str]:
"""
从运行时脚本中提取指定环境变量的值。
"""
match = re.search(
rf"{re.escape(key)}:\s*['\"`]([^'\"`]+)['\"`]",
script or "",
)
if not match:
return None
return match.group(1).strip()
@staticmethod
def _extract_skill_archive(zf: zipfile.ZipFile, target_dir: Path) -> bool:
"""
@@ -744,6 +930,48 @@ class SkillHelper(metaclass=WeakSingleton):
logger.warning("下载技能市场仓库失败:%s", repo["repo_url"])
return None
@staticmethod
def _request_convex_query(
deployment_url: str,
path: str,
args: dict,
timeout: int = 30,
):
"""
以官方前端相同的请求格式调用 Convex query 接口。
"""
headers = {
"Content-Type": "application/json",
"Convex-Client": _CLAWHUB_CONVEX_CLIENT,
}
payload = {
"path": path,
"format": "convex_encoded_json",
"args": [args or {}],
}
strategies = []
if settings.PROXY_HOST:
strategies.append({"proxies": settings.PROXY, "timeout": timeout})
strategies.append({"timeout": timeout})
for kwargs in strategies:
try:
response = RequestUtils(headers=headers, **kwargs).post_res(
url=f"{deployment_url.rstrip('/')}/api/query",
json=payload,
raise_exception=True,
)
if response is not None and response.status_code == 200:
return response
except Exception as e:
logger.warning(
"请求 Convex 技能列表失败:%s/api/query - %s",
deployment_url.rstrip("/"),
e,
)
return None
@staticmethod
def _request_registry(
url: str,

View File

@@ -159,6 +159,65 @@ class TestSkillsCommand(unittest.TestCase):
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={
@@ -173,7 +232,9 @@ class TestSkillsCommand(unittest.TestCase):
}
)
with patch.object(helper, "_request_registry", return_value=response):
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)
@@ -261,6 +322,50 @@ class TestSkillsCommand(unittest.TestCase):
self.assertIn("社区源,安装前请自行甄别安全性", text)
self.assertIn("ClawHub 属于社区注册表", text)
def test_skills_chain_market_view_filters_by_search_query(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.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 = SkillsChain()
request = skills_interaction_manager.create_or_replace(
@@ -283,6 +388,79 @@ class TestSkillsCommand(unittest.TestCase):
self.assertIn("社区注册表 · ClawHub", text)
self.assertIn("官方仓库 · openai/skills", text)
def test_skills_chain_callback_enters_search_input_mode(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.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=MessageChannel.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 = SkillsChain()
request = skills_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
username="tester",
)
request.view = "market"
with patch.object(chain, "_render_interaction") as render:
handled = chain.handle_text_interaction(
channel=MessageChannel.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 = SkillsChain()
request = skills_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.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=MessageChannel.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_updates_buttons_via_edit_message(self):
chain = SkillsChain()
buttons = [[{"text": "安装 1", "callback_data": "skills:req:install:1"}]]