Compare commits

...

3 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
5 changed files with 261 additions and 9 deletions

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

@@ -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:

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