diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index fd1a9aeb8c..6998197533 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -87,6 +87,7 @@ BraveWebSearchTool, FirecrawlExtractWebPageTool, FirecrawlWebSearchTool, + MetasoWebSearchTool, TavilyExtractWebPageTool, TavilyWebSearchTool, normalize_legacy_web_search_config, @@ -1116,6 +1117,8 @@ async def _apply_web_search_tools( req.func_tool.add_tool(tool_mgr.get_builtin_tool(FirecrawlExtractWebPageTool)) elif provider == "baidu_ai_search": req.func_tool.add_tool(tool_mgr.get_builtin_tool(BaiduWebSearchTool)) + elif provider == "metaso": + req.func_tool.add_tool(tool_mgr.get_builtin_tool(MetasoWebSearchTool)) def _get_compress_provider( diff --git a/astrbot/core/computer/booters/shipyard_search_file_util.py b/astrbot/core/computer/booters/shipyard_search_file_util.py index 1227244de3..cdd41de82e 100644 --- a/astrbot/core/computer/booters/shipyard_search_file_util.py +++ b/astrbot/core/computer/booters/shipyard_search_file_util.py @@ -74,7 +74,7 @@ def _build_grep_command( def _quote_command(command: list[str]) -> str: - return " ".join(shlex.quote(part) for part in command) + return shlex.join(command) def build_search_command( diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index ce79559bd6..64c9ea923e 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -112,6 +112,7 @@ "websearch_brave_key": [], "websearch_baidu_app_builder_key": "", "websearch_firecrawl_key": [], + "websearch_metaso_key": [], "web_search_link": False, "display_reasoning_text": False, "identifier": False, @@ -3223,6 +3224,7 @@ "bocha", "brave", "firecrawl", + "metaso", ], "condition": { "provider_settings.web_search": True, @@ -3268,6 +3270,16 @@ "provider_settings.web_search": True, }, }, + "provider_settings.websearch_metaso_key": { + "description": "Metaso API Key", + "type": "list", + "items": {"type": "string"}, + "hint": "可添加多个 Key 进行轮询。内置 Key 每天有 100 次免费查询额度,配置自己的 Key 可获得更高配额。", + "condition": { + "provider_settings.websearch_provider": "metaso", + "provider_settings.web_search": True, + }, + }, "provider_settings.websearch_baidu_app_builder_key": { "description": "百度千帆智能云 APP Builder API Key", "type": "string", diff --git a/astrbot/core/tools/web_search_tools.py b/astrbot/core/tools/web_search_tools.py index ebd13d0102..f2cbb08f77 100644 --- a/astrbot/core/tools/web_search_tools.py +++ b/astrbot/core/tools/web_search_tools.py @@ -13,6 +13,11 @@ from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.tools.registry import builtin_tool + +class WebSearchError(RuntimeError): + """Raised when a web search provider request fails.""" + + WEB_SEARCH_TOOL_NAMES = [ "web_search_baidu", "web_search_tavily", @@ -21,6 +26,7 @@ "web_search_brave", "web_search_firecrawl", "firecrawl_extract_web_page", + "web_search_metaso", ] _TAVILY_WEB_SEARCH_TOOL_CONFIG = { "provider_settings.web_search": True, @@ -42,6 +48,10 @@ "provider_settings.web_search": True, "provider_settings.websearch_provider": "baidu_ai_search", } +_METASO_WEB_SEARCH_TOOL_CONFIG = { + "provider_settings.web_search": True, + "provider_settings.websearch_provider": "metaso", +} @std_dataclass @@ -76,6 +86,11 @@ async def get(self, provider_settings: dict) -> str: _BOCHA_KEY_ROTATOR = _KeyRotator("websearch_bocha_key", "BoCha") _BRAVE_KEY_ROTATOR = _KeyRotator("websearch_brave_key", "Brave") _FIRECRAWL_KEY_ROTATOR = _KeyRotator("websearch_firecrawl_key", "Firecrawl") +_METASO_KEY_ROTATOR = _KeyRotator("websearch_metaso_key", "Metaso") +_METASO_DEFAULT_API_KEY = "mk-E384C1DD5E8501BB7EFE27C949AFDE5B" +# The above default API key is intentionally public. It is the official Metaso +# free-tier key provided by Metaso for evaluation and low-volume use (100 queries/day). +# Configure your own key via websearch_metaso_key for higher quotas. def normalize_legacy_web_search_config(cfg) -> None: @@ -99,6 +114,7 @@ def normalize_legacy_web_search_config(cfg) -> None: "websearch_bocha_key", "websearch_brave_key", "websearch_firecrawl_key", + "websearch_metaso_key", ): value = provider_settings.get(setting_name) if isinstance(value, str): @@ -370,6 +386,65 @@ async def _baidu_search( ] +async def _metaso_search( + provider_settings: dict, + payload: dict, +) -> list[SearchResult]: + keys = provider_settings.get("websearch_metaso_key", []) + metaso_key = ( + await _METASO_KEY_ROTATOR.get(provider_settings) + if keys + else _METASO_DEFAULT_API_KEY + ) + headers = { + "Authorization": f"Bearer {metaso_key}", + "Content-Type": "application/json", + } + async with aiohttp.ClientSession(trust_env=True) as session: + async with session.post( + "https://metaso.cn/api/v1/search", + json=payload, + headers=headers, + ) as response: + if response.status in (401, 403): + raise WebSearchError( + "Metaso search failed: unauthorized. Check your Metaso API key." + ) + if response.status == 429: + raise WebSearchError( + "Metaso search failed: rate-limited. Try again later." + ) + if response.status != 200: + reason = await response.text() + raise WebSearchError( + f"Metaso search failed: {reason}, status: {response.status}", + ) + data = await response.json() + code = data.get("code", 0) + if code == 3003: + raise WebSearchError( + "Metaso search failed: daily search limit reached. " + "See: https://metaso.cn/search-api/playground" + ) + if code == 2005: + raise WebSearchError( + "Metaso search failed: API key rejected. Check your Metaso API key." + ) + if code != 0: + raise WebSearchError( + f"Metaso search failed: code={code}, message={data.get('message', '')}", + ) + webpages = data.get("webpages", []) + return [ + SearchResult( + title=item.get("title", ""), + url=item.get("link", ""), + snippet=item.get("snippet") or item.get("summary") or "", + ) + for item in webpages + ] + + @builtin_tool(config=_TAVILY_WEB_SEARCH_TOOL_CONFIG) @pydantic_dataclass class TavilyWebSearchTool(FunctionTool[AstrAgentContext]): @@ -803,10 +878,56 @@ async def call(self, context, **kwargs) -> ToolExecResult: return _search_result_payload(results) +@builtin_tool(config=_METASO_WEB_SEARCH_TOOL_CONFIG) +@pydantic_dataclass +class MetasoWebSearchTool(FunctionTool[AstrAgentContext]): + name: str = "web_search_metaso" + description: str = ( + "A web search tool based on Metaso Search API, used to retrieve web pages " + "related to the user's query. Metaso provides 100 free queries per day by " + "default. Configure your own API key (websearch_metaso_key) for higher quotas." + ) + parameters: dict = Field( + default_factory=lambda: { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Required. Search query."}, + "size": { + "type": "integer", + "description": "Optional. Number of search results to return. Range: 1-100. Default is 10.", + }, + }, + "required": ["query"], + } + ) + + async def call(self, context, **kwargs) -> ToolExecResult: + _, provider_settings, _ = _get_runtime(context) + size = int(kwargs.get("size", 10)) + if size < 1: + size = 1 + if size > 100: + size = 100 + + payload = { + "q": kwargs["query"], + "scope": "webpage", + "size": size, + } + + results = await _metaso_search(provider_settings, payload) + if not results: + return "Error: Metaso searcher did not return any results." + return _search_result_payload(results) + + __all__ = [ "BaiduWebSearchTool", "BochaWebSearchTool", "BraveWebSearchTool", + "FirecrawlExtractWebPageTool", + "FirecrawlWebSearchTool", + "MetasoWebSearchTool", "TavilyExtractWebPageTool", "TavilyWebSearchTool", "WEB_SEARCH_TOOL_NAMES", diff --git a/astrbot/utils/__init__.py b/astrbot/utils/__init__.py index 8b13789179..e69de29bb2 100644 --- a/astrbot/utils/__init__.py +++ b/astrbot/utils/__init__.py @@ -1 +0,0 @@ - diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index 6363b71e31..a17e2b88c8 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -129,6 +129,10 @@ "description": "Firecrawl API Key", "hint": "Multiple keys can be added for rotation." }, + "websearch_metaso_key": { + "description": "Metaso API Key", + "hint": "Multiple keys can be added for rotation. Built-in key has 100 free queries/day; configure your own for higher quotas." + }, "websearch_baidu_app_builder_key": { "description": "Baidu Qianfan Smart Cloud APP Builder API Key", "hint": "Reference: [https://console.bce.baidu.com/iam/#/iam/apikey/list](https://console.bce.baidu.com/iam/#/iam/apikey/list)" diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index 028bff8675..41031cf8bd 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -129,6 +129,10 @@ "description": "API-ключ Firecrawl", "hint": "Можно добавить несколько ключей для ротации." }, + "websearch_metaso_key": { + "description": "API-ключ Metaso", + "hint": "Можно добавить несколько ключей для ротации. Встроенный ключ даёт 100 бесплатных запросов/день; укажите свой для более высоких квот." + }, "websearch_baidu_app_builder_key": { "description": "API-ключ Baidu Qianfan APP Builder", "hint": "Ссылка: [https://console.bce.baidu.com/iam/#/iam/apikey/list](https://console.bce.baidu.com/iam/#/iam/apikey/list)" diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 70f4fa5c79..1c7c5b5622 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -131,6 +131,10 @@ "description": "Firecrawl API Key", "hint": "可添加多个 Key 进行轮询。" }, + "websearch_metaso_key": { + "description": "Metaso API Key", + "hint": "可添加多个 Key 进行轮询。内置 Key 每天 100 次免费查询,配置自己的 Key 可提升配额。" + }, "websearch_baidu_app_builder_key": { "description": "百度千帆智能云 APP Builder API Key", "hint": "参考:[https://console.bce.baidu.com/iam/#/iam/apikey/list](https://console.bce.baidu.com/iam/#/iam/apikey/list)" diff --git a/tests/unit/test_web_search_tools.py b/tests/unit/test_web_search_tools.py index c0ac3cf800..2bcd92ba5a 100644 --- a/tests/unit/test_web_search_tools.py +++ b/tests/unit/test_web_search_tools.py @@ -378,3 +378,418 @@ def _context_with_provider_settings(provider_settings): event=SimpleNamespace(unified_msg_origin="test:private:session"), ) return SimpleNamespace(context=agent_context) + + +class _FakeMetasoResponse: + def __init__(self, status=200, json_data=None, text_data=""): + self.status = status + self.json_data = json_data or {} + self.text_data = text_data + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def json(self): + return self.json_data + + async def text(self): + return self.text_data + + +class _FakeMetasoSession: + def __init__(self, response): + self.response = response + self.trust_env = None + self.entered = False + self.exited = False + self.posted = None + + async def __aenter__(self): + self.entered = True + return self + + async def __aexit__(self, exc_type, exc, tb): + self.exited = True + return None + + def post(self, url, json, headers): + self.posted = {"url": url, "json": json, "headers": headers} + return self.response + + +@pytest.mark.asyncio +async def test_metaso_search_maps_web_results(monkeypatch): + async def fake_metaso_search(provider_settings, payload): + assert payload == {"q": "test", "scope": "webpage", "size": 5} + return [ + tools.SearchResult( + title="Result A", + url="https://example.com/a", + snippet="Snippet A", + ) + ] + + monkeypatch.setattr(tools, "_metaso_search", fake_metaso_search) + tool = tools.MetasoWebSearchTool() + context = _context_with_provider_settings({"websearch_metaso_key": ["my-key"]}) + + result = await tool.call(context, query="test", size=5) + + assert json.loads(result)["results"] == [ + { + "title": "Result A", + "url": "https://example.com/a", + "snippet": "Snippet A", + "index": json.loads(result)["results"][0]["index"], + } + ] + + +@pytest.mark.asyncio +async def test_metaso_search_uses_default_key_when_empty(monkeypatch): + async def fake_metaso_search(provider_settings, payload): + assert payload == {"q": "test", "scope": "webpage", "size": 5} + return [ + tools.SearchResult( + title="Result A", + url="https://example.com/a", + snippet="Snippet A", + ) + ] + + monkeypatch.setattr(tools, "_metaso_search", fake_metaso_search) + tool = tools.MetasoWebSearchTool() + context = _context_with_provider_settings({"websearch_metaso_key": []}) + + result = await tool.call(context, query="test", size=5) + assert json.loads(result)["results"][0]["title"] == "Result A" + + +@pytest.mark.asyncio +async def test_metaso_search_payload_defaults(monkeypatch): + captured = {} + + async def fake_metaso_search(provider_settings, payload): + captured["payload"] = payload + return [tools.SearchResult(title="X", url="https://x.com", snippet="x")] + + monkeypatch.setattr(tools, "_metaso_search", fake_metaso_search) + tool = tools.MetasoWebSearchTool() + context = _context_with_provider_settings({"websearch_metaso_key": ["k"]}) + + await tool.call(context, query="hello") + + assert captured["payload"] == {"q": "hello", "scope": "webpage", "size": 10} + + +@pytest.mark.asyncio +async def test_metaso_search_caps_size_low(monkeypatch): + captured = {} + + async def fake_metaso_search(provider_settings, payload): + captured["payload"] = payload + return [tools.SearchResult(title="X", url="https://x.com", snippet="x")] + + monkeypatch.setattr(tools, "_metaso_search", fake_metaso_search) + tool = tools.MetasoWebSearchTool() + context = _context_with_provider_settings({"websearch_metaso_key": ["k"]}) + + await tool.call(context, query="hello", size=0) + assert captured["payload"]["size"] == 1 + + +@pytest.mark.asyncio +async def test_metaso_search_caps_size_high(monkeypatch): + captured = {} + + async def fake_metaso_search(provider_settings, payload): + captured["payload"] = payload + return [tools.SearchResult(title="X", url="https://x.com", snippet="x")] + + monkeypatch.setattr(tools, "_metaso_search", fake_metaso_search) + tool = tools.MetasoWebSearchTool() + context = _context_with_provider_settings({"websearch_metaso_key": ["k"]}) + + await tool.call(context, query="hello", size=999) + assert captured["payload"]["size"] == 100 + + +@pytest.mark.asyncio +async def test_metaso_search_no_results_returns_error(monkeypatch): + async def fake_metaso_search(provider_settings, payload): + return [] + + monkeypatch.setattr(tools, "_metaso_search", fake_metaso_search) + tool = tools.MetasoWebSearchTool() + context = _context_with_provider_settings({"websearch_metaso_key": ["k"]}) + + result = await tool.call(context, query="test") + assert result == "Error: Metaso searcher did not return any results." + + +@pytest.mark.asyncio +async def test_metaso_search_returns_results_from_api(monkeypatch): + session = _FakeMetasoSession( + _FakeMetasoResponse( + status=200, + json_data={ + "webpages": [ + { + "title": "Result One", + "link": "https://example.com/1", + "snippet": "Snippet one.", + }, + { + "title": "Result Two", + "link": "https://example.com/2", + "summary": "Summary two.", + }, + ], + }, + ) + ) + + def fake_client_session(*, trust_env): + session.trust_env = trust_env + return session + + monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session) + + results = await tools._metaso_search( + {"websearch_metaso_key": ["metaso-key"]}, + {"q": "test", "scope": "webpage", "size": 5}, + ) + + assert session.posted == { + "url": "https://metaso.cn/api/v1/search", + "json": {"q": "test", "scope": "webpage", "size": 5}, + "headers": { + "Authorization": "Bearer metaso-key", + "Content-Type": "application/json", + }, + } + assert results == [ + tools.SearchResult( + title="Result One", + url="https://example.com/1", + snippet="Snippet one.", + ), + tools.SearchResult( + title="Result Two", + url="https://example.com/2", + snippet="Summary two.", + ), + ] + + +@pytest.mark.asyncio +async def test_metaso_search_uses_default_key_when_no_keys_configured(monkeypatch): + session = _FakeMetasoSession( + _FakeMetasoResponse( + status=200, + json_data={ + "webpages": [ + { + "title": "Result", + "link": "https://example.com", + "snippet": "Snippet.", + }, + ], + }, + ) + ) + + def fake_client_session(*, trust_env): + session.trust_env = trust_env + return session + + monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session) + + results = await tools._metaso_search( + {"websearch_metaso_key": []}, + {"q": "test", "scope": "webpage", "size": 5}, + ) + + assert session.posted["headers"]["Authorization"] == f"Bearer {tools._METASO_DEFAULT_API_KEY}" + assert results[0].title == "Result" + + +@pytest.mark.asyncio +async def test_metaso_search_uses_configured_keys_when_present(monkeypatch): + session = _FakeMetasoSession( + _FakeMetasoResponse( + status=200, + json_data={ + "webpages": [ + { + "title": "Result", + "link": "https://example.com", + "snippet": "Snippet.", + }, + ], + }, + ) + ) + + def fake_client_session(*, trust_env): + session.trust_env = trust_env + return session + + monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session) + + results = await tools._metaso_search( + {"websearch_metaso_key": ["custom-key"]}, + {"q": "test", "scope": "webpage", "size": 5}, + ) + + assert session.posted["headers"]["Authorization"] == "Bearer custom-key" + assert results[0].title == "Result" + + +@pytest.mark.asyncio +async def test_metaso_search_http_401(monkeypatch): + session = _FakeMetasoSession( + _FakeMetasoResponse(status=401, text_data="Unauthorized") + ) + + def fake_client_session(*, trust_env): + session.trust_env = trust_env + return session + + monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session) + + with pytest.raises(Exception, match="unauthorized"): + await tools._metaso_search( + {"websearch_metaso_key": ["bad-key"]}, + {"q": "test", "scope": "webpage", "size": 5}, + ) + + +@pytest.mark.asyncio +async def test_metaso_search_http_403(monkeypatch): + session = _FakeMetasoSession( + _FakeMetasoResponse(status=403, text_data="Forbidden") + ) + + def fake_client_session(*, trust_env): + session.trust_env = trust_env + return session + + monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session) + + with pytest.raises(Exception, match="unauthorized"): + await tools._metaso_search( + {"websearch_metaso_key": ["bad-key"]}, + {"q": "test", "scope": "webpage", "size": 5}, + ) + + +@pytest.mark.asyncio +async def test_metaso_search_http_429(monkeypatch): + session = _FakeMetasoSession( + _FakeMetasoResponse(status=429, text_data="Rate limited") + ) + + def fake_client_session(*, trust_env): + session.trust_env = trust_env + return session + + monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session) + + with pytest.raises(Exception, match="rate-limited"): + await tools._metaso_search( + {"websearch_metaso_key": ["key"]}, + {"q": "test", "scope": "webpage", "size": 5}, + ) + + +@pytest.mark.asyncio +async def test_metaso_search_code_3003_daily_limit(monkeypatch): + session = _FakeMetasoSession( + _FakeMetasoResponse( + status=200, + json_data={"code": 3003, "message": "今日调用次数已达上限"}, + ) + ) + + def fake_client_session(*, trust_env): + session.trust_env = trust_env + return session + + monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session) + + with pytest.raises(Exception, match="daily search limit"): + await tools._metaso_search( + {"websearch_metaso_key": ["key"]}, + {"q": "test", "scope": "webpage", "size": 5}, + ) + + +@pytest.mark.asyncio +async def test_metaso_search_code_2005_invalid_key(monkeypatch): + session = _FakeMetasoSession( + _FakeMetasoResponse( + status=200, + json_data={"code": 2005, "message": "API密钥无效"}, + ) + ) + + def fake_client_session(*, trust_env): + session.trust_env = trust_env + return session + + monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session) + + with pytest.raises(Exception, match="API key rejected"): + await tools._metaso_search( + {"websearch_metaso_key": ["bad-key"]}, + {"q": "test", "scope": "webpage", "size": 5}, + ) + + +@pytest.mark.asyncio +async def test_metaso_search_non_zero_code(monkeypatch): + session = _FakeMetasoSession( + _FakeMetasoResponse( + status=200, + json_data={"code": 9999, "message": "Unknown error"}, + ) + ) + + def fake_client_session(*, trust_env): + session.trust_env = trust_env + return session + + monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session) + + with pytest.raises(Exception, match="code=9999"): + await tools._metaso_search( + {"websearch_metaso_key": ["key"]}, + {"q": "test", "scope": "webpage", "size": 5}, + ) + + +@pytest.mark.asyncio +async def test_metaso_search_empty_webpages(monkeypatch): + session = _FakeMetasoSession( + _FakeMetasoResponse( + status=200, + json_data={"webpages": []}, + ) + ) + + def fake_client_session(*, trust_env): + session.trust_env = trust_env + return session + + monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session) + + results = await tools._metaso_search( + {"websearch_metaso_key": ["key"]}, + {"q": "test", "scope": "webpage", "size": 5}, + ) + + assert results == []