Skip to content

Commit ddddd8a

Browse files
authored
Merge branch 'AstrBotDevs:master' into Sjshi763/issue4409
2 parents fbc7b2b + 5e5207d commit ddddd8a

30 files changed

Lines changed: 1316 additions & 305 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,8 +264,9 @@ pre-commit install
264264

265265
<div align="center">
266266

267-
_私は、高性能ですから!_
267+
_陪伴与能力从来不应该是对立面。我们希望创造的是一个既能理解情绪、给予陪伴,也能可靠完成工作的机器人。_
268268

269-
陪伴与能力从来不应该是对立面。我们希望创造的是一个既能理解情绪、给予陪伴,也能可靠完成工作的机器人。
269+
_私は、高性能ですから!_
270270

271+
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
271272

astrbot/builtin_stars/web_searcher/main.py

Lines changed: 179 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,17 @@ class Main(star.Star):
2323
"fetch_url",
2424
"web_search_tavily",
2525
"tavily_extract_web_page",
26+
"web_search_bocha",
2627
]
2728

2829
def __init__(self, context: star.Context) -> None:
2930
self.context = context
3031
self.tavily_key_index = 0
3132
self.tavily_key_lock = asyncio.Lock()
3233

34+
self.bocha_key_index = 0
35+
self.bocha_key_lock = asyncio.Lock()
36+
3337
# 将 str 类型的 key 迁移至 list[str],并保存
3438
cfg = self.context.get_config()
3539
provider_settings = cfg.get("provider_settings")
@@ -45,6 +49,14 @@ def __init__(self, context: star.Context) -> None:
4549
provider_settings["websearch_tavily_key"] = []
4650
cfg.save_config()
4751

