mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
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:
@@ -6,6 +6,22 @@ from typing import Dict, List, Any, AsyncGenerator
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from langbot.pkg.utils import httpclient
|
||||
|
||||
_MAX_COZE_RESPONSE_BYTES = 16 * 1024 * 1024
|
||||
_MAX_COZE_EVENT_BYTES = 1024 * 1024
|
||||
_MAX_COZE_MEDIA_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def _read_local_media_limited(path: Path) -> bytes:
|
||||
if path.stat().st_size > _MAX_COZE_MEDIA_BYTES:
|
||||
raise ValueError('Coze upload exceeds the size limit')
|
||||
with path.open('rb') as handle:
|
||||
body = handle.read(_MAX_COZE_MEDIA_BYTES + 1)
|
||||
if len(body) > _MAX_COZE_MEDIA_BYTES:
|
||||
raise ValueError('Coze upload exceeds the size limit')
|
||||
return body
|
||||
|
||||
|
||||
class AsyncCozeAPIClient:
|
||||
def __init__(self, api_key: str, api_base: str = 'https://api.coze.cn'):
|
||||
@@ -58,19 +74,24 @@ class AsyncCozeAPIClient:
|
||||
if isinstance(file, Path):
|
||||
if not file.exists():
|
||||
raise ValueError(f'File not found: {file}')
|
||||
with open(file, 'rb') as f:
|
||||
file = f.read()
|
||||
file = await asyncio.to_thread(_read_local_media_limited, file)
|
||||
|
||||
# 处理文件路径字符串
|
||||
elif isinstance(file, str):
|
||||
if not os.path.isfile(file):
|
||||
raise ValueError(f'File not found: {file}')
|
||||
with open(file, 'rb') as f:
|
||||
file = f.read()
|
||||
file = await asyncio.to_thread(
|
||||
_read_local_media_limited,
|
||||
Path(file),
|
||||
)
|
||||
|
||||
# 处理文件对象
|
||||
elif hasattr(file, 'read'):
|
||||
file = file.read()
|
||||
file = await asyncio.to_thread(file.read, _MAX_COZE_MEDIA_BYTES + 1)
|
||||
if not isinstance(file, (bytes, bytearray)):
|
||||
raise ValueError('Unsupported Coze upload type')
|
||||
if len(file) > _MAX_COZE_MEDIA_BYTES:
|
||||
raise ValueError('Coze upload exceeds the size limit')
|
||||
|
||||
session = await self.coze_session()
|
||||
url = f'{self.api_base}/v1/files/upload'
|
||||
@@ -87,13 +108,18 @@ class AsyncCozeAPIClient:
|
||||
if response.status == 401:
|
||||
raise Exception('Coze API 认证失败,请检查 API Key 是否正确')
|
||||
|
||||
response_text = await response.text()
|
||||
response_text = (
|
||||
await httpclient.read_limited(
|
||||
response,
|
||||
max_bytes=_MAX_COZE_EVENT_BYTES,
|
||||
)
|
||||
).decode('utf-8', errors='replace')
|
||||
|
||||
if response.status != 200:
|
||||
raise Exception(f'文件上传失败,状态码: {response.status}, 响应: {response_text}')
|
||||
try:
|
||||
result = await response.json()
|
||||
except json.JSONDecodeError:
|
||||
result = json.loads(response_text)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raise Exception(f'文件上传响应解析失败: {response_text}')
|
||||
|
||||
if result.get('code') != 0:
|
||||
@@ -158,7 +184,15 @@ class AsyncCozeAPIClient:
|
||||
if response.status != 200:
|
||||
raise Exception(f'Coze API 流式请求失败,状态码: {response.status}')
|
||||
|
||||
total_bytes = 0
|
||||
chunk_type = 'message'
|
||||
chunk_data = ''
|
||||
async for chunk in response.content:
|
||||
total_bytes += len(chunk)
|
||||
if total_bytes > _MAX_COZE_RESPONSE_BYTES:
|
||||
raise Exception('Coze API stream exceeds the runtime limit')
|
||||
if len(chunk) > _MAX_COZE_EVENT_BYTES:
|
||||
raise Exception('Coze API event exceeds the runtime limit')
|
||||
chunk = chunk.decode('utf-8')
|
||||
if chunk != '\n':
|
||||
if chunk.startswith('event:'):
|
||||
|
||||
@@ -12,10 +12,26 @@ from collections.abc import AsyncGenerator
|
||||
|
||||
import httpx
|
||||
|
||||
from langbot.pkg.utils import httpclient
|
||||
|
||||
from .errors import DeerFlowAPIError
|
||||
|
||||
|
||||
SSE_MAX_BUFFER_CHARS = 1_048_576
|
||||
SSE_MAX_TOTAL_BYTES = 16 * 1024 * 1024
|
||||
ERROR_BODY_MAX_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
async def _read_error_body(response: httpx.Response) -> str:
|
||||
body = bytearray()
|
||||
async for chunk in response.aiter_bytes(8192):
|
||||
body.extend(chunk)
|
||||
if len(body) > ERROR_BODY_MAX_BYTES:
|
||||
raise DeerFlowAPIError(
|
||||
operation='read error response',
|
||||
body='response exceeds the runtime limit',
|
||||
)
|
||||
return body.decode('utf-8', errors='replace')
|
||||
|
||||
|
||||
def _normalize_sse_newlines(text: str) -> str:
|
||||
@@ -94,6 +110,7 @@ class AsyncDeerFlowClient:
|
||||
async with httpx.AsyncClient(
|
||||
trust_env=True,
|
||||
timeout=timeout,
|
||||
event_hooks=httpclient.httpx_response_limit_hooks(),
|
||||
) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
@@ -101,13 +118,14 @@ class AsyncDeerFlowClient:
|
||||
json=payload,
|
||||
)
|
||||
if response.status_code not in (200, 201):
|
||||
body = await httpclient.response_text(response)
|
||||
raise DeerFlowAPIError(
|
||||
operation='create thread',
|
||||
status=response.status_code,
|
||||
body=response.text,
|
||||
body=body,
|
||||
url=url,
|
||||
)
|
||||
return response.json()
|
||||
return await httpclient.parse_json_response(response)
|
||||
|
||||
async def delete_thread(self, thread_id: str, timeout: float = 20) -> None:
|
||||
"""删除指定 thread"""
|
||||
@@ -116,13 +134,15 @@ class AsyncDeerFlowClient:
|
||||
async with httpx.AsyncClient(
|
||||
trust_env=True,
|
||||
timeout=timeout,
|
||||
event_hooks=httpclient.httpx_response_limit_hooks(),
|
||||
) as client:
|
||||
response = await client.delete(url, headers=self.headers)
|
||||
if response.status_code not in (200, 202, 204, 404):
|
||||
body = await httpclient.response_text(response)
|
||||
raise DeerFlowAPIError(
|
||||
operation='delete thread',
|
||||
status=response.status_code,
|
||||
body=response.text,
|
||||
body=body,
|
||||
url=url,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
@@ -163,19 +183,27 @@ class AsyncDeerFlowClient:
|
||||
json=payload,
|
||||
) as resp:
|
||||
if resp.status_code != 200:
|
||||
body = await resp.aread()
|
||||
raise DeerFlowAPIError(
|
||||
operation='runs/stream request',
|
||||
status=resp.status_code,
|
||||
body=body.decode('utf-8', errors='replace'),
|
||||
body=await _read_error_body(resp),
|
||||
url=url,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
decoder = codecs.getincrementaldecoder('utf-8')('replace')
|
||||
buffer = ''
|
||||
total_bytes = 0
|
||||
|
||||
async for chunk in resp.aiter_bytes(8192):
|
||||
total_bytes += len(chunk)
|
||||
if total_bytes > SSE_MAX_TOTAL_BYTES:
|
||||
raise DeerFlowAPIError(
|
||||
operation='runs/stream response',
|
||||
body='response exceeds the runtime limit',
|
||||
url=url,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
buffer += _normalize_sse_newlines(decoder.decode(chunk))
|
||||
|
||||
while '\n\n' in buffer:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import typing
|
||||
import json
|
||||
@@ -8,6 +9,75 @@ from .errors import DifyAPIError
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
_MAX_DIFY_RESPONSE_BYTES = 1024 * 1024
|
||||
_MAX_DIFY_SSE_LINE_BYTES = 1024 * 1024
|
||||
_MAX_DIFY_STREAM_BYTES = 16 * 1024 * 1024
|
||||
_MAX_DIFY_UPLOAD_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
async def _read_limited_response(
|
||||
response: httpx.Response,
|
||||
*,
|
||||
max_bytes: int = _MAX_DIFY_RESPONSE_BYTES,
|
||||
) -> bytes:
|
||||
content_length = response.headers.get('Content-Length')
|
||||
if content_length is not None:
|
||||
try:
|
||||
if int(content_length) > max_bytes:
|
||||
raise DifyAPIError(f'Remote response exceeds the {max_bytes}-byte limit')
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
body = bytearray()
|
||||
async for chunk in response.aiter_bytes(chunk_size=8192):
|
||||
body.extend(chunk)
|
||||
if len(body) > max_bytes:
|
||||
raise DifyAPIError(f'Remote response exceeds the {max_bytes}-byte limit')
|
||||
return bytes(body)
|
||||
|
||||
|
||||
async def _iter_sse_json(
|
||||
response: httpx.Response,
|
||||
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
||||
"""Parse Dify's one-JSON-per-data-line SSE without unbounded line buffering."""
|
||||
|
||||
buffer = bytearray()
|
||||
total = 0
|
||||
async for chunk in response.aiter_bytes(chunk_size=8192):
|
||||
total += len(chunk)
|
||||
if total > _MAX_DIFY_STREAM_BYTES:
|
||||
raise DifyAPIError('Dify SSE stream exceeds the runtime limit')
|
||||
buffer.extend(chunk)
|
||||
while b'\n' in buffer:
|
||||
raw_line, _, remainder = buffer.partition(b'\n')
|
||||
buffer = bytearray(remainder)
|
||||
if len(raw_line) > _MAX_DIFY_SSE_LINE_BYTES:
|
||||
raise DifyAPIError('Dify SSE event exceeds the runtime limit')
|
||||
line = raw_line.rstrip(b'\r').strip()
|
||||
if not line or not line.startswith(b'data:'):
|
||||
continue
|
||||
payload = json.loads(line[5:].decode('utf-8', errors='replace'))
|
||||
if isinstance(payload, dict):
|
||||
yield payload
|
||||
if len(buffer) > _MAX_DIFY_SSE_LINE_BYTES:
|
||||
raise DifyAPIError('Dify SSE event exceeds the runtime limit')
|
||||
|
||||
line = bytes(buffer).rstrip(b'\r').strip()
|
||||
if line.startswith(b'data:'):
|
||||
payload = json.loads(line[5:].decode('utf-8', errors='replace'))
|
||||
if isinstance(payload, dict):
|
||||
yield payload
|
||||
|
||||
|
||||
def _read_local_file_limited(path: Path) -> bytes:
|
||||
if path.stat().st_size > _MAX_DIFY_UPLOAD_BYTES:
|
||||
raise ValueError('Dify upload exceeds the size limit')
|
||||
with path.open('rb') as handle:
|
||||
body = handle.read(_MAX_DIFY_UPLOAD_BYTES + 1)
|
||||
if len(body) > _MAX_DIFY_UPLOAD_BYTES:
|
||||
raise ValueError('Dify upload exceeds the size limit')
|
||||
return body
|
||||
|
||||
|
||||
class AsyncDifyServiceClient:
|
||||
"""Dify Service API 客户端"""
|
||||
@@ -22,6 +92,21 @@ class AsyncDifyServiceClient:
|
||||
) -> None:
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
def _get_client(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
trust_env=True,
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def aclose(self) -> None:
|
||||
client = self._client
|
||||
self._client = None
|
||||
if client is not None:
|
||||
await client.aclose()
|
||||
|
||||
async def chat_messages(
|
||||
self,
|
||||
@@ -38,37 +123,32 @@ class AsyncDifyServiceClient:
|
||||
if response_mode != 'streaming':
|
||||
raise DifyAPIError('当前仅支持 streaming 模式')
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
trust_env=True,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
payload = {
|
||||
'inputs': inputs,
|
||||
'query': query,
|
||||
'user': user,
|
||||
'response_mode': response_mode,
|
||||
'conversation_id': conversation_id,
|
||||
'files': files,
|
||||
'model_config': model_config or {},
|
||||
}
|
||||
client = self._get_client()
|
||||
payload = {
|
||||
'inputs': inputs,
|
||||
'query': query,
|
||||
'user': user,
|
||||
'response_mode': response_mode,
|
||||
'conversation_id': conversation_id,
|
||||
'files': files,
|
||||
'model_config': model_config or {},
|
||||
}
|
||||
|
||||
async with client.stream(
|
||||
'POST',
|
||||
'/chat-messages',
|
||||
headers={
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json=payload,
|
||||
) as r:
|
||||
async for chunk in r.aiter_lines():
|
||||
if r.status_code != 200:
|
||||
raise DifyAPIError(f'{r.status_code} {chunk}')
|
||||
if chunk.strip() == '':
|
||||
continue
|
||||
if chunk.startswith('data:'):
|
||||
yield json.loads(chunk[5:])
|
||||
async with client.stream(
|
||||
'POST',
|
||||
'/chat-messages',
|
||||
headers={
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json=payload,
|
||||
timeout=timeout,
|
||||
) as r:
|
||||
if r.status_code != 200:
|
||||
body = await _read_limited_response(r)
|
||||
raise DifyAPIError(f'{r.status_code} {body.decode(errors="replace")}')
|
||||
async for event in _iter_sse_json(r):
|
||||
yield event
|
||||
|
||||
async def workflow_run(
|
||||
self,
|
||||
@@ -82,32 +162,27 @@ class AsyncDifyServiceClient:
|
||||
if response_mode != 'streaming':
|
||||
raise DifyAPIError('当前仅支持 streaming 模式')
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
trust_env=True,
|
||||
client = self._get_client()
|
||||
async with client.stream(
|
||||
'POST',
|
||||
'/workflows/run',
|
||||
headers={
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json={
|
||||
'inputs': inputs,
|
||||
'user': user,
|
||||
'response_mode': response_mode,
|
||||
'files': files,
|
||||
},
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
async with client.stream(
|
||||
'POST',
|
||||
'/workflows/run',
|
||||
headers={
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json={
|
||||
'inputs': inputs,
|
||||
'user': user,
|
||||
'response_mode': response_mode,
|
||||
'files': files,
|
||||
},
|
||||
) as r:
|
||||
async for chunk in r.aiter_lines():
|
||||
if r.status_code != 200:
|
||||
raise DifyAPIError(f'{r.status_code} {chunk}')
|
||||
if chunk.strip() == '':
|
||||
continue
|
||||
if chunk.startswith('data:'):
|
||||
yield json.loads(chunk[5:])
|
||||
) as r:
|
||||
if r.status_code != 200:
|
||||
body = await _read_limited_response(r)
|
||||
raise DifyAPIError(f'{r.status_code} {body.decode(errors="replace")}')
|
||||
async for event in _iter_sse_json(r):
|
||||
yield event
|
||||
|
||||
async def workflow_submit(
|
||||
self,
|
||||
@@ -129,41 +204,38 @@ class AsyncDifyServiceClient:
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
trust_env=True,
|
||||
client = self._get_client()
|
||||
# Step 1: Submit the form
|
||||
payload: dict[str, typing.Any] = {
|
||||
'inputs': inputs if isinstance(inputs, dict) else {},
|
||||
'user': user,
|
||||
'action': action,
|
||||
}
|
||||
|
||||
async with client.stream(
|
||||
'POST',
|
||||
f'/form/human_input/{form_token}',
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
# Step 1: Submit the form
|
||||
payload: dict[str, typing.Any] = {
|
||||
'inputs': inputs if isinstance(inputs, dict) else {},
|
||||
'user': user,
|
||||
'action': action,
|
||||
}
|
||||
|
||||
submit_resp = await client.post(
|
||||
f'/form/human_input/{form_token}',
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
) as submit_resp:
|
||||
submit_body = await _read_limited_response(submit_resp)
|
||||
if submit_resp.status_code != 200:
|
||||
raise DifyAPIError(f'{submit_resp.status_code} {submit_resp.text}')
|
||||
raise DifyAPIError(f'{submit_resp.status_code} {submit_body.decode(errors="replace")}')
|
||||
|
||||
# Step 2: Stream resumed workflow events
|
||||
async with client.stream(
|
||||
'GET',
|
||||
f'/workflow/{workflow_run_id}/events',
|
||||
headers={'Authorization': f'Bearer {self.api_key}'},
|
||||
params={'user': user},
|
||||
) as r:
|
||||
if r.status_code != 200:
|
||||
body = (await r.aread()).decode(errors='replace')
|
||||
raise DifyAPIError(f'{r.status_code} {body}')
|
||||
async for chunk in r.aiter_lines():
|
||||
if chunk.strip() == '':
|
||||
continue
|
||||
if chunk.startswith('data:'):
|
||||
yield json.loads(chunk[5:])
|
||||
# Step 2: Stream resumed workflow events
|
||||
async with client.stream(
|
||||
'GET',
|
||||
f'/workflow/{workflow_run_id}/events',
|
||||
headers={'Authorization': f'Bearer {self.api_key}'},
|
||||
params={'user': user},
|
||||
timeout=timeout,
|
||||
) as r:
|
||||
if r.status_code != 200:
|
||||
body = await _read_limited_response(r)
|
||||
raise DifyAPIError(f'{r.status_code} {body.decode(errors="replace")}')
|
||||
async for event in _iter_sse_json(r):
|
||||
yield event
|
||||
|
||||
async def upload_file(
|
||||
self,
|
||||
@@ -175,37 +247,30 @@ class AsyncDifyServiceClient:
|
||||
if isinstance(file, Path):
|
||||
if not file.exists():
|
||||
raise ValueError(f'File not found: {file}')
|
||||
with open(file, 'rb') as f:
|
||||
file = f.read()
|
||||
file = await asyncio.to_thread(_read_local_file_limited, file)
|
||||
|
||||
# 处理文件路径字符串
|
||||
elif isinstance(file, str):
|
||||
if not os.path.isfile(file):
|
||||
raise ValueError(f'File not found: {file}')
|
||||
with open(file, 'rb') as f:
|
||||
file = f.read()
|
||||
file = await asyncio.to_thread(_read_local_file_limited, Path(file))
|
||||
|
||||
# 处理文件对象
|
||||
elif hasattr(file, 'read'):
|
||||
file = file.read()
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
trust_env=True,
|
||||
file = await asyncio.to_thread(file.read, _MAX_DIFY_UPLOAD_BYTES + 1)
|
||||
if len(file) > _MAX_DIFY_UPLOAD_BYTES:
|
||||
raise ValueError('Dify upload exceeds the size limit')
|
||||
client = self._get_client()
|
||||
# multipart/form-data
|
||||
async with client.stream(
|
||||
'POST',
|
||||
'/files/upload',
|
||||
headers={'Authorization': f'Bearer {self.api_key}'},
|
||||
files={'file': file},
|
||||
data={'user': user},
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
# multipart/form-data
|
||||
response = await client.post(
|
||||
'/files/upload',
|
||||
headers={'Authorization': f'Bearer {self.api_key}'},
|
||||
files={
|
||||
'file': file,
|
||||
},
|
||||
data={
|
||||
'user': user,
|
||||
},
|
||||
)
|
||||
|
||||
) as response:
|
||||
body = await _read_limited_response(response)
|
||||
if response.status_code != 201:
|
||||
raise DifyAPIError(f'{response.status_code} {response.text}')
|
||||
|
||||
return response.json()
|
||||
raise DifyAPIError(f'{response.status_code} {body.decode(errors="replace")}')
|
||||
return json.loads(body)
|
||||
|
||||
@@ -7,6 +7,7 @@ import time
|
||||
import typing
|
||||
import uuid
|
||||
import urllib.parse
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Awaitable, Callable, Optional
|
||||
import dingtalk_stream # type: ignore
|
||||
import websockets
|
||||
@@ -15,12 +16,42 @@ from .card_callback import DingTalkCardActionHandler
|
||||
from .dingtalkevent import DingTalkEvent
|
||||
import httpx
|
||||
import traceback
|
||||
from langbot.pkg.utils import httpclient
|
||||
|
||||
|
||||
_stdout_logger = logging.getLogger('langbot.dingtalk_api')
|
||||
|
||||
|
||||
DINGTALK_OPENAPI_BASE = 'https://api.dingtalk.com'
|
||||
_MAX_MEDIA_BYTES = 10 * 1024 * 1024
|
||||
_MAX_GATEWAY_MESSAGE_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
def _read_local_media_limited(file_path: str) -> bytes:
|
||||
if os.path.getsize(file_path) > _MAX_MEDIA_BYTES:
|
||||
raise ValueError('DingTalk media exceeds the size limit')
|
||||
with open(file_path, 'rb') as file:
|
||||
body = file.read(_MAX_MEDIA_BYTES + 1)
|
||||
if len(body) > _MAX_MEDIA_BYTES:
|
||||
raise ValueError('DingTalk media exceeds the size limit')
|
||||
return body
|
||||
|
||||
|
||||
async def _read_httpx_media_limited(response: httpx.Response) -> bytes:
|
||||
content_length = response.headers.get('Content-Length')
|
||||
if content_length is not None:
|
||||
try:
|
||||
if int(content_length) > _MAX_MEDIA_BYTES:
|
||||
raise ValueError('DingTalk media exceeds the size limit')
|
||||
except (TypeError, ValueError) as exc:
|
||||
if 'exceeds' in str(exc):
|
||||
raise
|
||||
body = bytearray()
|
||||
async for chunk in response.aiter_bytes():
|
||||
body.extend(chunk)
|
||||
if len(body) > _MAX_MEDIA_BYTES:
|
||||
raise ValueError('DingTalk media exceeds the size limit')
|
||||
return bytes(body)
|
||||
|
||||
|
||||
def _stringify_card_param_map(card_param_map: Optional[dict]) -> dict:
|
||||
@@ -44,6 +75,8 @@ def _stringify_card_param_map(card_param_map: Optional[dict]) -> dict:
|
||||
|
||||
|
||||
class DingTalkClient:
|
||||
_MAX_INBOUND_TASKS = 100
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client_id: str,
|
||||
@@ -86,6 +119,37 @@ class DingTalkClient:
|
||||
self.legacy_access_token = ''
|
||||
self.legacy_access_token_expiry_time: typing.Optional[float] = None
|
||||
self._stopped = False # Flag to control the event loop
|
||||
self._inbound_tasks: set[asyncio.Task] = set()
|
||||
self._http_client: httpx.AsyncClient | None = None
|
||||
|
||||
@asynccontextmanager
|
||||
async def _http_client_context(self):
|
||||
"""Reuse one connection pool while preserving existing call structure."""
|
||||
|
||||
if self._http_client is None or self._http_client.is_closed:
|
||||
self._http_client = httpx.AsyncClient(event_hooks=httpclient.httpx_response_limit_hooks())
|
||||
yield self._http_client
|
||||
|
||||
def _start_inbound_task(self, coro: typing.Coroutine) -> bool:
|
||||
"""Start one bounded inbound callback task."""
|
||||
|
||||
for task in tuple(self._inbound_tasks):
|
||||
if task.done():
|
||||
self._inbound_tasks.discard(task)
|
||||
if len(self._inbound_tasks) >= self._MAX_INBOUND_TASKS:
|
||||
coro.close()
|
||||
return False
|
||||
|
||||
task = asyncio.create_task(coro)
|
||||
self._inbound_tasks.add(task)
|
||||
|
||||
def done(done_task: asyncio.Task) -> None:
|
||||
self._inbound_tasks.discard(done_task)
|
||||
if not done_task.cancelled():
|
||||
done_task.exception()
|
||||
|
||||
task.add_done_callback(done)
|
||||
return True
|
||||
|
||||
async def _on_card_action(self, payload: dict) -> None:
|
||||
"""Dispatch a parsed card-action payload to the adapter callback."""
|
||||
@@ -101,11 +165,11 @@ class DingTalkClient:
|
||||
url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken'
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
data = {'appKey': self.key, 'appSecret': self.secret}
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
try:
|
||||
response = await client.post(url, json=data, headers=headers)
|
||||
if response.status_code == 200:
|
||||
response_data = response.json()
|
||||
response_data = await httpclient.parse_json_response(response)
|
||||
self.access_token = response_data.get('accessToken')
|
||||
expires_in = int(response_data.get('expireIn', 7200))
|
||||
self.access_token_expiry_time = time.time() + expires_in - 60
|
||||
@@ -129,28 +193,28 @@ class DingTalkClient:
|
||||
url = 'https://api.dingtalk.com/v1.0/robot/messageFiles/download'
|
||||
params = {'downloadCode': download_code, 'robotCode': self.robot_code}
|
||||
headers = {'x-acs-dingtalk-access-token': self.access_token}
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
response = await client.post(url, headers=headers, json=params)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
result = await httpclient.parse_json_response(response)
|
||||
download_url = result.get('downloadUrl')
|
||||
else:
|
||||
await self.logger.error(f'failed to get download url: {response.json()}')
|
||||
error_payload = await httpclient.parse_json_response(response)
|
||||
await self.logger.error(f'failed to get download url: {error_payload}')
|
||||
|
||||
if download_url:
|
||||
return await self.download_url_to_base64(download_url)
|
||||
|
||||
async def download_url_to_base64(self, download_url):
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(download_url)
|
||||
|
||||
if response.status_code == 200:
|
||||
file_bytes = response.content
|
||||
mime_type = response.headers.get('Content-Type', 'application/octet-stream')
|
||||
base64_str = base64.b64encode(file_bytes).decode('utf-8')
|
||||
return f'data:{mime_type};base64,{base64_str}'
|
||||
else:
|
||||
await self.logger.error(f'failed to get files: {response.json()}')
|
||||
async with self._http_client_context() as client:
|
||||
async with client.stream('GET', download_url) as response:
|
||||
if response.status_code == 200:
|
||||
file_bytes = await _read_httpx_media_limited(response)
|
||||
mime_type = response.headers.get('Content-Type', 'application/octet-stream')
|
||||
base64_str = (await asyncio.to_thread(base64.b64encode, file_bytes)).decode('utf-8')
|
||||
return f'data:{mime_type};base64,{base64_str}'
|
||||
error_body = await _read_httpx_media_limited(response)
|
||||
await self.logger.error(f'failed to get files: {error_body[:300]!r}')
|
||||
|
||||
async def get_audio_url(self, download_code: str):
|
||||
if not await self.check_access_token():
|
||||
@@ -158,17 +222,19 @@ class DingTalkClient:
|
||||
url = 'https://api.dingtalk.com/v1.0/robot/messageFiles/download'
|
||||
params = {'downloadCode': download_code, 'robotCode': self.robot_code}
|
||||
headers = {'x-acs-dingtalk-access-token': self.access_token}
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
response = await client.post(url, headers=headers, json=params)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
result = await httpclient.parse_json_response(response)
|
||||
download_url = result.get('downloadUrl')
|
||||
if download_url:
|
||||
return await self.download_url_to_base64(download_url)
|
||||
else:
|
||||
await self.logger.error(f'failed to get audio: {response.json()}')
|
||||
error_payload = await httpclient.parse_json_response(response)
|
||||
await self.logger.error(f'failed to get audio: {error_payload}')
|
||||
else:
|
||||
raise Exception(f'Error: {response.status_code}, {response.text}')
|
||||
body = await httpclient.response_text(response)
|
||||
raise Exception(f'Error: {response.status_code}, {body}')
|
||||
|
||||
async def get_file_url(self, download_code: str):
|
||||
if not await self.check_access_token():
|
||||
@@ -176,17 +242,19 @@ class DingTalkClient:
|
||||
url = 'https://api.dingtalk.com/v1.0/robot/messageFiles/download'
|
||||
params = {'downloadCode': download_code, 'robotCode': self.robot_code}
|
||||
headers = {'x-acs-dingtalk-access-token': self.access_token}
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
response = await client.post(url, headers=headers, json=params)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
result = await httpclient.parse_json_response(response)
|
||||
download_url = result.get('downloadUrl')
|
||||
if download_url:
|
||||
return download_url
|
||||
else:
|
||||
await self.logger.error(f'failed to get file: {response.json()}')
|
||||
error_payload = await httpclient.parse_json_response(response)
|
||||
await self.logger.error(f'failed to get file: {error_payload}')
|
||||
else:
|
||||
raise Exception(f'Error: {response.status_code}, {response.text}')
|
||||
body = await httpclient.response_text(response)
|
||||
raise Exception(f'Error: {response.status_code}, {body}')
|
||||
|
||||
async def update_incoming_message(self, message):
|
||||
"""异步更新 DingTalkClient 中的 incoming_message"""
|
||||
@@ -503,12 +571,13 @@ class DingTalkClient:
|
||||
len(content),
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
response_body = await httpclient.response_text(response, max_chars=500)
|
||||
_stdout_logger.info(
|
||||
'DingTalk send_proactive_message_to_one response: status=%d body=%s',
|
||||
response.status_code,
|
||||
response.text[:500],
|
||||
response_body,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return
|
||||
@@ -535,7 +604,7 @@ class DingTalkClient:
|
||||
'msgParam': json.dumps({'content': content}),
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
if response.status_code == 200:
|
||||
return
|
||||
@@ -667,22 +736,23 @@ class DingTalkClient:
|
||||
'DingTalk createAndDeliver request body: %s',
|
||||
json.dumps(body, ensure_ascii=False)[:1500],
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
response = await client.post(url, headers=headers, json=body, timeout=30.0)
|
||||
response_body = await httpclient.response_text(response, max_chars=500)
|
||||
if response.status_code == 200:
|
||||
_stdout_logger.info(
|
||||
'DingTalk createAndDeliver response: %s',
|
||||
response.text[:500],
|
||||
response_body,
|
||||
)
|
||||
return True
|
||||
_stdout_logger.error(
|
||||
'DingTalk createAndDeliver failed: status=%s body=%s',
|
||||
response.status_code,
|
||||
response.text,
|
||||
response_body,
|
||||
)
|
||||
if self.logger:
|
||||
await self.logger.error(
|
||||
f'DingTalk createAndDeliver failed: status={response.status_code} body={response.text}'
|
||||
f'DingTalk createAndDeliver failed: status={response.status_code} body={response_body}'
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
@@ -725,13 +795,14 @@ class DingTalkClient:
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
response = await client.put(url, headers=headers, json=body, timeout=30.0)
|
||||
if response.status_code == 200:
|
||||
return True
|
||||
if self.logger:
|
||||
response_body = await httpclient.response_text(response)
|
||||
await self.logger.error(
|
||||
f'DingTalk card streaming failed: status={response.status_code} body={response.text}'
|
||||
f'DingTalk card streaming failed: status={response.status_code} body={response_body}'
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
@@ -768,18 +839,19 @@ class DingTalkClient:
|
||||
out_track_id,
|
||||
json.dumps(body, ensure_ascii=False)[:1500],
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
response = await client.put(url, headers=headers, json=body, timeout=30.0)
|
||||
response_body = await httpclient.response_text(response, max_chars=300)
|
||||
_stdout_logger.info(
|
||||
'DingTalk update_card_data response: status=%d body=%s',
|
||||
response.status_code,
|
||||
response.text[:300],
|
||||
response_body,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return True
|
||||
if self.logger:
|
||||
await self.logger.error(
|
||||
f'DingTalk update card failed: status={response.status_code} body={response.text}'
|
||||
f'DingTalk update card failed: status={response.status_code} body={response_body}'
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
@@ -808,17 +880,18 @@ class DingTalkClient:
|
||||
|
||||
url = 'https://oapi.dingtalk.com/gettoken'
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
response = await client.get(url, params={'appkey': self.key, 'appsecret': self.secret}, timeout=15.0)
|
||||
data = response.json() if response.status_code == 200 else {}
|
||||
data = await httpclient.parse_json_response(response) if response.status_code == 200 else {}
|
||||
if data.get('errcode') == 0 and data.get('access_token'):
|
||||
self.legacy_access_token = data['access_token']
|
||||
expires_in = int(data.get('expires_in', 7200))
|
||||
self.legacy_access_token_expiry_time = now + expires_in - 60
|
||||
return self.legacy_access_token
|
||||
if self.logger:
|
||||
response_body = await httpclient.response_text(response, max_chars=200)
|
||||
await self.logger.error(
|
||||
f'DingTalk legacy gettoken failed: status={response.status_code} body={response.text[:200]}'
|
||||
f'DingTalk legacy gettoken failed: status={response.status_code} body={response_body}'
|
||||
)
|
||||
except Exception:
|
||||
_stdout_logger.exception('DingTalk legacy gettoken error')
|
||||
@@ -848,8 +921,7 @@ class DingTalkClient:
|
||||
|
||||
url = 'https://oapi.dingtalk.com/media/upload'
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
file_bytes = f.read()
|
||||
file_bytes = await asyncio.to_thread(_read_local_media_limited, file_path)
|
||||
file_name = os.path.basename(file_path)
|
||||
# Best-effort content-type guess; DingTalk accepts the major image
|
||||
# mime types and otherwise infers from the bytes.
|
||||
@@ -857,20 +929,21 @@ class DingTalkClient:
|
||||
mime = {'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif'}.get(
|
||||
ext, 'application/octet-stream'
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
params={'access_token': token, 'type': 'image'},
|
||||
files={'media': (file_name, file_bytes, mime)},
|
||||
timeout=30.0,
|
||||
)
|
||||
data = response.json() if response.status_code == 200 else {}
|
||||
data = await httpclient.parse_json_response(response) if response.status_code == 200 else {}
|
||||
if data.get('errcode') == 0 and data.get('media_id'):
|
||||
_stdout_logger.info('DingTalk upload_image_media OK: media_id=%s', data['media_id'])
|
||||
return data['media_id']
|
||||
if self.logger:
|
||||
response_body = await httpclient.response_text(response, max_chars=300)
|
||||
await self.logger.error(
|
||||
f'DingTalk upload_image_media failed: status={response.status_code} body={response.text[:300]}'
|
||||
f'DingTalk upload_image_media failed: status={response.status_code} body={response_body}'
|
||||
)
|
||||
except Exception:
|
||||
_stdout_logger.exception('DingTalk upload_image_media error')
|
||||
@@ -897,15 +970,19 @@ class DingTalkClient:
|
||||
continue
|
||||
|
||||
uri = '%s?ticket=%s' % (connection['endpoint'], urllib.parse.quote_plus(connection['ticket']))
|
||||
async with websockets.connect(uri) as websocket:
|
||||
async with websockets.connect(uri, max_size=_MAX_GATEWAY_MESSAGE_BYTES) as websocket:
|
||||
self.client.websocket = websocket
|
||||
keepalive_task = asyncio.create_task(self._keepalive(websocket))
|
||||
try:
|
||||
async for raw_message in websocket:
|
||||
if self._stopped:
|
||||
break
|
||||
json_message = json.loads(raw_message)
|
||||
asyncio.create_task(self.client.background_task(json_message))
|
||||
json_message = await asyncio.to_thread(json.loads, raw_message)
|
||||
if not self._start_inbound_task(self.client.background_task(json_message)):
|
||||
if self.logger:
|
||||
await self.logger.warning(
|
||||
'DingTalk inbound task capacity reached; dropping message'
|
||||
)
|
||||
finally:
|
||||
keepalive_task.cancel()
|
||||
try:
|
||||
@@ -948,5 +1025,15 @@ class DingTalkClient:
|
||||
await self.client.websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
inbound_tasks = list(self._inbound_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_tasks.clear()
|
||||
# Clear message handlers to prevent stale callbacks
|
||||
self._message_handlers = {'example': []}
|
||||
if self._http_client is not None:
|
||||
await self._http_client.aclose()
|
||||
self._http_client = None
|
||||
|
||||
@@ -21,8 +21,14 @@ xml_template = """
|
||||
</xml>
|
||||
"""
|
||||
|
||||
_MAX_CALLBACK_BODY_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
class OAClient:
|
||||
_STATE_TTL_SECONDS = 600
|
||||
_STATE_MAX = 4096
|
||||
_MAX_CONTENT_CHARS = 200000
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token: str,
|
||||
@@ -41,6 +47,7 @@ class OAClient:
|
||||
self.access_token = ''
|
||||
self.unified_mode = unified_mode
|
||||
self.app = Quart(__name__)
|
||||
self.app.config['MAX_CONTENT_LENGTH'] = _MAX_CALLBACK_BODY_BYTES
|
||||
|
||||
# 只有在非统一模式下才注册独立路由
|
||||
if not self.unified_mode:
|
||||
@@ -57,8 +64,38 @@ class OAClient:
|
||||
self.access_token_expiry_time = None
|
||||
self.msg_id_map = {}
|
||||
self.generated_content = {}
|
||||
self._msg_seen_at = {}
|
||||
self._generated_at = {}
|
||||
self._last_state_prune = 0.0
|
||||
self.logger = logger
|
||||
|
||||
def _prune_state(self) -> None:
|
||||
now = time.monotonic()
|
||||
if now - self._last_state_prune >= 60:
|
||||
self._last_state_prune = now
|
||||
for message_id, seen_at in tuple(self._msg_seen_at.items()):
|
||||
if now - seen_at > self._STATE_TTL_SECONDS:
|
||||
self._msg_seen_at.pop(message_id, None)
|
||||
self.msg_id_map.pop(message_id, None)
|
||||
for message_id, generated_at in tuple(self._generated_at.items()):
|
||||
if now - generated_at > self._STATE_TTL_SECONDS:
|
||||
self._generated_at.pop(message_id, None)
|
||||
self.generated_content.pop(message_id, None)
|
||||
while len(self.msg_id_map) > self._STATE_MAX:
|
||||
message_id = next(iter(self.msg_id_map))
|
||||
self.msg_id_map.pop(message_id, None)
|
||||
self._msg_seen_at.pop(message_id, None)
|
||||
while len(self.generated_content) > self._STATE_MAX:
|
||||
message_id = next(iter(self.generated_content))
|
||||
self.generated_content.pop(message_id, None)
|
||||
self._generated_at.pop(message_id, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
self.msg_id_map.clear()
|
||||
self.generated_content.clear()
|
||||
self._msg_seen_at.clear()
|
||||
self._generated_at.clear()
|
||||
|
||||
async def handle_callback_request(self):
|
||||
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
||||
return await self._handle_callback_internal(request)
|
||||
@@ -104,8 +141,16 @@ class OAClient:
|
||||
raise Exception('拒绝请求')
|
||||
elif req.method == 'POST':
|
||||
encryt_msg = await req.data
|
||||
if len(encryt_msg) > _MAX_CALLBACK_BODY_BYTES:
|
||||
raise ValueError('Official Account callback body exceeds the size limit')
|
||||
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
||||
ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce)
|
||||
ret, xml_msg = await asyncio.to_thread(
|
||||
wxcpt.DecryptMsg,
|
||||
encryt_msg,
|
||||
msg_signature,
|
||||
timestamp,
|
||||
nonce,
|
||||
)
|
||||
xml_msg = xml_msg.decode('utf-8')
|
||||
|
||||
if ret != 0:
|
||||
@@ -118,7 +163,7 @@ class OAClient:
|
||||
if event:
|
||||
await self._handle_message(event)
|
||||
|
||||
root = ET.fromstring(xml_msg)
|
||||
root = await asyncio.to_thread(ET.fromstring, xml_msg)
|
||||
from_user = root.find('FromUserName').text # 发送者
|
||||
to_user = root.find('ToUserName').text # 机器人
|
||||
|
||||
@@ -126,6 +171,7 @@ class OAClient:
|
||||
interval = 0.1
|
||||
while True:
|
||||
content = self.generated_content.pop(message_data['MsgId'], None)
|
||||
self._generated_at.pop(message_data['MsgId'], None)
|
||||
if content:
|
||||
response_xml = xml_template.format(
|
||||
to_user=from_user,
|
||||
@@ -156,7 +202,7 @@ class OAClient:
|
||||
traceback.print_exc()
|
||||
|
||||
async def get_message(self, xml_msg: str):
|
||||
root = ET.fromstring(xml_msg)
|
||||
root = await asyncio.to_thread(ET.fromstring, xml_msg)
|
||||
|
||||
message_data = {
|
||||
'ToUserName': root.find('ToUserName').text,
|
||||
@@ -193,21 +239,30 @@ class OAClient:
|
||||
处理消息事件。
|
||||
"""
|
||||
message_id = event.message_id
|
||||
self._prune_state()
|
||||
if message_id in self.msg_id_map.keys():
|
||||
self.msg_id_map[message_id] += 1
|
||||
self._msg_seen_at[message_id] = time.monotonic()
|
||||
return
|
||||
|
||||
self.msg_id_map[message_id] = 1
|
||||
self._msg_seen_at[message_id] = time.monotonic()
|
||||
msg_type = event.type
|
||||
if msg_type in self._message_handlers:
|
||||
for handler in self._message_handlers[msg_type]:
|
||||
await handler(event)
|
||||
|
||||
async def set_message(self, msg_id: int, content: str):
|
||||
self.generated_content[msg_id] = content
|
||||
self.generated_content[msg_id] = str(content)[: self._MAX_CONTENT_CHARS]
|
||||
self._generated_at[msg_id] = time.monotonic()
|
||||
self._prune_state()
|
||||
|
||||
|
||||
class OAClientForLongerResponse:
|
||||
_MAX_USERS = 4096
|
||||
_MAX_MESSAGES_PER_USER = 20
|
||||
_MAX_CONTENT_CHARS = 200000
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token: str,
|
||||
@@ -227,6 +282,7 @@ class OAClientForLongerResponse:
|
||||
self.access_token = ''
|
||||
self.unified_mode = unified_mode
|
||||
self.app = Quart(__name__)
|
||||
self.app.config['MAX_CONTENT_LENGTH'] = _MAX_CALLBACK_BODY_BYTES
|
||||
|
||||
# 只有在非统一模式下才注册独立路由
|
||||
if not self.unified_mode:
|
||||
@@ -244,8 +300,28 @@ class OAClientForLongerResponse:
|
||||
self.loading_message = LoadingMessage
|
||||
self.msg_queue = {}
|
||||
self.user_msg_queue = {}
|
||||
self._last_queue_cleanup = 0.0
|
||||
self.logger = logger
|
||||
|
||||
def _prune_queues(self) -> None:
|
||||
now = time.monotonic()
|
||||
if now - self._last_queue_cleanup >= 60:
|
||||
self._last_queue_cleanup = now
|
||||
for user_id, queue in tuple(self.msg_queue.items()):
|
||||
if not queue:
|
||||
self.msg_queue.pop(user_id, None)
|
||||
for user_id, queue in tuple(self.user_msg_queue.items()):
|
||||
if not queue:
|
||||
self.user_msg_queue.pop(user_id, None)
|
||||
while len(self.msg_queue) > self._MAX_USERS:
|
||||
self.msg_queue.pop(next(iter(self.msg_queue)), None)
|
||||
while len(self.user_msg_queue) > self._MAX_USERS:
|
||||
self.user_msg_queue.pop(next(iter(self.user_msg_queue)), None)
|
||||
|
||||
def clear(self) -> None:
|
||||
self.msg_queue.clear()
|
||||
self.user_msg_queue.clear()
|
||||
|
||||
async def handle_callback_request(self):
|
||||
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
||||
return await self._handle_callback_internal(request)
|
||||
@@ -285,8 +361,16 @@ class OAClientForLongerResponse:
|
||||
|
||||
elif req.method == 'POST':
|
||||
encryt_msg = await req.data
|
||||
if len(encryt_msg) > _MAX_CALLBACK_BODY_BYTES:
|
||||
raise ValueError('Official Account callback body exceeds the size limit')
|
||||
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
||||
ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce)
|
||||
ret, xml_msg = await asyncio.to_thread(
|
||||
wxcpt.DecryptMsg,
|
||||
encryt_msg,
|
||||
msg_signature,
|
||||
timestamp,
|
||||
nonce,
|
||||
)
|
||||
xml_msg = xml_msg.decode('utf-8')
|
||||
|
||||
if ret != 0:
|
||||
@@ -294,7 +378,7 @@ class OAClientForLongerResponse:
|
||||
raise Exception('消息解密失败')
|
||||
|
||||
# 解析 XML
|
||||
root = ET.fromstring(xml_msg)
|
||||
root = await asyncio.to_thread(ET.fromstring, xml_msg)
|
||||
from_user = root.find('FromUserName').text
|
||||
to_user = root.find('ToUserName').text
|
||||
|
||||
@@ -305,6 +389,7 @@ class OAClientForLongerResponse:
|
||||
# 弹出用户消息
|
||||
if self.user_msg_queue.get(from_user) and self.user_msg_queue[from_user]:
|
||||
self.user_msg_queue[from_user].pop(0)
|
||||
self._prune_queues()
|
||||
|
||||
response_xml = xml_template.format(
|
||||
to_user=from_user,
|
||||
@@ -332,9 +417,13 @@ class OAClientForLongerResponse:
|
||||
if event:
|
||||
self.user_msg_queue.setdefault(from_user, []).append(
|
||||
{
|
||||
'content': event.message,
|
||||
'content': str(event.message)[: self._MAX_CONTENT_CHARS],
|
||||
}
|
||||
)
|
||||
self.user_msg_queue[from_user] = self.user_msg_queue[from_user][
|
||||
-self._MAX_MESSAGES_PER_USER :
|
||||
]
|
||||
self._prune_queues()
|
||||
await self._handle_message(event)
|
||||
|
||||
return response_xml
|
||||
@@ -344,7 +433,7 @@ class OAClientForLongerResponse:
|
||||
traceback.print_exc()
|
||||
|
||||
async def get_message(self, xml_msg: str):
|
||||
root = ET.fromstring(xml_msg)
|
||||
root = await asyncio.to_thread(ET.fromstring, xml_msg)
|
||||
|
||||
message_data = {
|
||||
'ToUserName': root.find('ToUserName').text,
|
||||
@@ -393,6 +482,8 @@ class OAClientForLongerResponse:
|
||||
self.msg_queue[from_user].append(
|
||||
{
|
||||
'msg_id': message_id,
|
||||
'content': content,
|
||||
'content': str(content)[: self._MAX_CONTENT_CHARS],
|
||||
}
|
||||
)
|
||||
self.msg_queue[from_user] = self.msg_queue[from_user][-self._MAX_MESSAGES_PER_USER :]
|
||||
self._prune_queues()
|
||||
|
||||
@@ -10,7 +10,9 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
@@ -21,6 +23,8 @@ from urllib.parse import quote
|
||||
|
||||
import aiohttp
|
||||
|
||||
from langbot.pkg.utils import httpclient
|
||||
|
||||
from .types import (
|
||||
ApiError,
|
||||
CDNMedia,
|
||||
@@ -58,6 +62,51 @@ DEFAULT_BOT_TYPE = '3'
|
||||
|
||||
# Maximum text length per message chunk (WeChat limit)
|
||||
MAX_TEXT_CHUNK_SIZE = 2000
|
||||
MAX_CDN_MEDIA_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
async def _response_text(response: aiohttp.ClientResponse) -> str:
|
||||
body = await httpclient.read_limited(
|
||||
response,
|
||||
max_bytes=MAX_CDN_MEDIA_BYTES,
|
||||
)
|
||||
return body.decode('utf-8', errors='replace')
|
||||
|
||||
|
||||
async def _response_json(response: aiohttp.ClientResponse) -> dict:
|
||||
payload = json.loads(await _response_text(response))
|
||||
if not isinstance(payload, dict):
|
||||
raise ApiError('OpenClaw API returned a non-object response', status=response.status)
|
||||
return payload
|
||||
|
||||
|
||||
def _decrypt_cdn_payload(encrypted: bytes, aes_key: bytes) -> bytes:
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives.padding import PKCS7
|
||||
|
||||
cipher = Cipher(algorithms.AES(aes_key), modes.ECB())
|
||||
decryptor = cipher.decryptor()
|
||||
padded = decryptor.update(encrypted) + decryptor.finalize()
|
||||
unpadder = PKCS7(128).unpadder()
|
||||
return unpadder.update(padded) + unpadder.finalize()
|
||||
|
||||
|
||||
def _encrypt_cdn_payload(
|
||||
file_bytes: bytes,
|
||||
) -> tuple[str, str, bytes, str]:
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives.padding import PKCS7
|
||||
|
||||
raw_key = os.urandom(16)
|
||||
aes_key_hex = raw_key.hex()
|
||||
encoded_key = base64.b64encode(aes_key_hex.encode('utf-8')).decode('utf-8')
|
||||
padder = PKCS7(128).padder()
|
||||
padded = padder.update(file_bytes) + padder.finalize()
|
||||
cipher = Cipher(algorithms.AES(raw_key), modes.ECB())
|
||||
encryptor = cipher.encryptor()
|
||||
encrypted = encryptor.update(padded) + encryptor.finalize()
|
||||
raw_md5 = hashlib.md5(file_bytes).hexdigest()
|
||||
return aes_key_hex, encoded_key, encrypted, raw_md5
|
||||
|
||||
|
||||
def _random_wechat_uin() -> str:
|
||||
@@ -125,12 +174,12 @@ class OpenClawWeixinClient:
|
||||
url, json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=timeout)
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
text = await _response_text(resp)
|
||||
raise ApiError(
|
||||
f'OpenClaw API error {resp.status}: {text}',
|
||||
status=resp.status,
|
||||
)
|
||||
data = await resp.json(content_type=None)
|
||||
data = await _response_json(resp)
|
||||
|
||||
# Check for application-level errors in the response body
|
||||
errcode = data.get('errcode') or data.get('ret')
|
||||
@@ -170,12 +219,12 @@ class OpenClawWeixinClient:
|
||||
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
text = await _response_text(resp)
|
||||
raise ApiError(
|
||||
f'OpenClaw API error {resp.status}: {text}',
|
||||
status=resp.status,
|
||||
)
|
||||
data = await resp.json(content_type=None)
|
||||
data = await _response_json(resp)
|
||||
|
||||
except (asyncio.TimeoutError, aiohttp.ServerTimeoutError):
|
||||
return GetUpdatesResponse(ret=0, msgs=[], get_updates_buf=get_updates_buf)
|
||||
@@ -258,9 +307,6 @@ class OpenClawWeixinClient:
|
||||
Returns:
|
||||
Decrypted file bytes.
|
||||
"""
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives.padding import PKCS7
|
||||
|
||||
if not media.encrypt_query_param:
|
||||
raise ApiError('CDN media has no encrypt_query_param', status=0)
|
||||
if not media.aes_key:
|
||||
@@ -285,17 +331,14 @@ class OpenClawWeixinClient:
|
||||
|
||||
async with session.get(cdn_url, timeout=aiohttp.ClientTimeout(total=120)) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
text = await _response_text(resp)
|
||||
raise ApiError(f'CDN download failed: {resp.status} {text}', status=resp.status)
|
||||
encrypted = await resp.read()
|
||||
encrypted = await httpclient.read_limited(
|
||||
resp,
|
||||
max_bytes=MAX_CDN_MEDIA_BYTES,
|
||||
)
|
||||
|
||||
# Decrypt AES-128-ECB with PKCS7 padding
|
||||
cipher = Cipher(algorithms.AES(aes_key), modes.ECB())
|
||||
decryptor = cipher.decryptor()
|
||||
padded = decryptor.update(encrypted) + decryptor.finalize()
|
||||
|
||||
unpadder = PKCS7(128).unpadder()
|
||||
return unpadder.update(padded) + unpadder.finalize()
|
||||
return await asyncio.to_thread(_decrypt_cdn_payload, encrypted, aes_key)
|
||||
|
||||
async def upload_media(
|
||||
self,
|
||||
@@ -313,28 +356,13 @@ class OpenClawWeixinClient:
|
||||
Returns:
|
||||
CDNMedia with encrypt_query_param and aes_key for use in sendMessage.
|
||||
"""
|
||||
import hashlib
|
||||
if len(file_bytes) > MAX_CDN_MEDIA_BYTES:
|
||||
raise ApiError('CDN media exceeds the size limit', status=0)
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives.padding import PKCS7
|
||||
|
||||
# 1. Generate random 16-byte AES key
|
||||
raw_key = os.urandom(16)
|
||||
aes_key_hex = raw_key.hex() # 32-char hex string
|
||||
|
||||
# 2. Encode key for CDNMedia: base64(hex_string) — same for all media types
|
||||
# Matches official SDK: Buffer.from(aeskey_hex).toString("base64")
|
||||
encoded_key = base64.b64encode(aes_key_hex.encode('utf-8')).decode('utf-8')
|
||||
|
||||
# 3. Encrypt file with AES-128-ECB + PKCS7
|
||||
padder = PKCS7(128).padder()
|
||||
padded = padder.update(file_bytes) + padder.finalize()
|
||||
cipher = Cipher(algorithms.AES(raw_key), modes.ECB())
|
||||
encryptor = cipher.encryptor()
|
||||
encrypted = encryptor.update(padded) + encryptor.finalize()
|
||||
|
||||
# 4. Get upload URL
|
||||
raw_md5 = hashlib.md5(file_bytes).hexdigest()
|
||||
aes_key_hex, encoded_key, encrypted, raw_md5 = await asyncio.to_thread(
|
||||
_encrypt_cdn_payload,
|
||||
file_bytes,
|
||||
)
|
||||
filekey = os.urandom(16).hex() # 32-char hex, matches official SDK
|
||||
|
||||
upload_resp = await self.get_upload_url(
|
||||
@@ -370,7 +398,7 @@ class OpenClawWeixinClient:
|
||||
timeout=aiohttp.ClientTimeout(total=120),
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
text = await _response_text(resp)
|
||||
logger.error('CDN upload failed: status=%d url=%s body=%s', resp.status, cdn_url, text[:500])
|
||||
raise ApiError(f'CDN upload failed: {resp.status} {text}', status=resp.status)
|
||||
download_param = resp.headers.get('x-encrypted-param', '')
|
||||
@@ -491,12 +519,12 @@ class OpenClawWeixinClient:
|
||||
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=DEFAULT_API_TIMEOUT)) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
text = await _response_text(resp)
|
||||
raise ApiError(
|
||||
f'Failed to fetch QR code: {resp.status} {text}',
|
||||
status=resp.status,
|
||||
)
|
||||
data = await resp.json(content_type=None)
|
||||
data = await _response_json(resp)
|
||||
|
||||
logger.debug(
|
||||
'fetch_qrcode response: qrcode=%s, img=%s', data.get('qrcode'), bool(data.get('qrcode_img_content'))
|
||||
@@ -536,12 +564,12 @@ class OpenClawWeixinClient:
|
||||
url, headers=headers, timeout=aiohttp.ClientTimeout(total=DEFAULT_QR_POLL_TIMEOUT)
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
text = await _response_text(resp)
|
||||
raise ApiError(
|
||||
f'Failed to poll QR status: {resp.status} {text}',
|
||||
status=resp.status,
|
||||
)
|
||||
data = await resp.json(content_type=None)
|
||||
data = await _response_json(resp)
|
||||
logger.debug('QR status poll response: %s', data)
|
||||
except (asyncio.TimeoutError, aiohttp.ServerTimeoutError):
|
||||
return QRStatusResponse(status='wait')
|
||||
|
||||
@@ -9,10 +9,13 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
from .qqofficialevent import QQOfficialEvent
|
||||
import json
|
||||
import traceback
|
||||
from contextlib import asynccontextmanager
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
from langbot.pkg.utils import httpclient
|
||||
|
||||
|
||||
QQ_SELECT_ACTION_PREFIX = '__langbot_select__:'
|
||||
_MAX_CALLBACK_BODY_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
def get_select_field_options(form_data: dict) -> tuple[str, list[str]]:
|
||||
@@ -152,6 +155,7 @@ class QQOfficialClient:
|
||||
def __init__(self, secret: str, token: str, app_id: str, logger: None, unified_mode: bool = False):
|
||||
self.unified_mode = unified_mode
|
||||
self.app = Quart(__name__)
|
||||
self.app.config['MAX_CONTENT_LENGTH'] = _MAX_CALLBACK_BODY_BYTES
|
||||
|
||||
# 只有在非统一模式下才注册独立路由
|
||||
if not self.unified_mode:
|
||||
@@ -176,6 +180,32 @@ class QQOfficialClient:
|
||||
self.logger = logger
|
||||
self._msg_seq_counter = 0
|
||||
self._token_refresh_task: Optional[asyncio.Task] = None
|
||||
self._http_clients: dict[float | None, httpx.AsyncClient] = {}
|
||||
|
||||
@asynccontextmanager
|
||||
async def _http_client_context(self, timeout: float | None = None):
|
||||
client = self._http_clients.get(timeout)
|
||||
if client is None or client.is_closed:
|
||||
response_hooks = httpclient.httpx_response_limit_hooks()
|
||||
client = (
|
||||
httpx.AsyncClient(event_hooks=response_hooks)
|
||||
if timeout is None
|
||||
else httpx.AsyncClient(timeout=timeout, event_hooks=response_hooks)
|
||||
)
|
||||
self._http_clients[timeout] = client
|
||||
yield client
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Stop client-owned background work."""
|
||||
|
||||
if self._token_refresh_task and not self._token_refresh_task.done():
|
||||
self._token_refresh_task.cancel()
|
||||
await asyncio.gather(self._token_refresh_task, return_exceptions=True)
|
||||
self._token_refresh_task = None
|
||||
clients = list(self._http_clients.values())
|
||||
self._http_clients.clear()
|
||||
if clients:
|
||||
await asyncio.gather(*(client.aclose() for client in clients), return_exceptions=True)
|
||||
|
||||
async def check_access_token(self):
|
||||
"""检查access_token是否存在"""
|
||||
@@ -186,7 +216,7 @@ class QQOfficialClient:
|
||||
async def get_access_token(self):
|
||||
"""获取access_token"""
|
||||
url = 'https://bots.qq.com/app/getAppAccessToken'
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
params = {
|
||||
'appId': self.app_id,
|
||||
'clientSecret': self.secret,
|
||||
@@ -196,8 +226,9 @@ class QQOfficialClient:
|
||||
}
|
||||
response = await client.post(url, json=params, headers=headers)
|
||||
if response.status_code != 200:
|
||||
raise Exception(f'Failed to get access_token: HTTP {response.status_code} {response.text}')
|
||||
response_data = response.json()
|
||||
body = await httpclient.response_text(response)
|
||||
raise Exception(f'Failed to get access_token: HTTP {response.status_code} {body}')
|
||||
response_data = await httpclient.parse_json_response(response)
|
||||
access_token = response_data.get('access_token')
|
||||
expires_in = int(response_data.get('expires_in', 7200))
|
||||
self.access_token_expiry_time = time.time() + expires_in - 60
|
||||
@@ -236,8 +267,10 @@ class QQOfficialClient:
|
||||
if not body or len(body) == 0:
|
||||
await self.logger.info('Received empty body, might be health check or GET request')
|
||||
return {'code': 0, 'message': 'ok'}, 200
|
||||
if len(body) > _MAX_CALLBACK_BODY_BYTES:
|
||||
return {'error': 'callback body exceeds the size limit'}, 413
|
||||
|
||||
payload = json.loads(body)
|
||||
payload = await asyncio.to_thread(json.loads, body)
|
||||
|
||||
if payload.get('op') == 13:
|
||||
validation_data = payload.get('d')
|
||||
@@ -367,7 +400,7 @@ class QQOfficialClient:
|
||||
await self.get_access_token()
|
||||
|
||||
url = self.base_url + '/v2/users/' + user_openid + '/messages'
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
headers = {
|
||||
'Authorization': f'QQBot {self.access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
@@ -382,7 +415,7 @@ class QQOfficialClient:
|
||||
if event_id:
|
||||
data['event_id'] = event_id
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
response_data = response.json()
|
||||
response_data = await httpclient.parse_json_response(response)
|
||||
if response.status_code == 200:
|
||||
return
|
||||
else:
|
||||
@@ -406,7 +439,7 @@ class QQOfficialClient:
|
||||
await self.get_access_token()
|
||||
|
||||
url = self.base_url + '/v2/groups/' + group_openid + '/messages'
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
headers = {
|
||||
'Authorization': f'QQBot {self.access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
@@ -424,8 +457,9 @@ class QQOfficialClient:
|
||||
if response.status_code == 200:
|
||||
return
|
||||
else:
|
||||
await self.logger.error(f'Failed to send group message: {response.json()}')
|
||||
raise Exception(response.read().decode())
|
||||
error_payload = await httpclient.parse_json_response(response)
|
||||
await self.logger.error(f'Failed to send group message: {error_payload}')
|
||||
raise Exception(str(error_payload))
|
||||
|
||||
async def send_channle_group_text_msg(self, channel_id: str, content: str, msg_id: str):
|
||||
"""发送频道群聊消息"""
|
||||
@@ -433,7 +467,7 @@ class QQOfficialClient:
|
||||
await self.get_access_token()
|
||||
|
||||
url = self.base_url + '/channels/' + channel_id + '/messages'
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
headers = {
|
||||
'Authorization': f'QQBot {self.access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
@@ -447,7 +481,8 @@ class QQOfficialClient:
|
||||
if response.status_code == 200:
|
||||
return True
|
||||
else:
|
||||
await self.logger.error(f'Failed to send channel group message: {response.json()}')
|
||||
error_payload = await httpclient.parse_json_response(response)
|
||||
await self.logger.error(f'Failed to send channel group message: {error_payload}')
|
||||
raise Exception(response)
|
||||
|
||||
async def send_channle_private_text_msg(self, guild_id: str, content: str, msg_id: str):
|
||||
@@ -456,7 +491,7 @@ class QQOfficialClient:
|
||||
await self.get_access_token()
|
||||
|
||||
url = self.base_url + '/dms/' + guild_id + '/messages'
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
headers = {
|
||||
'Authorization': f'QQBot {self.access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
@@ -470,7 +505,8 @@ class QQOfficialClient:
|
||||
if response.status_code == 200:
|
||||
return True
|
||||
else:
|
||||
await self.logger.error(f'Failed to send channel private message: {response.json()}')
|
||||
error_payload = await httpclient.parse_json_response(response)
|
||||
await self.logger.error(f'Failed to send channel private message: {error_payload}')
|
||||
raise Exception(response)
|
||||
|
||||
# ---- 富媒体消息 ----
|
||||
@@ -532,20 +568,21 @@ class QQOfficialClient:
|
||||
if file_type == self.MEDIA_TYPE_FILE and file_name:
|
||||
body['file_name'] = file_name
|
||||
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
async with self._http_client_context(timeout=120) as client:
|
||||
headers = {
|
||||
'Authorization': f'QQBot {self.access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
response = await client.post(url, headers=headers, json=body)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
data = await httpclient.parse_json_response(response)
|
||||
file_info = data.get('file_info', '')
|
||||
preview = file_info[:80] + '...' if len(file_info) > 80 else file_info
|
||||
await self.logger.info(f'Upload media success, file_info={preview}')
|
||||
return file_info
|
||||
else:
|
||||
raise Exception(f'Failed to upload media: HTTP {response.status_code} {response.text}')
|
||||
body = await httpclient.response_text(response)
|
||||
raise Exception(f'Failed to upload media: HTTP {response.status_code} {body}')
|
||||
|
||||
async def _send_media_msg(
|
||||
self,
|
||||
@@ -578,7 +615,7 @@ class QQOfficialClient:
|
||||
if msg_id:
|
||||
body['msg_id'] = msg_id
|
||||
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
async with self._http_client_context(timeout=120) as client:
|
||||
headers = {
|
||||
'Authorization': f'QQBot {self.access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
@@ -586,7 +623,8 @@ class QQOfficialClient:
|
||||
await self.logger.info(f'Sending rich media: {json.dumps(body, ensure_ascii=False)[:200]}')
|
||||
response = await client.post(url, headers=headers, json=body)
|
||||
if response.status_code != 200:
|
||||
raise Exception(f'Failed to send rich media message: HTTP {response.status_code} {response.text}')
|
||||
response_body = await httpclient.response_text(response)
|
||||
raise Exception(f'Failed to send rich media message: HTTP {response.status_code} {response_body}')
|
||||
|
||||
async def send_image_msg(
|
||||
self,
|
||||
@@ -678,15 +716,16 @@ class QQOfficialClient:
|
||||
if stream_msg_id:
|
||||
body['stream_msg_id'] = stream_msg_id
|
||||
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
async with self._http_client_context(timeout=120) as client:
|
||||
headers = {
|
||||
'Authorization': f'QQBot {self.access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
response = await client.post(url, headers=headers, json=body)
|
||||
if response.status_code != 200:
|
||||
raise Exception(f'Failed to send stream message: HTTP {response.status_code} {response.text}')
|
||||
return response.json()
|
||||
response_body = await httpclient.response_text(response)
|
||||
raise Exception(f'Failed to send stream message: HTTP {response.status_code} {response_body}')
|
||||
return await httpclient.parse_json_response(response)
|
||||
|
||||
async def send_markdown_keyboard(
|
||||
self,
|
||||
@@ -743,18 +782,19 @@ class QQOfficialClient:
|
||||
if event_id:
|
||||
body['event_id'] = event_id
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
async with self._http_client_context(timeout=30) as client:
|
||||
headers = {
|
||||
'Authorization': f'QQBot {self.access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
response = await client.post(url, headers=headers, json=body)
|
||||
if response.status_code != 200:
|
||||
response_body = await httpclient.response_text(response)
|
||||
await self.logger.error(
|
||||
f'Failed to send markdown+keyboard: HTTP {response.status_code} {response.text}'
|
||||
f'Failed to send markdown+keyboard: HTTP {response.status_code} {response_body}'
|
||||
)
|
||||
raise Exception(f'Failed to send markdown+keyboard: HTTP {response.status_code} {response.text}')
|
||||
return response.json()
|
||||
raise Exception(f'Failed to send markdown+keyboard: HTTP {response.status_code} {response_body}')
|
||||
return await httpclient.parse_json_response(response)
|
||||
|
||||
async def ack_interaction(self, interaction_id: str, code: int = 0) -> None:
|
||||
"""Acknowledge a button-click INTERACTION_CREATE event.
|
||||
@@ -775,7 +815,7 @@ class QQOfficialClient:
|
||||
await self.get_access_token()
|
||||
|
||||
url = f'{self.base_url}/interactions/{interaction_id}'
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with self._http_client_context(timeout=10) as client:
|
||||
headers = {
|
||||
'Authorization': f'QQBot {self.access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
@@ -783,8 +823,9 @@ class QQOfficialClient:
|
||||
try:
|
||||
response = await client.put(url, headers=headers, json={'code': code})
|
||||
if response.status_code >= 400:
|
||||
response_body = await httpclient.response_text(response)
|
||||
await self.logger.warning(
|
||||
f'ack_interaction non-success: HTTP {response.status_code} {response.text}'
|
||||
f'ack_interaction non-success: HTTP {response.status_code} {response_body}'
|
||||
)
|
||||
except Exception as e:
|
||||
await self.logger.warning(f'ack_interaction error (non-fatal): {e}')
|
||||
@@ -796,10 +837,11 @@ class QQOfficialClient:
|
||||
return time.time() > self.access_token_expiry_time
|
||||
|
||||
async def repeat_seed(self, bot_secret: str, target_size: int = 32) -> bytes:
|
||||
seed = bot_secret
|
||||
while len(seed) < target_size:
|
||||
seed *= 2
|
||||
return seed[:target_size].encode('utf-8')
|
||||
if not bot_secret:
|
||||
raise ValueError('QQ bot secret must not be empty')
|
||||
target_size = max(int(target_size), 1)
|
||||
repeats = (target_size + len(bot_secret) - 1) // len(bot_secret)
|
||||
return (bot_secret * repeats)[:target_size].encode('utf-8')
|
||||
|
||||
async def verify(self, validation_payload: dict):
|
||||
seed = await self.repeat_seed(self.secret)
|
||||
@@ -843,19 +885,20 @@ class QQOfficialClient:
|
||||
await self.get_access_token()
|
||||
|
||||
url = f'{self.base_url}/gateway'
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
headers = {
|
||||
'Authorization': f'QQBot {self.access_token}',
|
||||
}
|
||||
response = await client.get(url, headers=headers)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
data = await httpclient.parse_json_response(response)
|
||||
ws_url = data.get('url', '')
|
||||
if not ws_url:
|
||||
raise Exception('Gateway URL is empty')
|
||||
return ws_url
|
||||
else:
|
||||
raise Exception(f'Failed to get Gateway URL: HTTP {response.status_code} {response.text}')
|
||||
body = await httpclient.response_text(response)
|
||||
raise Exception(f'Failed to get Gateway URL: HTTP {response.status_code} {body}')
|
||||
|
||||
async def _background_token_refresh(self):
|
||||
"""在 token 到期前主动刷新"""
|
||||
@@ -935,7 +978,7 @@ class QQOfficialClient:
|
||||
|
||||
try:
|
||||
await self.logger.info('Connecting to WebSocket gateway...')
|
||||
ws = await websockets.connect(ws_url)
|
||||
ws = await websockets.connect(ws_url, max_size=_MAX_CALLBACK_BODY_BYTES)
|
||||
await self.logger.info('WebSocket connected')
|
||||
except Exception as e:
|
||||
await self.logger.error(f'WebSocket connection failed: {e}')
|
||||
@@ -948,7 +991,7 @@ class QQOfficialClient:
|
||||
try:
|
||||
async for raw_msg in ws:
|
||||
try:
|
||||
payload = json.loads(raw_msg)
|
||||
payload = await asyncio.to_thread(json.loads, raw_msg)
|
||||
except json.JSONDecodeError:
|
||||
await self.logger.error(f'Failed to parse message: {raw_msg}')
|
||||
continue
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import json
|
||||
import traceback
|
||||
from quart import Quart, jsonify, request
|
||||
@@ -6,6 +7,8 @@ from .slackevent import SlackEvent
|
||||
from typing import Callable
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
|
||||
_MAX_CALLBACK_BODY_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
class SlackClient:
|
||||
def __init__(self, bot_token: str, signing_secret: str, logger: None, unified_mode: bool = False):
|
||||
@@ -13,6 +16,7 @@ class SlackClient:
|
||||
self.signing_secret = signing_secret
|
||||
self.unified_mode = unified_mode
|
||||
self.app = Quart(__name__)
|
||||
self.app.config['MAX_CONTENT_LENGTH'] = _MAX_CALLBACK_BODY_BYTES
|
||||
self.client = AsyncWebClient(self.bot_token)
|
||||
|
||||
# 只有在非统一模式下才注册独立路由
|
||||
@@ -50,7 +54,9 @@ class SlackClient:
|
||||
"""
|
||||
try:
|
||||
body = await req.get_data()
|
||||
data = json.loads(body)
|
||||
if len(body) > _MAX_CALLBACK_BODY_BYTES:
|
||||
raise ValueError('Slack callback body exceeds the size limit')
|
||||
data = await asyncio.to_thread(json.loads, body)
|
||||
if 'type' in data:
|
||||
if data['type'] == 'url_verification':
|
||||
return data['challenge']
|
||||
|
||||
@@ -1,7 +1,32 @@
|
||||
from langbot.libs.wechatpad_api.util.http_util import post_json
|
||||
import httpx
|
||||
import asyncio
|
||||
import base64
|
||||
|
||||
import httpx
|
||||
|
||||
from langbot.libs.wechatpad_api.util.http_util import post_json
|
||||
from langbot.pkg.utils import httpclient
|
||||
|
||||
|
||||
_MAX_WECHATPAD_MEDIA_BYTES = 16 * 1024 * 1024
|
||||
|
||||
|
||||
async def _read_media_limited(response: httpx.Response) -> bytes:
|
||||
content_length = response.headers.get('content-length')
|
||||
if content_length is not None:
|
||||
try:
|
||||
declared_size = int(content_length)
|
||||
except ValueError:
|
||||
declared_size = None
|
||||
if declared_size is not None and declared_size > _MAX_WECHATPAD_MEDIA_BYTES:
|
||||
raise RuntimeError('WeChatPad media exceeds the runtime limit')
|
||||
|
||||
body = bytearray()
|
||||
async for chunk in response.aiter_bytes(chunk_size=64 * 1024):
|
||||
body.extend(chunk)
|
||||
if len(body) > _MAX_WECHATPAD_MEDIA_BYTES:
|
||||
raise RuntimeError('WeChatPad media exceeds the runtime limit')
|
||||
return bytes(body)
|
||||
|
||||
|
||||
class DownloadApi:
|
||||
def __init__(self, base_url, token):
|
||||
@@ -19,12 +44,13 @@ class DownloadApi:
|
||||
return post_json(url, token=self.token, data=json_data)
|
||||
|
||||
async def download_url_to_base64(self, download_url):
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(download_url)
|
||||
|
||||
if response.status_code == 200:
|
||||
file_bytes = response.content
|
||||
base64_str = base64.b64encode(file_bytes).decode('utf-8') # 返回字符串格式
|
||||
return base64_str
|
||||
else:
|
||||
raise Exception('获取文件失败')
|
||||
async with httpx.AsyncClient(
|
||||
timeout=30,
|
||||
event_hooks=httpclient.httpx_response_limit_hooks(_MAX_WECHATPAD_MEDIA_BYTES),
|
||||
) as client:
|
||||
async with client.stream('GET', download_url) as response:
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError('获取文件失败')
|
||||
file_bytes = await _read_media_limited(response)
|
||||
encoded = await asyncio.to_thread(base64.b64encode, file_bytes)
|
||||
return encoded.decode('utf-8')
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
import json as json_module
|
||||
|
||||
import requests
|
||||
from langbot.pkg.utils import httpclient
|
||||
|
||||
_MAX_WECHATPAD_RESPONSE_BYTES = 16 * 1024 * 1024
|
||||
|
||||
|
||||
def _read_requests_response_limited(response: requests.Response) -> dict:
|
||||
content_length = response.headers.get('Content-Length')
|
||||
if content_length is not None:
|
||||
try:
|
||||
if int(content_length) > _MAX_WECHATPAD_RESPONSE_BYTES:
|
||||
raise RuntimeError('WeChatPad response exceeds the runtime limit')
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
body = bytearray()
|
||||
for chunk in response.iter_content(chunk_size=64 * 1024):
|
||||
body.extend(chunk)
|
||||
if len(body) > _MAX_WECHATPAD_RESPONSE_BYTES:
|
||||
raise RuntimeError('WeChatPad response exceeds the runtime limit')
|
||||
result = json_module.loads(body)
|
||||
if not isinstance(result, dict):
|
||||
raise RuntimeError('WeChatPad returned a non-object response')
|
||||
return result
|
||||
|
||||
|
||||
def post_json(base_url, token, data=None):
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
@@ -8,16 +31,21 @@ def post_json(base_url, token, data=None):
|
||||
url = base_url + f'?key={token}'
|
||||
|
||||
try:
|
||||
response = requests.post(url, json=data, headers=headers, timeout=60)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
with requests.post(
|
||||
url,
|
||||
json=data,
|
||||
headers=headers,
|
||||
timeout=60,
|
||||
stream=True,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
result = _read_requests_response_limited(response)
|
||||
|
||||
if result:
|
||||
return result
|
||||
else:
|
||||
raise RuntimeError(response.text)
|
||||
raise RuntimeError('WeChatPad returned an empty response')
|
||||
except Exception as e:
|
||||
print(f'http请求失败, url={url}, exception={e}')
|
||||
raise RuntimeError(str(e))
|
||||
|
||||
|
||||
@@ -27,16 +55,20 @@ def get_json(base_url, token):
|
||||
url = base_url + f'?key={token}'
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=60)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
with requests.get(
|
||||
url,
|
||||
headers=headers,
|
||||
timeout=60,
|
||||
stream=True,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
result = _read_requests_response_limited(response)
|
||||
|
||||
if result:
|
||||
return result
|
||||
else:
|
||||
raise RuntimeError(response.text)
|
||||
raise RuntimeError('WeChatPad returned an empty response')
|
||||
except Exception as e:
|
||||
print(f'http请求失败, url={url}, exception={e}')
|
||||
raise RuntimeError(str(e))
|
||||
|
||||
|
||||
@@ -68,7 +100,12 @@ async def async_request(
|
||||
method=method, url=url, params=params, headers=headers, data=data, json=json
|
||||
) as response:
|
||||
response.raise_for_status() # 如果状态码不是200,抛出异常
|
||||
result = await response.json()
|
||||
result = json_module.loads(
|
||||
await httpclient.read_limited(
|
||||
response,
|
||||
max_bytes=_MAX_WECHATPAD_RESPONSE_BYTES,
|
||||
)
|
||||
)
|
||||
# print(result)
|
||||
return result
|
||||
# if result.get('Code') == 200:
|
||||
|
||||
@@ -10,13 +10,16 @@ import re
|
||||
from typing import Any, Callable, Optional, Tuple
|
||||
from urllib.parse import unquote
|
||||
|
||||
import httpx
|
||||
from Crypto.Cipher import AES
|
||||
from quart import Quart, request, Response, jsonify
|
||||
|
||||
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
||||
from langbot.libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt
|
||||
from langbot.pkg.platform.logger import EventLogger
|
||||
from langbot.pkg.utils import httpclient
|
||||
|
||||
_CLIENT_TRANSIENT_CACHE_MAX = 4096
|
||||
_MAX_STREAM_CONTENT_CHARS = 200000
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -56,7 +59,7 @@ class StreamSession:
|
||||
last_access: float = field(default_factory=time.time)
|
||||
|
||||
# 将流水线增量结果缓存到队列,刷新请求逐条消费
|
||||
queue: asyncio.Queue = field(default_factory=asyncio.Queue)
|
||||
queue: asyncio.Queue = field(default_factory=lambda: asyncio.Queue(maxsize=1))
|
||||
|
||||
# 是否已经完成(收到最终片段)
|
||||
finished: bool = False
|
||||
@@ -85,6 +88,7 @@ class StreamSessionManager:
|
||||
# full like → cancel → dislike feedback flow. Must align with the adapter's
|
||||
# _stream_to_monitoring_msg TTL (wecombot.py).
|
||||
_FEEDBACK_SESSION_TTL = 600 # 10 minutes
|
||||
_MAX_SESSIONS = 4096
|
||||
|
||||
def __init__(self, logger: EventLogger, ttl: int = 60) -> None:
|
||||
self.logger = logger
|
||||
@@ -165,6 +169,26 @@ class StreamSessionManager:
|
||||
if task_id:
|
||||
self._task_index.pop(task_id, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Release every retained stream and reverse index."""
|
||||
|
||||
self._sessions.clear()
|
||||
self._msg_index.clear()
|
||||
self._feedback_index.clear()
|
||||
self._task_index.clear()
|
||||
|
||||
def _drop_session(self, stream_id: str) -> StreamSession | None:
|
||||
session = self._sessions.pop(stream_id, None)
|
||||
if session is None:
|
||||
return None
|
||||
if session.msg_id and self._msg_index.get(session.msg_id) == stream_id:
|
||||
self._msg_index.pop(session.msg_id, None)
|
||||
if session.feedback_id:
|
||||
self._feedback_index.pop(session.feedback_id, None)
|
||||
if session.pending_form_task_id:
|
||||
self._task_index.pop(session.pending_form_task_id, None)
|
||||
return session
|
||||
|
||||
def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]:
|
||||
"""根据企业微信回调创建或获取会话。
|
||||
|
||||
@@ -185,6 +209,14 @@ class StreamSessionManager:
|
||||
session.last_access = time.time()
|
||||
return session, False
|
||||
|
||||
self.cleanup()
|
||||
while len(self._sessions) >= self._MAX_SESSIONS:
|
||||
oldest_stream_id = min(
|
||||
self._sessions,
|
||||
key=lambda candidate: self._sessions[candidate].last_access,
|
||||
)
|
||||
self._drop_session(oldest_stream_id)
|
||||
|
||||
stream_id = str(uuid.uuid4())
|
||||
session = StreamSession(
|
||||
stream_id=stream_id,
|
||||
@@ -221,8 +253,13 @@ class StreamSessionManager:
|
||||
try:
|
||||
session.queue.put_nowait(chunk)
|
||||
except asyncio.QueueFull:
|
||||
# 默认无界队列,此处兜底防御
|
||||
await session.queue.put(chunk)
|
||||
# Each chunk is a complete snapshot. Coalesce a slow consumer to
|
||||
# the newest value instead of retaining every intermediate body.
|
||||
try:
|
||||
session.queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
session.queue.put_nowait(chunk)
|
||||
|
||||
if chunk.is_final:
|
||||
session.finished = True
|
||||
@@ -265,7 +302,7 @@ class StreamSessionManager:
|
||||
session.finished = True
|
||||
session.last_access = time.time()
|
||||
|
||||
def cleanup(self) -> None:
|
||||
def cleanup(self) -> list[str]:
|
||||
"""定期清理过期会话,防止队列与映射无上限累积。
|
||||
|
||||
已注册 feedback_id 的会话使用更长的 TTL,确保用户在点赞/取消/点踩流程中
|
||||
@@ -279,16 +316,14 @@ class StreamSessionManager:
|
||||
if now - session.last_access > effective_ttl:
|
||||
expired.append(stream_id)
|
||||
|
||||
removed_msg_ids: list[str] = []
|
||||
for stream_id in expired:
|
||||
session = self._sessions.pop(stream_id, None)
|
||||
session = self._drop_session(stream_id)
|
||||
if not session:
|
||||
continue
|
||||
msg_id = session.msg_id
|
||||
if msg_id and self._msg_index.get(msg_id) == stream_id:
|
||||
self._msg_index.pop(msg_id, None)
|
||||
# Clean up feedback index for expired sessions
|
||||
if session.feedback_id:
|
||||
self._feedback_index.pop(session.feedback_id, None)
|
||||
if session.msg_id:
|
||||
removed_msg_ids.append(session.msg_id)
|
||||
return removed_msg_ids
|
||||
|
||||
|
||||
def _decrypt_file(encrypted_data: bytes, aes_key_str: str) -> bytes:
|
||||
@@ -405,19 +440,19 @@ async def download_encrypted_file(
|
||||
|
||||
filename: Optional[str] = None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(download_url)
|
||||
if response.status_code != 200:
|
||||
await logger.error(f'Failed to download file (HTTP {response.status_code}): {response.text[:200]}')
|
||||
client = httpclient.get_session()
|
||||
async with client.get(download_url, timeout=30.0) as response:
|
||||
if response.status != 200:
|
||||
await logger.error(f'Failed to download file (HTTP {response.status})')
|
||||
return None, None
|
||||
encrypted_bytes = response.content
|
||||
encrypted_bytes = await httpclient.read_limited(response)
|
||||
filename = _extract_filename(response.headers.get('content-disposition', ''))
|
||||
except Exception:
|
||||
await logger.error(f'Failed to download file: {traceback.format_exc()}')
|
||||
return None, None
|
||||
|
||||
try:
|
||||
decrypted = _decrypt_file(encrypted_bytes, aes_key)
|
||||
decrypted = await asyncio.to_thread(_decrypt_file, encrypted_bytes, aes_key)
|
||||
return decrypted, filename
|
||||
except Exception:
|
||||
await logger.error(f'Failed to decrypt file: {traceback.format_exc()}')
|
||||
@@ -466,7 +501,7 @@ async def parse_wecom_bot_message(
|
||||
"""Download, decrypt, and convert to data URI for backward compatibility."""
|
||||
data, _filename = await _safe_download(url, per_msg_aeskey)
|
||||
if data:
|
||||
return _bytes_to_data_uri(data)
|
||||
return await asyncio.to_thread(_bytes_to_data_uri, data)
|
||||
return None
|
||||
|
||||
if msg_type == 'text':
|
||||
@@ -579,7 +614,10 @@ async def parse_wecom_bot_message(
|
||||
if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
||||
file_bytes, dl_filename = await _safe_download(download_url, item_aeskey)
|
||||
if file_bytes:
|
||||
file_data['base64'] = _bytes_to_data_uri(file_bytes)
|
||||
file_data['base64'] = await asyncio.to_thread(
|
||||
_bytes_to_data_uri,
|
||||
file_bytes,
|
||||
)
|
||||
if dl_filename and not file_data.get('filename'):
|
||||
file_data['filename'] = dl_filename
|
||||
files.append(file_data)
|
||||
@@ -1567,6 +1605,8 @@ def build_multiple_interaction_update_card(
|
||||
|
||||
|
||||
class WecomBotClient:
|
||||
_MAX_DISPATCH_TASKS = 100
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
Token: str,
|
||||
@@ -1613,6 +1653,7 @@ class WecomBotClient:
|
||||
self._feedback_callback: Optional[Callable] = None
|
||||
self._card_action_callback: Optional[Callable] = None
|
||||
self._stream_last_content: dict[str, str] = {}
|
||||
self._dispatch_tasks: set[asyncio.Task] = set()
|
||||
# Optional `source` block injected into every interactive template_card
|
||||
# the client builds. Set via `set_card_source` from the adapter after
|
||||
# reading config. Format: {icon_url, desc, desc_color}.
|
||||
@@ -1695,7 +1736,12 @@ class WecomBotClient:
|
||||
"""
|
||||
reply_plain_str = json.dumps(payload, ensure_ascii=False)
|
||||
reply_timestamp = str(int(time.time()))
|
||||
ret, encrypt_text = self.wxcpt.EncryptMsg(reply_plain_str, nonce, reply_timestamp)
|
||||
ret, encrypt_text = await asyncio.to_thread(
|
||||
self.wxcpt.EncryptMsg,
|
||||
reply_plain_str,
|
||||
nonce,
|
||||
reply_timestamp,
|
||||
)
|
||||
if ret != 0:
|
||||
await self.logger.error(f'加密失败: {ret}')
|
||||
return jsonify({'error': 'encrypt_failed'}), 500
|
||||
@@ -1718,6 +1764,41 @@ class WecomBotClient:
|
||||
except Exception:
|
||||
await self.logger.error(traceback.format_exc())
|
||||
|
||||
def _start_dispatch_task(self, event: wecombotevent.WecomBotEvent) -> bool:
|
||||
"""Start one bounded pipeline dispatch task."""
|
||||
|
||||
for task in tuple(self._dispatch_tasks):
|
||||
if task.done():
|
||||
self._dispatch_tasks.discard(task)
|
||||
if len(self._dispatch_tasks) >= self._MAX_DISPATCH_TASKS:
|
||||
return False
|
||||
|
||||
task = asyncio.create_task(self._dispatch_event(event))
|
||||
self._dispatch_tasks.add(task)
|
||||
|
||||
def done(done_task: asyncio.Task) -> None:
|
||||
self._dispatch_tasks.discard(done_task)
|
||||
if not done_task.cancelled():
|
||||
done_task.exception()
|
||||
|
||||
task.add_done_callback(done)
|
||||
return True
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Cancel callbacks and release retained webhook state."""
|
||||
|
||||
dispatch_tasks = list(self._dispatch_tasks)
|
||||
for task in dispatch_tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
if dispatch_tasks:
|
||||
await asyncio.gather(*dispatch_tasks, return_exceptions=True)
|
||||
self._dispatch_tasks.clear()
|
||||
self.generated_content.clear()
|
||||
self.msg_id_map.clear()
|
||||
self._stream_last_content.clear()
|
||||
self.stream_sessions.clear()
|
||||
|
||||
async def _handle_post_initial_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||
"""处理企业微信首次推送的消息,返回 stream_id 并开启流水线。
|
||||
|
||||
@@ -1747,7 +1828,8 @@ class WecomBotClient:
|
||||
await self.logger.error(traceback.format_exc())
|
||||
else:
|
||||
if is_new:
|
||||
asyncio.create_task(self._dispatch_event(event))
|
||||
if not self._start_dispatch_task(event):
|
||||
await self.logger.warning('WeCom webhook dispatch capacity reached; dropping message')
|
||||
|
||||
payload = self._build_stream_payload(session.stream_id, '', False, feedback_id)
|
||||
return await self._encrypt_and_reply(payload, nonce)
|
||||
@@ -1870,7 +1952,10 @@ class WecomBotClient:
|
||||
async def _handle_post_callback(self, req) -> tuple[Response, int] | Response:
|
||||
"""处理企业微信的 POST 回调请求。"""
|
||||
|
||||
self.stream_sessions.cleanup()
|
||||
for expired_msg_id in self.stream_sessions.cleanup():
|
||||
self.generated_content.pop(expired_msg_id, None)
|
||||
self._stream_last_content.pop(expired_msg_id, None)
|
||||
self.msg_id_map.pop(expired_msg_id, None)
|
||||
|
||||
msg_signature = unquote(req.args.get('msg_signature', ''))
|
||||
timestamp = unquote(req.args.get('timestamp', ''))
|
||||
@@ -1883,12 +1968,18 @@ class WecomBotClient:
|
||||
return Response('Bad Request', status=400)
|
||||
|
||||
xml_post_data = f'<xml><Encrypt><![CDATA[{encrypted_msg}]]></Encrypt></xml>'
|
||||
ret, decrypted_xml = self.wxcpt.DecryptMsg(xml_post_data, msg_signature, timestamp, nonce)
|
||||
ret, decrypted_xml = await asyncio.to_thread(
|
||||
self.wxcpt.DecryptMsg,
|
||||
xml_post_data,
|
||||
msg_signature,
|
||||
timestamp,
|
||||
nonce,
|
||||
)
|
||||
if ret != 0:
|
||||
await self.logger.error('解密失败')
|
||||
return Response('解密失败', status=400)
|
||||
|
||||
msg_json = json.loads(decrypted_xml)
|
||||
msg_json = await asyncio.to_thread(json.loads, decrypted_xml)
|
||||
|
||||
event_type = extract_wecom_event_type(msg_json)
|
||||
|
||||
@@ -2014,6 +2105,8 @@ class WecomBotClient:
|
||||
self.msg_id_map[message_id] += 1
|
||||
return
|
||||
self.msg_id_map[message_id] = 1
|
||||
while len(self.msg_id_map) > _CLIENT_TRANSIENT_CACHE_MAX:
|
||||
self.msg_id_map.pop(next(iter(self.msg_id_map)), None)
|
||||
msg_type = event.type
|
||||
if msg_type in self._message_handlers:
|
||||
for handler in self._message_handlers[msg_type]:
|
||||
@@ -2047,6 +2140,8 @@ class WecomBotClient:
|
||||
next_content = previous_content
|
||||
else:
|
||||
next_content = previous_content + content if previous_content else content
|
||||
if len(next_content) > _MAX_STREAM_CONTENT_CHARS:
|
||||
next_content = next_content[-_MAX_STREAM_CONTENT_CHARS:]
|
||||
|
||||
if not is_final and next_content == previous_content:
|
||||
return True
|
||||
@@ -2096,7 +2191,9 @@ class WecomBotClient:
|
||||
"""
|
||||
handled = await self.push_stream_chunk(msg_id, content, is_final=True)
|
||||
if not handled:
|
||||
self.generated_content[msg_id] = content
|
||||
self.generated_content[msg_id] = content[-_MAX_STREAM_CONTENT_CHARS:]
|
||||
while len(self.generated_content) > _CLIENT_TRANSIENT_CACHE_MAX:
|
||||
self.generated_content.pop(next(iter(self.generated_content)), None)
|
||||
|
||||
def on_message(self, msg_type: str):
|
||||
def decorator(func: Callable[[wecombotevent.WecomBotEvent], None]):
|
||||
@@ -2119,7 +2216,7 @@ class WecomBotClient:
|
||||
async def download_url_to_base64(self, download_url, encoding_aes_key):
|
||||
data, _filename = await download_encrypted_file(download_url, encoding_aes_key, self.logger)
|
||||
if data:
|
||||
return _bytes_to_data_uri(data)
|
||||
return await asyncio.to_thread(_bytes_to_data_uri, data)
|
||||
return None
|
||||
|
||||
async def run_task(self, host: str, port: int, *args, **kwargs):
|
||||
|
||||
@@ -47,6 +47,17 @@ CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg'
|
||||
CMD_RESPOND_UPDATE = 'aibot_respond_update_msg'
|
||||
CMD_SEND_MSG = 'aibot_send_msg'
|
||||
|
||||
_DEDUP_CACHE_MAX = 4096
|
||||
_STREAM_CACHE_MAX = 1024
|
||||
_FEEDBACK_CACHE_MAX = 4096
|
||||
_PENDING_FORM_MAX = 1024
|
||||
_PENDING_FORM_TTL_SECONDS = 1800
|
||||
_MAX_STREAM_CONTENT_CHARS = 200000
|
||||
_MAX_CALLBACK_TASKS = 100
|
||||
_MAX_REPLY_WORKERS = 100
|
||||
_MAX_REPLY_QUEUE_SIZE = 100
|
||||
_MAX_PENDING_ACKS = 256
|
||||
|
||||
|
||||
def _generate_req_id(prefix: str) -> str:
|
||||
"""Generate a unique request ID in the format: {prefix}_{timestamp}_{random}."""
|
||||
@@ -106,6 +117,7 @@ class WecomBotWsClient:
|
||||
# Per-req_id serial reply queues
|
||||
self._reply_queues: dict[str, asyncio.Queue] = {}
|
||||
self._reply_workers: dict[str, asyncio.Task] = {}
|
||||
self._callback_tasks: set[asyncio.Task] = set()
|
||||
self._reply_ack_timeout = 5.0
|
||||
|
||||
# Stream ID tracking for WebSocket mode
|
||||
@@ -135,6 +147,31 @@ class WecomBotWsClient:
|
||||
# `set_card_source` from the adapter after reading config.
|
||||
self.card_source: Optional[dict] = None
|
||||
|
||||
@staticmethod
|
||||
def _cap_mapping(mapping: dict, max_entries: int) -> None:
|
||||
while len(mapping) > max_entries:
|
||||
mapping.pop(next(iter(mapping)), None)
|
||||
|
||||
def _prune_stream_state(self) -> None:
|
||||
while len(self._stream_sessions) > _STREAM_CACHE_MAX:
|
||||
msg_id = next(iter(self._stream_sessions))
|
||||
self._stream_sessions.pop(msg_id, None)
|
||||
self._stream_ids.pop(msg_id, None)
|
||||
self._stream_last_content.pop(msg_id, None)
|
||||
task_id = self._task_id_by_msg.pop(msg_id, None)
|
||||
if task_id:
|
||||
self._pending_forms_by_task.pop(task_id, None)
|
||||
|
||||
def _prune_pending_forms(self) -> None:
|
||||
cutoff = time.monotonic() - _PENDING_FORM_TTL_SECONDS
|
||||
for task_id, pending in tuple(self._pending_forms_by_task.items()):
|
||||
if float(pending.get('created_at', 0.0)) <= cutoff:
|
||||
self._drop_pending_form_task(task_id, pending)
|
||||
while len(self._pending_forms_by_task) > _PENDING_FORM_MAX:
|
||||
task_id = next(iter(self._pending_forms_by_task))
|
||||
pending = self._pending_forms_by_task.get(task_id, {})
|
||||
self._drop_pending_form_task(task_id, pending)
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────
|
||||
|
||||
async def connect(self):
|
||||
@@ -173,17 +210,40 @@ class WecomBotWsClient:
|
||||
async def disconnect(self):
|
||||
"""Gracefully disconnect from the WebSocket server."""
|
||||
self._running = False
|
||||
heartbeat_tasks = []
|
||||
if self._heartbeat_task and not self._heartbeat_task.done():
|
||||
self._heartbeat_task.cancel()
|
||||
for task in self._reply_workers.values():
|
||||
heartbeat_tasks.append(self._heartbeat_task)
|
||||
reply_workers = list(self._reply_workers.values())
|
||||
for task in reply_workers:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
callback_tasks = list(self._callback_tasks)
|
||||
for task in callback_tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
shutdown_tasks = [*heartbeat_tasks, *reply_workers, *callback_tasks]
|
||||
if shutdown_tasks:
|
||||
await asyncio.gather(*shutdown_tasks, return_exceptions=True)
|
||||
self._clear_pending_acks('Connection closed')
|
||||
if self._ws and not self._ws.closed:
|
||||
await self._ws.close()
|
||||
self._ws = None
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
self._heartbeat_task = None
|
||||
self._reply_queues.clear()
|
||||
self._reply_workers.clear()
|
||||
self._callback_tasks.clear()
|
||||
self._stream_ids.clear()
|
||||
self._stream_last_content.clear()
|
||||
self._stream_sessions.clear()
|
||||
self._feedback_sessions.clear()
|
||||
self._msg_feedback_ids.clear()
|
||||
self._pending_forms_by_task.clear()
|
||||
self._task_id_by_msg.clear()
|
||||
self._msg_id_map.clear()
|
||||
|
||||
def on_message(self, msg_type: str) -> Callable:
|
||||
"""Decorator to register a message handler.
|
||||
@@ -366,8 +426,10 @@ class WecomBotWsClient:
|
||||
'chat_id': session_info.get('chat_id', ''),
|
||||
'stream_id': stream_id,
|
||||
'req_id': req_id,
|
||||
'created_at': time.monotonic(),
|
||||
}
|
||||
self._task_id_by_msg[msg_id] = task_id
|
||||
self._prune_pending_forms()
|
||||
|
||||
card_payload = build_human_input_template_card_payload(
|
||||
form_data,
|
||||
@@ -458,6 +520,8 @@ class WecomBotWsClient:
|
||||
next_content = previous_content
|
||||
else:
|
||||
next_content = previous_content + content if previous_content else content
|
||||
if len(next_content) > _MAX_STREAM_CONTENT_CHARS:
|
||||
next_content = next_content[-_MAX_STREAM_CONTENT_CHARS:]
|
||||
|
||||
# Skip sending if content hasn't changed (e.g. during tool call argument streaming)
|
||||
if not is_final and next_content == previous_content:
|
||||
@@ -485,6 +549,8 @@ class WecomBotWsClient:
|
||||
session_info = self._stream_sessions.get(msg_id)
|
||||
if session_info:
|
||||
self._feedback_sessions[feedback_id] = session_info
|
||||
self._cap_mapping(self._feedback_sessions, _FEEDBACK_CACHE_MAX)
|
||||
self._cap_mapping(self._msg_feedback_ids, _FEEDBACK_CACHE_MAX)
|
||||
|
||||
# WeCom replaces the displayed stream content on each refresh, so
|
||||
# every frame must contain the complete snapshot, not only a delta.
|
||||
@@ -516,7 +582,7 @@ class WecomBotWsClient:
|
||||
|
||||
self._session = aiohttp.ClientSession()
|
||||
try:
|
||||
self._ws = await self._session.ws_connect(self.ws_url)
|
||||
self._ws = await self._session.ws_connect(self.ws_url, max_msg_size=1024 * 1024)
|
||||
self._missed_pong_count = 0
|
||||
self._reconnect_attempts = 0
|
||||
await self.logger.info('WebSocket connected, sending auth...')
|
||||
@@ -539,6 +605,8 @@ class WecomBotWsClient:
|
||||
finally:
|
||||
if self._heartbeat_task and not self._heartbeat_task.done():
|
||||
self._heartbeat_task.cancel()
|
||||
await asyncio.gather(self._heartbeat_task, return_exceptions=True)
|
||||
self._heartbeat_task = None
|
||||
self._clear_pending_acks('Connection closed')
|
||||
finally:
|
||||
if self._ws and not self._ws.closed:
|
||||
@@ -565,7 +633,7 @@ class WecomBotWsClient:
|
||||
try:
|
||||
msg = await asyncio.wait_for(self._ws.receive(), timeout=10.0)
|
||||
if msg.type in (aiohttp.WSMsgType.TEXT,):
|
||||
frame = json.loads(msg.data)
|
||||
frame = await asyncio.to_thread(json.loads, msg.data)
|
||||
req_id = frame.get('headers', {}).get('req_id', '')
|
||||
if req_id.startswith(CMD_SUBSCRIBE) and frame.get('errcode') == 0:
|
||||
return True
|
||||
@@ -614,7 +682,7 @@ class WecomBotWsClient:
|
||||
break
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
try:
|
||||
frame = json.loads(msg.data)
|
||||
frame = await asyncio.to_thread(json.loads, msg.data)
|
||||
await self._handle_frame(frame)
|
||||
except json.JSONDecodeError:
|
||||
await self.logger.error(f'Failed to parse WebSocket message: {str(msg.data)[:200]}')
|
||||
@@ -622,7 +690,7 @@ class WecomBotWsClient:
|
||||
await self.logger.error(f'Error handling frame: {traceback.format_exc()}')
|
||||
elif msg.type == aiohttp.WSMsgType.BINARY:
|
||||
try:
|
||||
frame = json.loads(msg.data)
|
||||
frame = await asyncio.to_thread(json.loads, msg.data)
|
||||
await self._handle_frame(frame)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error handling binary frame: {traceback.format_exc()}')
|
||||
@@ -638,12 +706,14 @@ class WecomBotWsClient:
|
||||
|
||||
# Message push
|
||||
if cmd == CMD_MSG_CALLBACK:
|
||||
asyncio.create_task(self._handle_message_callback(frame))
|
||||
if not self._start_callback_task(self._handle_message_callback(frame)):
|
||||
await self.logger.warning('WeCom WebSocket callback capacity reached; dropping message')
|
||||
return
|
||||
|
||||
# Event push
|
||||
if cmd == CMD_EVENT_CALLBACK:
|
||||
asyncio.create_task(self._handle_event_callback(frame))
|
||||
if not self._start_callback_task(self._handle_event_callback(frame)):
|
||||
await self.logger.warning('WeCom WebSocket callback capacity reached; dropping event')
|
||||
return
|
||||
|
||||
# No cmd → response/ACK frame, dispatch by req_id prefix
|
||||
@@ -665,6 +735,27 @@ class WecomBotWsClient:
|
||||
# Unknown frame
|
||||
await self.logger.warning(f'Unknown frame: {_frame_snippet(frame)}')
|
||||
|
||||
def _start_callback_task(self, coro) -> bool:
|
||||
"""Start one bounded inbound frame callback."""
|
||||
|
||||
for task in tuple(self._callback_tasks):
|
||||
if task.done():
|
||||
self._callback_tasks.discard(task)
|
||||
if len(self._callback_tasks) >= _MAX_CALLBACK_TASKS:
|
||||
coro.close()
|
||||
return False
|
||||
|
||||
task = asyncio.create_task(coro)
|
||||
self._callback_tasks.add(task)
|
||||
|
||||
def done(done_task: asyncio.Task) -> None:
|
||||
self._callback_tasks.discard(done_task)
|
||||
if not done_task.cancelled():
|
||||
done_task.exception()
|
||||
|
||||
task.add_done_callback(done)
|
||||
return True
|
||||
|
||||
async def _handle_message_callback(self, frame: dict):
|
||||
"""Handle an incoming message callback frame."""
|
||||
try:
|
||||
@@ -697,6 +788,7 @@ class WecomBotWsClient:
|
||||
'chat_id': message_data.get('chatid', ''),
|
||||
'chat_type': message_data.get('type', 'single'),
|
||||
}
|
||||
self._prune_stream_state()
|
||||
message_data['stream_id'] = stream_id
|
||||
message_data['req_id'] = req_id
|
||||
|
||||
@@ -748,7 +840,7 @@ class WecomBotWsClient:
|
||||
)
|
||||
|
||||
# Look up session by feedback_id
|
||||
session_info = self._feedback_sessions.get(feedback_id)
|
||||
session_info = self._feedback_sessions.pop(feedback_id, None)
|
||||
session = None
|
||||
if session_info:
|
||||
session = StreamSession(
|
||||
@@ -806,6 +898,10 @@ class WecomBotWsClient:
|
||||
if pending is None:
|
||||
await self.logger.warning(f'No pending_form found for task_id={task_id} (ws); card event ignored')
|
||||
return
|
||||
if time.monotonic() - float(pending.get('created_at', 0.0)) > _PENDING_FORM_TTL_SECONDS:
|
||||
self._drop_pending_form_task(task_id, pending)
|
||||
await self.logger.warning(f'Pending form expired for task_id={task_id} (ws)')
|
||||
return
|
||||
|
||||
req_id_for_update = frame.get('headers', {}).get('req_id', '')
|
||||
form_data = pending.get('form_data', {}) or {}
|
||||
@@ -868,6 +964,7 @@ class WecomBotWsClient:
|
||||
self._msg_id_map[message_id] += 1
|
||||
return
|
||||
self._msg_id_map[message_id] = 1
|
||||
self._cap_mapping(self._msg_id_map, _DEDUP_CACHE_MAX)
|
||||
|
||||
msg_type = event.type
|
||||
if msg_type in self._message_handlers:
|
||||
@@ -899,40 +996,61 @@ class WecomBotWsClient:
|
||||
|
||||
# Ensure serial delivery per req_id
|
||||
if req_id not in self._reply_queues:
|
||||
self._reply_queues[req_id] = asyncio.Queue()
|
||||
if len(self._reply_queues) >= _MAX_REPLY_WORKERS:
|
||||
await self.logger.warning('WeCom WebSocket reply worker capacity reached; dropping reply')
|
||||
return None
|
||||
self._reply_queues[req_id] = asyncio.Queue(maxsize=_MAX_REPLY_QUEUE_SIZE)
|
||||
self._reply_workers[req_id] = asyncio.create_task(self._reply_queue_worker(req_id))
|
||||
|
||||
future: asyncio.Future = asyncio.get_event_loop().create_future()
|
||||
await self._reply_queues[req_id].put((frame, future))
|
||||
try:
|
||||
self._reply_queues[req_id].put_nowait((frame, future))
|
||||
except asyncio.QueueFull:
|
||||
await self.logger.warning(f'WeCom WebSocket reply queue full for req_id={req_id}; dropping reply')
|
||||
return None
|
||||
return await future
|
||||
|
||||
async def _reply_queue_worker(self, req_id: str):
|
||||
"""Process reply queue items serially for a given req_id."""
|
||||
queue = self._reply_queues[req_id]
|
||||
current_future: asyncio.Future | None = None
|
||||
try:
|
||||
while self._running:
|
||||
try:
|
||||
frame, future = await asyncio.wait_for(queue.get(), timeout=60.0)
|
||||
frame, current_future = await asyncio.wait_for(queue.get(), timeout=60.0)
|
||||
except asyncio.TimeoutError:
|
||||
# Queue idle, clean up worker
|
||||
break
|
||||
|
||||
try:
|
||||
ack = await self._send_and_wait_ack(frame)
|
||||
if not future.done():
|
||||
future.set_result(ack)
|
||||
if not current_future.done():
|
||||
current_future.set_result(ack)
|
||||
except Exception as e:
|
||||
if not future.done():
|
||||
future.set_exception(e)
|
||||
if not current_future.done():
|
||||
current_future.set_exception(e)
|
||||
finally:
|
||||
current_future = None
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if current_future is not None and not current_future.done():
|
||||
current_future.set_exception(ConnectionError('Connection closed'))
|
||||
finally:
|
||||
while True:
|
||||
try:
|
||||
_, future = queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
if not future.done():
|
||||
future.set_exception(ConnectionError('Reply worker stopped'))
|
||||
self._reply_queues.pop(req_id, None)
|
||||
self._reply_workers.pop(req_id, None)
|
||||
|
||||
async def _send_and_wait_ack(self, frame: dict) -> Optional[dict]:
|
||||
"""Send a frame and wait for the corresponding ACK."""
|
||||
req_id = frame['headers']['req_id']
|
||||
if len(self._pending_acks) >= _MAX_PENDING_ACKS:
|
||||
await self.logger.warning('WeCom WebSocket pending ACK capacity reached; dropping frame')
|
||||
return None
|
||||
ack_future: asyncio.Future = asyncio.get_event_loop().create_future()
|
||||
self._pending_acks[req_id] = ack_future
|
||||
|
||||
|
||||
@@ -1,16 +1,82 @@
|
||||
from quart import request
|
||||
from .WXBizMsgCrypt3 import WXBizMsgCrypt
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import contextvars
|
||||
import functools
|
||||
import httpx
|
||||
import os
|
||||
import traceback
|
||||
from urllib.parse import quote
|
||||
from quart import Quart
|
||||
import xml.etree.ElementTree as ET
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Callable, Dict, Any
|
||||
from .wecomevent import WecomEvent
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import aiofiles
|
||||
from langbot.pkg.utils import httpclient
|
||||
|
||||
_MAX_MEDIA_BYTES = 10 * 1024 * 1024
|
||||
_MAX_CALLBACK_BODY_BYTES = 1024 * 1024
|
||||
_EXTENDED_HTTP_TIMEOUT_SECONDS = 120
|
||||
|
||||
|
||||
async def _read_httpx_media_limited(response: httpx.Response) -> bytes:
|
||||
content_length = response.headers.get('Content-Length')
|
||||
if content_length is not None:
|
||||
try:
|
||||
if int(content_length) > _MAX_MEDIA_BYTES:
|
||||
raise ValueError('WeCom media exceeds the size limit')
|
||||
except (TypeError, ValueError) as exc:
|
||||
if 'exceeds' in str(exc):
|
||||
raise
|
||||
content = bytearray()
|
||||
async for chunk in response.aiter_bytes():
|
||||
content.extend(chunk)
|
||||
if len(content) > _MAX_MEDIA_BYTES:
|
||||
raise ValueError('WeCom media exceeds the size limit')
|
||||
return bytes(content)
|
||||
|
||||
|
||||
async def _read_local_media_limited(path: str) -> bytes:
|
||||
if await asyncio.to_thread(os.path.getsize, path) > _MAX_MEDIA_BYTES:
|
||||
raise ValueError('WeCom media exceeds the size limit')
|
||||
async with aiofiles.open(path, 'rb') as file:
|
||||
content = await file.read(_MAX_MEDIA_BYTES + 1)
|
||||
if len(content) > _MAX_MEDIA_BYTES:
|
||||
raise ValueError('WeCom media exceeds the size limit')
|
||||
return content
|
||||
|
||||
|
||||
async def _decode_media_base64_limited(value: str) -> bytes:
|
||||
max_encoded_chars = 4 * ((_MAX_MEDIA_BYTES + 2) // 3) + 4
|
||||
if len(value) > max_encoded_chars:
|
||||
raise ValueError('WeCom media exceeds the size limit')
|
||||
content = await asyncio.to_thread(base64.b64decode, value)
|
||||
if len(content) > _MAX_MEDIA_BYTES:
|
||||
raise ValueError('WeCom media exceeds the size limit')
|
||||
return content
|
||||
|
||||
|
||||
def _bounded_token_retry(method):
|
||||
"""Allow one token-refresh retry without unbounded async recursion."""
|
||||
|
||||
depth = contextvars.ContextVar(f'{method.__name__}_token_retry_depth', default=0)
|
||||
|
||||
@functools.wraps(method)
|
||||
async def wrapped(*args, **kwargs):
|
||||
current_depth = depth.get()
|
||||
if current_depth >= 2:
|
||||
raise RuntimeError(f'{method.__name__} exceeded the token refresh retry limit')
|
||||
token = depth.set(current_depth + 1)
|
||||
try:
|
||||
return await method(*args, **kwargs)
|
||||
finally:
|
||||
depth.reset(token)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
class WecomClient:
|
||||
@@ -36,6 +102,7 @@ class WecomClient:
|
||||
self.logger = logger
|
||||
self.unified_mode = unified_mode
|
||||
self.app = Quart(__name__)
|
||||
self.app.config['MAX_CONTENT_LENGTH'] = _MAX_CALLBACK_BODY_BYTES
|
||||
|
||||
# 只有在非统一模式下才注册独立路由
|
||||
if not self.unified_mode:
|
||||
@@ -49,6 +116,29 @@ class WecomClient:
|
||||
self._message_handlers = {
|
||||
'example': [],
|
||||
}
|
||||
self._http_clients: dict[bool, httpx.AsyncClient] = {}
|
||||
|
||||
@asynccontextmanager
|
||||
async def _http_client_context(self, *, unbounded_timeout: bool = False):
|
||||
client = self._http_clients.get(unbounded_timeout)
|
||||
if client is None or client.is_closed:
|
||||
response_hooks = httpclient.httpx_response_limit_hooks()
|
||||
client = (
|
||||
httpx.AsyncClient(
|
||||
timeout=_EXTENDED_HTTP_TIMEOUT_SECONDS,
|
||||
event_hooks=response_hooks,
|
||||
)
|
||||
if unbounded_timeout
|
||||
else httpx.AsyncClient(event_hooks=response_hooks)
|
||||
)
|
||||
self._http_clients[unbounded_timeout] = client
|
||||
yield client
|
||||
|
||||
async def close(self) -> None:
|
||||
clients = list(self._http_clients.values())
|
||||
self._http_clients.clear()
|
||||
if clients:
|
||||
await asyncio.gather(*(client.aclose() for client in clients), return_exceptions=True)
|
||||
|
||||
# access——token操作
|
||||
async def check_access_token(self):
|
||||
@@ -59,15 +149,16 @@ class WecomClient:
|
||||
|
||||
async def get_access_token(self, secret):
|
||||
url = f'{self.base_url}/gettoken?corpid={self.corpid}&corpsecret={secret}'
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
response = await client.get(url)
|
||||
data = response.json()
|
||||
data = await httpclient.parse_json_response(response)
|
||||
if 'access_token' in data:
|
||||
return data['access_token']
|
||||
else:
|
||||
await self.logger.error(f'获取accesstoken失败:{response.json()}')
|
||||
await self.logger.error(f'获取accesstoken失败:{data}')
|
||||
raise Exception(f'未获取access token: {data}')
|
||||
|
||||
@_bounded_token_retry
|
||||
async def get_user_info(self, userid: str) -> dict:
|
||||
"""
|
||||
Get user information by user ID using the application secret.
|
||||
@@ -82,9 +173,9 @@ class WecomClient:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
|
||||
url = self.base_url + '/user/get?access_token=' + self.access_token + '&userid=' + quote(userid)
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
response = await client.get(url)
|
||||
data = response.json()
|
||||
data = await httpclient.parse_json_response(response)
|
||||
if data.get('errcode') == 40014 or data.get('errcode') == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
return await self.get_user_info(userid)
|
||||
@@ -98,13 +189,13 @@ class WecomClient:
|
||||
self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts)
|
||||
|
||||
url = self.base_url + '/user/list_id?access_token=' + self.access_token_for_contacts
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
params = {
|
||||
'cursor': '',
|
||||
'limit': 10000,
|
||||
}
|
||||
response = await client.post(url, json=params)
|
||||
data = response.json()
|
||||
data = await httpclient.parse_json_response(response)
|
||||
if data['errcode'] == 0:
|
||||
dept_users = data['dept_user']
|
||||
userid = []
|
||||
@@ -121,7 +212,7 @@ class WecomClient:
|
||||
url = self.base_url + '/message/send?access_token=' + self.access_token_for_contacts
|
||||
user_ids = await self.get_users()
|
||||
user_ids_string = '|'.join(user_ids)
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
params = {
|
||||
'touser': user_ids_string,
|
||||
'msgtype': 'text',
|
||||
@@ -135,16 +226,17 @@ class WecomClient:
|
||||
'duplicate_check_interval': 1800,
|
||||
}
|
||||
response = await client.post(url, json=params)
|
||||
data = response.json()
|
||||
data = await httpclient.parse_json_response(response)
|
||||
if data['errcode'] != 0:
|
||||
raise Exception('Failed to send message: ' + str(data))
|
||||
|
||||
@_bounded_token_retry
|
||||
async def send_image(self, user_id: str, agent_id: int, media_id: str):
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
|
||||
url = self.base_url + '/message/send?access_token=' + self.access_token
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
params = {
|
||||
'touser': user_id,
|
||||
'msgtype': 'image',
|
||||
@@ -158,7 +250,7 @@ class WecomClient:
|
||||
'duplicate_check_interval': 1800,
|
||||
}
|
||||
response = await client.post(url, json=params)
|
||||
data = response.json()
|
||||
data = await httpclient.parse_json_response(response)
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
return await self.send_image(user_id, agent_id, media_id)
|
||||
@@ -166,11 +258,12 @@ class WecomClient:
|
||||
await self.logger.error(f'发送图片失败:{data}')
|
||||
raise Exception('Failed to send image: ' + str(data))
|
||||
|
||||
@_bounded_token_retry
|
||||
async def send_voice(self, user_id: str, agent_id: int, media_id: str):
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
url = self.base_url + '/message/send?access_token=' + self.access_token
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
params = {
|
||||
'touser': user_id,
|
||||
'msgtype': 'voice',
|
||||
@@ -184,7 +277,7 @@ class WecomClient:
|
||||
'duplicate_check_interval': 1800,
|
||||
}
|
||||
response = await client.post(url, json=params)
|
||||
data = response.json()
|
||||
data = await httpclient.parse_json_response(response)
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
return await self.send_voice(user_id, agent_id, media_id)
|
||||
@@ -192,11 +285,12 @@ class WecomClient:
|
||||
await self.logger.error(f'发送语音失败:{data}')
|
||||
raise Exception('Failed to send voice: ' + str(data))
|
||||
|
||||
@_bounded_token_retry
|
||||
async def send_file(self, user_id: str, agent_id: int, media_id: str):
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
url = self.base_url + '/message/send?access_token=' + self.access_token
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
params = {
|
||||
'touser': user_id,
|
||||
'msgtype': 'file',
|
||||
@@ -210,7 +304,7 @@ class WecomClient:
|
||||
'duplicate_check_interval': 1800,
|
||||
}
|
||||
response = await client.post(url, json=params)
|
||||
data = response.json()
|
||||
data = await httpclient.parse_json_response(response)
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
return await self.send_file(user_id, agent_id, media_id)
|
||||
@@ -218,12 +312,13 @@ class WecomClient:
|
||||
await self.logger.error(f'发送文件失败:{data}')
|
||||
raise Exception('Failed to send file: ' + str(data))
|
||||
|
||||
@_bounded_token_retry
|
||||
async def send_private_msg(self, user_id: str, agent_id: int, content: str):
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
|
||||
url = self.base_url + '/message/send?access_token=' + self.access_token
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with self._http_client_context(unbounded_timeout=True) as client:
|
||||
params = {
|
||||
'touser': user_id,
|
||||
'msgtype': 'text',
|
||||
@@ -237,7 +332,7 @@ class WecomClient:
|
||||
'duplicate_check_interval': 1800,
|
||||
}
|
||||
response = await client.post(url, json=params)
|
||||
data = response.json()
|
||||
data = await httpclient.parse_json_response(response)
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
return await self.send_private_msg(user_id, agent_id, content)
|
||||
@@ -283,7 +378,15 @@ class WecomClient:
|
||||
|
||||
elif req.method == 'POST':
|
||||
encrypt_msg = await req.data
|
||||
ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)
|
||||
if len(encrypt_msg) > _MAX_CALLBACK_BODY_BYTES:
|
||||
raise ValueError('WeCom callback body exceeds the size limit')
|
||||
ret, xml_msg = await asyncio.to_thread(
|
||||
wxcpt.DecryptMsg,
|
||||
encrypt_msg,
|
||||
msg_signature,
|
||||
timestamp,
|
||||
nonce,
|
||||
)
|
||||
if ret != 0:
|
||||
await self.logger.error('消息解密失败')
|
||||
raise Exception(f'消息解密失败,错误码: {ret}')
|
||||
@@ -332,7 +435,7 @@ class WecomClient:
|
||||
"""
|
||||
解析微信返回的 XML 消息并转换为字典。
|
||||
"""
|
||||
root = ET.fromstring(xml_msg)
|
||||
root = await asyncio.to_thread(ET.fromstring, xml_msg)
|
||||
message_data = {
|
||||
'ToUserName': root.find('ToUserName').text,
|
||||
'FromUserName': root.find('FromUserName').text,
|
||||
@@ -366,6 +469,7 @@ class WecomClient:
|
||||
return ext
|
||||
return 'jpg' # 默认返回jpg
|
||||
|
||||
@_bounded_token_retry
|
||||
async def upload_image_to_work(self, image: platform_message.Image):
|
||||
"""
|
||||
获取 media_id
|
||||
@@ -379,9 +483,8 @@ class WecomClient:
|
||||
|
||||
# 获取文件的二进制数据
|
||||
if image.path:
|
||||
async with aiofiles.open(image.path, 'rb') as f:
|
||||
file_bytes = await f.read()
|
||||
file_name = image.path.split('/')[-1]
|
||||
file_bytes = await _read_local_media_limited(image.path)
|
||||
file_name = image.path.split('/')[-1]
|
||||
elif image.url:
|
||||
file_bytes = await self.download_media_to_bytes(image.url)
|
||||
file_name = image.url.split('/')[-1]
|
||||
@@ -392,7 +495,7 @@ class WecomClient:
|
||||
base64_data = base64_data.split(',', 1)[1]
|
||||
padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0
|
||||
padded_base64 = base64_data + '=' * padding
|
||||
file_bytes = base64.b64decode(padded_base64)
|
||||
file_bytes = await _decode_media_base64_limited(padded_base64)
|
||||
except binascii.Error as e:
|
||||
raise ValueError(f'Invalid base64 string: {str(e)}')
|
||||
else:
|
||||
@@ -400,6 +503,8 @@ class WecomClient:
|
||||
raise ValueError('image对象出错')
|
||||
|
||||
# 设置 multipart/form-data 格式的文件
|
||||
if len(file_bytes) > _MAX_MEDIA_BYTES:
|
||||
raise ValueError('WeCom media exceeds the size limit')
|
||||
boundary = '-------------------------acebdf13572468'
|
||||
headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}
|
||||
body = (
|
||||
@@ -413,9 +518,9 @@ class WecomClient:
|
||||
)
|
||||
|
||||
# 上传文件
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
response = await client.post(url, headers=headers, content=body)
|
||||
data = response.json()
|
||||
data = await httpclient.parse_json_response(response)
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
media_id = await self.upload_image_to_work(image)
|
||||
@@ -426,6 +531,7 @@ class WecomClient:
|
||||
media_id = data.get('media_id')
|
||||
return media_id
|
||||
|
||||
@_bounded_token_retry
|
||||
async def upload_voice_to_work(self, voice: platform_message.Voice):
|
||||
"""
|
||||
上传语音文件到企业微信
|
||||
@@ -437,9 +543,8 @@ class WecomClient:
|
||||
file_name = 'voice.mp3'
|
||||
|
||||
if voice.path:
|
||||
async with aiofiles.open(voice.path, 'rb') as f:
|
||||
file_bytes = await f.read()
|
||||
file_name = voice.path.split('/')[-1]
|
||||
file_bytes = await _read_local_media_limited(voice.path)
|
||||
file_name = voice.path.split('/')[-1]
|
||||
elif voice.url:
|
||||
file_bytes = await self.download_media_to_bytes(voice.url)
|
||||
file_name = voice.url.split('/')[-1]
|
||||
@@ -450,13 +555,15 @@ class WecomClient:
|
||||
base64_data = base64_data.split(',', 1)[1]
|
||||
padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0
|
||||
padded_base64 = base64_data + '=' * padding
|
||||
file_bytes = base64.b64decode(padded_base64)
|
||||
file_bytes = await _decode_media_base64_limited(padded_base64)
|
||||
except binascii.Error as e:
|
||||
raise ValueError(f'Invalid base64 string: {str(e)}')
|
||||
else:
|
||||
await self.logger.error('Voice对象出错')
|
||||
raise ValueError('voice对象出错')
|
||||
|
||||
if len(file_bytes) > _MAX_MEDIA_BYTES:
|
||||
raise ValueError('WeCom media exceeds the size limit')
|
||||
boundary = '-------------------------acebdf13572468'
|
||||
headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}
|
||||
body = (
|
||||
@@ -470,9 +577,9 @@ class WecomClient:
|
||||
)
|
||||
|
||||
# print(body)
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
response = await client.post(url, headers=headers, content=body)
|
||||
data = response.json()
|
||||
data = await httpclient.parse_json_response(response)
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
media_id = await self.upload_voice_to_work(voice)
|
||||
@@ -482,6 +589,7 @@ class WecomClient:
|
||||
media_id = data.get('media_id')
|
||||
return media_id
|
||||
|
||||
@_bounded_token_retry
|
||||
async def upload_file_to_work(self, file: platform_message.File):
|
||||
"""
|
||||
上传文件到企业微信
|
||||
@@ -492,9 +600,8 @@ class WecomClient:
|
||||
file_bytes = None
|
||||
file_name = 'file.txt'
|
||||
if file.path:
|
||||
async with aiofiles.open(file.path, 'rb') as f:
|
||||
file_bytes = await f.read()
|
||||
file_name = file.path.split('/')[-1]
|
||||
file_bytes = await _read_local_media_limited(file.path)
|
||||
file_name = file.path.split('/')[-1]
|
||||
elif file.url:
|
||||
file_bytes = await self.download_media_to_bytes(file.url)
|
||||
file_name = file.url.split('/')[-1]
|
||||
@@ -505,12 +612,14 @@ class WecomClient:
|
||||
base64_data = base64_data.split(',', 1)[1]
|
||||
padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0
|
||||
padded_base64 = base64_data + '=' * padding
|
||||
file_bytes = base64.b64decode(padded_base64)
|
||||
file_bytes = await _decode_media_base64_limited(padded_base64)
|
||||
except binascii.Error as e:
|
||||
raise ValueError(f'Invalid base64 string: {str(e)}')
|
||||
else:
|
||||
await self.logger.error('File对象出错')
|
||||
raise ValueError('file对象出错')
|
||||
if len(file_bytes) > _MAX_MEDIA_BYTES:
|
||||
raise ValueError('WeCom media exceeds the size limit')
|
||||
boundary = '-------------------------acebdf13572468'
|
||||
headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}
|
||||
body = (
|
||||
@@ -522,9 +631,9 @@ class WecomClient:
|
||||
+ file_bytes
|
||||
+ f'\r\n--{boundary}--\r\n'.encode('utf-8')
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
response = await client.post(url, headers=headers, content=body)
|
||||
data = response.json()
|
||||
data = await httpclient.parse_json_response(response)
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
media_id = await self.upload_file_to_work(file)
|
||||
@@ -535,10 +644,10 @@ class WecomClient:
|
||||
return media_id
|
||||
|
||||
async def download_media_to_bytes(self, url: str) -> bytes:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
async with self._http_client_context() as client:
|
||||
async with client.stream('GET', url) as response:
|
||||
response.raise_for_status()
|
||||
return await _read_httpx_media_limited(response)
|
||||
|
||||
# 进行media_id的获取
|
||||
async def get_media_id(self, media: platform_message.Image | platform_message.Voice | platform_message.File):
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
from quart import request
|
||||
from ..wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import contextvars
|
||||
import functools
|
||||
import httpx
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
from quart import Quart
|
||||
import xml.etree.ElementTree as ET
|
||||
@@ -11,9 +16,72 @@ from .wecomcsevent import WecomCSEvent
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import aiofiles
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from langbot.pkg.utils import httpclient
|
||||
|
||||
_MAX_MEDIA_BYTES = 10 * 1024 * 1024
|
||||
_MAX_CALLBACK_BODY_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
async def _read_httpx_media_limited(response: httpx.Response) -> bytes:
|
||||
content_length = response.headers.get('Content-Length')
|
||||
if content_length is not None:
|
||||
try:
|
||||
if int(content_length) > _MAX_MEDIA_BYTES:
|
||||
raise ValueError('WeCom customer-service media exceeds the size limit')
|
||||
except (TypeError, ValueError) as exc:
|
||||
if 'exceeds' in str(exc):
|
||||
raise
|
||||
content = bytearray()
|
||||
async for chunk in response.aiter_bytes():
|
||||
content.extend(chunk)
|
||||
if len(content) > _MAX_MEDIA_BYTES:
|
||||
raise ValueError('WeCom customer-service media exceeds the size limit')
|
||||
return bytes(content)
|
||||
|
||||
|
||||
async def _read_local_media_limited(path: str) -> bytes:
|
||||
if await asyncio.to_thread(os.path.getsize, path) > _MAX_MEDIA_BYTES:
|
||||
raise ValueError('WeCom customer-service media exceeds the size limit')
|
||||
async with aiofiles.open(path, 'rb') as file:
|
||||
content = await file.read(_MAX_MEDIA_BYTES + 1)
|
||||
if len(content) > _MAX_MEDIA_BYTES:
|
||||
raise ValueError('WeCom customer-service media exceeds the size limit')
|
||||
return content
|
||||
|
||||
|
||||
async def _decode_media_base64_limited(value: str) -> bytes:
|
||||
max_encoded_chars = 4 * ((_MAX_MEDIA_BYTES + 2) // 3) + 4
|
||||
if len(value) > max_encoded_chars:
|
||||
raise ValueError('WeCom customer-service media exceeds the size limit')
|
||||
content = await asyncio.to_thread(base64.b64decode, value)
|
||||
if len(content) > _MAX_MEDIA_BYTES:
|
||||
raise ValueError('WeCom customer-service media exceeds the size limit')
|
||||
return content
|
||||
|
||||
|
||||
def _bounded_token_retry(method):
|
||||
"""Allow one token-refresh retry without unbounded async recursion."""
|
||||
|
||||
depth = contextvars.ContextVar(f'{method.__name__}_token_retry_depth', default=0)
|
||||
|
||||
@functools.wraps(method)
|
||||
async def wrapped(*args, **kwargs):
|
||||
current_depth = depth.get()
|
||||
if current_depth >= 2:
|
||||
raise RuntimeError(f'{method.__name__} exceeded the token refresh retry limit')
|
||||
token = depth.set(current_depth + 1)
|
||||
try:
|
||||
return await method(*args, **kwargs)
|
||||
finally:
|
||||
depth.reset(token)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
class WecomCSClient:
|
||||
_CUSTOMER_CACHE_MAX = 4096
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
corpid: str,
|
||||
@@ -34,10 +102,12 @@ class WecomCSClient:
|
||||
self.logger = logger
|
||||
self.unified_mode = unified_mode
|
||||
self.app = Quart(__name__)
|
||||
self.app.config['MAX_CONTENT_LENGTH'] = _MAX_CALLBACK_BODY_BYTES
|
||||
|
||||
# Customer info cache: {external_userid: (info_dict, timestamp)}
|
||||
self._customer_cache: dict[str, tuple[dict, float]] = {}
|
||||
self._cache_ttl = 60 # Cache TTL in seconds (1 minute)
|
||||
self._customer_cache_cleanup_at = 0.0
|
||||
|
||||
# 只有在非统一模式下才注册独立路由
|
||||
if not self.unified_mode:
|
||||
@@ -48,29 +118,40 @@ class WecomCSClient:
|
||||
self._message_handlers = {
|
||||
'example': [],
|
||||
}
|
||||
self._http_client: httpx.AsyncClient | None = None
|
||||
|
||||
@asynccontextmanager
|
||||
async def _http_client_context(self):
|
||||
if self._http_client is None or self._http_client.is_closed:
|
||||
self._http_client = httpx.AsyncClient(event_hooks=httpclient.httpx_response_limit_hooks())
|
||||
yield self._http_client
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._http_client is not None:
|
||||
await self._http_client.aclose()
|
||||
self._http_client = None
|
||||
|
||||
@_bounded_token_retry
|
||||
async def get_pic_url(self, media_id: str):
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
|
||||
url = f'{self.base_url}/media/get?access_token={self.access_token}&media_id={media_id}'
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url)
|
||||
if response.headers.get('Content-Type', '').startswith('application/json'):
|
||||
data = response.json()
|
||||
if data.get('errcode') in [40014, 42001]:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
return await self.get_pic_url(media_id)
|
||||
else:
|
||||
async with self._http_client_context() as client:
|
||||
async with client.stream('GET', url) as response:
|
||||
image_bytes = await _read_httpx_media_limited(response)
|
||||
content_type = response.headers.get('Content-Type', '')
|
||||
if content_type.startswith('application/json'):
|
||||
data = json.loads(image_bytes)
|
||||
if data.get('errcode') in [40014, 42001]:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
return await self.get_pic_url(media_id)
|
||||
raise Exception('Failed to get image: ' + str(data))
|
||||
|
||||
# 否则是图片,转成 base64
|
||||
image_bytes = response.content
|
||||
content_type = response.headers.get('Content-Type', '')
|
||||
base64_str = base64.b64encode(image_bytes).decode('utf-8')
|
||||
base64_str = f'data:{content_type};base64,{base64_str}'
|
||||
return base64_str
|
||||
# 否则是图片,转成 base64
|
||||
base64_str = (await asyncio.to_thread(base64.b64encode, image_bytes)).decode('utf-8')
|
||||
return f'data:{content_type};base64,{base64_str}'
|
||||
|
||||
# access——token操作
|
||||
async def check_access_token(self):
|
||||
@@ -81,19 +162,20 @@ class WecomCSClient:
|
||||
|
||||
async def get_access_token(self, secret):
|
||||
url = f'{self.base_url}/gettoken?corpid={self.corpid}&corpsecret={secret}'
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
response = await client.get(url)
|
||||
data = response.json()
|
||||
data = await httpclient.parse_json_response(response)
|
||||
if 'access_token' in data:
|
||||
return data['access_token']
|
||||
else:
|
||||
raise Exception(f'未获取access token: {data}')
|
||||
|
||||
@_bounded_token_retry
|
||||
async def get_detailed_message_list(self, xml_msg: str):
|
||||
# 在本方法中解析消息,并且获得消息的具体内容
|
||||
if isinstance(xml_msg, bytes):
|
||||
xml_msg = xml_msg.decode('utf-8')
|
||||
root = ET.fromstring(xml_msg)
|
||||
root = await asyncio.to_thread(ET.fromstring, xml_msg)
|
||||
token = root.find('Token').text
|
||||
open_kfid = root.find('OpenKfId').text
|
||||
|
||||
@@ -106,14 +188,14 @@ class WecomCSClient:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
|
||||
url = self.base_url + '/kf/sync_msg?access_token=' + self.access_token
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
params = {
|
||||
'token': token,
|
||||
'voice_format': 0,
|
||||
'open_kfid': open_kfid,
|
||||
}
|
||||
response = await client.post(url, json=params)
|
||||
data = response.json()
|
||||
data = await httpclient.parse_json_response(response)
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
return await self.get_detailed_message_list(xml_msg)
|
||||
@@ -130,11 +212,12 @@ class WecomCSClient:
|
||||
# await self.change_service_status(userid=external_userid,openkfid=open_kfid,servicer=servicer)
|
||||
return last_msg_data
|
||||
|
||||
@_bounded_token_retry
|
||||
async def change_service_status(self, userid: str, openkfid: str, servicer: str):
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
url = self.base_url + '/kf/service_state/get?access_token=' + self.access_token
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
params = {
|
||||
'open_kfid': openkfid,
|
||||
'external_userid': userid,
|
||||
@@ -142,18 +225,19 @@ class WecomCSClient:
|
||||
'servicer_userid': servicer,
|
||||
}
|
||||
response = await client.post(url, json=params)
|
||||
data = response.json()
|
||||
data = await httpclient.parse_json_response(response)
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
return await self.change_service_status(userid, openkfid)
|
||||
return await self.change_service_status(userid, openkfid, servicer)
|
||||
if data['errcode'] != 0:
|
||||
raise Exception('Failed to change service status: ' + str(data))
|
||||
|
||||
@_bounded_token_retry
|
||||
async def send_image(self, user_id: str, agent_id: int, media_id: str):
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
url = self.base_url + '/media/upload?access_token=' + self.access_token
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
params = {
|
||||
'touser': user_id,
|
||||
'toparty': '',
|
||||
@@ -170,7 +254,7 @@ class WecomCSClient:
|
||||
}
|
||||
try:
|
||||
response = await client.post(url, json=params)
|
||||
data = response.json()
|
||||
data = await httpclient.parse_json_response(response)
|
||||
except Exception as e:
|
||||
raise Exception('Failed to send image: ' + str(e))
|
||||
|
||||
@@ -182,6 +266,7 @@ class WecomCSClient:
|
||||
if data['errcode'] != 0:
|
||||
raise Exception('Failed to send image: ' + str(data))
|
||||
|
||||
@_bounded_token_retry
|
||||
async def send_text_msg(self, open_kfid: str, external_userid: str, msgid: str, content: str):
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
@@ -198,10 +283,10 @@ class WecomCSClient:
|
||||
},
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
response = await client.post(url, json=payload)
|
||||
|
||||
data = response.json()
|
||||
data = await httpclient.parse_json_response(response)
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
return await self.send_text_msg(open_kfid, external_userid, msgid, content)
|
||||
@@ -250,7 +335,15 @@ class WecomCSClient:
|
||||
|
||||
elif req.method == 'POST':
|
||||
encrypt_msg = await req.data
|
||||
ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)
|
||||
if len(encrypt_msg) > _MAX_CALLBACK_BODY_BYTES:
|
||||
raise ValueError('WeCom customer-service callback body exceeds the size limit')
|
||||
ret, xml_msg = await asyncio.to_thread(
|
||||
wxcpt.DecryptMsg,
|
||||
encrypt_msg,
|
||||
msg_signature,
|
||||
timestamp,
|
||||
nonce,
|
||||
)
|
||||
if ret != 0:
|
||||
raise Exception(f'消息解密失败,错误码: {ret}')
|
||||
|
||||
@@ -315,6 +408,7 @@ class WecomCSClient:
|
||||
return ext
|
||||
return 'jpg' # 默认返回jpg
|
||||
|
||||
@_bounded_token_retry
|
||||
async def upload_to_work(self, image: platform_message.Image):
|
||||
"""
|
||||
获取 media_id
|
||||
@@ -328,9 +422,8 @@ class WecomCSClient:
|
||||
|
||||
# 获取文件的二进制数据
|
||||
if image.path:
|
||||
async with aiofiles.open(image.path, 'rb') as f:
|
||||
file_bytes = await f.read()
|
||||
file_name = image.path.split('/')[-1]
|
||||
file_bytes = await _read_local_media_limited(image.path)
|
||||
file_name = image.path.split('/')[-1]
|
||||
elif image.url:
|
||||
file_bytes = await self.download_image_to_bytes(image.url)
|
||||
file_name = image.url.split('/')[-1]
|
||||
@@ -341,13 +434,15 @@ class WecomCSClient:
|
||||
base64_data = base64_data.split(',', 1)[1]
|
||||
padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0
|
||||
padded_base64 = base64_data + '=' * padding
|
||||
file_bytes = base64.b64decode(padded_base64)
|
||||
file_bytes = await _decode_media_base64_limited(padded_base64)
|
||||
except binascii.Error as e:
|
||||
raise ValueError(f'Invalid base64 string: {str(e)}')
|
||||
else:
|
||||
raise ValueError('image对象出错')
|
||||
|
||||
# 设置 multipart/form-data 格式的文件
|
||||
if len(file_bytes) > _MAX_MEDIA_BYTES:
|
||||
raise ValueError('WeCom customer-service media exceeds the size limit')
|
||||
boundary = '-------------------------acebdf13572468'
|
||||
headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}
|
||||
body = (
|
||||
@@ -361,9 +456,9 @@ class WecomCSClient:
|
||||
)
|
||||
|
||||
# 上传文件
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
response = await client.post(url, headers=headers, content=body)
|
||||
data = response.json()
|
||||
data = await httpclient.parse_json_response(response)
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
media_id = await self.upload_to_work(image)
|
||||
@@ -374,16 +469,17 @@ class WecomCSClient:
|
||||
return media_id
|
||||
|
||||
async def download_image_to_bytes(self, url: str) -> bytes:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
async with self._http_client_context() as client:
|
||||
async with client.stream('GET', url) as response:
|
||||
response.raise_for_status()
|
||||
return await _read_httpx_media_limited(response)
|
||||
|
||||
# 进行media_id的获取
|
||||
async def get_media_id(self, image: platform_message.Image):
|
||||
media_id = await self.upload_to_work(image=image)
|
||||
return media_id
|
||||
|
||||
@_bounded_token_retry
|
||||
async def get_customer_info(self, external_userid: str) -> dict | None:
|
||||
"""
|
||||
Get customer information by external_userid with caching.
|
||||
@@ -398,6 +494,11 @@ class WecomCSClient:
|
||||
"""
|
||||
# Check cache first
|
||||
current_time = time.time()
|
||||
if current_time - self._customer_cache_cleanup_at >= 30:
|
||||
self._customer_cache_cleanup_at = current_time
|
||||
for user_id, (_, cached_time) in tuple(self._customer_cache.items()):
|
||||
if current_time - cached_time >= self._cache_ttl:
|
||||
self._customer_cache.pop(user_id, None)
|
||||
if external_userid in self._customer_cache:
|
||||
cached_info, cached_time = self._customer_cache[external_userid]
|
||||
if current_time - cached_time < self._cache_ttl:
|
||||
@@ -413,9 +514,9 @@ class WecomCSClient:
|
||||
'external_userid_list': [external_userid],
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with self._http_client_context() as client:
|
||||
response = await client.post(url, json=payload)
|
||||
data = response.json()
|
||||
data = await httpclient.parse_json_response(response)
|
||||
|
||||
if data.get('errcode') in [40014, 42001]:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
@@ -431,5 +532,10 @@ class WecomCSClient:
|
||||
customer_info = customer_list[0]
|
||||
# Store in cache
|
||||
self._customer_cache[external_userid] = (customer_info, current_time)
|
||||
while len(self._customer_cache) > self._CUSTOMER_CACHE_MAX:
|
||||
self._customer_cache.pop(next(iter(self._customer_cache)), None)
|
||||
return customer_info
|
||||
return None
|
||||
|
||||
def clear(self) -> None:
|
||||
self._customer_cache.clear()
|
||||
|
||||
@@ -6,6 +6,56 @@ import json
|
||||
|
||||
from .errors import WeKnoraAPIError
|
||||
|
||||
_MAX_WENKORA_RESPONSE_BYTES = 1024 * 1024
|
||||
_MAX_WENKORA_STREAM_BYTES = 16 * 1024 * 1024
|
||||
_MAX_WENKORA_SSE_LINE_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
async def _read_limited_response(response: httpx.Response) -> bytes:
|
||||
body = bytearray()
|
||||
async for chunk in response.aiter_bytes(chunk_size=8192):
|
||||
body.extend(chunk)
|
||||
if len(body) > _MAX_WENKORA_RESPONSE_BYTES:
|
||||
raise WeKnoraAPIError('WeKnora response exceeds the runtime limit')
|
||||
return bytes(body)
|
||||
|
||||
|
||||
async def _iter_sse_json(
|
||||
response: httpx.Response,
|
||||
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
||||
buffer = bytearray()
|
||||
total = 0
|
||||
async for chunk in response.aiter_bytes(chunk_size=8192):
|
||||
total += len(chunk)
|
||||
if total > _MAX_WENKORA_STREAM_BYTES:
|
||||
raise WeKnoraAPIError('WeKnora stream exceeds the runtime limit')
|
||||
buffer.extend(chunk)
|
||||
while b'\n' in buffer:
|
||||
raw_line, _, remainder = buffer.partition(b'\n')
|
||||
buffer = bytearray(remainder)
|
||||
if len(raw_line) > _MAX_WENKORA_SSE_LINE_BYTES:
|
||||
raise WeKnoraAPIError('WeKnora SSE event exceeds the runtime limit')
|
||||
line = raw_line.rstrip(b'\r').strip()
|
||||
if not line.startswith(b'data:'):
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line[5:].strip())
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(data, dict):
|
||||
yield data
|
||||
if len(buffer) > _MAX_WENKORA_SSE_LINE_BYTES:
|
||||
raise WeKnoraAPIError('WeKnora SSE event exceeds the runtime limit')
|
||||
|
||||
line = bytes(buffer).rstrip(b'\r').strip()
|
||||
if line.startswith(b'data:'):
|
||||
try:
|
||||
data = json.loads(line[5:].strip())
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
if isinstance(data, dict):
|
||||
yield data
|
||||
|
||||
|
||||
class AsyncWeKnoraClient:
|
||||
"""WeKnora API 客户端"""
|
||||
@@ -39,19 +89,19 @@ class AsyncWeKnoraClient:
|
||||
if description:
|
||||
payload['description'] = description
|
||||
|
||||
response = await client.post(
|
||||
async with client.stream(
|
||||
'POST',
|
||||
'/sessions',
|
||||
headers={
|
||||
'X-API-Key': self.api_key,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
|
||||
if response.status_code not in (200, 201):
|
||||
raise WeKnoraAPIError(f'{response.status_code} {response.text}')
|
||||
|
||||
data = response.json()
|
||||
) as response:
|
||||
body = await _read_limited_response(response)
|
||||
if response.status_code not in (200, 201):
|
||||
raise WeKnoraAPIError(f'{response.status_code} {body.decode("utf-8", errors="replace")}')
|
||||
data = json.loads(body)
|
||||
return data['data']['id']
|
||||
|
||||
async def agent_chat(
|
||||
@@ -107,20 +157,13 @@ class AsyncWeKnoraClient:
|
||||
},
|
||||
json=payload,
|
||||
) as r:
|
||||
async for chunk in r.aiter_lines():
|
||||
if r.status_code != 200:
|
||||
raise WeKnoraAPIError(f'{r.status_code} {chunk}')
|
||||
if chunk.strip() == '':
|
||||
continue
|
||||
if chunk.startswith('data:'):
|
||||
try:
|
||||
data = json.loads(chunk[5:].strip())
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
yield data
|
||||
# 收到 error 事件后主动结束流,避免上层未 raise 时持续等待
|
||||
if data.get('response_type') == 'error':
|
||||
return
|
||||
if r.status_code != 200:
|
||||
body = await _read_limited_response(r)
|
||||
raise WeKnoraAPIError(f'{r.status_code} {body.decode("utf-8", errors="replace")}')
|
||||
async for data in _iter_sse_json(r):
|
||||
yield data
|
||||
if data.get('response_type') == 'error':
|
||||
return
|
||||
|
||||
async def knowledge_chat(
|
||||
self,
|
||||
@@ -164,17 +207,10 @@ class AsyncWeKnoraClient:
|
||||
},
|
||||
json=payload,
|
||||
) as r:
|
||||
async for chunk in r.aiter_lines():
|
||||
if r.status_code != 200:
|
||||
raise WeKnoraAPIError(f'{r.status_code} {chunk}')
|
||||
if chunk.strip() == '':
|
||||
continue
|
||||
if chunk.startswith('data:'):
|
||||
try:
|
||||
data = json.loads(chunk[5:].strip())
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
yield data
|
||||
# 收到 error 事件后主动结束流,避免上层未 raise 时持续等待
|
||||
if data.get('response_type') == 'error':
|
||||
return
|
||||
if r.status_code != 200:
|
||||
body = await _read_limited_response(r)
|
||||
raise WeKnoraAPIError(f'{r.status_code} {body.decode("utf-8", errors="replace")}')
|
||||
async for data in _iter_sse_json(r):
|
||||
yield data
|
||||
if data.get('response_type') == 'error':
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user