feat(tenancy): add Workspace multi-tenant foundation (#2353)

* 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>
This commit is contained in:
RockChinQ
2026-07-30 21:43:35 +08:00
committed by GitHub
parent 463b120923
commit e1ac5e0fc8
468 changed files with 78320 additions and 13137 deletions
@@ -23,6 +23,7 @@ _GROUP_NAME_LOOKUP_TIMEOUT_SECONDS = 2
_GROUP_MEMBER_INFO_CACHE_TTL_SECONDS = 86400
_GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS = 600
_GROUP_MEMBER_INFO_LOOKUP_TIMEOUT_SECONDS = 2
_LOOKUP_CACHE_MAX = 4096
def _normalize_base64_payload(value: str) -> str:
@@ -372,6 +373,31 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
tuple[typing.Union[int, str], typing.Union[int, str]], tuple[dict, float]
] = {}
self._group_member_info_negative_cache: dict[tuple[typing.Union[int, str], typing.Union[int, str]], float] = {}
self._last_cache_cleanup = 0.0
def _prune_caches(self, now: float) -> None:
caches = (
self._group_name_cache,
self._group_name_negative_cache,
self._group_member_info_cache,
self._group_member_info_negative_cache,
)
if now - self._last_cache_cleanup < 60 and all(len(cache) <= _LOOKUP_CACHE_MAX for cache in caches):
return
self._last_cache_cleanup = now
for cache in caches:
for key, value in tuple(cache.items()):
expires_at = value[1] if isinstance(value, tuple) else value
if expires_at <= now:
cache.pop(key, None)
while len(cache) > _LOOKUP_CACHE_MAX:
cache.pop(next(iter(cache)), None)
def clear(self) -> None:
self._group_name_cache.clear()
self._group_name_negative_cache.clear()
self._group_member_info_cache.clear()
self._group_member_info_negative_cache.clear()
@staticmethod
async def yiri2target(event: platform_events.MessageEvent, bot_account_id: int):
@@ -379,6 +405,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
async def _get_group_name(self, group_id: typing.Union[int, str], bot=None) -> str:
now = time.monotonic()
self._prune_caches(now)
if group_id in self._group_name_cache:
group_name, expires_at = self._group_name_cache[group_id]
if expires_at > now:
@@ -414,6 +441,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
bot=None,
) -> dict:
now = time.monotonic()
self._prune_caches(now)
cache_key = (group_id, user_id)
if cache_key in self._group_member_info_cache:
member_info, expires_at = self._group_member_info_cache[cache_key]
@@ -532,6 +560,8 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
return
self.on_websocket_connection_event_cache.append(event)
if len(self.on_websocket_connection_event_cache) > 100:
self.on_websocket_connection_event_cache.pop(0)
await self.logger.info(f'WebSocket connection established, bot id: {event.self_id}')
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
@@ -697,4 +727,6 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
async def kill(self) -> bool:
# Current issue: existing connection will not be closed
# self.should_shutdown = True
self.on_websocket_connection_event_cache.clear()
self.event_converter.clear()
return False
+70 -1
View File
@@ -5,6 +5,7 @@ import re
import traceback
import typing
import uuid
import time
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
import langbot_plugin.api.entities.builtin.platform.message as platform_message
@@ -544,11 +545,59 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot=bot,
listeners={},
)
self._background_tasks: set[asyncio.Task] = set()
# Wire the card-action callback after super().__init__ so we can reference
# self.* — the client's handler stores this as a soft reference and reads
# it at fire time.
self.bot.card_action_callback = self._on_card_action
def _start_background_task(self, coro) -> bool:
"""Start one bounded adapter-side auxiliary task."""
background_tasks = getattr(self, '_background_tasks', None)
if background_tasks is None:
background_tasks = set()
object.__setattr__(self, '_background_tasks', background_tasks)
for task in tuple(background_tasks):
if task.done():
background_tasks.discard(task)
if len(background_tasks) >= 100:
coro.close()
return False
task = asyncio.create_task(coro)
background_tasks.add(task)
def done(done_task: asyncio.Task) -> None:
background_tasks.discard(done_task)
if not done_task.cancelled():
done_task.exception()
task.add_done_callback(done)
return True
def _prune_card_state(self) -> None:
now = time.monotonic()
ttl_seconds = 1800
for card_id, state in tuple(self.card_state.items()):
if now - float(state.get('created_at', now)) <= ttl_seconds:
continue
self.card_state.pop(card_id, None)
for session_key, active_card_id in tuple(self.active_turn_card.items()):
if active_card_id == card_id:
self.active_turn_card.pop(session_key, None)
self.active_turn_text.pop(session_key, None)
while len(self.card_state) > 1000:
card_id = next(iter(self.card_state))
self.card_state.pop(card_id, None)
while len(self.active_turn_card) > 1000:
session_key = next(iter(self.active_turn_card))
self.active_turn_card.pop(session_key, None)
self.active_turn_text.pop(session_key, None)
card_instances = getattr(self, 'card_instance_id_dict', None)
if isinstance(card_instances, dict):
while len(card_instances) > 1000:
card_instances.pop(next(iter(card_instances)), None)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
@@ -674,6 +723,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return is_stream
async def create_message_card(self, message_id, event):
self._prune_card_state()
form_template_id = (self.config.get('human_input_card_template_id') or '').strip()
legacy_template_id = self.config.get('card_template_id', '')
@@ -806,6 +856,20 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return params
async def kill(self) -> bool:
task_set = getattr(self, '_background_tasks', set())
background_tasks = list(task_set)
for task in background_tasks:
if not task.done():
task.cancel()
if background_tasks:
await asyncio.gather(*background_tasks, return_exceptions=True)
task_set.clear()
card_instances = getattr(self, 'card_instance_id_dict', None)
if isinstance(card_instances, dict):
card_instances.clear()
self.card_state.clear()
self.active_turn_card.clear()
self.active_turn_text.clear()
await self.bot.stop()
return True
@@ -931,6 +995,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
)
# Record form state for the click-handler.
self._prune_card_state()
launcher_type, launcher_id, sender_user_id = self._derive_session_descriptor(message_source)
self.card_state[out_track_id] = {
'session_key': session_key,
@@ -947,6 +1012,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'current_input_field': str(form_data.get('_current_input_field') or ''),
'input_defs': _dingtalk_form_input_defs(form_data),
'inputs': form_data.get('inputs') or {},
'created_at': time.monotonic(),
}
btns = self._build_btns(actions if should_show_actions else [], out_track_id)
@@ -1040,6 +1106,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
f'options={len(component_params.get("select_options") or [])}'
)
self._prune_card_state()
self.card_state[out_track_id] = {
'session_key': session_key,
'launcher_type': launcher_type.value,
@@ -1057,6 +1124,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'inputs': form_data.get('inputs') or {},
'open_space_id': open_space_id,
'is_group': is_group,
'created_at': time.monotonic(),
}
parts = []
@@ -1223,6 +1291,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
f'payload_action_id={payload.get("action_id")!r} params={payload.get("params")!r}'
)
out_track_id = payload.get('out_track_id') or ''
self._prune_card_state()
params = payload.get('params') or {}
# ButtonGroup `sendCardRequest` events surface the click id at the
# callback top level as `actionId`; fall back to `params.action_id`
@@ -1359,7 +1428,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# output lives on a separate new card (lazy-created in
# reply_message_chunk on the synthetic event), so the form card
# stays put as a record of the user's selection.
asyncio.create_task(
self._start_background_task(
self._mark_card_resolved(
out_track_id,
action_title,
+70 -66
View File
@@ -28,6 +28,21 @@ import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_
from ..logger import EventLogger
_MAX_DISCORD_MEDIA_BYTES = 10 * 1024 * 1024
def _decode_discord_base64_limited(value: str) -> bytes:
if ',' in value:
value = value.split(',', 1)[1]
max_encoded_bytes = 4 * ((_MAX_DISCORD_MEDIA_BYTES + 2) // 3)
if len(value) > max_encoded_bytes:
raise ValueError('Discord media exceeds the size limit')
decoded = base64.b64decode(value)
if len(decoded) > _MAX_DISCORD_MEDIA_BYTES:
raise ValueError('Discord media exceeds the size limit')
return decoded
# 语音功能相关异常定义
class VoiceConnectionError(Exception):
"""语音连接基础异常"""
@@ -604,7 +619,6 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
for ele in message_chain:
if isinstance(ele, platform_message.Image):
image_bytes = None
filename = f'{uuid.uuid4()}.png' # 默认文件名
if ele.base64:
@@ -618,60 +632,17 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
filename = f'{uuid.uuid4()}.gif'
elif 'webp' in data_header:
filename = f'{uuid.uuid4()}.webp'
# 去掉data:image/xxx;base64,前缀
base64_data = ele.base64.split(',')[1]
else:
base64_data = ele.base64
image_bytes = base64.b64decode(base64_data)
elif ele.url:
# 从URL下载图片
session = httpclient.get_session()
async with session.get(ele.url) as response:
image_bytes = await response.read()
# 从URL或Content-Type推断文件类型
content_type = response.headers.get('Content-Type', '')
if 'jpeg' in content_type or 'jpg' in content_type:
filename = f'{uuid.uuid4()}.jpg'
elif 'gif' in content_type:
filename = f'{uuid.uuid4()}.gif'
elif 'webp' in content_type:
filename = f'{uuid.uuid4()}.webp'
elif ele.url.lower().endswith(('.jpg', '.jpeg')):
filename = f'{uuid.uuid4()}.jpg'
elif ele.url.lower().endswith('.gif'):
filename = f'{uuid.uuid4()}.gif'
elif ele.url.lower().endswith('.webp'):
filename = f'{uuid.uuid4()}.webp'
elif ele.path:
# 从文件路径读取图片
# 确保路径没有空字节
clean_path = ele.path.replace('\x00', '')
clean_path = os.path.abspath(clean_path)
if not os.path.exists(clean_path):
continue # 跳过不存在的文件
try:
with open(clean_path, 'rb') as f:
image_bytes = f.read()
# 从文件路径获取文件名,保持原始扩展名
original_filename = os.path.basename(clean_path)
if original_filename and '.' in original_filename:
# 保持原始文件名的扩展名
ext = original_filename.split('.')[-1].lower()
filename = f'{uuid.uuid4()}.{ext}'
else:
# 如果没有扩展名,尝试从文件内容检测
if image_bytes.startswith(b'\xff\xd8\xff'):
filename = f'{uuid.uuid4()}.jpg'
elif image_bytes.startswith(b'GIF'):
filename = f'{uuid.uuid4()}.gif'
elif image_bytes.startswith(b'RIFF') and b'WEBP' in image_bytes[:20]:
filename = f'{uuid.uuid4()}.webp'
# 默认保持PNG
except Exception as e:
print(f'Error reading image file {clean_path}: {e}')
continue # 跳过读取失败的文件
try:
image_bytes, mime_type = await ele.get_bytes()
except Exception as exc:
print(f'Error reading Discord image: {exc}')
continue
if 'jpeg' in mime_type or 'jpg' in mime_type:
filename = f'{uuid.uuid4()}.jpg'
elif 'gif' in mime_type:
filename = f'{uuid.uuid4()}.gif'
elif 'webp' in mime_type:
filename = f'{uuid.uuid4()}.webp'
if image_bytes:
files.append(discord.File(fp=io.BytesIO(image_bytes), filename=filename))
@@ -702,27 +673,34 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
elif 'webm' in data_header:
filename = f'{uuid.uuid4()}.webm'
file_base64 = ele.base64.split(',')[-1]
file_bytes = base64.b64decode(file_base64)
file_bytes = await asyncio.to_thread(
_decode_discord_base64_limited,
ele.base64,
)
elif ele.url:
session = httpclient.get_session()
async with session.get(ele.url) as response:
file_bytes = await response.read()
file_bytes = await httpclient.read_limited(
response,
max_bytes=_MAX_DISCORD_MEDIA_BYTES,
)
if file_bytes:
files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename))
elif isinstance(ele, platform_message.File):
file_bytes = None
filename = f'{uuid.uuid4()}.{ele.name.split(".")[-1]}'
if ele.base64:
if ele.base64.startswith('data:'):
file_base64 = ele.base64.split(',')[1]
file_bytes = base64.b64decode(file_base64)
else:
file_bytes = base64.b64decode(ele.base64)
file_bytes = await asyncio.to_thread(
_decode_discord_base64_limited,
ele.base64,
)
elif ele.url:
session = httpclient.get_session()
async with session.get(ele.url) as response:
file_bytes = await response.read()
file_bytes = await httpclient.read_limited(
response,
max_bytes=_MAX_DISCORD_MEDIA_BYTES,
)
if file_bytes:
files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename))
elif isinstance(ele, platform_message.Forward):
@@ -780,8 +758,11 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
for attachment in message.attachments:
session = httpclient.get_session(trust_env=True)
async with session.get(attachment.url) as response:
image_data = await response.read()
image_base64 = base64.b64encode(image_data).decode('utf-8')
image_data = await httpclient.read_limited(
response,
max_bytes=_MAX_DISCORD_MEDIA_BYTES,
)
image_base64 = (await asyncio.to_thread(base64.b64encode, image_data)).decode('utf-8')
image_format = response.headers['Content-Type']
element_list.append(
platform_message.Image(url=attachment.url, base64=f'data:{image_format};base64,{image_base64}')
@@ -970,6 +951,20 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# resume.
self._pending_forms: dict[str, dict] = {}
def _prune_transient_state(self) -> None:
now = time.time()
ttl_seconds = 1800
for message_id, state in tuple(self._stream_buffer.items()):
if now - float(state.get('updated_at', now)) > ttl_seconds:
self._stream_buffer.pop(message_id, None)
for session_key, state in tuple(self._pending_forms.items()):
if now - float(state.get('posted_at', now)) > ttl_seconds:
self._pending_forms.pop(session_key, None)
while len(self._stream_buffer) > 100:
self._stream_buffer.pop(next(iter(self._stream_buffer)), None)
while len(self._pending_forms) > 1000:
self._pending_forms.pop(next(iter(self._pending_forms)), None)
# Voice functionality methods
async def join_voice_channel(self, guild_id: int, channel_id: int, user_id: int = None) -> discord.VoiceClient:
"""
@@ -1248,11 +1243,13 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
source = event.source_platform_object
if not isinstance(source, discord.Message):
return False
self._prune_transient_state()
self._stream_buffer[message_id] = {
'channel': source.channel,
'sent_message': None, # discord.Message set on first send
'last_content': '',
'chunk_count': 0,
'updated_at': time.time(),
}
return True
@@ -1276,6 +1273,8 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
form_data = getattr(bot_message, '_form_data', None) if not isinstance(bot_message, dict) else None
ctx = self._stream_buffer.get(msg_id) if msg_id else None
if ctx is not None:
ctx['updated_at'] = time.time()
# If the stream ctx was not set up (create_message_card wasn't
# called, e.g. synthetic event), or the final chunk carries a
@@ -1344,6 +1343,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
callback synthesizes a ``_dify_form_action`` query so the runner's
``_merge_pending_form_action`` resumes the workflow.
"""
self._prune_transient_state()
source = message_source.source_platform_object
actions = form_data.get('actions') or []
@@ -1447,6 +1447,8 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
and disable the View buttons so the choice is visually locked in."""
import langbot_plugin.api.entities.builtin.provider.session as provider_session
self._prune_transient_state()
# ACK first (3-second deadline before Discord shows "interaction failed").
try:
await interaction.response.defer()
@@ -1655,5 +1657,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if self.voice_manager:
await self.voice_manager.disconnect_all()
self._stream_buffer.clear()
self._pending_forms.clear()
await self.bot.close()
return True
+163 -25
View File
@@ -28,6 +28,7 @@ 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
@@ -54,6 +55,7 @@ _ERR = {
'bad_signature': (401, 40101),
'duplicate': (409, 40901),
'too_large': (413, 41301),
'overloaded': (503, 50301),
'internal': (500, 50001),
}
@@ -63,16 +65,27 @@ _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=1000)
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:
@@ -99,6 +112,7 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
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)
@@ -108,6 +122,7 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.outbound_states = {}
self.idempotency_cache = {}
self.sync_waiters = {}
self.inbound_tasks = set()
# -- framework hooks ------------------------------------------------------
@@ -156,10 +171,19 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await asyncio.sleep(3600)
async def kill(self):
# Cancel any outbound workers.
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 --------------------------------------------------------------
@@ -168,14 +192,52 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
status, code = _ERR[kind]
return quart.jsonify({'code': code, 'msg': detail or kind, 'data': None}), status
def _prune_idempotency(self) -> None:
now = time.time()
if len(self.idempotency_cache) > _IDEMPOTENCY_MAX:
self.idempotency_cache.clear()
return
expired = [k for k, ts in self.idempotency_cache.items() if now - ts > _IDEMPOTENCY_TTL]
for k in expired:
self.idempotency_cache.pop(k, None)
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.
@@ -213,7 +275,7 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return None, self._err('bad_signature', f'invalid signature: {reason}')
try:
data = json.loads(body)
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):
@@ -264,10 +326,11 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# Idempotency.
idem = request.headers.get(signing.HEADER_IDEMPOTENCY)
if idem:
self._prune_idempotency()
if idem in self.idempotency_cache:
idempotency_result = self._reserve_idempotency_key(idem)
if idempotency_result == 'duplicate':
return self._err('duplicate', 'idempotency key already accepted')
self.idempotency_cache[idem] = time.time()
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)
@@ -282,7 +345,8 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return await self._run_sync(event, listener, session_id, message_id)
# Fire-and-collect: kick the pipeline, return 202 immediately.
asyncio.create_task(listener(event, self))
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,
@@ -311,18 +375,40 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
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 (
str(s.launcher_type.value if hasattr(s.launcher_type, 'value') else s.launcher_type) == launcher_type
and str(s.launcher_id) == launcher_id
)
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
@@ -339,7 +425,8 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return ''
def _next_sequence(self, session_id: str, is_final: bool) -> int:
state = self.outbound_states.setdefault(session_id, _SessionOutbound())
state = self._outbound_state(session_id)
state.last_active = time.monotonic()
if state.last_was_final:
state.sequence = 1
else:
@@ -347,8 +434,43 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
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_states.setdefault(session_id, _SessionOutbound())
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:
@@ -364,13 +486,23 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def _outbound_worker(self, session_id: str, state: _SessionOutbound) -> None:
while True:
payload = await state.queue.get()
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', '')
@@ -486,8 +618,11 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
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:
asyncio.create_task(listener(event, self))
timeout = int(self.config.get('callback_timeout', 15)) * 4
try:
await asyncio.wait_for(collector.done.wait(), timeout=timeout)
@@ -495,6 +630,9 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
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(
{
+47 -31
View File
@@ -6,7 +6,6 @@ import json
import base64
import zlib
import traceback
import time
import aiohttp
@@ -21,6 +20,39 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
_KOOK_MAX_GATEWAY_MESSAGE_BYTES = 10 * 1024 * 1024
def _bounded_zlib_decompress(payload: bytes) -> bytes:
decompressor = zlib.decompressobj()
decoded = decompressor.decompress(
payload,
_KOOK_MAX_GATEWAY_MESSAGE_BYTES + 1,
)
if len(decoded) > _KOOK_MAX_GATEWAY_MESSAGE_BYTES or decompressor.unconsumed_tail:
raise ValueError('KOOK gateway message exceeds the decompressed size limit')
decoded += decompressor.flush(_KOOK_MAX_GATEWAY_MESSAGE_BYTES + 1 - len(decoded))
if len(decoded) > _KOOK_MAX_GATEWAY_MESSAGE_BYTES or not decompressor.eof:
raise ValueError('KOOK gateway message exceeds the decompressed size limit')
return decoded
def _decode_gateway_message(message: str | bytes) -> dict:
if isinstance(message, bytes):
try:
message_bytes = _bounded_zlib_decompress(message)
except zlib.error:
message_bytes = message
else:
message_bytes = message.encode('utf-8')
if len(message_bytes) > _KOOK_MAX_GATEWAY_MESSAGE_BYTES:
raise ValueError('KOOK gateway message exceeds the size limit')
decoded = json.loads(message_bytes)
if not isinstance(decoded, dict):
raise ValueError('KOOK gateway message must be a JSON object')
return decoded
class KookMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
"""Convert between LangBot MessageChain and KOOK message format"""
@@ -125,8 +157,8 @@ class KookMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
session = httpclient.get_session()
async with session.get(content) as response:
if response.status == 200:
image_bytes = await response.read()
image_base64 = base64.b64encode(image_bytes).decode('utf-8')
image_bytes = await httpclient.read_limited(response)
image_base64 = (await asyncio.to_thread(base64.b64encode, image_bytes)).decode('utf-8')
# Detect image format
content_type = response.headers.get('Content-Type', 'image/png')
components.append(
@@ -270,10 +302,6 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
http_session: typing.Optional[aiohttp.ClientSession] = pydantic.Field(exclude=True, default=None)
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
# Debug: Track init
with open('/tmp/kook_adapter_init.txt', 'w') as f:
f.write(f'KOOK adapter __init__ called at {time.time()}\n')
# Validate required config
if 'token' not in config:
raise Exception('KOOK adapter requires "token" in config')
@@ -300,7 +328,7 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
session = httpclient.get_session()
async with session.get(base_url, params=params, headers=headers) as response:
if response.status == 200:
data = await response.json()
data = await httpclient.read_json_limited(response)
if data.get('code') == 0:
gateway_url = data['data']['url']
return gateway_url
@@ -320,7 +348,7 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
session = httpclient.get_session()
async with session.get(base_url, headers=headers) as response:
if response.status == 200:
data = await response.json()
data = await httpclient.read_json_limited(response)
if data.get('code') == 0:
user_info = data['data']
return user_info
@@ -409,17 +437,10 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# Wait for HELLO within 6 seconds
try:
hello_msg = await asyncio.wait_for(ws.recv(), timeout=6.0)
# Handle compressed messages (same as main message loop)
if isinstance(hello_msg, bytes):
# Decompress if compressed
try:
hello_msg = zlib.decompress(hello_msg).decode('utf-8')
except Exception:
# Not compressed or decompression failed
hello_msg = hello_msg.decode('utf-8')
hello_data = json.loads(hello_msg)
hello_data = await asyncio.to_thread(
_decode_gateway_message,
hello_msg,
)
if hello_data.get('s') == 1: # HELLO signal
await self._handle_hello(hello_data['d'])
@@ -433,16 +454,11 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# Main message loop
async for message in ws:
if isinstance(message, bytes):
# Decompress if compressed
try:
message = zlib.decompress(message).decode('utf-8')
except Exception:
# Not compressed or decompression failed
message = message.decode('utf-8')
try:
msg_data = json.loads(message)
msg_data = await asyncio.to_thread(
_decode_gateway_message,
message,
)
signal = msg_data.get('s')
if signal == 0: # EVENT
@@ -516,7 +532,7 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async with self.http_session.post(url, json=payload, headers=headers) as response:
if response.status == 200:
result = await response.json()
result = await httpclient.read_json_limited(response)
if result.get('code') == 0:
await self.logger.debug(f'Message sent successfully to {target_id}')
else:
@@ -582,7 +598,7 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async with self.http_session.post(url, json=payload, headers=headers) as response:
if response.status == 200:
result = await response.json()
result = await httpclient.read_json_limited(response)
if result.get('code') == 0:
await self.logger.debug('Reply sent successfully')
else:
+229 -119
View File
@@ -15,7 +15,7 @@ import hashlib
from Crypto.Cipher import AES
import tempfile
import os
import mimetypes
import threading
from langbot.pkg.utils import httpclient
import lark_oapi.ws.exception
@@ -34,6 +34,53 @@ import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_
import langbot_plugin.api.entities.builtin.provider.session as provider_session
_MAX_LARK_MEDIA_BYTES = 10 * 1024 * 1024
def _decode_lark_base64_limited(value: str) -> bytes:
if ',' in value:
value = value.split(',', 1)[1]
max_encoded_bytes = 4 * ((_MAX_LARK_MEDIA_BYTES + 2) // 3)
if len(value) > max_encoded_bytes:
raise ValueError('Lark media exceeds the size limit')
decoded = base64.b64decode(value)
if len(decoded) > _MAX_LARK_MEDIA_BYTES:
raise ValueError('Lark media exceeds the size limit')
return decoded
def _read_lark_path_limited(path: str) -> bytes:
if os.path.getsize(path) > _MAX_LARK_MEDIA_BYTES:
raise ValueError('Lark media exceeds the size limit')
with open(path, 'rb') as file:
body = file.read(_MAX_LARK_MEDIA_BYTES + 1)
if len(body) > _MAX_LARK_MEDIA_BYTES:
raise ValueError('Lark media exceeds the size limit')
return body
def _write_lark_temp_file(data: bytes) -> str:
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
temp_file.write(data)
temp_file.flush()
return temp_file.name
def _read_lark_response_file_limited(response) -> bytes:
content_length = response.raw.headers.get('content-length')
if content_length is not None:
try:
if int(content_length) > _MAX_LARK_MEDIA_BYTES:
raise ValueError('Lark media exceeds the size limit')
except (TypeError, ValueError) as exc:
if 'exceeds' in str(exc):
raise
body = response.file.read(_MAX_LARK_MEDIA_BYTES + 1)
if len(body) > _MAX_LARK_MEDIA_BYTES:
raise ValueError('Lark media exceeds the size limit')
return body
def _lark_form_component_name(prefix: str, field_name: str, index: int) -> str:
safe_name = re.sub(r'[^A-Za-z0-9_]', '_', field_name)[:8] or 'field'
digest = hashlib.sha1(field_name.encode('utf-8')).hexdigest()[:6]
@@ -299,68 +346,33 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def upload_image_to_lark(msg: platform_message.Image, api_client: lark_oapi.Client) -> typing.Optional[str]:
"""Upload an image to Lark and return the image_key, or None if upload fails."""
image_bytes = None
if msg.base64:
try:
# Remove data URL prefix if present
base64_data = msg.base64
if base64_data.startswith('data:'):
base64_data = base64_data.split(',', 1)[1]
image_bytes = base64.b64decode(base64_data)
except Exception as e:
print(f'Failed to decode base64 image: {e}')
traceback.print_exc()
return None
elif msg.url:
try:
session = httpclient.get_session()
async with session.get(msg.url) as response:
if response.status == 200:
image_bytes = await response.read()
else:
print(f'Failed to download image from {msg.url}: HTTP {response.status}')
return None
except Exception as e:
print(f'Failed to download image from {msg.url}: {e}')
traceback.print_exc()
return None
elif msg.path:
try:
with open(msg.path, 'rb') as f:
image_bytes = f.read()
except Exception as e:
print(f'Failed to read image from path {msg.path}: {e}')
traceback.print_exc()
return None
if image_bytes is None:
try:
image_bytes, _mime_type = await msg.get_bytes()
except Exception as exc:
print(f'Failed to load Lark image: {exc}')
traceback.print_exc()
return None
if not image_bytes:
print(
f'No image data available for Image message (url={msg.url}, base64={bool(msg.base64)}, path={msg.path})'
)
return None
try:
# Create a temporary file to store the image bytes
import tempfile
import os
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
temp_file.write(image_bytes)
temp_file.flush()
temp_file_path = temp_file.name
temp_file_path = await asyncio.to_thread(
_write_lark_temp_file,
image_bytes,
)
try:
# Create image request using the temporary file
request = (
CreateImageRequest.builder()
.request_body(
CreateImageRequestBody.builder().image_type('message').image(open(temp_file_path, 'rb')).build()
with open(temp_file_path, 'rb') as upload_file:
request = (
CreateImageRequest.builder()
.request_body(CreateImageRequestBody.builder().image_type('message').image(upload_file).build())
.build()
)
.build()
)
response = await api_client.im.v1.image.acreate(request)
response = await api_client.im.v1.image.acreate(request)
if not response.success():
print(
@@ -395,23 +407,24 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
duration: Duration in milliseconds (for audio files).
"""
try:
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
temp_file.write(file_bytes)
temp_file_path = temp_file.name
if len(file_bytes) > _MAX_LARK_MEDIA_BYTES:
raise ValueError('Lark media exceeds the size limit')
temp_file_path = await asyncio.to_thread(
_write_lark_temp_file,
file_bytes,
)
try:
body_builder = (
CreateFileRequestBody.builder()
.file_type(file_type)
.file_name(file_name)
.file(open(temp_file_path, 'rb'))
)
if duration is not None:
body_builder = body_builder.duration(duration)
with open(temp_file_path, 'rb') as upload_file:
body_builder = (
CreateFileRequestBody.builder().file_type(file_type).file_name(file_name).file(upload_file)
)
if duration is not None:
body_builder = body_builder.duration(duration)
request = CreateFileRequest.builder().request_body(body_builder.build()).build()
request = CreateFileRequest.builder().request_body(body_builder.build()).build()
response = await api_client.im.v1.file.acreate(request)
response = await api_client.im.v1.file.acreate(request)
if not response.success():
print(
@@ -436,10 +449,10 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
if msg.base64:
try:
base64_str = msg.base64
if ',' in base64_str:
base64_str = base64_str.split(',', 1)[1]
data = base64.b64decode(base64_str)
data = await asyncio.to_thread(
_decode_lark_base64_limited,
msg.base64,
)
except Exception:
pass
elif msg.url:
@@ -447,13 +460,18 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
session = httpclient.get_session()
async with session.get(msg.url) as resp:
if resp.status == 200:
data = await resp.read()
data = await httpclient.read_limited(
resp,
max_bytes=_MAX_LARK_MEDIA_BYTES,
)
except Exception:
pass
elif msg.path:
try:
with open(msg.path, 'rb') as f:
data = f.read()
data = await asyncio.to_thread(
_read_lark_path_limited,
str(msg.path),
)
except Exception:
pass
@@ -694,8 +712,11 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
f'client.im.v1.message_resource.get failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
)
image_bytes = response.file.read()
image_base64 = base64.b64encode(image_bytes).decode()
image_bytes = await asyncio.to_thread(
_read_lark_response_file_limited,
response,
)
image_base64 = (await asyncio.to_thread(base64.b64encode, image_bytes)).decode()
image_format = response.raw.headers['content-type']
@@ -721,27 +742,18 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
lb_msg_list.append(platform_message.Plain(text='[Audio file download failed]'))
return platform_message.MessageChain(lb_msg_list)
# Read audio bytes
audio_bytes = response.file.read()
audio_base64 = base64.b64encode(audio_bytes).decode()
audio_bytes = await asyncio.to_thread(
_read_lark_response_file_limited,
response,
)
audio_base64 = (await asyncio.to_thread(base64.b64encode, audio_bytes)).decode()
# Get content type from response headers
content_type = response.raw.headers.get('content-type', 'audio/mpeg')
mime_main = content_type.split(';')[0].strip()
ext = mimetypes.guess_extension(mime_main) or '.bin'
temp_dir = tempfile.gettempdir()
temp_file_path = os.path.join(temp_dir, f'lark_audio_{file_key}{ext}')
with open(temp_file_path, 'wb') as f:
f.write(audio_bytes)
# Create Voice message: prefer path/url + length, include base64 as optional data URI
lb_msg_list.append(
platform_message.Voice(
voice_id=file_key,
url=f'file://{temp_file_path}',
path=temp_file_path,
base64=f'data:{content_type};base64,{audio_base64}',
length=(duration // 1000) if duration else None,
)
@@ -770,40 +782,22 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
f'client.im.v1.message_resource.get failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
)
file_bytes = response.file.read()
file_base64 = base64.b64encode(file_bytes).decode()
file_bytes = await asyncio.to_thread(
_read_lark_response_file_limited,
response,
)
file_base64 = (await asyncio.to_thread(base64.b64encode, file_bytes)).decode()
file_format = response.raw.headers['content-type']
file_size = len(file_bytes)
# Determine extension from content-type if possible
content_type = response.raw.headers.get('content-type', '')
mime_main = content_type.split(';')[0].strip() if content_type else ''
ext = mimetypes.guess_extension(mime_main) or ''
# Ensure a safe filename (avoid path components)
safe_name = os.path.basename(file_name).replace('/', '_').replace('\\', '_')
if ext and not safe_name.lower().endswith(ext.lower()):
filename_with_ext = f'{safe_name}{ext}'
else:
filename_with_ext = safe_name
temp_dir = tempfile.gettempdir()
temp_file_path = os.path.join(temp_dir, f'lark_{file_key}_{filename_with_ext}')
with open(temp_file_path, 'wb') as f:
f.write(file_bytes)
# Create File message with local path and file:// URL
lb_msg_list.append(
platform_message.File(
id=file_key,
name=file_name,
size=file_size,
url=f'file://{temp_file_path}',
path=temp_file_path,
base64=f'data:{file_format};base64,{file_base64}', # not including base64 by default to save memory; can be added if needed
base64=f'data:{file_format};base64,{file_base64}',
)
)
@@ -1042,16 +1036,26 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# card_id → input_defs / inputs captured for the selected-action notice
card_form_input_defs: dict[str, list[dict]]
card_form_inputs: dict[str, dict]
card_last_accessed: dict[str, float]
card_cleanup_at: float
# set of card_ids that have already transitioned from "buttons visible" to "resume layout"
card_resume_transitioned: set[str]
inbound_event_tasks: set[asyncio.Task]
threadsafe_event_futures: set[typing.Any]
threadsafe_event_lock: typing.Any = pydantic.Field(exclude=True)
_MONITORING_MAPPING_TTL = 600 # 10 minutes
_MAX_INBOUND_EVENTS = 100
_MAX_TENANT_ACCESS_TOKENS = 1024
seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识
bot_uuid: str = None # 机器人UUID
app_ticket: str = None # 商店应用用到
app_access_token: str = None # 商店应用用到
app_access_token_expire_at: int = None
tenant_access_tokens: dict[str, dict[str, str]] = {} # 租户access_token映射
tenant_access_tokens: dict[str, dict[str, str]] = pydantic.Field(
default_factory=dict,
exclude=True,
)
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
quart_app = quart.Quart(__name__)
@@ -1062,11 +1066,11 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.listeners[type(lb_event)](lb_event, self)
def sync_on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
asyncio.create_task(on_message(event))
self._schedule_inbound_event(on_message(event))
def schedule_on_app_loop(coro):
"""Run a coroutine on the application event loop from sync callbacks."""
return asyncio.run_coroutine_threadsafe(coro, self.ap.event_loop)
return self._schedule_threadsafe_event(coro)
def sync_on_card_action(event):
try:
@@ -1289,7 +1293,13 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
card_form_content={},
card_form_input_defs={},
card_form_inputs={},
card_last_accessed={},
card_cleanup_at=0.0,
card_resume_transitioned=set(),
inbound_event_tasks=set(),
threadsafe_event_futures=set(),
threadsafe_event_lock=threading.Lock(),
tenant_access_tokens={},
seq=1,
listeners={},
quart_app=quart_app,
@@ -1300,6 +1310,45 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
**kwargs,
)
def _schedule_inbound_event(self, coro) -> None:
for task in tuple(self.inbound_event_tasks):
if task.done():
self.inbound_event_tasks.discard(task)
if len(self.inbound_event_tasks) >= self._MAX_INBOUND_EVENTS:
coro.close()
return
task = asyncio.create_task(coro)
self.inbound_event_tasks.add(task)
def done(done_task: asyncio.Task) -> None:
self.inbound_event_tasks.discard(done_task)
if not done_task.cancelled():
done_task.exception()
task.add_done_callback(done)
def _schedule_threadsafe_event(self, coro):
"""Submit one bounded callback from the Lark SDK's sync boundary."""
with self.threadsafe_event_lock:
for future in tuple(self.threadsafe_event_futures):
if future.done():
self.threadsafe_event_futures.discard(future)
if len(self.threadsafe_event_futures) >= self._MAX_INBOUND_EVENTS:
coro.close()
return None
future = asyncio.run_coroutine_threadsafe(coro, self.ap.event_loop)
self.threadsafe_event_futures.add(future)
def done(done_future) -> None:
with self.threadsafe_event_lock:
self.threadsafe_event_futures.discard(done_future)
if not done_future.cancelled():
done_future.exception()
future.add_done_callback(done)
return future
def request_app_ticket(self, api_client, config):
app_id = config['app_id']
app_secret = config['app_secret']
@@ -1376,6 +1425,12 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'token': tenant_access_token,
'expire_at': int(time.time()) + expire - 300,
}
now = int(time.time())
for cached_key, cached_token in tuple(self.tenant_access_tokens.items()):
if int(cached_token.get('expire_at', 0)) <= now:
self.tenant_access_tokens.pop(cached_key, None)
while len(self.tenant_access_tokens) > self._MAX_TENANT_ACCESS_TOKENS:
self.tenant_access_tokens.pop(next(iter(self.tenant_access_tokens)), None)
def get_tenant_access_token(self, tenant_key: str):
if tenant_key is None or 'isv' != self.config.get('app_type', 'self'):
@@ -1558,6 +1613,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
user_msg_id = query.message_event.message_chain.message_id
if user_msg_id:
self.pending_monitoring_msg[user_msg_id] = monitoring_message_id
while len(self.pending_monitoring_msg) > CARD_ID_CACHE_SIZE:
self.pending_monitoring_msg.pop(next(iter(self.pending_monitoring_msg)), None)
except Exception as e:
await self.logger.debug(f'Failed to map message to monitoring message: {e}')
@@ -1570,6 +1627,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
def _next_card_sequence(self, card_id: str, suggested: int = 1) -> int:
"""Return the next strictly increasing sequence for a card update."""
self._touch_card(card_id)
current = self.card_sequence_dict.get(card_id, 0)
next_seq = max(current + 1, suggested)
self.card_sequence_dict[card_id] = next_seq
@@ -1577,6 +1635,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
def _register_card_for_source(self, card_id: str, *source_ids: str) -> None:
"""Register a card_id under one or more source message ids."""
self._touch_card(card_id)
bucket = self.card_id_to_source_ids.setdefault(card_id, set())
for sid in source_ids:
if not sid:
@@ -1596,8 +1655,24 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.card_form_content.pop(card_id, None)
self.card_form_input_defs.pop(card_id, None)
self.card_form_inputs.pop(card_id, None)
self.card_last_accessed.pop(card_id, None)
self.card_resume_transitioned.discard(card_id)
def _touch_card(self, card_id: str) -> None:
now = time.monotonic()
if now - self.card_cleanup_at >= 60 or len(self.card_last_accessed) >= CARD_ID_CACHE_SIZE:
self.card_cleanup_at = now
for stale_card_id, last_accessed in tuple(self.card_last_accessed.items()):
if now - last_accessed >= CARD_ID_CACHE_MAX_LIFETIME:
self._drop_card_state(stale_card_id)
while len(self.card_last_accessed) >= CARD_ID_CACHE_SIZE:
oldest_card_id = min(
self.card_last_accessed,
key=self.card_last_accessed.__getitem__,
)
self._drop_card_state(oldest_card_id)
self.card_last_accessed[card_id] = now
async def create_card_id(self, message_id):
try:
# self.logger.debug('飞书支持stream输出,创建卡片......')
@@ -1793,6 +1868,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.card_id_dict[message_id] = response.data.card_id
card_id = response.data.card_id
self._touch_card(card_id)
self.card_sequence_dict[card_id] = 0
return card_id
@@ -1864,7 +1940,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.reply_to_monitoring_msg[reply_msg_id] = (monitoring_msg_id, time.time())
self._cleanup_monitoring_mapping()
except Exception as e:
asyncio.create_task(self.logger.debug(f'Failed to transfer monitoring mapping in create_message_card: {e}'))
await self.logger.debug(f'Failed to transfer monitoring mapping in create_message_card: {e}')
return True
@@ -2872,8 +2948,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
data = await request.json
if 'encrypt' in data:
data = self.cipher.decrypt_string(data['encrypt'])
data = json.loads(data)
encrypted = data['encrypt']
data = await asyncio.to_thread(lambda: json.loads(self.cipher.decrypt_string(encrypted)))
type = self.get_event_type(data)
context = EventContext(data)
if 'url_verification' == type:
@@ -3143,4 +3219,38 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# 所以要设置_auto_reconnect=False,让其不重连。
self.bot._auto_reconnect = False
await self.bot._disconnect()
inbound_tasks = list(self.inbound_event_tasks)
for task in inbound_tasks:
if not task.done():
task.cancel()
if inbound_tasks:
await asyncio.gather(*inbound_tasks, return_exceptions=True)
self.inbound_event_tasks.clear()
with self.threadsafe_event_lock:
threadsafe_futures = list(self.threadsafe_event_futures)
for future in threadsafe_futures:
future.cancel()
if threadsafe_futures:
await asyncio.gather(
*(asyncio.wrap_future(future) for future in threadsafe_futures),
return_exceptions=True,
)
with self.threadsafe_event_lock:
self.threadsafe_event_futures.clear()
self.tenant_access_tokens.clear()
self.card_id_dict.clear()
self.pending_monitoring_msg.clear()
self.reply_to_monitoring_msg.clear()
for card_id in tuple(self.card_last_accessed):
self._drop_card_state(card_id)
self.card_last_accessed.clear()
self.reply_message_card_ids.clear()
self.card_sequence_dict.clear()
self.card_id_to_source_ids.clear()
self.card_streaming_text.clear()
self.card_pre_pause_text.clear()
self.card_form_content.clear()
self.card_form_input_defs.clear()
self.card_form_inputs.clear()
self.card_resume_transitioned.clear()
return False
@@ -6,7 +6,6 @@ import traceback
import time
import re
import copy
import threading
import quart
from langbot.pkg.utils import httpclient
@@ -483,6 +482,7 @@ class GeWeChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.message_converter = GewechatMessageConverter(config)
self.event_converter = GewechatEventConverter(config)
self.listeners = {}
@self.quart_app.route('/gewechat/callback', methods=['POST'])
async def gewechat_callback():
@@ -518,9 +518,13 @@ class GeWeChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
at_targets = at_targets or []
member_info = []
if at_targets:
member_info = self.bot.get_chatroom_member_detail(self.config['app_id'], target_id, at_targets[::-1])[
'data'
]
member_result = await asyncio.to_thread(
self.bot.get_chatroom_member_detail,
self.config['app_id'],
target_id,
at_targets[::-1],
)
member_info = member_result['data']
# 处理消息组件
for msg in content_list:
@@ -596,7 +600,7 @@ class GeWeChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
}
if handler := handler_map.get(msg['type']):
handler(msg)
await asyncio.to_thread(handler, msg)
else:
await self.logger.warning(f'未处理的消息类型: {msg["type"]}')
continue
@@ -645,8 +649,9 @@ class GeWeChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
json={'app_id': self.config['app_id']},
) as response:
if response.status != 200:
raise Exception(f'获取gewechat token失败: {await response.text()}')
self.config['token'] = (await response.json())['data']
error = await httpclient.read_text_limited(response)
raise Exception(f'获取gewechat token失败: {error}')
self.config['token'] = (await httpclient.read_json_limited(response))['data']
self.bot = gewechat_client.GewechatClient(f'{self.config["gewechat_url"]}/v2/api', self.config['token'])
@@ -672,7 +677,7 @@ class GeWeChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
except Exception as e:
raise Exception(f'设置 Gewechat 回调失败, token失效: {e}')
threading.Thread(target=gewechat_login_process).start()
await asyncio.to_thread(gewechat_login_process)
async def shutdown_trigger_placeholder():
while True:
@@ -311,15 +311,17 @@ class NakuruAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
try:
import requests
resp = requests.get(
url='http://{}:{}/get_login_info'.format(self.cfg['host'], self.cfg['http_port']),
resp = await asyncio.to_thread(
requests.get,
'http://{}:{}/get_login_info'.format(self.cfg['host'], self.cfg['http_port']),
headers={'Authorization': 'Bearer ' + self.cfg['token'] if 'token' in self.cfg else ''},
timeout=5,
proxies=None,
)
if resp.status_code == 403:
raise Exception('go-cqhttp拒绝访问,请检查配置文件中nakuru适配器的配置')
self.bot_account_id = int(resp.json()['data']['user_id'])
response_data = await httpclient.parse_json_response(resp)
self.bot_account_id = int(response_data['data']['user_id'])
except Exception:
raise Exception('获取go-cqhttp账号信息失败, 请检查是否已启动go-cqhttp并配置正确')
await self.bot._run()
@@ -5,6 +5,7 @@ import typing
import datetime
import re
import traceback
from collections import OrderedDict
import botpy
import botpy.message as botpy_message
@@ -40,7 +41,8 @@ event_handler_mapping = {
}
cached_message_ids = {}
_CACHED_MESSAGE_ID_LIMIT = 10000
cached_message_ids: OrderedDict[str, str] = OrderedDict()
"""由于QQ官方的消息id是字符串,而YiriMirai的消息id是整数,所以需要一个索引来进行转换"""
id_index = 0
@@ -53,6 +55,8 @@ def save_msg_id(message_id: str) -> int:
crt_index = id_index
id_index += 1
cached_message_ids[str(crt_index)] = message_id
while len(cached_message_ids) > _CACHED_MESSAGE_ID_LIMIT:
cached_message_ids.popitem(last=False)
return crt_index
@@ -355,6 +359,7 @@ class OfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.cfg = cfg
self.ap = ap
self.logger = logger
self.cached_official_messages = OrderedDict()
self.group_msg_seq = 1
self.c2c_msg_seq = 1
@@ -490,6 +495,8 @@ class OfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
],
):
self.cached_official_messages[str(message.id)] = message
while len(self.cached_official_messages) > 1000:
self.cached_official_messages.popitem(last=False)
await callback(self.event_converter.target2yiri(message), self)
for event_handler in event_handler_mapping[event_type]:
@@ -519,6 +526,8 @@ class OfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await (await self.bot.start(**self.cfg))
async def kill(self) -> bool:
self.cached_official_messages.clear()
if not self.bot.is_closed():
await self.bot.close()
return True
return True
+23 -7
View File
@@ -13,6 +13,7 @@ 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
from ..logger import EventLogger
from ...utils import bounded_executor
from linebot.v3 import WebhookHandler
@@ -30,6 +31,14 @@ from linebot.v3.webhooks import (
from linebot.v3.webhook import WebhookParser
from linebot.v3.messaging import MessagingApiBlob
MAX_LINE_MEDIA_BYTES = 10 * 1024 * 1024
def _validate_line_media_content(content: bytes) -> bytes:
if len(content) > MAX_LINE_MEDIA_BYTES:
raise ValueError(f'LINE media exceeds the {MAX_LINE_MEDIA_BYTES}-byte limit')
return content
class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
@@ -63,9 +72,13 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
elif isinstance(message.message, VideoMessageContent):
pass
elif isinstance(message.message, ImageMessageContent):
message_content = MessagingApiBlob(bot_client).get_message_content(message.message.id)
message_content = await asyncio.to_thread(
MessagingApiBlob(bot_client).get_message_content,
message.message.id,
)
_validate_line_media_content(message_content)
base64_string = base64.b64encode(message_content).decode('utf-8')
base64_string = await asyncio.to_thread(lambda: base64.b64encode(message_content).decode('utf-8'))
# 如果需要Data URI格式(用于直接嵌入HTML等)
# 首先需要知道图片类型,LINE图片通常是JPEG
@@ -173,20 +186,22 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
for content in content_list:
if content['type'] == 'text':
self.bot.reply_message_with_http_info(
await asyncio.to_thread(
self.bot.reply_message_with_http_info,
ReplyMessageRequest(
reply_token=message_source.source_platform_object.reply_token,
messages=[TextMessage(text=content['content'])],
)
),
)
elif content['type'] == 'image':
# LINE ImageMessage requires original_content_url and preview_image_url
image_url = content['image']
self.bot.reply_message_with_http_info(
await asyncio.to_thread(
self.bot.reply_message_with_http_info,
ReplyMessageRequest(
reply_token=message_source.source_platform_object.reply_token,
messages=[ImageMessage(original_content_url=image_url, preview_image_url=image_url)],
)
),
)
async def is_muted(self, group_id: int) -> bool:
@@ -266,4 +281,5 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await keep_alive()
async def kill(self) -> bool:
pass
await bounded_executor.run_blocking_cleanup(self.api_client.close)
return True
+98 -27
View File
@@ -5,6 +5,8 @@ import asyncio
import traceback
import base64
import json
import os
from urllib.parse import urlparse
import nio
@@ -16,6 +18,58 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
_MAX_MATRIX_MEDIA_BYTES = 10 * 1024 * 1024
def _decode_matrix_base64_limited(value: str) -> bytes:
if ';base64,' in value:
value = value.split(';base64,', 1)[1]
max_encoded_bytes = 4 * ((_MAX_MATRIX_MEDIA_BYTES + 2) // 3)
if len(value) > max_encoded_bytes:
raise ValueError('Matrix media exceeds the size limit')
decoded = base64.b64decode(value)
if len(decoded) > _MAX_MATRIX_MEDIA_BYTES:
raise ValueError('Matrix media exceeds the size limit')
return decoded
def _read_matrix_file_limited(path: str) -> bytes:
if os.path.getsize(path) > _MAX_MATRIX_MEDIA_BYTES:
raise ValueError('Matrix media exceeds the size limit')
with open(path, 'rb') as file:
body = file.read(_MAX_MATRIX_MEDIA_BYTES + 1)
if len(body) > _MAX_MATRIX_MEDIA_BYTES:
raise ValueError('Matrix media exceeds the size limit')
return body
async def _download_matrix_media_limited(
client: nio.AsyncClient,
mxc_url: str,
) -> tuple[bytes, str]:
parsed = urlparse(mxc_url)
if parsed.scheme != 'mxc' or not parsed.netloc or not parsed.path.strip('/'):
raise ValueError('Invalid Matrix media URL')
method, path = nio.Api.download(
parsed.netloc,
parsed.path.replace('/', ''),
access_token=None,
)
headers = {}
if client.access_token:
headers['Authorization'] = f'Bearer {client.access_token}'
response = await client.send(method, path, headers=headers, timeout=30)
try:
response.raise_for_status()
body = await httpclient.read_limited(
response,
max_bytes=_MAX_MATRIX_MEDIA_BYTES,
)
return body, response.headers.get('Content-Type', 'application/octet-stream')
finally:
response.release()
class MatrixMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain, client: nio.AsyncClient) -> list[dict]:
@@ -26,17 +80,22 @@ class MatrixMessageConverter(abstract_platform_adapter.AbstractMessageConverter)
elif isinstance(component, platform_message.Image):
image_bytes = None
if component.base64:
b64_data = component.base64
if ';base64,' in b64_data:
b64_data = b64_data.split(';base64,', 1)[1]
image_bytes = base64.b64decode(b64_data)
image_bytes = await asyncio.to_thread(
_decode_matrix_base64_limited,
component.base64,
)
elif component.url:
session = httpclient.get_session()
async with session.get(component.url) as response:
image_bytes = await response.read()
image_bytes = await httpclient.read_limited(
response,
max_bytes=_MAX_MATRIX_MEDIA_BYTES,
)
elif component.path:
with open(component.path, 'rb') as f:
image_bytes = f.read()
image_bytes = await asyncio.to_thread(
_read_matrix_file_limited,
str(component.path),
)
if image_bytes:
resp = await client.upload(image_bytes, content_type='image/png')
if isinstance(resp, nio.UploadResponse):
@@ -44,17 +103,22 @@ class MatrixMessageConverter(abstract_platform_adapter.AbstractMessageConverter)
elif isinstance(component, platform_message.File):
file_bytes = None
if component.base64:
b64_data = component.base64
if ';base64,' in b64_data:
b64_data = b64_data.split(';base64,', 1)[1]
file_bytes = base64.b64decode(b64_data)
file_bytes = await asyncio.to_thread(
_decode_matrix_base64_limited,
component.base64,
)
elif component.url:
session = httpclient.get_session()
async with session.get(component.url) as response:
file_bytes = await response.read()
file_bytes = await httpclient.read_limited(
response,
max_bytes=_MAX_MATRIX_MEDIA_BYTES,
)
elif component.path:
with open(component.path, 'rb') as f:
file_bytes = f.read()
file_bytes = await asyncio.to_thread(
_read_matrix_file_limited,
str(component.path),
)
if file_bytes:
file_name = getattr(component, 'name', None) or 'file'
resp = await client.upload(file_bytes, content_type='application/octet-stream', filename=file_name)
@@ -86,11 +150,12 @@ class MatrixMessageConverter(abstract_platform_adapter.AbstractMessageConverter)
elif isinstance(event, nio.RoomMessageImage):
mxc_url = event.url
if mxc_url:
resp = await client.download(mxc_url)
if isinstance(resp, nio.DownloadResponse):
b64 = base64.b64encode(resp.body).decode('utf-8')
content_type = resp.content_type or 'image/png'
message_components.append(platform_message.Image(base64=f'data:{content_type};base64,{b64}'))
body, content_type = await _download_matrix_media_limited(
client,
mxc_url,
)
b64 = (await asyncio.to_thread(base64.b64encode, body)).decode('utf-8')
message_components.append(platform_message.Image(base64=f'data:{content_type};base64,{b64}'))
if event.body:
message_components.append(platform_message.Plain(text=event.body))
@@ -431,14 +496,15 @@ class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if not mxc_url:
return
try:
resp = await self.client.download(mxc_url)
if isinstance(resp, nio.DownloadResponse):
b64 = base64.b64encode(resp.body).decode('utf-8')
content_type = resp.content_type or 'image/png'
await self.logger.info(
f'[{_b.user_id}] Bridge 发送了二维码,请扫码登录:',
images=[platform_message.Image(base64=f'data:{content_type};base64,{b64}')],
)
body, content_type = await _download_matrix_media_limited(
self.client,
mxc_url,
)
b64 = (await asyncio.to_thread(base64.b64encode, body)).decode('utf-8')
await self.logger.info(
f'[{_b.user_id}] Bridge 发送了二维码,请扫码登录:',
images=[platform_message.Image(base64=f'data:{content_type};base64,{b64}')],
)
except Exception:
await self.logger.error(
f'[{_b.user_id}] Failed to download bridge QR image: {traceback.format_exc()}'
@@ -672,11 +738,16 @@ class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def kill(self) -> bool:
self._running = False
bridge_tasks = []
for bridge in self._bridges:
if bridge.login_task and not bridge.login_task.done():
bridge.login_task.cancel()
bridge_tasks.append(bridge.login_task)
if bridge.check_task and not bridge.check_task.done():
bridge.check_task.cancel()
bridge_tasks.append(bridge.check_task)
if bridge_tasks:
await asyncio.gather(*bridge_tasks, return_exceptions=True)
if self.client:
await self.client.close()
await self.logger.debug('Matrix adapter stopped')
@@ -164,6 +164,7 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
await keep_alive()
async def kill(self) -> bool:
self.bot.clear()
return False
async def unregister_listener(
@@ -10,6 +10,7 @@ from __future__ import annotations
import asyncio
import base64
import os
import traceback
import typing
@@ -26,6 +27,7 @@ from langbot.libs.openclaw_weixin_api.types import (
WeixinMessage,
)
from langbot.pkg.entity.persistence import bot as persistence_bot
from langbot.pkg.utils import httpclient
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
@@ -33,6 +35,10 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.pkg.api.http.context import ExecutionContext
_MAX_OPENCLAW_COMPONENT_BYTES = 10 * 1024 * 1024
class OpenClawWeixinMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
"""Converts between LangBot MessageChain and OpenClaw WeChat message items."""
@@ -112,7 +118,12 @@ class OpenClawWeixinMessageConverter(abstract_platform_adapter.AbstractMessageCo
elif item.type == MessageItem.IMAGE and item.image_item:
if hasattr(item.image_item, '_downloaded_bytes') and item.image_item._downloaded_bytes:
b64 = base64.b64encode(item.image_item._downloaded_bytes).decode('utf-8')
b64 = (
await asyncio.to_thread(
base64.b64encode,
item.image_item._downloaded_bytes,
)
).decode('utf-8')
components.append(platform_message.Image(base64=f'data:image/jpeg;base64,{b64}'))
else:
components.append(platform_message.Unknown(text='[Image]'))
@@ -278,11 +289,36 @@ class OpenClawWeixinAdapter(abstract_platform_adapter.AbstractMessagePlatformAda
return
try:
ap = self.logger.ap
await ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_bot.Bot)
.where(persistence_bot.Bot.uuid == self._bot_uuid)
.values(adapter_config=self.config)
)
execution_context = getattr(self.logger, 'execution_context', None)
if not isinstance(execution_context, ExecutionContext):
raise RuntimeError('Weixin Bot config persistence requires an ExecutionContext')
if execution_context.bot_uuid != self._bot_uuid:
raise RuntimeError('Weixin Bot UUID does not match its ExecutionContext')
async def persist() -> None:
binding = await ap.workspace_service.get_execution_binding(
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
if binding.instance_uuid != execution_context.instance_uuid:
raise RuntimeError('Weixin Bot Workspace belongs to another LangBot instance')
await ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_bot.Bot)
.where(persistence_bot.Bot.workspace_uuid == execution_context.workspace_uuid)
.where(persistence_bot.Bot.uuid == self._bot_uuid)
.values(adapter_config=self.config)
)
cloud_runtime = getattr(getattr(ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
if cloud_runtime:
tenant_uow = getattr(ap.persistence_mgr, 'tenant_uow', None)
if not callable(tenant_uow):
raise RuntimeError('Cloud adapter persistence requires an explicit tenant UoW')
async with tenant_uow(execution_context.workspace_uuid):
await persist()
else:
await persist()
except Exception as e:
await self.logger.warning(f'Failed to persist adapter config: {e}')
@@ -374,19 +410,30 @@ class OpenClawWeixinAdapter(abstract_platform_adapter.AbstractMessagePlatformAda
path_val = getattr(component, 'path', None)
if b64_val:
return base64.b64decode(b64_val)
max_encoded_chars = 4 * ((_MAX_OPENCLAW_COMPONENT_BYTES + 2) // 3) + 4
if len(b64_val) > max_encoded_chars:
raise ValueError('OpenClaw media exceeds the size limit')
data = await asyncio.to_thread(base64.b64decode, b64_val)
if len(data) > _MAX_OPENCLAW_COMPONENT_BYTES:
raise ValueError('OpenClaw media exceeds the size limit')
return data
elif url_val and url_val.startswith(('http://', 'https://')):
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(url_val) as resp:
if resp.status == 200:
return await resp.read()
session = httpclient.get_session()
async with session.get(url_val) as resp:
if resp.status == 200:
return await httpclient.read_limited(resp)
elif path_val:
import asyncio
if await asyncio.to_thread(os.path.getsize, path_val) > _MAX_OPENCLAW_COMPONENT_BYTES:
raise ValueError('OpenClaw media exceeds the size limit')
with open(path_val, 'rb') as f:
return await asyncio.to_thread(f.read)
def read_file() -> bytes:
with open(path_val, 'rb') as file:
return file.read(_MAX_OPENCLAW_COMPONENT_BYTES + 1)
data = await asyncio.to_thread(read_file)
if len(data) > _MAX_OPENCLAW_COMPONENT_BYTES:
raise ValueError('OpenClaw media exceeds the size limit')
return data
return None
def register_listener(
@@ -517,6 +564,8 @@ class OpenClawWeixinAdapter(abstract_platform_adapter.AbstractMessagePlatformAda
"""Process a single inbound message from getUpdates."""
if msg.context_token and msg.from_user_id:
self._context_tokens[msg.from_user_id] = msg.context_token
while len(self._context_tokens) > 4096:
self._context_tokens.pop(next(iter(self._context_tokens)), None)
# Download CDN media (files, images) before converting to LangBot events
await self._download_media_items(msg)
@@ -572,6 +621,8 @@ class OpenClawWeixinAdapter(abstract_platform_adapter.AbstractMessagePlatformAda
await self._poll_task
except asyncio.CancelledError:
pass
self._poll_task = None
self._context_tokens.clear()
await self.client.close()
await self.logger.info('OpenClaw WeChat adapter stopped')
return True
+61 -4
View File
@@ -241,6 +241,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
# per (msg_id|event_id) within 60 min, but each reuse needs a
# fresh ``msg_seq`` — re-sending with msg_seq=1 is silently dedup'd.
self._anchor_msg_seq: dict[str, int] = {}
self._background_tasks: set[asyncio.Task] = set()
# Wire button-click handler so webhook mode catches INTERACTION_CREATE.
# (ws mode is wired separately via on_event in _run_websocket so the
@@ -249,6 +250,30 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
async def _on_interaction(event_data: dict, interaction_id: typing.Optional[str]):
await self._handle_interaction_create(event_data, interaction_id)
def _start_background_task(self, coro) -> bool:
"""Start one bounded adapter-side auxiliary task."""
background_tasks = getattr(self, '_background_tasks', None)
if background_tasks is None:
background_tasks = set()
object.__setattr__(self, '_background_tasks', background_tasks)
for task in tuple(background_tasks):
if task.done():
background_tasks.discard(task)
if len(background_tasks) >= 100:
coro.close()
return False
task = asyncio.create_task(coro)
background_tasks.add(task)
def done(done_task: asyncio.Task) -> None:
background_tasks.discard(done_task)
if not done_task.cancelled():
done_task.exception()
task.add_done_callback(done)
return True
async def reply_message(
self,
message_source: platform_events.MessageEvent,
@@ -449,6 +474,14 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
pass
async def kill(self) -> bool:
task_set = getattr(self, '_background_tasks', set())
background_tasks = list(task_set)
for task in background_tasks:
if not task.done():
task.cancel()
if background_tasks:
await asyncio.gather(*background_tasks, return_exceptions=True)
task_set.clear()
if self._ws_task:
self._ws_task.cancel()
try:
@@ -456,6 +489,14 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
except asyncio.CancelledError:
pass
self._ws_task = None
await self.bot.close()
self._pending_forms.clear()
self._session_event_ids.clear()
self._anchor_msg_seq.clear()
self._stream_ctx.clear()
self._stream_ctx_ts.clear()
self._fallback_text.clear()
self._fallback_text_ts.clear()
return True
# --------------- 流式输出 ---------------
@@ -473,6 +514,14 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
for mid in stale_fb:
self._fallback_text.pop(mid, None)
self._fallback_text_ts.pop(mid, None)
while len(self._stream_ctx) > 1000:
oldest = min(self._stream_ctx_ts, key=self._stream_ctx_ts.__getitem__)
self._stream_ctx.pop(oldest, None)
self._stream_ctx_ts.pop(oldest, None)
while len(self._fallback_text) > 1000:
oldest = min(self._fallback_text_ts, key=self._fallback_text_ts.__getitem__)
self._fallback_text.pop(oldest, None)
self._fallback_text_ts.pop(oldest, None)
if stale_ids or stale_fb:
await self.logger.debug(f'Cleaned up {len(stale_ids)} stream contexts, {len(stale_fb)} fallback texts')
@@ -508,6 +557,8 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
# msg_seq=2 instead of being deduplicated by QQ as another seq=1 send.
if source.d_id:
self._anchor_msg_seq[source.d_id] = max(self._anchor_msg_seq.get(source.d_id, 0), 1)
while len(self._anchor_msg_seq) > 4096:
self._anchor_msg_seq.pop(next(iter(self._anchor_msg_seq)), None)
ctx = {
'user_openid': source.user_openid,
@@ -577,7 +628,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
# 非流式场景(如群聊不支持流式),累积文本后一次性回复
if chunk_text:
# Chunks carry the latest full snapshot, not a text delta.
self._fallback_text[message_id] = chunk_text
self._fallback_text[message_id] = chunk_text[:200000]
self._fallback_text_ts[message_id] = time.time()
if is_final:
full_text = self._fallback_text.pop(message_id, '')
@@ -590,7 +641,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
# 累积文本
if chunk_text:
ctx['accumulated_text'] = chunk_text
ctx['accumulated_text'] = chunk_text[:200000]
# 未启动会话时,等第一个有内容的 chunk 来建立会话
if not ctx['session_started']:
@@ -668,6 +719,8 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
if used >= self._MAX_REPLIES_PER_ANCHOR:
return None
self._anchor_msg_seq[anchor] = used + 1
while len(self._anchor_msg_seq) > 4096:
self._anchor_msg_seq.pop(next(iter(self._anchor_msg_seq)), None)
return used + 1
async def _reply_synthetic(
@@ -791,7 +844,9 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
k for k, v in self._session_event_ids.items() if now - v.get('posted_at', 0) > self._PENDING_FORM_TTL
]
for k in stale_e:
self._session_event_ids.pop(k, None)
stale_event = self._session_event_ids.pop(k, None)
if stale_event:
self._anchor_msg_seq.pop(stale_event.get('event_id'), None)
async def _handle_form_chunk(
self,
@@ -973,7 +1028,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
# ACK uses the interaction id, NOT the ws event id.
interaction_id = event_data.get('id') or ''
if interaction_id:
asyncio.create_task(self.bot.ack_interaction(interaction_id, code=0))
self._start_background_task(self.bot.ack_interaction(interaction_id, code=0))
resolved = (event_data.get('data') or {}).get('resolved') or {}
action_id = str(resolved.get('button_data') or resolved.get('button_id') or '').strip()
@@ -1018,6 +1073,8 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
}
# New anchor → fresh 5-reply budget.
self._anchor_msg_seq[cached_event_id] = 0
while len(self._anchor_msg_seq) > 4096:
self._anchor_msg_seq.pop(next(iter(self._anchor_msg_seq)), None)
if self.ap is not None and not ws_event_id:
self.ap.logger.warning(
'QQ Official: INTERACTION_CREATE lacked ws_event_id; '
+21 -7
View File
@@ -18,6 +18,9 @@ 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 langbot.pkg.utils import httpclient
_MAX_GATEWAY_MESSAGE_BYTES = 1024 * 1024
class SatoriMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@@ -63,7 +66,15 @@ class SatoriMessageConverter(abstract_platform_adapter.AbstractMessageConverter)
padding = 4 - len(raw_b64) % 4
if padding != 4:
raw_b64 += '=' * padding
image_bytes = base64.b64decode(raw_b64)
max_encoded_chars = 4 * ((10 * 1024 * 1024 + 2) // 3) + 4
if len(raw_b64) > max_encoded_chars:
raise ValueError('Satori image exceeds the 10 MiB limit')
image_bytes = await asyncio.to_thread(
base64.b64decode,
raw_b64,
)
if len(image_bytes) > 10 * 1024 * 1024:
raise ValueError('Satori image exceeds the 10 MiB limit')
uploaded_url = await adapter.upload_image(image_bytes, mime_type)
if uploaded_url:
await adapter.logger.info(f'Satori 图片上传成功: {len(image_bytes)} 字节')
@@ -492,7 +503,7 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
raise ValueError(f'WebSocket URL必须以ws://或wss://开头: {self.endpoint}')
try:
self.ws = await websockets.connect(self.endpoint)
self.ws = await websockets.connect(self.endpoint, max_size=_MAX_GATEWAY_MESSAGE_BYTES)
await asyncio.sleep(0.1)
await self.send_identify()
@@ -584,7 +595,7 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def handle_message(self, message: str):
"""Handle WebSocket message"""
try:
data = json.loads(message)
data = await asyncio.to_thread(json.loads, message)
op = data.get('op')
body = data.get('body', {})
@@ -831,9 +842,10 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
try:
async with self.session.request(method, url, headers=headers, json=data) as response:
if response.status == 200:
return await response.json()
result = await httpclient.read_json_limited(response)
return result if isinstance(result, dict) else None
else:
text = await response.text()
text = await httpclient.read_text_limited(response)
await self.logger.error(f'Satori API 请求失败: {response.status} - {text}')
return None
except Exception as e:
@@ -889,7 +901,7 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async with self.session.post(url, headers=headers, data=form_data) as response:
if response.status == 200:
result = await response.json()
result = await httpclient.read_json_limited(response)
# The response should contain the URL of the uploaded file
if isinstance(result, dict) and 'url' in result:
return result['url']
@@ -899,7 +911,7 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.logger.warning(f'Satori 图片上传响应格式未知: {result}')
return None
else:
text = await response.text()
text = await httpclient.read_text_limited(response)
await self.logger.error(f'Satori 图片上传失败: {response.status} - {text}')
return None
except Exception as e:
@@ -911,6 +923,8 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.running = False
if self.heartbeat_task:
self.heartbeat_task.cancel()
await asyncio.gather(self.heartbeat_task, return_exceptions=True)
self.heartbeat_task = None
if self.ws:
try:
await self.ws.close()
+72 -25
View File
@@ -10,6 +10,8 @@ import typing
import traceback
import json
import base64
import asyncio
import os
import time
import uuid
import pydantic
@@ -22,6 +24,31 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
_MAX_TELEGRAM_MEDIA_BYTES = 10 * 1024 * 1024
def _decode_telegram_base64_limited(value: str) -> bytes:
if ';base64,' in value:
value = value.split(';base64,', 1)[1]
max_encoded_bytes = 4 * ((_MAX_TELEGRAM_MEDIA_BYTES + 2) // 3)
if len(value) > max_encoded_bytes:
raise ValueError('Telegram media exceeds the size limit')
decoded = base64.b64decode(value)
if len(decoded) > _MAX_TELEGRAM_MEDIA_BYTES:
raise ValueError('Telegram media exceeds the size limit')
return decoded
def _read_telegram_file_limited(path: str) -> bytes:
if os.path.getsize(path) > _MAX_TELEGRAM_MEDIA_BYTES:
raise ValueError('Telegram media exceeds the size limit')
with open(path, 'rb') as file:
body = file.read(_MAX_TELEGRAM_MEDIA_BYTES + 1)
if len(body) > _MAX_TELEGRAM_MEDIA_BYTES:
raise ValueError('Telegram media exceeds the size limit')
return body
def _telegram_select_field_options(form_data: dict) -> tuple[str, list[str]]:
"""Return the active select field and its option values."""
field_name = str(form_data.get('_current_input_field') or '').strip()
@@ -86,35 +113,29 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
if isinstance(component, platform_message.Plain):
components.append({'type': 'text', 'text': component.text})
elif isinstance(component, platform_message.Image):
photo_bytes = None
if component.base64:
photo_bytes = base64.b64decode(component.base64)
elif component.url:
session = httpclient.get_session()
async with session.get(component.url) as response:
photo_bytes = await response.read()
elif component.path:
with open(component.path, 'rb') as f:
photo_bytes = f.read()
photo_bytes, _mime_type = await component.get_bytes()
components.append({'type': 'photo', 'photo': photo_bytes})
elif isinstance(component, platform_message.File):
file_bytes = None
if component.base64:
# Strip data URI prefix if present (e.g. "data:application/pdf;base64,...")
b64_data = component.base64
if ';base64,' in b64_data:
b64_data = b64_data.split(';base64,', 1)[1]
file_bytes = base64.b64decode(b64_data)
file_bytes = await asyncio.to_thread(
_decode_telegram_base64_limited,
component.base64,
)
elif component.url:
session = httpclient.get_session()
async with session.get(component.url) as response:
file_bytes = await response.read()
file_bytes = await httpclient.read_limited(
response,
max_bytes=_MAX_TELEGRAM_MEDIA_BYTES,
)
elif component.path:
with open(component.path, 'rb') as f:
file_bytes = f.read()
file_bytes = await asyncio.to_thread(
_read_telegram_file_limited,
str(component.path),
)
file_name = getattr(component, 'name', None) or 'file'
components.append({'type': 'document', 'document': file_bytes, 'filename': file_name})
@@ -152,13 +173,17 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
file_format = ''
async with httpclient.get_session(trust_env=True).get(file.file_path) as response:
file_bytes = await response.read()
file_bytes = await httpclient.read_limited(
response,
max_bytes=_MAX_TELEGRAM_MEDIA_BYTES,
)
file_format = 'image/jpeg'
encoded = await asyncio.to_thread(base64.b64encode, file_bytes)
message_components.append(
platform_message.Image(
url=file.file_path,
base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}',
base64=f'data:{file_format};base64,{encoded.decode("utf-8")}',
)
)
@@ -172,11 +197,15 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
file_format = message.voice.mime_type or 'audio/ogg'
async with httpclient.get_session(trust_env=True).get(file.file_path) as response:
file_bytes = await response.read()
file_bytes = await httpclient.read_limited(
response,
max_bytes=_MAX_TELEGRAM_MEDIA_BYTES,
)
encoded = await asyncio.to_thread(base64.b64encode, file_bytes)
message_components.append(
platform_message.Voice(
base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}',
base64=f'data:{file_format};base64,{encoded.decode("utf-8")}',
length=message.voice.duration,
)
)
@@ -189,16 +218,22 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
file_name = message.document.file_name or 'document'
file_size = message.document.file_size or 0
file_format = message.document.mime_type or 'application/octet-stream'
if file_size > _MAX_TELEGRAM_MEDIA_BYTES:
raise ValueError('Telegram media exceeds the size limit')
file_bytes = None
async with httpclient.get_session(trust_env=True).get(file.file_path) as response:
file_bytes = await response.read()
file_bytes = await httpclient.read_limited(
response,
max_bytes=_MAX_TELEGRAM_MEDIA_BYTES,
)
encoded = await asyncio.to_thread(base64.b64encode, file_bytes)
message_components.append(
platform_message.File(
name=file_name,
size=file_size,
base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}',
base64=f'data:{file_format};base64,{encoded.decode("utf-8")}',
)
)
@@ -264,6 +299,8 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
] = {}
_FORM_ACTION_CACHE_TTL = 30 * 60
_MAX_FORM_ACTION_TITLES = 4096
_MAX_STREAM_STATES = 1000
# callback_data -> (display title, pipeline UUID, expiration time, form group id)
_form_action_titles: typing.Dict[str, tuple[str, str, float, str]] = {}
@@ -286,6 +323,8 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self._form_action_titles.update(
{callback_data: (title, pipeline_uuid, expires_at, group_id) for callback_data, title in mappings.items()}
)
while len(self._form_action_titles) > self._MAX_FORM_ACTION_TITLES:
self._form_action_titles.pop(next(iter(self._form_action_titles)), None)
def _take_form_action_context(self, callback_data: str, now: float | None = None) -> tuple[str, str] | None:
"""Consume a callback and invalidate every button from the same form."""
@@ -446,6 +485,11 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot_account_id='',
listeners={},
)
self._form_action_titles = {}
def _cap_stream_states(self) -> None:
while len(self.msg_stream_id) > self._MAX_STREAM_STATES:
self.msg_stream_id.pop(next(iter(self.msg_stream_id)), None)
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
components = await TelegramMessageConverter.yiri2target(message, self.bot)
@@ -554,6 +598,7 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id)
send_msg = await self.bot.send_message(**args)
self.msg_stream_id[message_id] = ('message', send_msg.message_id, False)
self._cap_stream_states()
return True
@@ -845,4 +890,6 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if self.application.updater:
await self.application.updater.stop()
await self.logger.info('Telegram adapter stopped')
self.msg_stream_id.clear()
self._form_action_titles.clear()
return True
@@ -1,7 +1,9 @@
"""WebSocket适配器 - 支持双向通信的IM系统"""
import asyncio
import contextvars
import logging
import time
import typing
from datetime import datetime
@@ -13,9 +15,14 @@ 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 ...core import app
from .websocket_manager import WebSocketConnection, is_valid_session_id, ws_connection_manager
from ...core import entities as core_entities
from .websocket_manager import WebSocketConnection, WebSocketScope, is_valid_session_id, ws_connection_manager
logger = logging.getLogger(__name__)
_current_pipeline_uuid: contextvars.ContextVar[str | None] = contextvars.ContextVar(
'websocket_pipeline_uuid',
default=None,
)
class WebSocketMessage(pydantic.BaseModel):
@@ -40,21 +47,82 @@ class WebSocketSession:
stream_message_indexes: dict[str, dict[str, int]] = {}
"""流式消息索引 {pipeline_uuid: {resp_message_id: message_index}}"""
def __init__(self, id: str):
def __init__(
self,
id: str = '',
*,
max_conversations: int = 200,
max_messages: int = 100,
idle_ttl_seconds: int = 86400,
):
self.id = id
self.message_lists = {}
self.stream_message_indexes = {}
self.message_counters: dict[str, int] = {}
self.last_accessed: dict[str, float] = {}
self.max_conversations = max(int(max_conversations), 1)
self.max_messages = max(int(max_messages), 1)
self.idle_ttl_seconds = max(int(idle_ttl_seconds), 1)
def _prune(self, now: float) -> None:
expired = [
key for key, last_accessed in self.last_accessed.items() if now - last_accessed >= self.idle_ttl_seconds
]
for key in expired:
self.reset(key)
overflow = len(self.message_lists) - self.max_conversations + 1
if overflow <= 0:
return
oldest = sorted(self.last_accessed, key=self.last_accessed.get)
for key in oldest[:overflow]:
self.reset(key)
def get_message_list(self, pipeline_uuid: str) -> list[WebSocketMessage]:
now = time.monotonic()
self._prune(now)
if pipeline_uuid not in self.message_lists:
self.message_lists[pipeline_uuid] = []
self.last_accessed[pipeline_uuid] = now
return self.message_lists[pipeline_uuid]
def get_stream_message_indexes(self, pipeline_uuid: str) -> dict[str, int]:
if pipeline_uuid not in self.stream_message_indexes:
self.stream_message_indexes[pipeline_uuid] = {}
self.last_accessed[pipeline_uuid] = time.monotonic()
return self.stream_message_indexes[pipeline_uuid]
def next_message_id(self, conversation_key: str) -> int:
next_id = self.message_counters.get(conversation_key, 0) + 1
self.message_counters[conversation_key] = next_id
return next_id
def append_message(self, conversation_key: str, message: WebSocketMessage) -> None:
messages = self.get_message_list(conversation_key)
messages.append(message)
overflow = len(messages) - self.max_messages
if overflow <= 0:
return
del messages[:overflow]
indexes = self.stream_message_indexes.get(conversation_key, {})
adjusted_indexes = {
response_id: index - overflow for response_id, index in indexes.items() if index >= overflow
}
indexes.clear()
indexes.update(adjusted_indexes)
def reset(self, conversation_key: str) -> None:
self.message_lists.pop(conversation_key, None)
self.stream_message_indexes.pop(conversation_key, None)
self.message_counters.pop(conversation_key, None)
self.last_accessed.pop(conversation_key, None)
def clear(self) -> None:
self.message_lists.clear()
self.stream_message_indexes.clear()
self.message_counters.clear()
self.last_accessed.clear()
class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""WebSocket适配器 - 支持双向实时通信"""
@@ -70,7 +138,14 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
ap: app.Application = pydantic.Field(exclude=True)
# 主动推送消息的队列
outbound_message_queue: asyncio.Queue = pydantic.Field(default_factory=asyncio.Queue, exclude=True)
outbound_message_queue: asyncio.Queue = pydantic.Field(
default_factory=lambda: asyncio.Queue(maxsize=100),
exclude=True,
)
inbound_listener_tasks: set[asyncio.Task] = pydantic.Field(
default_factory=set,
exclude=True,
)
"""后端主动推送消息的队列"""
# 流式输出开关
@@ -84,11 +159,26 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
**kwargs,
)
self.websocket_person_session = WebSocketSession(id='websocketperson')
self.websocket_group_session = WebSocketSession(id='websocketgroup')
application = kwargs.get('ap')
instance_data = getattr(getattr(application, 'instance_config', None), 'data', {})
retention = (
instance_data.get('system', {}).get('websocket_retention', {}) if isinstance(instance_data, dict) else {}
)
session_options = {
'max_conversations': retention.get('max_conversations_per_workspace', 200),
'max_messages': retention.get('max_messages_per_conversation', 100),
'idle_ttl_seconds': retention.get('conversation_idle_ttl_seconds', 86400),
}
self.websocket_person_session = WebSocketSession(id='websocketperson', **session_options)
self.websocket_group_session = WebSocketSession(id='websocketgroup', **session_options)
self.bot_account_id = 'websocketbot'
self.outbound_message_queue = asyncio.Queue()
try:
outbound_queue_size = max(int(retention.get('send_queue_size', 100)), 1)
except (TypeError, ValueError):
outbound_queue_size = 100
self.outbound_message_queue = asyncio.Queue(maxsize=outbound_queue_size)
self.inbound_listener_tasks = set()
self.stream_enabled = True
@staticmethod
@@ -113,9 +203,38 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
return None
return pipeline_uuid, session_id
@classmethod
async def _get_connection_from_target(cls, target_id: str):
def _scope(self) -> WebSocketScope:
"""Return this adapter's immutable runtime placement."""
return WebSocketScope.from_context(self.logger.execution_context)
def get_pipeline_uuid_override(self) -> str | None:
"""Return the connection pipeline propagated into the listener task."""
return _current_pipeline_uuid.get()
def _listener_task_done(self, task: asyncio.Task) -> None:
listener_tasks = getattr(self, 'inbound_listener_tasks', None)
if listener_tasks is not None:
listener_tasks.discard(task)
if not task.cancelled():
task.exception()
@staticmethod
def _history_message_chain(message_chain: list[dict]) -> list[dict]:
"""Remove large transient payloads before retaining browser history."""
history = []
for component in message_chain:
copied = dict(component)
if copied.get('base64'):
copied['base64'] = ''
history.append(copied)
return history
async def _get_connection_from_target(self, target_id: str):
"""Resolve a person or group WebSocket launcher to its connection."""
scope = self._scope()
target_value = str(target_id)
for prefix in ('websocket_', 'websocketgroup_'):
if target_value.startswith(prefix):
@@ -123,14 +242,18 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
break
else:
return None
connection = await ws_connection_manager.get_connection(target)
connection = await ws_connection_manager.get_connection(target, scope=scope)
if connection is not None:
return connection
embed_target = cls._parse_embed_target(target_id)
embed_target = self._parse_embed_target(target_id)
if embed_target is not None:
pipeline_uuid, session_id = embed_target
return await ws_connection_manager.get_connection_by_session_id(session_id, pipeline_uuid)
return await ws_connection_manager.get_connection_by_session_id(target)
return await ws_connection_manager.get_connection_by_session_id(
session_id,
scope=scope,
pipeline_uuid=pipeline_uuid,
)
return await ws_connection_manager.get_connection_by_session_id(target, scope=scope)
async def _get_message_context(self, message_source) -> tuple[str, str | None]:
"""Resolve the originating pipeline and browser session for a reply."""
@@ -142,7 +265,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
embed_target = self._parse_embed_target(sender_id)
if embed_target is not None:
return embed_target
return typing.cast(str, self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid), None
raise ValueError('WebSocket reply target is not bound to this adapter scope')
async def send_message(
self,
@@ -160,22 +283,23 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
if connection is not None:
pipeline_uuid = connection.pipeline_uuid
session_id = connection.session_id
scope = connection.scope
else:
embed_target = self._parse_embed_target(target_id)
if embed_target is not None:
pipeline_uuid, session_id = embed_target
else:
pipeline_uuid = typing.cast(
str,
self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid,
)
pipeline_uuid = str(target_id).strip()
if not pipeline_uuid:
raise ValueError('WebSocket target pipeline is required')
session_id = None
scope = self._scope()
session_type = 'group' if target_type == 'group' else 'person'
conversation_key = self._conversation_key(pipeline_uuid, session_id)
session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session
msg_id = len(session.get_message_list(conversation_key)) + 1
msg_id = session.next_message_id(conversation_key)
message_data = WebSocketMessage(
id=msg_id,
@@ -186,7 +310,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
is_final=True,
)
session.get_message_list(conversation_key).append(message_data)
session.append_message(conversation_key, message_data)
await ws_connection_manager.broadcast_to_pipeline(
pipeline_uuid,
@@ -195,6 +319,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
'session_type': session_type,
'data': message_data.model_dump(),
},
scope=scope,
session_type=session_type,
session_id=session_id,
)
@@ -216,10 +341,11 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
)
pipeline_uuid, session_id = await self._get_message_context(message_source)
scope = self._scope()
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
conversation_key = self._conversation_key(pipeline_uuid, session_id)
msg_id = len(session.get_message_list(conversation_key)) + 1
msg_id = session.next_message_id(conversation_key)
message_data = WebSocketMessage(
id=msg_id,
@@ -230,7 +356,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
is_final=True,
)
session.get_message_list(conversation_key).append(message_data)
session.append_message(conversation_key, message_data)
await ws_connection_manager.broadcast_to_pipeline(
pipeline_uuid,
@@ -239,6 +365,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
'session_type': session_type,
'data': message_data.model_dump(),
},
scope=scope,
session_type=session_type,
session_id=session_id,
)
@@ -262,6 +389,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
)
pipeline_uuid, session_id = await self._get_message_context(message_source)
scope = self._scope()
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
conversation_key = self._conversation_key(pipeline_uuid, session_id)
message_list = session.get_message_list(conversation_key)
@@ -276,7 +404,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
if existing_index is None or existing_index >= len(message_list):
# 创建新消息
msg_id = len(message_list) + 1
msg_id = session.next_message_id(conversation_key)
message_data = WebSocketMessage(
id=msg_id,
role='assistant',
@@ -287,7 +415,8 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
)
# 立即添加到历史记录(即使is_final=False),以便后续块可以更新它
message_list.append(message_data)
session.append_message(conversation_key, message_data)
message_list = session.get_message_list(conversation_key)
if resp_message_id:
stream_message_indexes[resp_message_id] = len(message_list) - 1
else:
@@ -316,6 +445,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
'session_type': session_type,
'data': message_data.model_dump(),
},
scope=scope,
session_type=session_type,
session_id=session_id,
)
@@ -360,7 +490,11 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
message = await asyncio.wait_for(self.outbound_message_queue.get(), timeout=0.1)
# 广播到所有相关连接
target_id = message.get('target_id', '')
await ws_connection_manager.broadcast_to_pipeline(target_id, message)
await ws_connection_manager.broadcast_to_pipeline(
target_id,
message,
scope=self._scope(),
)
except asyncio.TimeoutError:
pass
@@ -370,9 +504,28 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
async def kill(self):
"""停止适配器"""
pass
await ws_connection_manager.close_scope(self._scope())
listener_tasks = getattr(self, 'inbound_listener_tasks', set())
inbound_tasks = list(listener_tasks)
for task in inbound_tasks:
if not task.done():
task.cancel()
if inbound_tasks:
await asyncio.gather(*inbound_tasks, return_exceptions=True)
listener_tasks.clear()
self.websocket_person_session.clear()
self.websocket_group_session.clear()
while not self.outbound_message_queue.empty():
try:
self.outbound_message_queue.get_nowait()
except asyncio.QueueEmpty:
break
async def _process_image_components(self, message_chain_obj: list):
async def _process_image_components(
self,
connection: WebSocketConnection,
message_chain_obj: list,
):
"""
处理消息链中的图片语音和文件组件 path 转换为 base64
@@ -387,18 +540,36 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
import base64
import mimetypes
storage_mgr = self.ap.storage_mgr
attachments = [
component
for component in message_chain_obj
if component.get('path') and component.get('type') in ('Image', 'Voice', 'File')
]
if not attachments:
return
for component in message_chain_obj:
storage_mgr = self.ap.storage_mgr
execution_context = connection.execution_context
expected_prefix = storage_mgr.scoped_prefix(execution_context, owner_type='upload_image')
for component in attachments:
comp_type = component.get('type', '')
comp_path = component.get('path', '')
if not comp_path or comp_type not in ('Image', 'Voice', 'File'):
continue
if not comp_path.startswith(expected_prefix) or not storage_mgr.is_scoped_object_key(
comp_path,
expected_owner_type='upload_image',
):
await self.logger.warning(f'Rejected {comp_type} attachment outside the WebSocket connection scope')
raise ValueError('Attachment key does not belong to this WebSocket connection')
try:
file_content = await storage_mgr.storage_provider.load(comp_path)
base64_str = base64.b64encode(file_content).decode('utf-8')
file_content = await storage_mgr.load_scoped_object_key(
execution_context,
comp_path,
expected_owner_type='upload_image',
)
base64_str = (await asyncio.to_thread(base64.b64encode, file_content)).decode('utf-8')
lowered = comp_path.lower()
if comp_type == 'Image':
@@ -416,10 +587,15 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
mime_type = mimetypes.guess_type(comp_path)[0] or 'application/octet-stream'
component['base64'] = f'data:{mime_type};base64,{base64_str}'
await storage_mgr.storage_provider.delete(comp_path)
await storage_mgr.delete_scoped_object_key(
execution_context,
comp_path,
expected_owner_type='upload_image',
)
component['path'] = ''
except Exception as e:
await self.logger.error(f'Failed to load {comp_type} file {comp_path}: {e}')
raise
async def handle_websocket_message(
self,
@@ -451,23 +627,23 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
message_chain_obj = message_data.get('message', [])
await self._process_image_components(message_chain_obj)
await self._process_image_components(connection, message_chain_obj)
message_chain = platform_message.MessageChain.model_validate(message_chain_obj)
message_id = len(use_session.get_message_list(conversation_key)) + 1
message_id = use_session.next_message_id(conversation_key)
# 保存用户消息
user_message = WebSocketMessage(
id=message_id,
role='user',
content=str(message_chain),
message_chain=message_chain_obj,
message_chain=self._history_message_chain(message_chain_obj),
timestamp=datetime.now().isoformat(),
connection_id=connection.connection_id,
is_final=True, # 用户消息始终是完整的,非流式
)
use_session.get_message_list(conversation_key).append(user_message)
use_session.append_message(conversation_key, user_message)
await ws_connection_manager.broadcast_to_pipeline(
pipeline_uuid,
@@ -476,6 +652,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
'session_type': session_type,
'data': user_message.model_dump(),
},
scope=connection.scope,
session_type=session_type,
session_id=connection.session_id,
)
@@ -506,11 +683,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
)
# 设置流水线UUID (proxy bot always needs it for reply_message routing)
self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
if owner_bot is not None:
owner_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
# 异步触发事件处理
# Use owner_bot's listeners if available, otherwise fall back to proxy bot
listeners = (
@@ -525,7 +697,38 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
owner_bot.adapter.set_ws_adapter(self)
callback_adapter = owner_bot.adapter if (owner_bot and hasattr(owner_bot, 'adapter')) else self
if event.__class__ in listeners:
asyncio.create_task(listeners[event.__class__](event, callback_adapter))
listener_tasks = getattr(self, 'inbound_listener_tasks', None)
if listener_tasks is None:
listener_tasks = set()
object.__setattr__(self, 'inbound_listener_tasks', listener_tasks)
for task in tuple(listener_tasks):
if task.done():
listener_tasks.discard(task)
if len(listener_tasks) >= 100:
await self.logger.warning('WebSocket inbound listener capacity reached; dropping message')
return
token = _current_pipeline_uuid.set(pipeline_uuid)
try:
task_manager = getattr(self.ap, 'task_mgr', None)
if task_manager is None or not isinstance(getattr(task_manager, 'tasks', None), list):
listener_task = asyncio.create_task(listeners[event.__class__](event, callback_adapter))
else:
listener_task = task_manager.create_task(
listeners[event.__class__](event, callback_adapter),
kind='websocket-message',
name=f'websocket-message-{connection.connection_id}',
scopes=[
core_entities.LifecycleControlScope.APPLICATION,
core_entities.LifecycleControlScope.PLATFORM,
],
instance_uuid=connection.instance_uuid,
workspace_uuid=connection.workspace_uuid,
placement_generation=connection.placement_generation,
).task
listener_tasks.add(listener_task)
listener_task.add_done_callback(self._listener_task_done)
finally:
_current_pipeline_uuid.reset(token)
def get_websocket_messages(
self,
@@ -547,10 +750,14 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
"""Reset one pipeline/client conversation."""
conversation_key = self._conversation_key(pipeline_uuid, session_id)
session = self.websocket_person_session if session_type == 'person' else self.websocket_group_session
if conversation_key in session.message_lists:
session.message_lists[conversation_key] = []
if conversation_key in session.stream_message_indexes:
session.stream_message_indexes[conversation_key] = {}
if isinstance(session, WebSocketSession):
session.reset(conversation_key)
else:
# Compatibility for lightweight adapter doubles.
if conversation_key in session.message_lists:
session.message_lists[conversation_key] = []
if conversation_key in session.stream_message_indexes:
session.stream_message_indexes[conversation_key] = {}
if session_id:
launcher_id = (
@@ -558,11 +765,15 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
if session_type == 'group'
else f'websocket_{pipeline_uuid}:{session_id}'
)
scope = self._scope()
self.ap.sess_mgr.session_list = [
candidate_session
for candidate_session in self.ap.sess_mgr.session_list
if not (
str(
getattr(candidate_session, 'instance_uuid', None) == scope.instance_uuid
and getattr(candidate_session, 'workspace_uuid', None) == scope.workspace_uuid
and getattr(candidate_session, 'placement_generation', None) == scope.placement_generation
and str(
candidate_session.launcher_type.value
if hasattr(candidate_session.launcher_type, 'value')
else candidate_session.launcher_type
@@ -1,6 +1,7 @@
"""WebSocket连接管理器 - 管理多个并发WebSocket连接"""
import asyncio
import dataclasses
import logging
import typing
import uuid
@@ -8,8 +9,34 @@ from datetime import datetime
import pydantic
from ...api.http.context import ExecutionContext
logger = logging.getLogger(__name__)
_SESSION_FILTER_UNSET = object()
_DEFAULT_SEND_QUEUE_SIZE = 100
@dataclasses.dataclass(frozen=True, slots=True)
class WebSocketScope:
"""Trusted runtime placement carried by every WebSocket connection."""
instance_uuid: str
workspace_uuid: str
placement_generation: int
def __post_init__(self) -> None:
if not self.instance_uuid.strip() or not self.workspace_uuid.strip():
raise ValueError('WebSocket scope requires an instance and Workspace')
if self.placement_generation <= 0:
raise ValueError('WebSocket scope requires a positive placement generation')
@classmethod
def from_context(cls, context: typing.Any) -> 'WebSocketScope':
return cls(
instance_uuid=str(getattr(context, 'instance_uuid', '')),
workspace_uuid=str(getattr(context, 'workspace_uuid', '')),
placement_generation=int(getattr(context, 'placement_generation', 0)),
)
def is_valid_session_id(value: str) -> bool:
@@ -29,6 +56,15 @@ class WebSocketConnection(pydantic.BaseModel):
connection_id: str = pydantic.Field(default_factory=lambda: str(uuid.uuid4()))
"""连接唯一ID"""
instance_uuid: str
"""Owning LangBot instance."""
workspace_uuid: str
"""Owning Workspace."""
placement_generation: int
"""Workspace placement generation captured at connect time."""
pipeline_uuid: str
"""关联的流水线UUID"""
@@ -47,7 +83,10 @@ class WebSocketConnection(pydantic.BaseModel):
last_active: datetime = pydantic.Field(default_factory=datetime.now)
"""最后活跃时间"""
send_queue: asyncio.Queue = pydantic.Field(default_factory=asyncio.Queue, exclude=True)
send_queue: asyncio.Queue = pydantic.Field(
default_factory=lambda: asyncio.Queue(maxsize=_DEFAULT_SEND_QUEUE_SIZE),
exclude=True,
)
"""发送消息队列"""
is_active: bool = True
@@ -56,6 +95,25 @@ class WebSocketConnection(pydantic.BaseModel):
metadata: dict = pydantic.Field(default_factory=dict)
"""连接元数据(可存储额外信息)"""
@property
def scope(self) -> WebSocketScope:
return WebSocketScope(
instance_uuid=self.instance_uuid,
workspace_uuid=self.workspace_uuid,
placement_generation=self.placement_generation,
)
@property
def execution_context(self) -> ExecutionContext:
"""Return the storage/runtime context captured for this connection."""
return ExecutionContext(
instance_uuid=self.instance_uuid,
workspace_uuid=self.workspace_uuid,
placement_generation=self.placement_generation,
pipeline_uuid=self.pipeline_uuid,
)
class WebSocketConnectionManager:
"""WebSocket连接管理器 - 支持多连接并发"""
@@ -64,11 +122,11 @@ class WebSocketConnectionManager:
self.connections: dict[str, WebSocketConnection] = {}
"""所有活跃连接 {connection_id: connection}"""
self.pipeline_connections: dict[str, set[str]] = {}
"""流水线到连接的映射 {pipeline_uuid: {connection_id, ...}}"""
self.pipeline_connections: dict[tuple[str, str, int, str], set[str]] = {}
"""Scoped pipeline to connection mapping."""
self.session_connections: dict[str, set[str]] = {}
"""会话类型到连接的映射 {session_type: {connection_id, ...}}"""
self.session_connections: dict[tuple[str, str, int, str], set[str]] = {}
"""Scoped session-type to connection mapping."""
self._lock = asyncio.Lock()
"""线程锁,保护并发访问"""
@@ -76,40 +134,96 @@ class WebSocketConnectionManager:
async def add_connection(
self,
websocket: typing.Any,
scope: WebSocketScope,
pipeline_uuid: str,
session_type: str,
metadata: dict | None = None,
session_id: str | None = None,
send_queue_size: int = _DEFAULT_SEND_QUEUE_SIZE,
max_connections: int = 1024,
max_connections_per_workspace: int = 32,
) -> WebSocketConnection:
"""Register a WebSocket connection and its optional embed session."""
try:
send_queue_size = max(int(send_queue_size), 1)
except (TypeError, ValueError):
send_queue_size = _DEFAULT_SEND_QUEUE_SIZE
max_connections = max(int(max_connections), 1)
max_connections_per_workspace = max(
min(int(max_connections_per_workspace), max_connections),
1,
)
async with self._lock:
if len(self.connections) >= max_connections:
raise RuntimeError(f'WebSocket connection capacity reached ({max_connections})')
workspace_connection_count = sum(
1
for connection in self.connections.values()
if connection.instance_uuid == scope.instance_uuid
and connection.workspace_uuid == scope.workspace_uuid
and connection.placement_generation == scope.placement_generation
)
if workspace_connection_count >= max_connections_per_workspace:
raise RuntimeError(f'Workspace WebSocket connection capacity reached ({max_connections_per_workspace})')
connection = WebSocketConnection(
instance_uuid=scope.instance_uuid,
workspace_uuid=scope.workspace_uuid,
placement_generation=scope.placement_generation,
pipeline_uuid=pipeline_uuid,
session_type=session_type,
session_id=session_id,
websocket=websocket,
metadata=metadata or {},
send_queue=asyncio.Queue(maxsize=send_queue_size),
)
self.connections[connection.connection_id] = connection
# 更新流水线映射
if pipeline_uuid not in self.pipeline_connections:
self.pipeline_connections[pipeline_uuid] = set()
self.pipeline_connections[pipeline_uuid].add(connection.connection_id)
pipeline_key = self._pipeline_key(scope, pipeline_uuid)
if pipeline_key not in self.pipeline_connections:
self.pipeline_connections[pipeline_key] = set()
self.pipeline_connections[pipeline_key].add(connection.connection_id)
# 更新会话类型映射
if session_type not in self.session_connections:
self.session_connections[session_type] = set()
self.session_connections[session_type].add(connection.connection_id)
session_key = self._session_key(scope, session_type)
if session_key not in self.session_connections:
self.session_connections[session_key] = set()
self.session_connections[session_key].add(connection.connection_id)
logger.debug(
f'WebSocket connection established: {connection.connection_id} '
f'(pipeline={pipeline_uuid}, session_type={session_type})'
f'(workspace={scope.workspace_uuid}, generation={scope.placement_generation}, '
f'pipeline={pipeline_uuid}, session_type={session_type})'
)
return connection
async def close_scope(self, scope: WebSocketScope) -> None:
"""Close and forget every live connection for one runtime placement."""
async with self._lock:
connection_ids = [
connection_id for connection_id, connection in self.connections.items() if connection.scope == scope
]
for connection_id in connection_ids:
connection = self.connections.get(connection_id)
if connection is None:
continue
close = getattr(connection.websocket, 'close', None)
if close is not None:
try:
result = close()
if asyncio.iscoroutine(result):
await result
except Exception:
logger.debug(
'Failed to close WebSocket connection %s',
connection_id,
exc_info=True,
)
await self.remove_connection(connection_id)
async def remove_connection(self, connection_id: str):
"""移除WebSocket连接"""
async with self._lock:
@@ -120,54 +234,103 @@ class WebSocketConnectionManager:
connection.is_active = False
# 从流水线映射中移除
if connection.pipeline_uuid in self.pipeline_connections:
self.pipeline_connections[connection.pipeline_uuid].discard(connection_id)
if not self.pipeline_connections[connection.pipeline_uuid]:
del self.pipeline_connections[connection.pipeline_uuid]
pipeline_key = self._pipeline_key(connection.scope, connection.pipeline_uuid)
if pipeline_key in self.pipeline_connections:
self.pipeline_connections[pipeline_key].discard(connection_id)
if not self.pipeline_connections[pipeline_key]:
del self.pipeline_connections[pipeline_key]
# 从会话类型映射中移除
if connection.session_type in self.session_connections:
self.session_connections[connection.session_type].discard(connection_id)
if not self.session_connections[connection.session_type]:
del self.session_connections[connection.session_type]
session_key = self._session_key(connection.scope, connection.session_type)
if session_key in self.session_connections:
self.session_connections[session_key].discard(connection_id)
if not self.session_connections[session_key]:
del self.session_connections[session_key]
del self.connections[connection_id]
logger.debug(f'WebSocket connection disconnected: {connection_id}')
async def get_connection(self, connection_id: str) -> WebSocketConnection | None:
"""Get a connection by its transport identifier."""
return self.connections.get(connection_id)
@staticmethod
def _pipeline_key(scope: WebSocketScope, pipeline_uuid: str) -> tuple[str, str, int, str]:
return (
scope.instance_uuid,
scope.workspace_uuid,
scope.placement_generation,
pipeline_uuid,
)
@staticmethod
def _session_key(scope: WebSocketScope, session_type: str) -> tuple[str, str, int, str]:
return (
scope.instance_uuid,
scope.workspace_uuid,
scope.placement_generation,
session_type,
)
async def get_connection(
self,
connection_id: str,
*,
scope: WebSocketScope,
) -> WebSocketConnection | None:
"""Get a connection only when it belongs to the expected placement."""
connection = self.connections.get(connection_id)
if connection is None or connection.scope != scope:
return None
return connection
async def get_connection_by_session_id(
self,
session_id: str,
*,
scope: WebSocketScope,
pipeline_uuid: str | None = None,
) -> WebSocketConnection | None:
"""Get an active embed connection by its stable browser session identifier."""
for connection in self.connections.values():
candidates: typing.Iterable[WebSocketConnection]
if pipeline_uuid is not None:
candidates = await self.get_connections_by_pipeline(pipeline_uuid, scope=scope)
else:
candidates = self.connections.values()
for connection in candidates:
if (
connection.session_id == session_id
and connection.is_active
and connection.scope == scope
and (pipeline_uuid is None or connection.pipeline_uuid == pipeline_uuid)
):
return connection
return None
async def get_connections_by_pipeline(self, pipeline_uuid: str) -> list[WebSocketConnection]:
async def get_connections_by_pipeline(
self,
pipeline_uuid: str,
*,
scope: WebSocketScope,
) -> list[WebSocketConnection]:
"""获取指定流水线的所有连接"""
connection_ids = self.pipeline_connections.get(pipeline_uuid, set())
connection_ids = self.pipeline_connections.get(self._pipeline_key(scope, pipeline_uuid), set())
return [self.connections[cid] for cid in connection_ids if cid in self.connections]
async def get_connections_by_session_type(self, session_type: str) -> list[WebSocketConnection]:
async def get_connections_by_session_type(
self,
session_type: str,
*,
scope: WebSocketScope,
) -> list[WebSocketConnection]:
"""获取指定会话类型的所有连接"""
connection_ids = self.session_connections.get(session_type, set())
connection_ids = self.session_connections.get(self._session_key(scope, session_type), set())
return [self.connections[cid] for cid in connection_ids if cid in self.connections]
async def broadcast_to_pipeline(
self,
pipeline_uuid: str,
message: dict,
*,
scope: WebSocketScope,
session_type: str | None = None,
session_id: typing.Any = _SESSION_FILTER_UNSET,
):
@@ -180,7 +343,7 @@ class WebSocketConnectionManager:
session_id: Embed conversation filter. Omit it to broadcast across
conversations; pass ``None`` to target non-embed connections.
"""
connections = await self.get_connections_by_pipeline(pipeline_uuid)
connections = await self.get_connections_by_pipeline(pipeline_uuid, scope=scope)
if session_type is not None:
connections = [conn for conn in connections if conn.session_type == session_type]
@@ -196,13 +359,26 @@ class WebSocketConnectionManager:
async def send_to_connection(self, connection_id: str, message: dict):
"""向指定连接发送消息"""
connection = await self.get_connection(connection_id)
connection = self.connections.get(connection_id)
if not connection or not connection.is_active:
logger.warning(f'Attempt to send message to invalid connection: {connection_id}')
return
try:
await connection.send_queue.put(message)
try:
connection.send_queue.put_nowait(message)
except asyncio.QueueFull:
# A slow or disconnected browser must not backpressure every
# other connection or retain an unbounded response stream.
try:
connection.send_queue.get_nowait()
except asyncio.QueueEmpty:
pass
connection.send_queue.put_nowait(message)
logger.warning(
'WebSocket send queue full; dropped oldest message for connection %s',
connection_id,
)
connection.last_active = datetime.now()
except Exception as e:
logger.error(f'Failed to send message to connection {connection_id}: {e}')
@@ -210,17 +386,24 @@ class WebSocketConnectionManager:
async def update_activity(self, connection_id: str):
"""更新连接活跃时间"""
connection = await self.get_connection(connection_id)
connection = self.connections.get(connection_id)
if connection:
connection.last_active = datetime.now()
def get_stats(self) -> dict:
"""获取连接统计信息"""
def get_stats(self, *, scope: WebSocketScope) -> dict:
"""Return connection statistics for one trusted placement."""
scoped_connections = [connection for connection in self.connections.values() if connection.scope == scope]
pipelines: dict[str, int] = {}
session_types: dict[str, int] = {}
for connection in scoped_connections:
pipelines[connection.pipeline_uuid] = pipelines.get(connection.pipeline_uuid, 0) + 1
session_types[connection.session_type] = session_types.get(connection.session_type, 0) + 1
return {
'total_connections': len(self.connections),
'pipelines': len(self.pipeline_connections),
'connections_by_pipeline': {k: len(v) for k, v in self.pipeline_connections.items()},
'connections_by_session_type': {k: len(v) for k, v in self.session_connections.items()},
'total_connections': len(scoped_connections),
'pipelines': len(pipelines),
'connections_by_pipeline': pipelines,
'connections_by_session_type': session_types,
}
+142 -65
View File
@@ -1,8 +1,6 @@
import requests
import websocket
import json
import time
import httpx
from langbot.libs.wechatpad_api.client import WeChatPadClient
@@ -17,6 +15,7 @@ import threading
import quart
from langbot.pkg.platform.logger import EventLogger
from langbot.pkg.utils import bounded_executor, httpclient
import xml.etree.ElementTree as ET
from typing import Optional, Tuple
from functools import partial
@@ -27,6 +26,8 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
_MAX_GATEWAY_MESSAGE_CHARS = 1024 * 1024
class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
@@ -53,12 +54,11 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert
content_list.append({'type': 'text', 'content': component.text})
elif isinstance(component, platform_message.Image):
if component.url:
async with httpx.AsyncClient() as client:
response = await client.get(component.url)
if response.status_code == 200:
file_bytes = response.content
base64_str = base64.b64encode(file_bytes).decode('utf-8') # 返回字符串格式
session = httpclient.get_session()
async with session.get(component.url) as response:
if response.status == 200:
file_bytes = await httpclient.read_limited(response)
base64_str = (await asyncio.to_thread(base64.b64encode, file_bytes)).decode('utf-8')
else:
raise Exception('获取文件失败')
# pass
@@ -156,9 +156,19 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert
cdnthumburl = img_tag.get('cdnthumburl')
# cdnmidimgurl = img_tag.get('cdnmidimgurl')
image_data = self.bot.cdn_download(aeskey=aeskey, file_type=1, file_url=cdnthumburl)
image_data = await asyncio.to_thread(
self.bot.cdn_download,
aeskey=aeskey,
file_type=1,
file_url=cdnthumburl,
)
if image_data['Data']['FileData'] == '':
image_data = self.bot.cdn_download(aeskey=aeskey, file_type=2, file_url=cdnthumburl)
image_data = await asyncio.to_thread(
self.bot.cdn_download,
aeskey=aeskey,
file_type=2,
file_url=cdnthumburl,
)
base64_str = image_data['Data']['FileData']
# self.logger.info(f"data:image/png;base64,{base64_str}")
@@ -186,7 +196,12 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert
if voicemsg is not None:
bufid = voicemsg.get('bufid')
length = voicemsg.get('voicelength')
voice_data = self.bot.get_msg_voice(buf_id=str(bufid), length=int(length), msgid=str(new_msg_id))
voice_data = await asyncio.to_thread(
self.bot.get_msg_voice,
buf_id=str(bufid),
length=int(length),
msgid=str(new_msg_id),
)
audio_base64 = voice_data['Data']['Base64']
# 验证语音数据有效性
@@ -319,7 +334,12 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert
# print(aeskey,cdnthumburl)
file_data = self.bot.cdn_download(aeskey=aeskey, file_type=5, file_url=cdnthumburl)
file_data = await asyncio.to_thread(
self.bot.cdn_download,
aeskey=aeskey,
file_type=5,
file_url=cdnthumburl,
)
file_base64 = file_data['Data']['FileData']
# print(file_data)
@@ -538,6 +558,7 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
_MAX_CALLBACK_FUTURES = 100
def __init__(self, config: dict, logger: EventLogger):
quart_app = quart.Quart(__name__)
@@ -556,6 +577,12 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
name='WeChatPad',
bot=bot,
)
self._event_loop: asyncio.AbstractEventLoop | None = None
self._ws_app: websocket.WebSocketApp | None = None
self._ws_thread: threading.Thread | None = None
self._stop_event = threading.Event()
self._callback_futures: set = set()
self._callback_futures_lock = threading.Lock()
async def ws_message(self, data):
"""处理接收到的消息"""
@@ -565,7 +592,7 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
except Exception:
await self.logger.error(f'Error in wechatpad callback: {traceback.format_exc()}')
if event.__class__ in self.listeners:
if event is not None and event.__class__ in self.listeners:
await self.listeners[event.__class__](event, self)
return 'ok'
@@ -580,9 +607,8 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
at_targets = at_targets or []
member_info = []
if at_targets:
member_info = self.bot.get_chatroom_member_detail(
target_id,
)['Data']['member_data']['chatroom_member_list']
member_result = await asyncio.to_thread(self.bot.get_chatroom_member_detail, target_id)
member_info = member_result['Data']['member_data']['chatroom_member_list']
# 处理消息组件
for msg in content_list:
@@ -623,11 +649,35 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
}
if handler := handler_map.get(msg['type']):
handler(msg)
await asyncio.to_thread(handler, msg)
else:
self.logger.warning(f'未处理的消息类型: {msg["type"]}')
await self.logger.warning(f'未处理的消息类型: {msg["type"]}')
continue
def _schedule_ws_message(self, data: dict) -> None:
loop = self._event_loop
if loop is None or loop.is_closed() or self._stop_event.is_set():
return
with self._callback_futures_lock:
if len(self._callback_futures) >= self._MAX_CALLBACK_FUTURES:
return
future = asyncio.run_coroutine_threadsafe(self.ws_message(data), loop)
self._callback_futures.add(future)
def done(completed) -> None:
with self._callback_futures_lock:
self._callback_futures.discard(completed)
if completed.cancelled():
return
try:
completed.result()
except asyncio.CancelledError:
pass
except Exception:
logging.getLogger(__name__).exception('WeChatPad callback failed')
future.add_done_callback(done)
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
"""主动发送消息"""
return await self._handle_message(message, target_id)
@@ -665,86 +715,113 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
pass
async def run_async(self):
self._event_loop = asyncio.get_running_loop()
self._stop_event.clear()
if not self.config['admin_key'] and not self.config['token']:
raise RuntimeError('无wechatpad管理密匙,请填入配置文件后重启')
else:
if self.config['token']:
self.bot = WeChatPadClient(self.config['wechatpad_url'], self.config['token'])
data = self.bot.get_login_status()
data = await asyncio.to_thread(self.bot.get_login_status)
if data['Code'] == 300 and data['Text'] == '你已退出微信':
response = requests.post(
response = await asyncio.to_thread(
requests.post,
f'{self.config["wechatpad_url"]}/admin/GenAuthKey1?key={self.config["admin_key"]}',
json={'Count': 1, 'Days': 365},
timeout=10,
)
if response.status_code != 200:
raise Exception(f'获取token失败: {response.text}')
self.config['token'] = response.json()['Data'][0]
body = await httpclient.response_text(response)
raise Exception(f'获取token失败: {body}')
response_data = await httpclient.parse_json_response(response)
self.config['token'] = response_data['Data'][0]
elif not self.config['token']:
response = requests.post(
response = await asyncio.to_thread(
requests.post,
f'{self.config["wechatpad_url"]}/admin/GenAuthKey1?key={self.config["admin_key"]}',
json={'Count': 1, 'Days': 365},
timeout=10,
)
if response.status_code != 200:
raise Exception(f'获取token失败: {response.text}')
self.config['token'] = response.json()['Data'][0]
body = await httpclient.response_text(response)
raise Exception(f'获取token失败: {body}')
response_data = await httpclient.parse_json_response(response)
self.config['token'] = response_data['Data'][0]
self.bot = WeChatPadClient(self.config['wechatpad_url'], self.config['token'], logger=self.logger)
await self.logger.info(self.config['token'])
thread_1 = threading.Event()
def wechat_login_process():
# 不登录,这些先注释掉,避免登陆态尝试拉qrcode。
# login_data =self.bot.get_login_qr()
# url = login_data['Data']["QrCodeUrl"]
profile = self.bot.get_profile()
# self.logger.info(profile)
self.bot_account_id = profile['Data']['userInfo']['nickName']['str']
self.config['wxid'] = profile['Data']['userInfo']['userName']['str']
thread_1.set()
# asyncio.create_task(wechat_login_process)
threading.Thread(target=wechat_login_process).start()
profile = await asyncio.to_thread(self.bot.get_profile)
self.bot_account_id = profile['Data']['userInfo']['nickName']['str']
self.config['wxid'] = profile['Data']['userInfo']['userName']['str']
def connect_websocket_sync() -> None:
thread_1.wait()
uri = f'{self.config["wechatpad_ws"]}/GetSyncMsg?key={self.config["token"]}'
print(f'Connecting to WebSocket: {uri}')
def on_message(ws, message):
try:
if len(message) > _MAX_GATEWAY_MESSAGE_CHARS:
logging.getLogger(__name__).warning('WeChatPad WebSocket message exceeds the size limit')
return
data = json.loads(message)
# 这里需要确保ws_message是同步的,或者使用asyncio.run调用异步方法
asyncio.run(self.ws_message(data))
self._schedule_ws_message(data)
except json.JSONDecodeError:
self.logger.error(f'Non-JSON message: {message[:100]}...')
logging.getLogger(__name__).warning('WeChatPad received a non-JSON message')
def on_error(ws, error):
self.logger.error(f'WebSocket error: {str(error)[:200]}')
logging.getLogger(__name__).warning('WeChatPad WebSocket error: %s', str(error)[:200])
def on_close(ws, close_status_code, close_msg):
self.logger.info('WebSocket closed, reconnecting...')
time.sleep(5)
connect_websocket_sync() # 自动重连
logging.getLogger(__name__).info('WeChatPad WebSocket closed')
def on_open(ws):
self.logger.info('WebSocket connected successfully!')
logging.getLogger(__name__).info('WeChatPad WebSocket connected')
ws = websocket.WebSocketApp(
uri, on_message=on_message, on_error=on_error, on_close=on_close, on_open=on_open
)
ws.run_forever(ping_interval=60, ping_timeout=20)
while not self._stop_event.is_set():
ws = websocket.WebSocketApp(
uri,
on_message=on_message,
on_error=on_error,
on_close=on_close,
on_open=on_open,
)
self._ws_app = ws
ws.run_forever(ping_interval=60, ping_timeout=20)
self._ws_app = None
if not self._stop_event.wait(5):
logging.getLogger(__name__).info('Reconnecting WeChatPad WebSocket')
# 直接调用同步版本(会阻塞)
# connect_websocket_sync()
# 这行代码会在WebSocket连接断开后才会执行
thread = threading.Thread(target=connect_websocket_sync, name='WebSocketClientThread', daemon=True)
thread.start()
self.logger.info('WebSocket client thread started')
self._ws_thread = threading.Thread(
target=connect_websocket_sync,
name='WebSocketClientThread',
daemon=True,
)
self._ws_thread.start()
await self.logger.info('WebSocket client thread started')
while not self._stop_event.is_set() and self._ws_thread.is_alive():
await asyncio.sleep(1)
if not self._stop_event.is_set():
raise RuntimeError('WeChatPad WebSocket client thread exited unexpectedly')
async def kill(self) -> bool:
pass
self._stop_event.set()
ws = self._ws_app
if ws is not None:
await bounded_executor.run_blocking_cleanup(ws.close)
with self._callback_futures_lock:
futures = list(self._callback_futures)
for future in futures:
future.cancel()
if futures:
await asyncio.gather(
*(asyncio.wrap_future(future) for future in futures),
return_exceptions=True,
)
thread = self._ws_thread
if thread is not None and thread.is_alive():
await bounded_executor.run_blocking_cleanup(thread.join, 5)
self._ws_thread = None
self._ws_app = None
self._event_loop = None
return True
@@ -321,6 +321,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await keep_alive()
async def kill(self) -> bool:
await self.bot.close()
return False
async def unregister_listener(
@@ -516,6 +516,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# do work. Lazy-create on first call.
object.__setattr__(self, '_synthetic_buffers', {})
buffers: dict[str, str] = self._synthetic_buffers
if buf_key not in buffers and len(buffers) >= 100:
buffers.pop(next(iter(buffers)), None)
if content and not form_data:
previous = buffers.get(buf_key, '')
if previous and content.startswith(previous):
@@ -524,6 +526,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
buffers[buf_key] = previous
else:
buffers[buf_key] = previous + content
if len(buffers[buf_key]) > 200000:
buffers[buf_key] = buffers[buf_key][-200000:]
if not is_final:
return {'stream': True, 'synthetic': True, 'buffered': True}
@@ -613,7 +617,11 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'chat_id': chat_id,
'stream_id': '',
'req_id': '',
'created_at': time.monotonic(),
}
prune = getattr(self.bot, '_prune_pending_forms', None)
if callable(prune):
prune()
return payload
async def send_message(self, target_type, target_id, message):
@@ -745,10 +753,13 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await keep_alive()
async def kill(self) -> bool:
if hasattr(self, '_synthetic_buffers'):
self._synthetic_buffers.clear()
_ws_mode = not self.config.get('enable-webhook', False)
if _ws_mode:
await self.bot.disconnect()
return True
await self.bot.close()
return False
async def unregister_listener(
@@ -254,6 +254,8 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await keep_alive()
async def kill(self) -> bool:
self.bot.clear()
await self.bot.close()
return False
async def is_muted(self, group_id: int) -> bool: