feat(onebot): refresh expired image URLs in history and model requests

- Cache NapCat rkeys by type and refresh only when their TTL expires
- Renew image URLs through get_msg for other OneBot v11 implementations
- Refresh session views, Runner history, and each model request attempt
- Isolate caches by connection without downloading images or rewriting history
This commit is contained in:
BiFangKNT
2026-10-23 08:41:50 +08:00
parent c538e696b2
commit baddaa2b74
14 changed files with 709 additions and 7 deletions
@@ -2113,6 +2113,9 @@ class MonitoringService(ExecutionDetailsMixin):
serialized_msg = self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringMessage, msg)
serialized.append(serialized_msg)
from ....platform.image_urls import refresh_message_rows
await refresh_message_rows(self.ap, workspace_uuid, serialized)
return (serialized, total)
async def get_llm_calls(
@@ -2797,10 +2800,14 @@ class MonitoringService(ExecutionDetailsMixin):
]
total_llm_calls = int(llm_stats.total_calls or 0)
from ....platform.image_urls import refresh_message_rows
serialized_message = self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringMessage, message)
await refresh_message_rows(self.ap, workspace_uuid, [serialized_message])
return {
'message_id': message_id,
'found': True,
'message': self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringMessage, message),
'message': serialized_message,
'llm_calls': llm_calls,
'llm_stats': {
'total_calls': total_llm_calls,
@@ -229,6 +229,9 @@ class ExecutionDetailsMixin:
for key, value in row.items()
}
)
from ....platform.image_urls import refresh_message_rows
await refresh_message_rows(self.ap, workspace, messages)
return messages, count
def _execution_json_text(self, column, key):
+1 -1
View File
@@ -188,7 +188,7 @@ class LangBotMCPServer:
return _dump(ap.box_service.get_recent_errors(context))
@mcp.tool(
description='Read bounded Workspace runtime records. LLM calls include optional details (timing in ms, complete provider usage, structured error); older records have null details. Filters follow the monitoring service: bot_ids, pipeline_ids, session_ids, start_time, end_time, mode (all/real/debug), execution_statuses (owning execution status groups), knowledge_base_id, user_query, is_active as supported by the record kind. Sessions read stored conversation summaries and do not support mode or execution_statuses.'
description='Read bounded Workspace runtime records. Message image URLs may be renewed through the connected OneBot peer without downloading images or rewriting stored records; URLs remain temporary. LLM calls include optional details (timing in ms, complete provider usage, structured error); older records have null details. Filters follow the monitoring service: bot_ids, pipeline_ids, session_ids, start_time, end_time, mode (all/real/debug), execution_statuses (owning execution status groups), knowledge_base_id, user_query, is_active as supported by the record kind. Sessions read stored conversation summaries and do not support mode or execution_statuses.'
)
async def get_monitoring_records(
kind: typing.Literal['messages', 'llm_calls', 'tool_calls', 'embedding_calls', 'sessions', 'errors'],
@@ -0,0 +1,171 @@
"""Connection-local OneBot image references and expiring download credentials."""
import asyncio
import math
import time
from collections import OrderedDict
from urllib.parse import parse_qsl, quote, unquote_plus, urlsplit, urlunsplit
from aiocqhttp import Message
def image_nodes(value, message_id=None):
"""Yield structured image nodes with the nearest original message identity."""
if isinstance(value, list):
source = next((v.get('id') for v in value if isinstance(v, dict) and v.get('type') == 'Source'), None)
current_id = source if source is not None else message_id
for child in value:
if isinstance(child, dict) and child.get('type') == 'Source':
current_id = child.get('id')
yield from image_nodes(child, current_id)
elif isinstance(value, dict):
message_id = value.get('message_id', message_id)
if value.get('type') in ('Image', 'image', 'image_url'):
target = value.get('image_url') if value.get('type') == 'image_url' else value
if isinstance(target, dict) and remote_image_url(target.get('url')):
yield target, message_id, value.get('image_id') or value.get('id')
for key, child in value.items():
if key == 'origin' and value.get('type') == 'Quote':
yield from image_nodes(child, value.get('id'))
elif key not in ('image_url',):
yield from image_nodes(child, message_id)
def remote_image_url(url):
"""Exclude inline data, local media paths and malformed URLs from peer refresh."""
if not isinstance(url, str):
return False
try:
parsed = urlsplit(url)
return (
parsed.scheme in ('http', 'https') and bool(parsed.hostname) and not parsed.username and not parsed.password
)
except ValueError:
return False
def nt_image_type(url):
"""Recognize only QQ NT download URLs with unambiguous resource parameters."""
try:
parsed = urlsplit(url)
pairs = parse_qsl(parsed.query, keep_blank_values=True)
params = dict(pairs)
if (
parsed.scheme not in ('http', 'https')
or parsed.hostname != 'multimedia.nt.qq.com.cn'
or parsed.username
or parsed.password
or parsed.port not in (None, 80, 443)
or parsed.path != '/download'
or any(sum(key == name for key, _ in pairs) != 1 for name in ('appid', 'fileid', 'rkey'))
or not params.get('fileid')
or not params.get('rkey')
):
return None
return {'1406': 10, '1407': 20}.get(params['appid'])
except ValueError:
return None
class OneBotImageURLs:
"""Keep credentials and bounded source mappings within a single connection."""
def __init__(self, connection):
self.connection = connection
self.lock = asyncio.Lock()
self.app_name = None
self.keys = {}
self.references = OrderedDict()
self.messages = OrderedDict()
self.retry_after = 0
def remember(self, url, message_id, image_id):
if message_id in (None, '', -1, '-1') or not image_id:
return
self.references[url] = (message_id, str(image_id))
self.references.move_to_end(url)
while len(self.references) > 2048:
self.references.popitem(last=False)
async def refresh(self, payload):
nodes = list(image_nodes(payload))
if not nodes or self.connection.closed:
return
for node, message_id, image_id in nodes:
self.remember(node['url'], message_id, image_id)
async with self.lock:
if self.connection.closed or time.monotonic() < self.retry_after:
return
try:
if self.app_name is None:
info = await self.connection.call_action('get_version_info')
self.app_name = info.get('app_name', '')
if self.app_name == 'NapCat.Onebot':
await self._napcat(nodes)
else:
await self._standard(nodes)
except Exception:
# Failed/unsupported APIs must not break history reads or hammer the peer.
self.retry_after = time.monotonic() + 30
raise
async def _napcat(self, nodes):
wanted = {kind for node, _, _ in nodes if (kind := nt_image_type(node['url'])) is not None}
if not wanted:
return
if any(self.keys.get(kind, ('', 0))[1] <= time.time() for kind in wanted):
result = await self.connection.call_action('nc_get_rkey')
keys = {}
for item in result:
kind = int(item['type'])
if kind not in (10, 20):
continue
expires = float(item['time']) + float(item['ttl'])
key = item['rkey']
if isinstance(key, str) and key.startswith('&rkey='):
key = key[len('&rkey=') :]
if isinstance(key, str) and key and math.isfinite(expires) and expires > time.time():
keys[kind] = (key, expires)
if not wanted.issubset(keys):
raise ValueError('NapCat returned no valid image credentials')
self.keys.update(keys)
for node, _, _ in nodes:
kind = nt_image_type(node['url'])
if kind not in wanted:
continue
parsed = urlsplit(node['url'])
parts = parsed.query.split('&')
# Preserve every other parameter byte-for-byte, including signed resource IDs.
parts = [
part.split('=', 1)[0] + '=' + quote(self.keys[kind][0], safe='')
if unquote_plus(part.split('=', 1)[0]) == 'rkey'
else part
for part in parts
]
node['url'] = urlunsplit(parsed._replace(query='&'.join(parts)))
async def _standard(self, nodes):
for node, _, _ in nodes:
old_url = node['url']
ref = self.references.get(old_url)
if ref is None:
continue
message_id, image_id = ref
cached = self.messages.get(str(message_id))
if cached is None or cached[1] <= time.monotonic():
raw = await self.connection.call_action('get_msg', message_id=int(message_id))
images = {}
for segment in Message(raw.get('message', [])):
if segment.type == 'image':
data = segment.data
if data.get('file') and remote_image_url(data.get('url')):
images[str(data['file'])] = data['url']
cached = (images, time.monotonic() + 60)
self.messages[str(message_id)] = cached
self.messages.move_to_end(str(message_id))
while len(self.messages) > 256:
self.messages.popitem(last=False)
new_url = cached[0].get(image_id)
if new_url:
self.remember(new_url, message_id, image_id)
node['url'] = new_url
@@ -9,6 +9,8 @@ import uuid
from aiocqhttp.exceptions import ActionFailed, ApiNotAvailable
from .image_urls import OneBotImageURLs
class OneBotConnection:
def __init__(self, send, receive, close, on_event, validate, *, self_id='', timeout=30):
@@ -23,6 +25,7 @@ class OneBotConnection:
self.events = asyncio.Queue(maxsize=256)
self.closed = False
self.send_lock = asyncio.Lock()
self.image_urls = OneBotImageURLs(self)
async def call_action(self, action, **params):
await self.validate()
@@ -96,6 +99,9 @@ class OneBotConnection:
if self.closed:
return
self.closed = True
self.image_urls.keys.clear()
self.image_urls.references.clear()
self.image_urls.messages.clear()
for future in self.pending.values():
if not future.done():
future.set_exception(ApiNotAvailable('OneBot connection closed'))
+123
View File
@@ -0,0 +1,123 @@
"""Refresh image references at authorized read/use boundaries, without persistence."""
import copy
import json
from .adapters.aiocqhttp.image_urls import image_nodes
async def refresh_image_payload(ap, workspace_uuid, bot_id, payload):
"""Return a refreshed copy, resolving bot ownership exclusively from trusted scope."""
if not workspace_uuid or not bot_id or not list(image_nodes(payload)):
return payload
manager = getattr(ap, 'platform_mgr', None)
if manager is None:
return payload
bot = await manager.resolve_public_bot(bot_id)
if bot is None or bot.workspace_uuid != workspace_uuid or not bot.enable or bot.bot_entity.adapter != 'onebot-v11':
return payload
connection = bot.adapter.bot.connection
if connection is None or connection.closed:
return payload
result = copy.deepcopy(payload)
try:
await connection.image_urls.refresh(result)
if bot.adapter.bot.connection is not connection or connection.closed:
return payload
return result
except Exception as exc:
# Do not log signed URLs, credentials, or arbitrary upstream response bodies.
ap.logger.warning(f'OneBot image URL refresh failed ({type(exc).__name__})')
return payload
async def refresh_message_rows(ap, workspace_uuid, rows):
"""Refresh bounded, already-authorized monitoring response rows in place."""
groups = {}
for row in rows:
raw = row.get('message_content')
if not isinstance(raw, str):
continue
try:
payload = json.loads(raw)
except ValueError:
continue
if list(image_nodes(payload)):
groups.setdefault(row.get('bot_id'), []).append((row, payload))
for bot_id, group in groups.items():
payloads = [payload for _, payload in group]
refreshed = await refresh_image_payload(ap, workspace_uuid, bot_id, payloads)
if refreshed is not payloads:
for (row, _), payload in zip(group, refreshed):
row['message_content'] = json.dumps(payload, ensure_ascii=False)
async def refresh_model_images(ap, context, query, messages):
"""Refresh on every actual model attempt, including reused Runner message lists."""
bot_id = (getattr(query, 'bot_uuid', None) if query is not None else None) or getattr(context, 'bot_uuid', None)
if not bot_id:
return messages
payload = [message.model_dump(mode='json') for message in messages]
if not list(image_nodes(payload)):
return messages
chain = getattr(query, 'message_chain', None)
if chain is not None and hasattr(chain, 'model_dump'):
# Keep current-message origins available when provider contents omit Source/Image IDs.
payload = {'message_chain': chain.model_dump(), 'messages': payload}
refreshed = await refresh_image_payload(ap, context.workspace_uuid, bot_id, payload)
if refreshed is payload:
return messages
if isinstance(refreshed, dict):
refreshed = refreshed['messages']
return [
message.model_copy(update={'content': type(message).model_validate(data).content})
for message, data in zip(messages, refreshed)
]
async def refresh_history_items(ap, workspace_uuid, bot_id, items):
"""Recover original image identities from scoped event facts before returning history."""
if not items or not workspace_uuid or not bot_id or not any(list(image_nodes(item)) for item in items):
return
import sqlalchemy as sa
from ..entity.persistence.event_log import EventLog
event_ids = [item['event_id'] for item in items if item.get('event_id')]
result = await ap.persistence_mgr.execute_async(
sa.select(EventLog.event_id, EventLog.metadata_json, EventLog.subject_type, EventLog.subject_id).where(
EventLog.workspace_id == workspace_uuid,
EventLog.bot_id == bot_id,
EventLog.event_id.in_(event_ids),
)
)
origins = {}
for event_id, raw, subject_type, subject_id in result.all():
try:
metadata = json.loads(raw) if raw else {}
except ValueError:
continue
if not isinstance(metadata, dict):
continue
event = metadata.get('input_event', {})
if isinstance(event, dict):
message_id = event.get('message_id')
if message_id is None and subject_type == 'message' and str(subject_id).lstrip('-').isdigit():
message_id = subject_id
origins[event_id] = {'message_id': message_id, 'message_chain': event.get('message_chain', [])}
scoped_items = []
payloads = []
for item in items:
if item.get('workspace_id') != workspace_uuid or item.get('bot_id') != bot_id:
continue
scoped_items.append(item)
payloads.append(
{
**origins.get(item.get('event_id'), {}),
'content_json': item.get('content_json'),
'attachment_refs': item.get('attachment_refs', []),
}
)
refreshed = await refresh_image_payload(ap, workspace_uuid, bot_id, payloads)
for item, payload in zip(scoped_items, refreshed):
item['content_json'] = payload['content_json']
item['attachment_refs'] = payload['attachment_refs']
@@ -4,6 +4,8 @@ from __future__ import annotations
from typing import Any
from ..platform.image_urls import refresh_history_items
from langbot_plugin.runtime.io import handler
from langbot_plugin.entities.io.actions.enums import (
@@ -88,6 +90,8 @@ def register(h):
**_run_scope_filters(session),
)
scope = _run_scope_filters(session)
await refresh_history_items(h.ap, scope['workspace_id'], scope['bot_id'], items)
return handler.ActionResponse.success(
data={
'items': items,
@@ -159,6 +163,8 @@ def register(h):
**_run_scope_filters(session),
)
scope = _run_scope_filters(session)
await refresh_history_items(h.ap, scope['workspace_id'], scope['bot_id'], items)
return handler.ActionResponse.success(
data={
'items': items,
+8 -1
View File
@@ -1371,8 +1371,9 @@ class RuntimeConnectionHandler(handler.Handler):
run_id = data.get('run_id')
caller_plugin_identity = data.get('caller_plugin_identity')
session = None
if run_id:
_, error = await _validate_run_authorization(
session, error = await _validate_run_authorization(
run_id,
'model',
llm_model_uuid,
@@ -1417,6 +1418,12 @@ class RuntimeConnectionHandler(handler.Handler):
return handler.ActionResponse.error(message='LLM provider does not support token counting')
try:
from ..platform.image_urls import refresh_model_images
query = _resolve_action_query(data, session, self.ap, action_context)
messages_obj = await refresh_model_images(
self.ap, self._execution_context(action_context), query, messages_obj
)
tokens = await count_tokens_method(
model=llm_model,
messages=messages_obj,
+13 -2
View File
@@ -11,6 +11,7 @@ from ...core import app
from ...api.http.context import ExecutionContext
from ...entity.persistence import model as persistence_model
from ...workspace.errors import WorkspaceInvariantError
from ...platform.image_urls import refresh_model_images
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
from . import token
from . import reasoning
@@ -164,11 +165,16 @@ class RuntimeProvider:
state.metrics['attempts'] += 1
state.attempt_start = time.perf_counter()
try:
attempt_messages = await state.wait(
lambda: refresh_model_images(self.requester.ap, context, query, messages),
'response',
state.policy.total_timeout,
)
result = await state.wait(
lambda: self.requester.invoke_llm(
query=query,
model=model,
messages=messages,
messages=attempt_messages,
funcs=funcs,
extra_args=extra_args,
remove_think=remove_think,
@@ -215,11 +221,16 @@ class RuntimeProvider:
state.metrics['attempts'] += 1
state.attempt_start = time.perf_counter()
try:
attempt_messages = await state.wait(
lambda: refresh_model_images(self.requester.ap, context, query, messages),
'first_event',
state.policy.total_timeout,
)
async with aclosing(
self.requester.invoke_llm_stream(
query=query,
model=model,
messages=messages,
messages=attempt_messages,
funcs=funcs,
extra_args=extra_args,
remove_think=remove_think,