From 460b3860040d544238bd3d87f87def25675801f4 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 22 Apr 2026 16:49:42 +0800 Subject: [PATCH] feat: add searchable skills marketplace --- app/chain/skills.py | 175 +++++++++++++++++++++++-- app/helper/skill.py | 246 +++++++++++++++++++++++++++++++++-- tests/test_skills_command.py | 180 ++++++++++++++++++++++++- 3 files changed, 580 insertions(+), 21 deletions(-) diff --git a/app/chain/skills.py b/app/chain/skills.py index 6ef2f18f..e5dc346b 100644 --- a/app/chain/skills.py +++ b/app/chain/skills.py @@ -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 diff --git a/app/helper/skill.py b/app/helper/skill.py index 04a10ec2..4879e691 100644 --- a/app/helper/skill.py +++ b/app/helper/skill.py @@ -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, diff --git a/tests/test_skills_command.py b/tests/test_skills_command.py index 234c934e..ba4f8766 100644 --- a/tests/test_skills_command.py +++ b/tests/test_skills_command.py @@ -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"}]]