52+
bocha_key = provider_settings.get("websearch_bocha_key")
53+
if isinstance(bocha_key, str):
54+
if bocha_key:
55+
provider_settings["websearch_bocha_key"] = [bocha_key]
56+
else:
57+
provider_settings["websearch_bocha_key"] = []
58+
cfg.save_config()
59+
4860
self.bing_search = Bing()
4961
self.sogo_search = Sogo()
5062
self.baidu_initialized = False
@@ -341,7 +353,7 @@ async def search_from_tavily(
341353
}
342354
)
343355
if result.favicon:
344-
sp.temorary_cache["_ws_favicon"][result.url] = result.favicon
356+
sp.temporary_cache["_ws_favicon"][result.url] = result.favicon
345357
# ret = "\n".join(ret_ls)
346358
ret = json.dumps({"results": ret_ls}, ensure_ascii=False)
347359
return ret
@@ -382,6 +394,160 @@ async def tavily_extract_web_page(
382394
return "Error: Tavily web searcher does not return any results."
383395
return ret
384396

397+
async def _get_bocha_key(self, cfg: AstrBotConfig) -> str:
398+
"""并发安全的从列表中获取并轮换BoCha API密钥。"""
399+
bocha_keys = cfg.get("provider_settings", {}).get("websearch_bocha_key", [])
400+
if not bocha_keys:
401+
raise ValueError("错误:BoCha API密钥未在AstrBot中配置。")
402+
403+
async with self.bocha_key_lock:
404+
key = bocha_keys[self.bocha_key_index]
405+
self.bocha_key_index = (self.bocha_key_index + 1) % len(bocha_keys)
406+
return key
407+
408+
async def _web_search_bocha(
409+
self,
410+
cfg: AstrBotConfig,
411+
payload: dict,
412+
) -> list[SearchResult]:
413+
"""使用 BoCha 搜索引擎进行搜索"""
414+
bocha_key = await self._get_bocha_key(cfg)
415+
url = "https://api.bochaai.com/v1/web-search"
416+
header = {
417+
"Authorization": f"Bearer {bocha_key}",
418+
"Content-Type": "application/json",
419+
}
420+
async with aiohttp.ClientSession(trust_env=True) as session:
421+
async with session.post(
422+
url,
423+
json=payload,
424+
headers=header,
425+
) as response:
426+
if response.status != 200:
427+
reason = await response.text()
428+
raise Exception(
429+
f"BoCha web search failed: {reason}, status: {response.status}",
430+
)
431+
data = await response.json()
432+
data = data["data"]["webPages"]["value"]
433+
results = []
434+
for item in data:
435+
result = SearchResult(
436+
title=item.get("name"),
437+
url=item.get("url"),
438+
snippet=item.get("snippet"),
439+
favicon=item.get("siteIcon"),
440+
)
441+
results.append(result)
442+
return results
443+
444+
@llm_tool("web_search_bocha")
445+
async def search_from_bocha(
446+
self,
447+
event: AstrMessageEvent,
448+
query: str,
449+
freshness: str = "noLimit",
450+
summary: bool = False,
451+
include: str = "",
452+
exclude: str = "",
453+
count: int = 10,
454+
) -> str:
455+
"""
456+
A web search tool based on Bocha Search API, used to retrieve web pages
457+
related to the user's query.
458+
459+
Args:
460+
query (string): Required. User's search query.
461+
462+
freshness (string): Optional. Specifies the time range of the search.
463+
Supported values:
464+
- "noLimit": No time limit (default, recommended).
465+
- "oneDay": Within one day.
466+
- "oneWeek": Within one week.
467+
- "oneMonth": Within one month.
468+
- "oneYear": Within one year.
469+
- "YYYY-MM-DD..YYYY-MM-DD": Search within a specific date range.
470+
Example: "2025-01-01..2025-04-06".
471+
- "YYYY-MM-DD": Search on a specific date.
472+
Example: "2025-04-06".
473+
It is recommended to use "noLimit", as the search algorithm will
474+
automatically optimize time relevance. Manually restricting the
475+
time range may result in no search results.
476+
477+
summary (boolean): Optional. Whether to include a text summary
478+
for each search result.
479+
- True: Include summary.
480+
- False: Do not include summary (default).
481+
482+
include (string): Optional. Specifies the domains to include in
483+
the search. Multiple domains can be separated by "|" or ",".
484+
A maximum of 100 domains is allowed.
485+
Examples:
486+
- "qq.com"
487+
- "qq.com|m.163.com"
488+
489+
exclude (string): Optional. Specifies the domains to exclude from
490+
the search. Multiple domains can be separated by "|" or ",".
491+
A maximum of 100 domains is allowed.
492+
Examples:
493+
- "qq.com"
494+
- "qq.com|m.163.com"
495+
496+
count (number): Optional. Number of search results to return.
497+
- Range: 1–50
498+
- Default: 10
499+
The actual number of returned results may be less than the
500+
specified count.
501+
"""
502+
logger.info(f"web_searcher - search_from_bocha: {query}")
503+
cfg = self.context.get_config(umo=event.unified_msg_origin)
504+
# websearch_link = cfg["provider_settings"].get("web_search_link", False)
505+
if not cfg.get("provider_settings", {}).get("websearch_bocha_key", []):
506+
raise ValueError("Error: BoCha API key is not configured in AstrBot.")
507+
508+
# build payload
509+
payload = {
510+
"query": query,
511+
"count": count,
512+
}
513+
514+
# freshness:时间范围
515+
if freshness:
516+
payload["freshness"] = freshness
517+
518+
# 是否返回摘要
519+
payload["summary"] = summary
520+
521+
# include:限制搜索域
522+
if include:
523+
payload["include"] = include
524+
525+
# exclude:排除搜索域
526+
if exclude:
527+
payload["exclude"] = exclude
528+
529+
results = await self._web_search_bocha(cfg, payload)
530+
if not results:
531+
return "Error: BoCha web searcher does not return any results."
532+
533+
ret_ls = []
534+
ref_uuid = str(uuid.uuid4())[:4]
535+
for idx, result in enumerate(results, 1):
536+
index = f"{ref_uuid}.{idx}"
537+
ret_ls.append(
538+
{
539+
"title": f"{result.title}",
540+
"url": f"{result.url}",
541+
"snippet": f"{result.snippet}",
542+
"index": index,
543+
}
544+
)
545+
if result.favicon:
546+
sp.temporary_cache["_ws_favicon"][result.url] = result.favicon
547+
# ret = "\n".join(ret_ls)
548+
ret = json.dumps({"results": ret_ls}, ensure_ascii=False)
549+
return ret
550+
385551
@filter.on_llm_request(priority=-10000)
386552
async def edit_web_search_tools(
387553
self,
@@ -419,6 +585,7 @@ async def edit_web_search_tools(
419585
tool_set.remove_tool("web_search_tavily")
420586
tool_set.remove_tool("tavily_extract_web_page")
421587
tool_set.remove_tool("AIsearch")
588+
tool_set.remove_tool("web_search_bocha")
422589
elif provider == "tavily":
423590
web_search_tavily = func_tool_mgr.get_func("web_search_tavily")
424591
tavily_extract_web_page = func_tool_mgr.get_func("tavily_extract_web_page")
@@ -429,6 +596,7 @@ async def edit_web_search_tools(
429596
tool_set.remove_tool("web_search")
430597
tool_set.remove_tool("fetch_url")
431598
tool_set.remove_tool("AIsearch")
599+
tool_set.remove_tool("web_search_bocha")
432600
elif provider == "baidu_ai_search":
433601
try:
434602
await self.ensure_baidu_ai_search_mcp(event.unified_msg_origin)
@@ -440,5 +608,15 @@ async def edit_web_search_tools(
440608
tool_set.remove_tool("fetch_url")
441609
tool_set.remove_tool("web_search_tavily")
442610
tool_set.remove_tool("tavily_extract_web_page")
611+
tool_set.remove_tool("web_search_bocha")
443612
except Exception as e:
444613
logger.error(f"Cannot Initialize Baidu AI Search MCP Server: {e}")
614+
elif provider == "bocha":
615+
web_search_bocha = func_tool_mgr.get_func("web_search_bocha")
616+
if web_search_bocha:
617+
tool_set.add_tool(web_search_bocha)
618+
tool_set.remove_tool("web_search")
619+
tool_set.remove_tool("fetch_url")
620+
tool_set.remove_tool("AIsearch")
621+
tool_set.remove_tool("web_search_tavily")
622+
tool_set.remove_tool("tavily_extract_web_page")

astrbot/cli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "4.14.4"
1+
__version__ = "4.14.5"

astrbot/core/agent/runners/tool_loop_agent_runner.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,10 @@ async def step(self):
254254
)
255255
if llm_resp.completion_text:
256256
parts.append(TextPart(text=llm_resp.completion_text))
257+
if len(parts) == 0:
258+
logger.warning(
259+
"LLM returned empty assistant message with no tool calls."
260+
)
257261
self.run_context.messages.append(Message(role="assistant", content=parts))
258262

259263
# call the on_agent_done hook
@@ -309,6 +313,8 @@ async def step(self):
309313
)
310314
if llm_resp.completion_text:
311315
parts.append(TextPart(text=llm_resp.completion_text))
316+
if len(parts) == 0:
317+
parts = None
312318
tool_calls_result = ToolCallsResult(
313319
tool_calls_info=AssistantMessageSegment(
314320
tool_calls=llm_resp.to_openai_to_calls_model(),

astrbot/core/agent/tool.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,18 @@ def convert_schema(schema: dict) -> dict:
246246

247247
result = {}
248248

249-
if "type" in schema and schema["type"] in supported_types:
250-
result["type"] = schema["type"]
249+
# Avoid side effects by not modifying the original schema
250+
origin_type = schema.get("type")
251+
target_type = origin_type
252+
253+
# Compatibility fix: Gemini API expects 'type' to be a string (enum),
254+
# but standard JSON Schema (MCP) allows lists (e.g. ["string", "null"]).
255+
# We fallback to the first non-null type.
256+
if isinstance(origin_type, list):
257+
target_type = next((t for t in origin_type if t != "null"), "string")
258+
259+
if target_type in supported_types:
260+
result["type"] = target_type
251261
if "format" in schema and schema["format"] in supported_formats.get(
252262
result["type"],
253263
set(),

astrbot/core/astr_agent_hooks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ async def on_tool_end(
5959
platform_name = run_context.context.event.get_platform_name()
6060
if (
6161
platform_name == "webchat"
62-
and tool.name == "web_search_tavily"
62+
and tool.name in ["web_search_tavily", "web_search_bocha"]
6363
and len(run_context.messages) > 0
6464
and tool_result
6565
and len(tool_result.content)

astrbot/core/config/default.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
77

8-
VERSION = "4.14.4"
8+
VERSION = "4.14.5"
99
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
1010

1111
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -74,6 +74,7 @@
7474
"web_search": False,
7575
"websearch_provider": "default",
7676
"websearch_tavily_key": [],
77+
"websearch_bocha_key": [],
7778
"websearch_baidu_app_builder_key": "",
7879
"web_search_link": False,
7980
"display_reasoning_text": False,
@@ -2563,7 +2564,7 @@ class ChatProviderTemplate(TypedDict):
25632564
"provider_settings.websearch_provider": {
25642565
"description": "网页搜索提供商",
25652566
"type": "string",
2566-
"options": ["default", "tavily", "baidu_ai_search"],
2567+
"options": ["default", "tavily", "baidu_ai_search", "bocha"],
25672568
"condition": {
25682569
"provider_settings.web_search": True,
25692570
},
@@ -2578,6 +2579,16 @@ class ChatProviderTemplate(TypedDict):
25782579
"provider_settings.web_search": True,
25792580
},
25802581
},
2582+
"provider_settings.websearch_bocha_key": {
2583+
"description": "BoCha API Key",
2584+
"type": "list",
2585+
"items": {"type": "string"},
2586+
"hint": "可添加多个 Key 进行轮询。",
2587+
"condition": {
2588+
"provider_settings.websearch_provider": "bocha",
2589+
"provider_settings.web_search": True,
2590+
},
2591+
},
25812592
"provider_settings.websearch_baidu_app_builder_key": {
25822593
"description": "百度千帆智能云 APP Builder API Key",
25832594
"type": "string",

astrbot/core/pipeline/content_safety_check/strategies/baidu_aip.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""使用此功能应该先 pip install baidu-aip"""
22

3+
from typing import Any, cast
4+
35
from aip import AipContentCensor
46

57
from . import ContentSafetyStrategy
@@ -23,7 +25,8 @@ def check(self, content: str) -> tuple[bool, str]:
2325
count = len(res["data"])
2426
parts = [f"百度审核服务发现 {count} 处违规:\n"]
2527
for i in res["data"]:
26-
parts.append(f"{i['msg']}\n")
28+
# 百度 AIP 返回结构是动态 dict;类型检查时 i 可能被推断为序列,转成 dict 后用 get 取字段
29+
parts.append(f"{cast(dict[str, Any], i).get('msg', '')}\n")
2730
parts.append("\n判断结果:" + res["conclusion"])
2831
info = "".join(parts)
2932
return False, info

astrbot/core/platform/message_session.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from dataclasses import dataclass
1+
from dataclasses import dataclass, field
22

33
from astrbot.core.platform.message_type import MessageType
44

@@ -13,7 +13,7 @@ class MessageSession:
1313
"""平台适配器实例的唯一标识符。自 AstrBot v4.0.0 起,该字段实际为 platform_id。"""
1414
message_type: MessageType
1515
session_id: str
16-
platform_id: str | None = None
16+
platform_id: str = field(init=False)
1717

1818
def __str__(self):
1919
return f"{self.platform_id}:{self.message_type.value}:{self.session_id}"

astrbot/core/platform/sources/discord/discord_platform_adapter.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -444,9 +444,20 @@ async def dynamic_callback(
444444
logger.warning(f"[Discord] 指令 '{cmd_name}' defer 失败: {e}")
445445

446446
# 2. 构建 AstrBotMessage
447+
channel = ctx.channel
447448
abm = AstrBotMessage()
448-
abm.type = self._get_message_type(ctx.channel, ctx.guild_id)
449-
abm.group_id = self._get_channel_id(ctx.channel)
449+
if channel is not None:
450+
abm.type = self._get_message_type(channel, ctx.guild_id)
451+
abm.group_id = self._get_channel_id(channel)
452+
else:
453+
# 防守式兜底:channel 取不到时,仍能根据 guild_id/channel_id 推断会话信息
454+
abm.type = (
455+
MessageType.GROUP_MESSAGE
456+
if ctx.guild_id is not None
457+
else MessageType.FRIEND_MESSAGE
458+
)
459+
abm.group_id = str(ctx.channel_id)
460+
450461
abm.message_str = message_str_for_filter
451462
abm.sender = MessageMember(
452463
user_id=str(ctx.author.id),

0 commit comments

Comments
 (0)