Compare commits

...

6 Commits

Author SHA1 Message Date
Soulter
133c7f74df fix: restore star context typing 2026-06-08 00:23:07 +08:00
Soulter
05c137eb29 fix: qq official webhook mode can not restart normally 2026-06-07 18:10:45 +08:00
Copilot
1a04998787 perf: handle Anthropic usage=None on content-filtered responses (#8647)
* Initial plan

* fix: handle missing anthropic usage on filtered responses

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-07 15:29:22 +08:00
Soulter
c4251e8210 chore: bump version to 4.25.4 2026-06-07 12:35:12 +08:00
Weilong Liao
66a10c08b2 perf: increase weixin http api request timeout from 15s to 120s (#8643) 2026-06-07 12:26:26 +08:00
Weilong Liao
c7e9d5b481 fix: Prevent duplicate web search citation prompts from being repeatedly appended to the system message after multiple tool invocations in a single interaction (#8642) 2026-06-07 12:23:03 +08:00
12 changed files with 373 additions and 45 deletions

View File

@@ -3,7 +3,6 @@ from typing import Any
from mcp.types import CallToolResult
from astrbot.core.agent.hooks import BaseAgentRunHooks
from astrbot.core.agent.message import Message
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import FunctionTool
from astrbot.core.astr_agent_context import AstrAgentContext
@@ -70,37 +69,6 @@ class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
tool_result,
)
# special handle web_search_tavily
platform_name = run_context.context.event.get_platform_name()
if (
platform_name == "webchat"
and tool.name
in [
"web_search_baidu",
"web_search_tavily",
"web_search_bocha",
"web_search_brave",
]
and len(run_context.messages) > 0
and tool_result
and len(tool_result.content)
):
# inject system prompt
first_part = run_context.messages[0]
if (
isinstance(first_part, Message)
and first_part.role == "system"
and first_part.content
and isinstance(first_part.content, str)
):
# we assume system part is str
first_part.content += (
"Always cite web search results you rely on. "
"Index is a unique identifier for each search result. "
"Use the exact citation format <ref>index</ref> (e.g. <ref>abcd.3</ref>) "
"after the sentence that uses the information. Do not invent citations."
)
class EmptyAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
pass

View File

@@ -115,6 +115,20 @@ from astrbot.core.utils.quoted_message_parser import (
from astrbot.core.utils.string_utils import normalize_and_dedupe_strings
LLM_ERROR_MESSAGE_EXTRA_KEY = "_llm_error_message"
WEB_SEARCH_CITATION_TOOL_NAMES = frozenset(
{
"web_search_baidu",
"web_search_tavily",
"web_search_bocha",
"web_search_brave",
}
)
WEB_SEARCH_CITATION_PROMPT = (
"Always cite web search results you rely on. "
"Index is a unique identifier for each search result. "
"Use the exact citation format <ref>index</ref> (e.g. <ref>abcd.3</ref>) "
"after the sentence that uses the information. Do not invent citations."
)
@dataclass(slots=True)
@@ -1149,6 +1163,23 @@ async def _apply_web_search_tools(
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BaiduWebSearchTool))
def _apply_web_search_citation_prompt(
event: AstrMessageEvent,
req: ProviderRequest,
) -> None:
if event.get_platform_name() != "webchat" or not req.func_tool:
return
if not any(req.func_tool.get_tool(name) for name in WEB_SEARCH_CITATION_TOOL_NAMES):
return
system_prompt = req.system_prompt or ""
if WEB_SEARCH_CITATION_PROMPT in system_prompt:
return
req.system_prompt = f"{system_prompt}\n{WEB_SEARCH_CITATION_PROMPT}\n"
def _get_compress_provider(
config: MainAgentBuildConfig,
plugin_context: Context,
@@ -1520,6 +1551,8 @@ async def build_main_agent(
if action_type == "live":
req.system_prompt += f"\n{LIVE_MODE_SYSTEM_PROMPT}\n"
_apply_web_search_citation_prompt(event, req)
reset_coro = agent_runner.reset(
provider=provider,
request=req,

View File

@@ -5,7 +5,7 @@ import os
from astrbot.core.computer.booters.cua_defaults import CUA_DEFAULT_CONFIG
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.25.3"
VERSION = "4.25.4"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
PERSONAL_WECHAT_CONFIG_METADATA = {
"weixin_oc_base_url": {
@@ -417,7 +417,7 @@ CONFIG_METADATA_2 = {
"weixin_oc_bot_type": "3",
"weixin_oc_qr_poll_interval": 1,
"weixin_oc_long_poll_timeout_ms": 35_000,
"weixin_oc_api_timeout_ms": 15_000,
"weixin_oc_api_timeout_ms": 120_000,
},
"飞书(Lark)": {
"id": "lark",

View File

@@ -1,10 +1,13 @@
import asyncio
import json
import logging
import time
from binascii import Error as BinasciiError
from typing import cast
import quart
from botpy import BotAPI, BotHttp, BotWebSocket, Client, ConnectionSession, Token
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric import ed25519
from astrbot.api import logger
@@ -13,6 +16,57 @@ from astrbot.api import logger
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
_SIGNATURE_HEADER = "X-Signature-Ed25519"
_SIGNATURE_TIMESTAMP_HEADER = "X-Signature-Timestamp"
_ED25519_SEED_SIZE = 32
_ED25519_SIGNATURE_SIZE = 64
def _build_ed25519_seed(secret: str) -> bytes:
if not secret:
raise ValueError("QQ official bot secret is empty.")
seed = secret.encode("utf-8")
while len(seed) < _ED25519_SEED_SIZE:
seed *= 2
return seed[:_ED25519_SEED_SIZE]
def _sign_qq_webhook_payload(secret: str, timestamp: str, payload: bytes) -> str:
seed = _build_ed25519_seed(secret)
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed)
return private_key.sign(timestamp.encode("utf-8") + payload).hex()
def _verify_qq_webhook_signature(
secret: str,
timestamp: str | None,
signature: str | None,
body: bytes,
) -> bool:
if not timestamp or not signature:
return False
try:
signature_buffer = bytes.fromhex(signature)
except (BinasciiError, ValueError):
return False
if (
len(signature_buffer) != _ED25519_SIGNATURE_SIZE
or signature_buffer[63] & 224 != 0
):
return False
try:
seed = _build_ed25519_seed(secret)
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed)
public_key = private_key.public_key()
public_key.verify(signature_buffer, timestamp.encode("utf-8") + body)
except (InvalidSignature, ValueError):
return False
return True
class QQOfficialWebhook:
def __init__(
@@ -27,7 +81,12 @@ class QQOfficialWebhook:
if isinstance(self.port, str):
self.port = int(self.port)
self.http: BotHttp = BotHttp(timeout=300, is_sandbox=self.is_sandbox)
self.http: BotHttp = BotHttp(
timeout=300,
is_sandbox=self.is_sandbox,
app_id=self.appid,
secret=self.secret,
)
self.api: BotAPI = BotAPI(http=self.http)
self.token = Token(self.appid, self.secret)
@@ -40,6 +99,7 @@ class QQOfficialWebhook:
self.client = botpy_client
self.event_queue = event_queue
self.shutdown_event = asyncio.Event()
self._connection: ConnectionSession | None = None
# Cache for extra fields extracted from raw webhook payloads, keyed by message id
self._extra_data_cache: dict[str, dict] = {}
@@ -55,6 +115,13 @@ class QQOfficialWebhook:
# 直接注入到 botpy 的 Client移花接木
self.client.api = self.api
self.client.http = self.http
self._setup_connection()
def _setup_connection(self) -> None:
if self._connection is not None:
return
self.client.api = self.api
self.client.http = self.http
async def bot_connect() -> None:
pass
@@ -105,7 +172,24 @@ class QQOfficialWebhook:
Returns:
响应数据
"""
msg: dict = await request.json
body = await request.get_data()
if not _verify_qq_webhook_signature(
self.secret,
request.headers.get(_SIGNATURE_TIMESTAMP_HEADER),
request.headers.get(_SIGNATURE_HEADER),
body,
):
logger.warning("qq_official_webhook signature verification failed.")
return {"error": "Invalid signature"}, 401
try:
msg = json.loads(body.decode("utf-8"))
except json.JSONDecodeError:
logger.warning("qq_official_webhook callback body is not valid JSON.")
return {"error": "Invalid JSON"}, 400
if not isinstance(msg, dict):
return {"error": "Invalid JSON"}, 400
logger.debug(f"收到 qq_official_webhook 回调: {msg}")
event = msg.get("t")
@@ -136,6 +220,13 @@ class QQOfficialWebhook:
if event and opcode == BotWebSocket.WS_DISPATCH_EVENT:
event = msg["t"].lower()
if self._connection is None:
logger.warning(
"qq_official_webhook botpy connection is not initialized; "
"creating parser connection lazily.",
)
self._setup_connection()
# Extract extra fields from raw payload before botpy parses and discards them
if data:
msg_id = data.get("id")

View File

@@ -130,7 +130,7 @@ class WeixinOCAdapter(Platform):
platform_config.get("weixin_oc_long_poll_timeout_ms", 35_000),
)
self.api_timeout_ms = int(
platform_config.get("weixin_oc_api_timeout_ms", 15_000),
platform_config.get("weixin_oc_api_timeout_ms", 120_000),
)
self.cdn_base_url = str(
platform_config.get(

View File

@@ -302,12 +302,14 @@ class ProviderAnthropic(Provider):
return system_prompt, new_messages
def _extract_usage(self, usage: Usage) -> TokenUsage:
def _extract_usage(self, usage: Usage | None) -> TokenUsage:
if usage is None:
return TokenUsage()
# https://docs.claude.com/en/docs/build-with-claude/prompt-caching#tracking-cache-performance
return TokenUsage(
input_other=usage.input_tokens or 0,
input_cached=usage.cache_read_input_tokens or 0,
output=usage.output_tokens,
output=usage.output_tokens or 0,
)
def _update_usage(self, token_usage: TokenUsage, usage: MessageDeltaUsage) -> None:

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
import logging
from typing import Any, Protocol
from typing import TYPE_CHECKING, Any
from astrbot.core import html_renderer
from astrbot.core.utils.command_parser import CommandParserMixin
@@ -9,6 +9,9 @@ from astrbot.core.utils.plugin_kv_store import PluginKVStoreMixin
from .star import StarMetadata, star_map, star_registry
if TYPE_CHECKING:
from .context import Context
logger = logging.getLogger("astrbot")
@@ -17,11 +20,9 @@ class Star(CommandParserMixin, PluginKVStoreMixin):
author: str
name: str
context: Context
class _ContextLike(Protocol):
def get_config(self, umo: str | None = None) -> Any: ...
def __init__(self, context: _ContextLike, config: dict | None = None) -> None:
def __init__(self, context: Context, config: dict | None = None) -> None:
self.context = context
def _get_context_config(self) -> Any:

35
changelogs/v4.25.4.md Normal file
View File

@@ -0,0 +1,35 @@
- [更新日志(简体中文)](#chinese)
- [Changelog(English)](#english)
<a id="chinese"></a>
## What's Changed
### 修复
- 回滚部分改动,修复偶现的 `Database is locked` 的问题。([#8639](https://github.com/AstrBotDevs/AstrBot/pull/8639))
- 修复 Pipeline 异步任务可能因缺少强引用而被垃圾回收的问题,提升事件处理稳定性。([#8618](https://github.com/AstrBotDevs/AstrBot/pull/8618))
- 修复 WebChat 使用 Web 搜索工具时,引用提示词在同一轮对话多次工具调用后被重复追加到系统消息的问题,避免破坏上下文缓存。([#8642](https://github.com/AstrBotDevs/AstrBot/pull/8642))
- 同步 Dashboard `pnpm-lock.yaml` 中的 overrides 配置,修复锁文件与工作区配置不一致的问题。([#8637](https://github.com/AstrBotDevs/AstrBot/pull/8637))
### 优化
- 将微信公众号 HTTP API 请求超时时间从 15 秒提升到 120 秒,降低较慢网络或接口响应下下载文件超时失败概率。([#8643](https://github.com/AstrBotDevs/AstrBot/pull/8643))
- Dashboard 登录表单启用完整凭据自动填充,改善浏览器密码管理器的使用体验。([#8631](https://github.com/AstrBotDevs/AstrBot/pull/8631))
<a id="english"></a>
## What's Changed (EN)
### Bug Fixes
- Fixed repeated Web search citation prompt appends in WebChat after multiple tool calls within the same interaction, preventing context cache invalidation. ([#8642](https://github.com/AstrBotDevs/AstrBot/pull/8642))
- Fixed Pipeline async tasks potentially being garbage-collected due to missing strong references, improving event processing stability. ([#8618](https://github.com/AstrBotDevs/AstrBot/pull/8618))
- Synced Dashboard `pnpm-lock.yaml` overrides with the workspace configuration. ([#8637](https://github.com/AstrBotDevs/AstrBot/pull/8637))
- Reverted the Provider stats SQLite lock retry change to avoid related regressions. ([#8639](https://github.com/AstrBotDevs/AstrBot/pull/8639))
- Reverted the macOS SQLAlchemy compatibility changes to avoid regressions in database initialization and vector storage paths. ([#8638](https://github.com/AstrBotDevs/AstrBot/pull/8638))
### Improvements
- Increased the WeChat Official Account HTTP API request timeout from 15 seconds to 120 seconds, reducing timeout failures on slower networks or API responses. ([#8643](https://github.com/AstrBotDevs/AstrBot/pull/8643))
- Enabled full credential autofill on the Dashboard login form for better browser password manager support. ([#8631](https://github.com/AstrBotDevs/AstrBot/pull/8631))

View File

@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.25.3"
version = "4.25.4"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
license = { text = "AGPL-3.0-or-later" }

View File

@@ -483,6 +483,40 @@ def _setup_provider_with_mock_client(monkeypatch) -> anthropic_source.ProviderAn
return provider
@pytest.mark.asyncio
async def test_query_handles_none_usage_when_content_filtered(monkeypatch):
provider = _setup_provider_with_mock_client(monkeypatch)
content_filter_message = (
"The request was rejected because it was considered high risk"
)
class _FakeMessageBlock:
def __init__(self, text: str):
self.type = "text"
self.text = text
class _FakeMessage:
def __init__(self):
self.id = "msg_content_filter"
self.content = [_FakeMessageBlock(content_filter_message)]
self.stop_reason = "content_filter"
self.usage = None
async def fake_create(**kwargs):
return _FakeMessage()
monkeypatch.setattr(anthropic_source, "Message", _FakeMessage)
provider.client.messages.create = fake_create
llm_response = await provider.text_chat(prompt="test")
assert llm_response.completion_text == content_filter_message
assert llm_response.usage is not None
assert llm_response.usage.input_other == 0
assert llm_response.usage.input_cached == 0
assert llm_response.usage.output == 0
@pytest.mark.asyncio
async def test_tool_choice_auto_converts_to_dict(monkeypatch):
"""tool_choice='auto' 应转换为 {'type': 'auto'}"""

View File

@@ -0,0 +1,124 @@
import asyncio
import json
import pytest
from astrbot.core.platform.sources.qqofficial_webhook.qo_webhook_server import (
_SIGNATURE_HEADER,
_SIGNATURE_TIMESTAMP_HEADER,
QQOfficialWebhook,
_sign_qq_webhook_payload,
_verify_qq_webhook_signature,
)
class FakeRequest:
def __init__(self, body: bytes, headers: dict[str, str] | None = None) -> None:
self._body = body
self.headers = headers or {}
async def get_data(self) -> bytes:
return self._body
class FakeBotpyClient:
api = None
http = None
def ws_dispatch(self, *_args, **_kwargs) -> None:
return None
def test_qq_webhook_signature_verification_accepts_valid_signature():
secret = "test-secret"
timestamp = "1710000000"
body = b'{"op":12,"d":0}'
signature = _sign_qq_webhook_payload(secret, timestamp, body)
assert _verify_qq_webhook_signature(secret, timestamp, signature, body)
def test_qq_webhook_signature_verification_rejects_tampered_body():
secret = "test-secret"
timestamp = "1710000000"
body = b'{"op":12,"d":0}'
signature = _sign_qq_webhook_payload(secret, timestamp, body)
assert not _verify_qq_webhook_signature(
secret,
timestamp,
signature,
b'{"op":12,"d":1}',
)
@pytest.mark.asyncio
async def test_qq_webhook_callback_rejects_missing_signature():
webhook = object.__new__(QQOfficialWebhook)
webhook.secret = "test-secret"
result = await webhook.handle_callback(FakeRequest(b'{"op":12,"d":0}'))
assert result == ({"error": "Invalid signature"}, 401)
@pytest.mark.asyncio
async def test_qq_webhook_callback_accepts_signed_validation():
secret = "test-secret"
event_ts = "1710000000"
plain_token = "plain-token"
body = json.dumps(
{"op": 13, "d": {"event_ts": event_ts, "plain_token": plain_token}},
separators=(",", ":"),
).encode("utf-8")
signature = _sign_qq_webhook_payload(secret, event_ts, body)
webhook = object.__new__(QQOfficialWebhook)
webhook.secret = secret
result = await webhook.handle_callback(
FakeRequest(
body,
{
_SIGNATURE_TIMESTAMP_HEADER: event_ts,
_SIGNATURE_HEADER: signature,
},
)
)
assert result == {
"plain_token": plain_token,
"signature": _sign_qq_webhook_payload(secret, event_ts, plain_token.encode()),
}
@pytest.mark.asyncio
async def test_qq_webhook_callback_lazily_creates_botpy_connection():
secret = "test-secret"
timestamp = "1710000000"
body = json.dumps(
{"op": 0, "t": "UNKNOWN_EVENT", "id": "event-id", "d": {"id": "message-id"}},
separators=(",", ":"),
).encode("utf-8")
signature = _sign_qq_webhook_payload(secret, timestamp, body)
webhook = QQOfficialWebhook(
{"appid": "123", "secret": secret},
asyncio.Queue(),
FakeBotpyClient(),
)
result = await webhook.handle_callback(
FakeRequest(
body,
{
_SIGNATURE_TIMESTAMP_HEADER: timestamp,
_SIGNATURE_HEADER: signature,
},
)
)
assert result == {"opcode": 12}
assert webhook._connection is not None
assert webhook.http._token is not None
assert webhook.http._token.app_id == "123"
assert webhook.client.api is webhook.api
assert webhook.client.http is webhook.http

View File

@@ -476,6 +476,46 @@ class TestBuiltinToolInjection:
assert req.func_tool.get_tool("web_search_firecrawl") is search_tool
assert req.func_tool.get_tool("firecrawl_extract_web_page") is extract_tool
def test_apply_web_search_citation_prompt_for_webchat(self, mock_event):
module = ama
req = ProviderRequest(system_prompt="base")
search_tool = MagicMock(spec=FunctionTool)
search_tool.name = "web_search_tavily"
req.func_tool = ToolSet()
req.func_tool.add_tool(search_tool)
mock_event.get_platform_name.return_value = "webchat"
module._apply_web_search_citation_prompt(mock_event, req)
assert module.WEB_SEARCH_CITATION_PROMPT in req.system_prompt
def test_apply_web_search_citation_prompt_is_idempotent(self, mock_event):
module = ama
req = ProviderRequest(system_prompt="")
search_tool = MagicMock(spec=FunctionTool)
search_tool.name = "web_search_tavily"
req.func_tool = ToolSet()
req.func_tool.add_tool(search_tool)
mock_event.get_platform_name.return_value = "webchat"
module._apply_web_search_citation_prompt(mock_event, req)
module._apply_web_search_citation_prompt(mock_event, req)
assert req.system_prompt.count(module.WEB_SEARCH_CITATION_PROMPT) == 1
def test_apply_web_search_citation_prompt_requires_webchat(self, mock_event):
module = ama
req = ProviderRequest(system_prompt="")
search_tool = MagicMock(spec=FunctionTool)
search_tool.name = "web_search_tavily"
req.func_tool = ToolSet()
req.func_tool.add_tool(search_tool)
mock_event.get_platform_name.return_value = "test_platform"
module._apply_web_search_citation_prompt(mock_event, req)
assert module.WEB_SEARCH_CITATION_PROMPT not in req.system_prompt
def test_proactive_cron_job_tools_uses_builtin_tool_manager(self, mock_context):
"""Test cron tool injection through the builtin tool manager."""
module = ama