mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-23 10:37:13 +00:00
e1ac5e0fc8
* Document multi-tenant workspace architecture * Add OSS and commercial workspace boundaries * docs: redesign multi-tenant workspace architecture * feat(tenancy): implement workspace isolation * docs(tenancy): record verification evidence * docs(tenancy): revise single-instance SaaS topology * docs(tenancy): refine architecture options * docs: finalize cloud v2 multi-tenant decisions * feat(tenancy): establish cloud isolation foundations * feat(tenancy): harden shared cloud runtime boundaries * docs(tenancy): record final isolation verification * fix(tenancy): close isolation and permission gaps * docs(tenancy): record final isolation verification * feat(tenancy): connect cloud workspace control plane * fix(build): install git for pinned SDK * docs(cloud): update control plane verification * chore: update multi-tenant SDK pin * fix(cloud): skip legacy model sync during startup * test(cloud): preserve minimal model manager fixtures * fix(cloud): preserve authenticated account context * fix(cloud): reuse authenticated account for user info * feat(cloud): complete Workspace settings navigation * test(web): cover Workspace dropdown menu * feat(web): place workspace controls in sidebar * refactor(web): streamline workspace controls * style(web): format workspace layout test * fix(cloud): surface runtime and workspace plan status * fix(plugin): keep runtime identity stable across restarts * fix(ui): widen and center workspace switcher * fix(ui): hide roles from workspace switcher * fix(ui): align workspace switcher with sidebar entries * feat(workspace): add in-product collaboration and direct Cloud launch * style: format collaboration changes * fix(workspace): bind collaboration APIs to tenant UoW * fix(cloud): preserve Core-owned collaboration state * test(cloud): require Space identity for invite registration * feat(cloud): complete secure invitation experience * style(web): format invitation flows * fix(cloud): recover box runtime without unscoped skill reload * feat(oss): enforce invitation account and owner billing flows * style: format OSS account service * test(oss): cover invitation logout handoff * fix(oss): resolve workspace owner in scoped session * feat(cloud): harden multi-tenant runtime resources * fix(cloud): bound runtime restart storms * fix(cloud): eliminate periodic runtime CPU spikes * fix(cloud): enforce instance capacity ceilings * fix(cloud): scope public login capability discovery * fix(cloud): bound tenant maintenance and monitoring work * fix(runtime): bound tenant resource amplification * fix(deps): pin green multi-tenant plugin SDK * fix(cloud): handle unavailable skill capability * fix(security): require authentication for image file endpoint (H-2) - Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY - Added Permission.RESOURCE_VIEW requirement - Prevents unauthenticated cross-tenant file access via leaked keys - Fixes HIGH severity finding from multi-tenant security review docs: add comprehensive database migration guide - Complete migration steps for OSS → multi-tenant - Backup, execution, verification procedures - Rollback scenarios and recovery plans - Performance tuning recommendations * test: add comprehensive cross-tenant isolation tests Added 7 critical test scenarios for multi-tenant boundaries: - Cross-tenant bot access prevention - Viewer role read-only enforcement - Removed member immediate access revocation - Model provider credential isolation - WebSocket message isolation - Invitation token workspace scoping - Multi-workspace context validation These tests address P0-2 coverage gaps for: - workspaces.py (membership & invitation flows) - user.py (authentication & authorization) - websocket_chat.py (real-time isolation) - plugins.py (resource access control) docs: finalize database migration guide * fix(security): resolve M-1, M-2, M-3 security findings M-1: WebSocket authorization TOCTOU race (FIXED) - Changed _revalidate_websocket_authorization to return RequestContext - Ensures validated context is used immediately without race window - Prevents removed members from sending messages during revalidation gap M-2: Model Manager cache workspace isolation (VERIFIED) - Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource) - Cache is properly scoped per workspace, no cross-tenant leakage possible - No code change needed, documented as working correctly M-3: Invitation lock workspace scoping (FIXED) - Changed lock key from token_digest to workspace_uuid:token_digest - Prevents DoS where attacker locks token in Workspace A to block Workspace B - Locks now isolated per workspace All MEDIUM severity findings from security review now resolved. * fix(cloud): unblock tenant CI and enforce knowledge quotas * fix(tenancy): scope rerank model sync --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
648 lines
26 KiB
Python
648 lines
26 KiB
Python
"""HTTP Bot adapter — standalone server-to-server platform adapter.
|
|
|
|
Lets any external backend drive a LangBot pipeline over plain HTTP:
|
|
|
|
* **Inbound** — the backend POSTs a signed message to the unified webhook
|
|
route ``POST /bots/<bot_uuid>``; this adapter verifies the signature, builds
|
|
a platform event carrying the caller-defined ``session_id`` as the launcher
|
|
id, and fires it into the normal pipeline (so message aggregation, N->1,
|
|
works for free).
|
|
* **Outbound** — every ``reply_message`` / ``reply_message_chunk`` the pipeline
|
|
emits is delivered as a signed POST to the configured ``callback_url``. A
|
|
single turn may emit many replies (1->M); each is one callback, ordered per
|
|
session via a small worker queue.
|
|
|
|
Design notes:
|
|
|
|
* The callback URL is taken **only** from adapter config (never from the
|
|
inbound message) to keep the SSRF surface closed.
|
|
* Replies for one ``session_id`` are delivered in ``sequence`` order; the
|
|
caller knows a turn is complete when ``is_final: true`` arrives.
|
|
* No new HTTP route is registered — the existing unified webhook dispatcher
|
|
(``pkg/api/http/controller/groups/webhooks.py``) calls
|
|
``handle_unified_webhook`` on this adapter.
|
|
|
|
See docs/platforms/http-bot.md for the full integration guide.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import itertools
|
|
import json
|
|
import time
|
|
import typing
|
|
import uuid
|
|
from datetime import datetime
|
|
|
|
import aiohttp
|
|
import pydantic
|
|
import quart
|
|
|
|
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
|
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
|
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
|
|
|
from . import http_bot_signing as signing
|
|
from ...utils import httpclient
|
|
|
|
|
|
# Error envelope codes (HTTP status -> body code), documented in the design doc.
|
|
_ERR = {
|
|
'bad_request': (400, 40001),
|
|
'bad_signature': (401, 40101),
|
|
'duplicate': (409, 40901),
|
|
'too_large': (413, 41301),
|
|
'overloaded': (503, 50301),
|
|
'internal': (500, 50001),
|
|
}
|
|
|
|
# Max accepted inbound body size (bytes).
|
|
_MAX_BODY = 1 * 1024 * 1024
|
|
|
|
# Idempotency dedup window (seconds) and cap.
|
|
_IDEMPOTENCY_TTL = 600
|
|
_IDEMPOTENCY_MAX = 4096
|
|
_IDEMPOTENCY_PRUNE_SCAN_MAX = 64
|
|
_OUTBOUND_QUEUE_MAX = 100
|
|
_OUTBOUND_IDLE_SECONDS = 60
|
|
_OUTBOUND_STATE_MAX = 4096
|
|
_OUTBOUND_PRUNE_SCAN_MAX = 64
|
|
_INBOUND_TASK_MAX = 100
|
|
|
|
|
|
class _OutboundStateCapacityError(RuntimeError):
|
|
"""Raised when a new outbound session cannot be admitted safely."""
|
|
|
|
|
|
class _SessionOutbound:
|
|
"""Per-session outbound state: ordered delivery queue + sequence counter."""
|
|
|
|
def __init__(self) -> None:
|
|
self.queue: asyncio.Queue = asyncio.Queue(maxsize=_OUTBOUND_QUEUE_MAX)
|
|
self.worker: asyncio.Task | None = None
|
|
self.sequence: int = 0
|
|
self.last_was_final: bool = True # so the first reply of a turn starts at seq 1
|
|
self.last_active: float = time.monotonic()
|
|
|
|
|
|
class _SyncCollector:
|
|
"""Collects reply parts for a /sync request and resolves when the turn ends."""
|
|
|
|
def __init__(self) -> None:
|
|
self.parts: list = []
|
|
self.done: asyncio.Event = asyncio.Event()
|
|
|
|
|
|
class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|
"""Standalone HTTP adapter (inbound webhook + outbound callbacks)."""
|
|
|
|
bot_uuid: str = pydantic.Field(default='', exclude=True)
|
|
|
|
listeners: dict[
|
|
typing.Type[platform_events.Event],
|
|
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
|
] = pydantic.Field(default_factory=dict, exclude=True)
|
|
|
|
# session_id -> outbound state
|
|
outbound_states: dict[str, _SessionOutbound] = pydantic.Field(default_factory=dict, exclude=True)
|
|
# idempotency key -> accepted-at epoch
|
|
idempotency_cache: dict[str, float] = pydantic.Field(default_factory=dict, exclude=True)
|
|
# session_id -> sync collector (set while a /sync request is awaiting a turn)
|
|
sync_waiters: dict[str, '_SyncCollector'] = pydantic.Field(default_factory=dict, exclude=True)
|
|
inbound_tasks: set[asyncio.Task] = pydantic.Field(default_factory=set, exclude=True)
|
|
|
|
model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
|
|
|
|
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
|
super().__init__(config=config, logger=logger, **kwargs)
|
|
self.bot_account_id = 'http_bot'
|
|
self.outbound_states = {}
|
|
self.idempotency_cache = {}
|
|
self.sync_waiters = {}
|
|
self.inbound_tasks = set()
|
|
|
|
# -- framework hooks ------------------------------------------------------
|
|
|
|
def set_bot_uuid(self, bot_uuid: str) -> None:
|
|
"""Called by the bot manager so the adapter knows its own bot uuid."""
|
|
object.__setattr__(self, 'bot_uuid', bot_uuid)
|
|
|
|
def get_launcher_id(self, event: platform_events.MessageEvent) -> str:
|
|
"""Map an inbound event to a LangBot launcher id.
|
|
|
|
We return the caller-defined ``session_id`` (stashed on the sender /
|
|
group id at inbound time) so that each external session maps 1:1 to an
|
|
isolated LangBot session.
|
|
"""
|
|
if isinstance(event, platform_events.GroupMessage):
|
|
return str(event.sender.group.id)
|
|
return str(event.sender.id)
|
|
|
|
def register_listener(
|
|
self,
|
|
event_type: typing.Type[platform_events.Event],
|
|
func: typing.Callable[
|
|
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
|
|
],
|
|
):
|
|
self.listeners[event_type] = func
|
|
|
|
def unregister_listener(
|
|
self,
|
|
event_type: typing.Type[platform_events.Event],
|
|
func: typing.Callable[
|
|
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
|
|
],
|
|
):
|
|
self.listeners.pop(event_type, None)
|
|
|
|
async def is_muted(self, group_id: int) -> bool:
|
|
return False
|
|
|
|
async def is_stream_output_supported(self) -> bool:
|
|
return True
|
|
|
|
async def run_async(self):
|
|
# Purely webhook-driven; nothing to poll. Stay alive.
|
|
while True:
|
|
await asyncio.sleep(3600)
|
|
|
|
async def kill(self):
|
|
tasks = list(self.inbound_tasks)
|
|
for task in tasks:
|
|
if not task.done():
|
|
task.cancel()
|
|
for state in self.outbound_states.values():
|
|
if state.worker and not state.worker.done():
|
|
state.worker.cancel()
|
|
tasks.append(state.worker)
|
|
if tasks:
|
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
self.outbound_states.clear()
|
|
self.sync_waiters.clear()
|
|
self.inbound_tasks.clear()
|
|
return True
|
|
|
|
# -- inbound --------------------------------------------------------------
|
|
|
|
def _err(self, kind: str, detail: str = ''):
|
|
status, code = _ERR[kind]
|
|
return quart.jsonify({'code': code, 'msg': detail or kind, 'data': None}), status
|
|
|
|
def _reserve_idempotency_key(self, key: str) -> str:
|
|
"""Reserve a key without allowing unbounded state or full-map scans."""
|
|
now = time.monotonic()
|
|
accepted_at = self.idempotency_cache.get(key)
|
|
if accepted_at is not None:
|
|
if now - accepted_at <= _IDEMPOTENCY_TTL:
|
|
return 'duplicate'
|
|
self.idempotency_cache.pop(key, None)
|
|
|
|
if len(self.idempotency_cache) >= _IDEMPOTENCY_MAX:
|
|
self._prune_idempotency(now)
|
|
if len(self.idempotency_cache) >= _IDEMPOTENCY_MAX:
|
|
return 'overloaded'
|
|
|
|
self.idempotency_cache[key] = now
|
|
return 'accepted'
|
|
|
|
def _prune_idempotency(self, now: float | None = None) -> None:
|
|
"""Remove at most a fixed number of oldest expired keys."""
|
|
current_time = time.monotonic() if now is None else now
|
|
oldest = itertools.islice(
|
|
self.idempotency_cache.items(),
|
|
_IDEMPOTENCY_PRUNE_SCAN_MAX,
|
|
)
|
|
for key, accepted_at in list(oldest):
|
|
if current_time - accepted_at <= _IDEMPOTENCY_TTL:
|
|
break
|
|
self.idempotency_cache.pop(key, None)
|
|
|
|
def _start_inbound_task(self, coro: typing.Coroutine) -> asyncio.Task | None:
|
|
self.inbound_tasks = {task for task in self.inbound_tasks if not task.done()}
|
|
if len(self.inbound_tasks) >= _INBOUND_TASK_MAX:
|
|
coro.close()
|
|
return None
|
|
task = asyncio.create_task(coro)
|
|
self.inbound_tasks.add(task)
|
|
|
|
def task_done(done_task: asyncio.Task) -> None:
|
|
self.inbound_tasks.discard(done_task)
|
|
if not done_task.cancelled():
|
|
# Retrieve failures so fire-and-forget callbacks never emit
|
|
# "Task exception was never retrieved" or retain tracebacks.
|
|
done_task.exception()
|
|
|
|
task.add_done_callback(task_done)
|
|
return task
|
|
|
|
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
|
|
"""Handle an inbound POST from the unified webhook dispatcher.
|
|
|
|
Sub-path routing:
|
|
(no path) -> push a message
|
|
"reset" -> reset a session's conversation (body: {session_id, session_type?})
|
|
"sync" -> push a message and wait for the final reply (collapses 1->M)
|
|
"""
|
|
object.__setattr__(self, 'bot_uuid', bot_uuid)
|
|
|
|
if path == 'reset':
|
|
return await self._handle_reset(request)
|
|
if path == 'sync':
|
|
return await self._handle_inbound(request, sync=True)
|
|
if path in ('', None):
|
|
return await self._handle_inbound(request, sync=False)
|
|
return self._err('bad_request', f'unknown sub-path: {path}')
|
|
|
|
async def _read_and_verify(self, request) -> tuple[dict | None, typing.Any]:
|
|
"""Read body, enforce size + signature. Returns (data, error_response)."""
|
|
body = await request.get_data()
|
|
if body and len(body) > _MAX_BODY:
|
|
return None, self._err('too_large', 'message too large')
|
|
|
|
if self.config.get('signature_required', True):
|
|
ok, reason = signing.verify(
|
|
secret=self.config.get('inbound_secret', ''),
|
|
body=body,
|
|
timestamp=request.headers.get(signing.HEADER_TIMESTAMP),
|
|
signature=request.headers.get(signing.HEADER_SIGNATURE),
|
|
)
|
|
if not ok:
|
|
await self.logger.warning(f'http_bot inbound signature rejected: {reason}')
|
|
return None, self._err('bad_signature', f'invalid signature: {reason}')
|
|
|
|
try:
|
|
data = await asyncio.to_thread(json.loads, body)
|
|
except (json.JSONDecodeError, ValueError):
|
|
return None, self._err('bad_request', 'body is not valid JSON')
|
|
if not isinstance(data, dict):
|
|
return None, self._err('bad_request', 'body must be a JSON object')
|
|
return data, None
|
|
|
|
def _build_event(self, data: dict) -> tuple[platform_events.MessageEvent, str, str, str]:
|
|
"""Build a platform event from inbound data.
|
|
|
|
Returns (event, session_id, session_type, message_id).
|
|
"""
|
|
session_id = str(data['session_id'])
|
|
session_type = data.get('session_type') or self.config.get('default_session_type', 'person')
|
|
sender_meta = data.get('sender') or {}
|
|
sender_name = str(sender_meta.get('name', 'User'))
|
|
|
|
message_id = 'in_' + uuid.uuid4().hex
|
|
chain = platform_message.MessageChain.model_validate(data['message'])
|
|
# Carry the inbound message id + timestamp as the Source component.
|
|
chain.insert(0, platform_message.Source(id=message_id, time=datetime.now()))
|
|
|
|
if session_type == 'group':
|
|
group = platform_entities.Group(
|
|
id=session_id,
|
|
name=str(sender_meta.get('group_name', session_id)),
|
|
permission=platform_entities.Permission.Member,
|
|
)
|
|
sender = platform_entities.GroupMember(
|
|
id=str(sender_meta.get('id', session_id)),
|
|
member_name=sender_name,
|
|
group=group,
|
|
permission=platform_entities.Permission.Member,
|
|
)
|
|
event = platform_events.GroupMessage(sender=sender, message_chain=chain, time=datetime.now().timestamp())
|
|
else:
|
|
sender = platform_entities.Friend(id=session_id, nickname=sender_name, remark=sender_name)
|
|
event = platform_events.FriendMessage(sender=sender, message_chain=chain, time=datetime.now().timestamp())
|
|
return event, session_id, session_type, message_id
|
|
|
|
async def _handle_inbound(self, request, sync: bool):
|
|
data, err = await self._read_and_verify(request)
|
|
if err is not None:
|
|
return err
|
|
|
|
if 'session_id' not in data or 'message' not in data:
|
|
return self._err('bad_request', 'session_id and message are required')
|
|
|
|
# Idempotency.
|
|
idem = request.headers.get(signing.HEADER_IDEMPOTENCY)
|
|
if idem:
|
|
idempotency_result = self._reserve_idempotency_key(idem)
|
|
if idempotency_result == 'duplicate':
|
|
return self._err('duplicate', 'idempotency key already accepted')
|
|
if idempotency_result == 'overloaded':
|
|
return self._err('overloaded', 'idempotency capacity reached; retry later')
|
|
|
|
try:
|
|
event, session_id, session_type, message_id = self._build_event(data)
|
|
except Exception as e: # noqa: BLE001
|
|
return self._err('bad_request', f'failed to parse message: {e}')
|
|
|
|
listener = self.listeners.get(type(event))
|
|
if listener is None:
|
|
return self._err('internal', 'no listener registered for event type')
|
|
|
|
if sync:
|
|
return await self._run_sync(event, listener, session_id, message_id)
|
|
|
|
# Fire-and-collect: kick the pipeline, return 202 immediately.
|
|
if self._start_inbound_task(listener(event, self)) is None:
|
|
return self._err('overloaded', 'too many inbound messages are already being processed')
|
|
return quart.jsonify(
|
|
{
|
|
'code': 0,
|
|
'msg': 'accepted',
|
|
'data': {
|
|
'session_id': session_id,
|
|
'accepted_message_id': message_id,
|
|
'aggregating': True,
|
|
},
|
|
}
|
|
), 202
|
|
|
|
async def _handle_reset(self, request):
|
|
data, err = await self._read_and_verify(request)
|
|
if err is not None:
|
|
return err
|
|
if 'session_id' not in data:
|
|
return self._err('bad_request', 'session_id is required')
|
|
|
|
session_id = str(data['session_id'])
|
|
session_type = data.get('session_type') or self.config.get('default_session_type', 'person')
|
|
launcher_type = 'group' if session_type == 'group' else 'person'
|
|
|
|
removed = await self._reset_session(launcher_type, session_id)
|
|
return quart.jsonify({'code': 0, 'msg': 'reset', 'data': {'session_id': session_id, 'removed': removed}}), 200
|
|
|
|
async def _reset_session(self, launcher_type: str, launcher_id: str) -> bool:
|
|
"""Drop the matching session so the next message starts a fresh conversation."""
|
|
execution_context = getattr(self.logger, 'execution_context', None)
|
|
if (
|
|
execution_context is None
|
|
or not execution_context.instance_uuid
|
|
or not execution_context.workspace_uuid
|
|
or execution_context.placement_generation <= 0
|
|
or not self.bot_uuid
|
|
):
|
|
raise RuntimeError('http_bot reset requires a trusted execution scope')
|
|
expected_prefix = (
|
|
execution_context.instance_uuid,
|
|
execution_context.workspace_uuid,
|
|
execution_context.placement_generation,
|
|
self.bot_uuid,
|
|
launcher_type,
|
|
)
|
|
|
|
sess_mgr = self.ap.sess_mgr
|
|
before = len(sess_mgr.session_list)
|
|
sess_mgr.session_list = [
|
|
s for s in sess_mgr.session_list if not self._matches_session_scope(s, expected_prefix, launcher_id)
|
|
]
|
|
return len(sess_mgr.session_list) < before
|
|
|
|
@staticmethod
|
|
def _matches_session_scope(session, expected_prefix: tuple[str, str, int, str, str], launcher_id: str) -> bool:
|
|
session_key = getattr(session, '_langbot_session_key', None)
|
|
return (
|
|
isinstance(session_key, tuple)
|
|
and len(session_key) == 6
|
|
and session_key[:5] == expected_prefix
|
|
and str(session_key[5]) == launcher_id
|
|
)
|
|
|
|
# -- outbound -------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _extract_session_id(message_source: platform_events.MessageEvent) -> str:
|
|
if isinstance(message_source, platform_events.GroupMessage):
|
|
return str(message_source.sender.group.id)
|
|
return str(message_source.sender.id)
|
|
|
|
@staticmethod
|
|
def _extract_reply_to(message_source: platform_events.MessageEvent) -> str:
|
|
for comp in message_source.message_chain:
|
|
if isinstance(comp, platform_message.Source):
|
|
return str(comp.id)
|
|
return ''
|
|
|
|
def _next_sequence(self, session_id: str, is_final: bool) -> int:
|
|
state = self._outbound_state(session_id)
|
|
state.last_active = time.monotonic()
|
|
if state.last_was_final:
|
|
state.sequence = 1
|
|
else:
|
|
state.sequence += 1
|
|
state.last_was_final = is_final
|
|
return state.sequence
|
|
|
|
def _outbound_state(self, session_id: str) -> _SessionOutbound:
|
|
state = self.outbound_states.get(session_id)
|
|
if state is not None:
|
|
# Dicts retain insertion order. Moving active sessions to the end
|
|
# keeps bounded admission-time pruning focused on old entries.
|
|
self.outbound_states.pop(session_id)
|
|
self.outbound_states[session_id] = state
|
|
return state
|
|
|
|
if len(self.outbound_states) >= _OUTBOUND_STATE_MAX:
|
|
self._prune_outbound_states()
|
|
if len(self.outbound_states) >= _OUTBOUND_STATE_MAX:
|
|
raise _OutboundStateCapacityError(f'http_bot outbound session capacity reached ({_OUTBOUND_STATE_MAX})')
|
|
|
|
state = _SessionOutbound()
|
|
self.outbound_states[session_id] = state
|
|
return state
|
|
|
|
def _prune_outbound_states(self) -> None:
|
|
now = time.monotonic()
|
|
oldest = itertools.islice(
|
|
self.outbound_states.items(),
|
|
_OUTBOUND_PRUNE_SCAN_MAX,
|
|
)
|
|
for session_id, state in list(oldest):
|
|
if (
|
|
(state.worker is not None and not state.worker.done())
|
|
or not state.queue.empty()
|
|
or now - state.last_active < _OUTBOUND_IDLE_SECONDS
|
|
):
|
|
continue
|
|
if self.outbound_states.get(session_id) is state:
|
|
self.outbound_states.pop(session_id, None)
|
|
|
|
async def _enqueue_callback(self, session_id: str, payload: dict) -> None:
|
|
state = self._outbound_state(session_id)
|
|
state.last_active = time.monotonic()
|
|
if state.worker is None or state.worker.done():
|
|
state.worker = asyncio.create_task(self._outbound_worker(session_id, state))
|
|
try:
|
|
state.queue.put_nowait(payload)
|
|
except asyncio.QueueFull:
|
|
# Drop oldest to bound memory, then enqueue (best-effort, at-least-once).
|
|
try:
|
|
state.queue.get_nowait()
|
|
except asyncio.QueueEmpty:
|
|
pass
|
|
await self.logger.warning(f'http_bot outbound queue full for session {session_id}; dropped oldest')
|
|
state.queue.put_nowait(payload)
|
|
|
|
async def _outbound_worker(self, session_id: str, state: _SessionOutbound) -> None:
|
|
while True:
|
|
try:
|
|
payload = await asyncio.wait_for(
|
|
state.queue.get(),
|
|
timeout=_OUTBOUND_IDLE_SECONDS,
|
|
)
|
|
except asyncio.TimeoutError:
|
|
if self.outbound_states.get(session_id) is state and state.queue.empty():
|
|
self.outbound_states.pop(session_id, None)
|
|
return
|
|
continue
|
|
try:
|
|
await self._deliver_callback(payload)
|
|
except Exception as e: # noqa: BLE001
|
|
await self.logger.error(f'http_bot callback delivery failed for {session_id}: {e}')
|
|
finally:
|
|
state.queue.task_done()
|
|
state.last_active = time.monotonic()
|
|
|
|
async def _deliver_callback(self, payload: dict) -> None:
|
|
callback_url = self.config.get('callback_url', '')
|
|
if not callback_url:
|
|
await self.logger.warning('http_bot has no callback_url configured; dropping reply')
|
|
return
|
|
|
|
body = json.dumps(payload, ensure_ascii=False).encode()
|
|
secret = self.config.get('outbound_secret') or self.config.get('inbound_secret', '')
|
|
ts, sig = signing.sign(secret, body)
|
|
headers = {
|
|
'Content-Type': 'application/json',
|
|
signing.HEADER_TIMESTAMP: ts,
|
|
signing.HEADER_SIGNATURE: sig,
|
|
}
|
|
timeout = aiohttp.ClientTimeout(total=int(self.config.get('callback_timeout', 15)))
|
|
max_retries = int(self.config.get('callback_max_retries', 3))
|
|
|
|
session = httpclient.get_session()
|
|
attempt = 0
|
|
while True:
|
|
attempt += 1
|
|
try:
|
|
async with session.post(callback_url, data=body, headers=headers, timeout=timeout) as resp:
|
|
if resp.status < 400:
|
|
return
|
|
if resp.status < 500 or attempt > max_retries:
|
|
await self.logger.warning(f'http_bot callback {callback_url} -> {resp.status}, giving up')
|
|
return
|
|
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
|
if attempt > max_retries:
|
|
await self.logger.warning(f'http_bot callback {callback_url} failed after {attempt} tries: {e}')
|
|
return
|
|
await asyncio.sleep(min(2 ** (attempt - 1), 30))
|
|
|
|
async def _emit_reply(
|
|
self,
|
|
message_source: platform_events.MessageEvent,
|
|
message: platform_message.MessageChain,
|
|
is_final: bool,
|
|
stream: bool,
|
|
) -> dict:
|
|
session_id = self._extract_session_id(message_source)
|
|
reply_to = self._extract_reply_to(message_source)
|
|
sequence = self._next_sequence(session_id, is_final)
|
|
parts = [c.model_dump() if hasattr(c, 'model_dump') else c.__dict__ for c in message]
|
|
payload = {
|
|
'session_id': session_id,
|
|
'reply_to': reply_to,
|
|
'sequence': sequence,
|
|
'is_final': is_final,
|
|
'stream': stream,
|
|
'message': parts,
|
|
'timestamp': datetime.now().isoformat(),
|
|
}
|
|
|
|
# If a /sync request is awaiting this session, collect instead of POSTing.
|
|
collector = self.sync_waiters.get(session_id)
|
|
if collector is not None:
|
|
collector.parts.extend(parts)
|
|
if is_final:
|
|
collector.done.set()
|
|
return payload
|
|
|
|
await self._enqueue_callback(session_id, payload)
|
|
return payload
|
|
|
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain) -> dict:
|
|
"""Proactively push a message to a session (target_id == session_id)."""
|
|
sequence = self._next_sequence(str(target_id), is_final=True)
|
|
payload = {
|
|
'session_id': str(target_id),
|
|
'reply_to': '',
|
|
'sequence': sequence,
|
|
'is_final': True,
|
|
'stream': False,
|
|
'message': [c.model_dump() if hasattr(c, 'model_dump') else c.__dict__ for c in message],
|
|
'timestamp': datetime.now().isoformat(),
|
|
}
|
|
await self._enqueue_callback(str(target_id), payload)
|
|
return payload
|
|
|
|
async def reply_message(
|
|
self,
|
|
message_source: platform_events.MessageEvent,
|
|
message: platform_message.MessageChain,
|
|
quote_origin: bool = False,
|
|
) -> dict:
|
|
return await self._emit_reply(message_source, message, is_final=True, stream=False)
|
|
|
|
async def reply_message_chunk(
|
|
self,
|
|
message_source: platform_events.MessageEvent,
|
|
bot_message,
|
|
message: platform_message.MessageChain,
|
|
quote_origin: bool = False,
|
|
is_final: bool = False,
|
|
) -> dict:
|
|
message_is_final = is_final and getattr(bot_message, 'tool_calls', None) is None
|
|
return await self._emit_reply(message_source, message, is_final=message_is_final, stream=True)
|
|
|
|
# -- sync convenience mode ------------------------------------------------
|
|
|
|
async def _run_sync(self, event, listener, session_id: str, message_id: str):
|
|
"""Push a message and wait for the final reply, collapsing 1->M parts.
|
|
|
|
Lossy by design (drops streaming/ordering nuance); documented as such.
|
|
Concurrency-safe: routing is via the per-session ``_sync_waiters``
|
|
registry that ``_emit_reply`` consults, not by patching methods.
|
|
"""
|
|
if session_id in self.sync_waiters:
|
|
return self._err('duplicate', 'a sync request is already in flight for this session')
|
|
|
|
collector = _SyncCollector()
|
|
self.sync_waiters[session_id] = collector
|
|
listener_task = self._start_inbound_task(listener(event, self))
|
|
if listener_task is None:
|
|
self.sync_waiters.pop(session_id, None)
|
|
return self._err('overloaded', 'too many inbound messages are already being processed')
|
|
try:
|
|
timeout = int(self.config.get('callback_timeout', 15)) * 4
|
|
try:
|
|
await asyncio.wait_for(collector.done.wait(), timeout=timeout)
|
|
except asyncio.TimeoutError:
|
|
await self.logger.warning(f'http_bot sync wait timed out for session {session_id}')
|
|
finally:
|
|
self.sync_waiters.pop(session_id, None)
|
|
state = self.outbound_states.get(session_id)
|
|
if state is not None and state.worker is None and state.queue.empty():
|
|
self.outbound_states.pop(session_id, None)
|
|
|
|
return quart.jsonify(
|
|
{
|
|
'code': 0,
|
|
'msg': 'ok',
|
|
'data': {
|
|
'session_id': session_id,
|
|
'reply_to': message_id,
|
|
'message': collector.parts,
|
|
},
|
|
}
|
|
), 200
|