mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 12:05:54 +00:00
Compare commits
16 Commits
feature/un
...
v4.9.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4db53b759 | ||
|
|
9f90341dcb | ||
|
|
67b726afb2 | ||
|
|
01852b81d4 | ||
|
|
4d6f109788 | ||
|
|
e1e5e7aedf | ||
|
|
cd53abc440 | ||
|
|
16a15a122a | ||
|
|
6fa653f232 | ||
|
|
c13971d7d6 | ||
|
|
9c659ce8fa | ||
|
|
c9fc64360f | ||
|
|
88a04fdbe8 | ||
|
|
bbe019f0c6 | ||
|
|
def798bf1f | ||
|
|
5290834b8b |
@@ -34,8 +34,6 @@
|
||||
|
||||
---
|
||||
|
||||
## 什么是 LangBot?
|
||||
|
||||
LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时通信机器人。它将大语言模型(LLM)连接到各种聊天平台,帮助你创建能够对话、执行任务、并集成到现有工作流程中的智能 Agent。
|
||||
|
||||
### 核心能力
|
||||
@@ -43,7 +41,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
|
||||
- **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG(知识库),深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。
|
||||
- **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。
|
||||
- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。
|
||||
- **插件生态** — 数百个插件,事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
|
||||
- **插件生态** — 数百个插件,跨进程的事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
|
||||
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
|
||||
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.9.3"
|
||||
version = "4.9.4"
|
||||
description = "Production-grade platform for building agentic IM bots"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -64,7 +64,7 @@ dependencies = [
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"pyseekdb==1.1.0.post3",
|
||||
"langbot-plugin==0.3.3",
|
||||
"langbot-plugin==0.3.5",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"tboxsdk>=0.0.10",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||
|
||||
__version__ = '4.9.3'
|
||||
__version__ = '4.9.4'
|
||||
|
||||
3
src/langbot/libs/openclaw_weixin_api/__init__.py
Normal file
3
src/langbot/libs/openclaw_weixin_api/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .client import OpenClawWeixinClient as OpenClawWeixinClient
|
||||
from .types import ApiError as ApiError
|
||||
from .types import LoginResult as LoginResult
|
||||
807
src/langbot/libs/openclaw_weixin_api/client.py
Normal file
807
src/langbot/libs/openclaw_weixin_api/client.py
Normal file
@@ -0,0 +1,807 @@
|
||||
"""Async HTTP client for the OpenClaw WeChat API.
|
||||
|
||||
Implements the iLink Bot API protocol.
|
||||
Reference: https://github.com/epiral/weixin-bot
|
||||
|
||||
Endpoints: getUpdates (long-poll), sendMessage, getUploadUrl, getConfig, sendTyping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
import typing
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .types import (
|
||||
ApiError,
|
||||
CDNMedia,
|
||||
FileItem,
|
||||
GetConfigResponse,
|
||||
GetUpdatesResponse,
|
||||
GetUploadUrlResponse,
|
||||
ImageItem,
|
||||
LoginResult,
|
||||
MessageItem,
|
||||
QRCodeResponse,
|
||||
QRStatusResponse,
|
||||
RefMessage,
|
||||
TextItem,
|
||||
VideoItem,
|
||||
VoiceItem,
|
||||
WeixinMessage,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('openclaw-weixin-sdk')
|
||||
|
||||
DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com'
|
||||
CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c'
|
||||
|
||||
CHANNEL_VERSION = '1.0.0'
|
||||
|
||||
DEFAULT_API_TIMEOUT = 15
|
||||
DEFAULT_LONG_POLL_TIMEOUT = 40
|
||||
DEFAULT_CONFIG_TIMEOUT = 10
|
||||
DEFAULT_QR_POLL_TIMEOUT = 35
|
||||
|
||||
SESSION_EXPIRED_ERRCODE = -14
|
||||
|
||||
DEFAULT_BOT_TYPE = '3'
|
||||
|
||||
# Maximum text length per message chunk (WeChat limit)
|
||||
MAX_TEXT_CHUNK_SIZE = 2000
|
||||
|
||||
|
||||
def _random_wechat_uin() -> str:
|
||||
"""Generate the X-WECHAT-UIN header: random uint32 -> decimal string -> base64."""
|
||||
rand_bytes = os.urandom(4)
|
||||
uint32_val = struct.unpack('>I', rand_bytes)[0]
|
||||
return base64.b64encode(str(uint32_val).encode('utf-8')).decode('utf-8')
|
||||
|
||||
|
||||
def _build_base_info() -> dict:
|
||||
"""Build the base_info payload included in every API request."""
|
||||
return {'channel_version': CHANNEL_VERSION}
|
||||
|
||||
|
||||
def _chunk_text(text: str, max_size: int = MAX_TEXT_CHUNK_SIZE) -> list[str]:
|
||||
"""Split long text into chunks that fit within WeChat's message size limit."""
|
||||
if len(text) <= max_size:
|
||||
return [text]
|
||||
chunks = []
|
||||
while text:
|
||||
chunks.append(text[:max_size])
|
||||
text = text[max_size:]
|
||||
return chunks
|
||||
|
||||
|
||||
class OpenClawWeixinClient:
|
||||
"""Async client for the OpenClaw WeChat HTTP JSON API."""
|
||||
|
||||
def __init__(self, base_url: str, token: str):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.token = token
|
||||
self._session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession()
|
||||
return self._session
|
||||
|
||||
async def close(self):
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
def _build_headers(self) -> dict[str, str]:
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'AuthorizationType': 'ilink_bot_token',
|
||||
'X-WECHAT-UIN': _random_wechat_uin(),
|
||||
}
|
||||
if self.token:
|
||||
headers['Authorization'] = f'Bearer {self.token}'
|
||||
return headers
|
||||
|
||||
async def _post(self, endpoint: str, payload: dict, timeout: float = DEFAULT_API_TIMEOUT) -> dict:
|
||||
"""Make a POST request and return the JSON response.
|
||||
|
||||
Raises ApiError on HTTP errors or when the response contains a non-zero errcode.
|
||||
"""
|
||||
payload['base_info'] = _build_base_info()
|
||||
|
||||
session = await self._get_session()
|
||||
url = f'{self.base_url}/{endpoint}'
|
||||
headers = self._build_headers()
|
||||
|
||||
async with session.post(
|
||||
url, json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=timeout)
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
raise ApiError(
|
||||
f'OpenClaw API error {resp.status}: {text}',
|
||||
status=resp.status,
|
||||
)
|
||||
data = await resp.json(content_type=None)
|
||||
|
||||
# Check for application-level errors in the response body
|
||||
errcode = data.get('errcode') or data.get('ret')
|
||||
if errcode and errcode != 0:
|
||||
raise ApiError(
|
||||
data.get('errmsg') or f'API errcode {errcode}',
|
||||
status=200,
|
||||
code=errcode,
|
||||
payload=data,
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
async def get_updates(
|
||||
self, get_updates_buf: str = '', timeout: float = DEFAULT_LONG_POLL_TIMEOUT
|
||||
) -> GetUpdatesResponse:
|
||||
"""Long-poll for new messages.
|
||||
|
||||
Note: This method does NOT raise ApiError for errcode responses —
|
||||
it returns them in the GetUpdatesResponse so the caller can handle
|
||||
session expiry and other errors with full context.
|
||||
"""
|
||||
try:
|
||||
# Bypass the errcode check in _post since get_updates needs
|
||||
# to return error info (e.g. session expired) to the caller.
|
||||
payload: dict = {'get_updates_buf': get_updates_buf}
|
||||
payload['base_info'] = _build_base_info()
|
||||
|
||||
session = await self._get_session()
|
||||
url = f'{self.base_url}/ilink/bot/getupdates'
|
||||
headers = self._build_headers()
|
||||
|
||||
async with session.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
raise ApiError(
|
||||
f'OpenClaw API error {resp.status}: {text}',
|
||||
status=resp.status,
|
||||
)
|
||||
data = await resp.json(content_type=None)
|
||||
|
||||
except (asyncio.TimeoutError, aiohttp.ServerTimeoutError):
|
||||
return GetUpdatesResponse(ret=0, msgs=[], get_updates_buf=get_updates_buf)
|
||||
except ApiError:
|
||||
raise
|
||||
except Exception as e:
|
||||
if 'timeout' in str(e).lower():
|
||||
return GetUpdatesResponse(ret=0, msgs=[], get_updates_buf=get_updates_buf)
|
||||
raise
|
||||
|
||||
return _parse_get_updates_response(data)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
to_user_id: str,
|
||||
item_list: list[MessageItem],
|
||||
context_token: str = '',
|
||||
) -> None:
|
||||
"""Send a message to a user."""
|
||||
items_payload = [_message_item_to_dict(item) for item in item_list]
|
||||
|
||||
payload = {
|
||||
'msg': {
|
||||
'from_user_id': '',
|
||||
'to_user_id': to_user_id,
|
||||
'client_id': f'langbot-{uuid.uuid4().hex[:16]}',
|
||||
'message_type': WeixinMessage.TYPE_BOT,
|
||||
'message_state': WeixinMessage.STATE_FINISH,
|
||||
'item_list': items_payload,
|
||||
'context_token': context_token or None,
|
||||
}
|
||||
}
|
||||
await self._post('ilink/bot/sendmessage', payload)
|
||||
|
||||
async def send_text(self, to_user_id: str, text: str, context_token: str = '') -> None:
|
||||
"""Send a plain text message, automatically chunking if too long."""
|
||||
chunks = _chunk_text(text)
|
||||
for chunk in chunks:
|
||||
item = MessageItem(type=MessageItem.TEXT, text_item=TextItem(text=chunk))
|
||||
await self.send_message(to_user_id, [item], context_token)
|
||||
|
||||
async def get_config(self, ilink_user_id: str, context_token: str = '') -> GetConfigResponse:
|
||||
"""Get bot config including typing_ticket."""
|
||||
data = await self._post(
|
||||
'ilink/bot/getconfig',
|
||||
{'ilink_user_id': ilink_user_id, 'context_token': context_token or None},
|
||||
timeout=DEFAULT_CONFIG_TIMEOUT,
|
||||
)
|
||||
return GetConfigResponse(
|
||||
ret=data.get('ret'),
|
||||
errmsg=data.get('errmsg'),
|
||||
typing_ticket=data.get('typing_ticket'),
|
||||
)
|
||||
|
||||
async def send_typing(self, ilink_user_id: str, typing_ticket: str, status: int = 1) -> None:
|
||||
"""Send typing indicator. status: 1=typing, 2=cancel."""
|
||||
await self._post(
|
||||
'ilink/bot/sendtyping',
|
||||
{
|
||||
'ilink_user_id': ilink_user_id,
|
||||
'typing_ticket': typing_ticket,
|
||||
'status': status,
|
||||
},
|
||||
timeout=DEFAULT_CONFIG_TIMEOUT,
|
||||
)
|
||||
|
||||
async def stop_typing(self, ilink_user_id: str, typing_ticket: str) -> None:
|
||||
"""Cancel the typing indicator for a user."""
|
||||
await self.send_typing(ilink_user_id, typing_ticket, status=2)
|
||||
|
||||
async def download_media(
|
||||
self,
|
||||
media: CDNMedia,
|
||||
) -> bytes:
|
||||
"""Download and decrypt a file from the WeChat CDN.
|
||||
|
||||
Args:
|
||||
media: CDNMedia object with encrypt_query_param and aes_key.
|
||||
|
||||
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:
|
||||
raise ApiError('CDN media has no aes_key', status=0)
|
||||
|
||||
# Derive 16-byte AES key
|
||||
# aes_key is base64-encoded; the decoded content may be:
|
||||
# - raw 16 bytes (direct AES key)
|
||||
# - 32-char hex string (decode hex to get 16 bytes)
|
||||
raw = base64.b64decode(media.aes_key)
|
||||
if len(raw) == 16:
|
||||
aes_key = raw
|
||||
elif len(raw) == 32:
|
||||
# Hex-encoded 16-byte key
|
||||
aes_key = bytes.fromhex(raw.decode('utf-8'))
|
||||
else:
|
||||
raise ApiError(f'Invalid AES key length: {len(raw)} (expected 16 or 32)', status=0)
|
||||
|
||||
# Download encrypted bytes from CDN
|
||||
session = await self._get_session()
|
||||
cdn_url = f'{CDN_BASE_URL}/download?encrypted_query_param={quote(media.encrypt_query_param, safe="")}'
|
||||
|
||||
async with session.get(cdn_url, timeout=aiohttp.ClientTimeout(total=120)) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
raise ApiError(f'CDN download failed: {resp.status} {text}', status=resp.status)
|
||||
encrypted = await resp.read()
|
||||
|
||||
# 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()
|
||||
|
||||
async def upload_media(
|
||||
self,
|
||||
file_bytes: bytes,
|
||||
to_user_id: str,
|
||||
media_type: int,
|
||||
) -> CDNMedia:
|
||||
"""Encrypt and upload media to WeChat CDN.
|
||||
|
||||
Args:
|
||||
file_bytes: Raw file bytes to upload.
|
||||
to_user_id: Recipient user ID.
|
||||
media_type: 1=IMAGE, 2=VIDEO, 3=FILE, 4=VOICE.
|
||||
|
||||
Returns:
|
||||
CDNMedia with encrypt_query_param and aes_key for use in sendMessage.
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
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()
|
||||
filekey = os.urandom(16).hex() # 32-char hex, matches official SDK
|
||||
|
||||
upload_resp = await self.get_upload_url(
|
||||
filekey=filekey,
|
||||
media_type=media_type,
|
||||
to_user_id=to_user_id,
|
||||
rawsize=len(file_bytes),
|
||||
rawfilemd5=raw_md5,
|
||||
filesize=len(encrypted),
|
||||
aeskey=aes_key_hex, # hex string, as expected by the API
|
||||
)
|
||||
|
||||
if not upload_resp.upload_param:
|
||||
raise ApiError('Failed to get upload URL', status=0)
|
||||
|
||||
# 5. Upload to CDN
|
||||
# upload_param is an opaque token from the server — pass it as-is
|
||||
session = await self._get_session()
|
||||
cdn_url = f'{CDN_BASE_URL}/upload?encrypted_query_param={quote(upload_resp.upload_param, safe="")}&filekey={quote(filekey, safe="")}'
|
||||
logger.debug(
|
||||
'CDN upload: url=%s raw_size=%d encrypted_size=%d md5=%s aeskey=%s',
|
||||
cdn_url,
|
||||
len(file_bytes),
|
||||
len(encrypted),
|
||||
raw_md5,
|
||||
encoded_key,
|
||||
)
|
||||
|
||||
async with session.post(
|
||||
cdn_url,
|
||||
data=encrypted,
|
||||
headers={'Content-Type': 'application/octet-stream'},
|
||||
timeout=aiohttp.ClientTimeout(total=120),
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
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', '')
|
||||
|
||||
if not download_param:
|
||||
raise ApiError('CDN upload succeeded but no x-encrypted-param returned', status=0)
|
||||
|
||||
return CDNMedia(
|
||||
encrypt_query_param=download_param,
|
||||
aes_key=encoded_key,
|
||||
encrypt_type=1,
|
||||
)
|
||||
|
||||
async def send_image(
|
||||
self,
|
||||
to_user_id: str,
|
||||
image_bytes: bytes,
|
||||
context_token: str = '',
|
||||
) -> None:
|
||||
"""Upload an image to CDN and send it."""
|
||||
media = await self.upload_media(image_bytes, to_user_id, media_type=1)
|
||||
item = MessageItem(
|
||||
type=MessageItem.IMAGE,
|
||||
image_item=ImageItem(
|
||||
media=media,
|
||||
aeskey=media.aes_key,
|
||||
),
|
||||
)
|
||||
await self.send_message(to_user_id, [item], context_token)
|
||||
|
||||
async def send_file(
|
||||
self,
|
||||
to_user_id: str,
|
||||
file_bytes: bytes,
|
||||
file_name: str,
|
||||
context_token: str = '',
|
||||
) -> None:
|
||||
"""Upload a file to CDN and send it."""
|
||||
import hashlib
|
||||
|
||||
media = await self.upload_media(file_bytes, to_user_id, media_type=3)
|
||||
item = MessageItem(
|
||||
type=MessageItem.FILE,
|
||||
file_item=FileItem(
|
||||
media=media,
|
||||
file_name=file_name,
|
||||
md5=hashlib.md5(file_bytes).hexdigest(),
|
||||
len=str(len(file_bytes)),
|
||||
),
|
||||
)
|
||||
await self.send_message(to_user_id, [item], context_token)
|
||||
|
||||
async def send_voice(
|
||||
self,
|
||||
to_user_id: str,
|
||||
voice_bytes: bytes,
|
||||
playtime: int = 0,
|
||||
context_token: str = '',
|
||||
) -> None:
|
||||
"""Upload a voice message to CDN and send it."""
|
||||
media = await self.upload_media(voice_bytes, to_user_id, media_type=4)
|
||||
item = MessageItem(
|
||||
type=MessageItem.VOICE,
|
||||
voice_item=VoiceItem(
|
||||
media=media,
|
||||
playtime=playtime,
|
||||
),
|
||||
)
|
||||
await self.send_message(to_user_id, [item], context_token)
|
||||
|
||||
async def get_upload_url(
|
||||
self,
|
||||
filekey: str,
|
||||
media_type: int,
|
||||
to_user_id: str,
|
||||
rawsize: int,
|
||||
rawfilemd5: str,
|
||||
filesize: int,
|
||||
thumb_rawsize: Optional[int] = None,
|
||||
thumb_rawfilemd5: Optional[str] = None,
|
||||
thumb_filesize: Optional[int] = None,
|
||||
aeskey: Optional[str] = None,
|
||||
) -> GetUploadUrlResponse:
|
||||
"""Get a pre-signed CDN upload URL."""
|
||||
payload: dict = {
|
||||
'filekey': filekey,
|
||||
'media_type': media_type,
|
||||
'to_user_id': to_user_id,
|
||||
'rawsize': rawsize,
|
||||
'rawfilemd5': rawfilemd5,
|
||||
'filesize': filesize,
|
||||
'no_need_thumb': True,
|
||||
}
|
||||
if thumb_rawsize is not None:
|
||||
payload['thumb_rawsize'] = thumb_rawsize
|
||||
if thumb_rawfilemd5 is not None:
|
||||
payload['thumb_rawfilemd5'] = thumb_rawfilemd5
|
||||
if thumb_filesize is not None:
|
||||
payload['thumb_filesize'] = thumb_filesize
|
||||
if aeskey is not None:
|
||||
payload['aeskey'] = aeskey
|
||||
|
||||
data = await self._post('ilink/bot/getuploadurl', payload)
|
||||
logger.debug('get_upload_url response: %s', data)
|
||||
return GetUploadUrlResponse(
|
||||
upload_param=data.get('upload_param'),
|
||||
thumb_upload_param=data.get('thumb_upload_param'),
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# QR Code Login
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
async def fetch_qrcode(self, bot_type: str = DEFAULT_BOT_TYPE) -> QRCodeResponse:
|
||||
"""Fetch a QR code for WeChat login authorization (GET, no auth needed)."""
|
||||
session = await self._get_session()
|
||||
url = f'{self.base_url}/ilink/bot/get_bot_qrcode?bot_type={bot_type}'
|
||||
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=DEFAULT_API_TIMEOUT)) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
raise ApiError(
|
||||
f'Failed to fetch QR code: {resp.status} {text}',
|
||||
status=resp.status,
|
||||
)
|
||||
data = await resp.json(content_type=None)
|
||||
|
||||
logger.debug(
|
||||
'fetch_qrcode response: qrcode=%s, img=%s', data.get('qrcode'), bool(data.get('qrcode_img_content'))
|
||||
)
|
||||
|
||||
return QRCodeResponse(
|
||||
qrcode=data.get('qrcode'),
|
||||
qrcode_img_content=data.get('qrcode_img_content'),
|
||||
)
|
||||
|
||||
async def _fetch_qr_image_base64(self, url: str) -> str:
|
||||
"""Generate a QR code image from the URL and return a data URI string.
|
||||
|
||||
The qrcode_img_content URL points to an HTML page (not a raw image),
|
||||
so we generate the QR code locally using the qrcode library.
|
||||
"""
|
||||
import qrcode
|
||||
|
||||
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color='black', back_color='white')
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||
return f'data:image/png;base64,{b64}'
|
||||
|
||||
async def poll_qrcode_status(self, qrcode: str) -> QRStatusResponse:
|
||||
"""Long-poll the QR code scan status (GET with iLink-App-ClientVersion header)."""
|
||||
session = await self._get_session()
|
||||
url = f'{self.base_url}/ilink/bot/get_qrcode_status?qrcode={quote(qrcode, safe="")}'
|
||||
headers = {'iLink-App-ClientVersion': '1'}
|
||||
|
||||
try:
|
||||
async with session.get(
|
||||
url, headers=headers, timeout=aiohttp.ClientTimeout(total=DEFAULT_QR_POLL_TIMEOUT)
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
raise ApiError(
|
||||
f'Failed to poll QR status: {resp.status} {text}',
|
||||
status=resp.status,
|
||||
)
|
||||
data = await resp.json(content_type=None)
|
||||
logger.debug('QR status poll response: %s', data)
|
||||
except (asyncio.TimeoutError, aiohttp.ServerTimeoutError):
|
||||
return QRStatusResponse(status='wait')
|
||||
|
||||
return QRStatusResponse(
|
||||
status=data.get('status'),
|
||||
bot_token=data.get('bot_token'),
|
||||
ilink_bot_id=data.get('ilink_bot_id'),
|
||||
baseurl=data.get('baseurl'),
|
||||
ilink_user_id=data.get('ilink_user_id'),
|
||||
)
|
||||
|
||||
async def login(
|
||||
self,
|
||||
max_retries: int = 5,
|
||||
poll_timeout_ms: int = 480_000,
|
||||
on_qrcode: Optional[typing.Callable[[str, str], typing.Any]] = None,
|
||||
on_status: Optional[typing.Callable[[str], typing.Any]] = None,
|
||||
) -> LoginResult:
|
||||
"""Complete QR code login flow with auto-retry on expiry.
|
||||
|
||||
Args:
|
||||
max_retries: Max number of QR code refreshes on expiry.
|
||||
poll_timeout_ms: Timeout per QR code in milliseconds.
|
||||
on_qrcode: Callback(qr_image_base64, qr_url) called each time a
|
||||
new QR code is fetched. Use this to display the QR code.
|
||||
on_status: Callback(status_str) called on each status poll change.
|
||||
|
||||
Returns:
|
||||
LoginResult with token, base_url, and account_id.
|
||||
|
||||
Raises:
|
||||
ApiError: On unrecoverable API errors.
|
||||
Exception: If all retries are exhausted.
|
||||
"""
|
||||
last_qr_base64: Optional[str] = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
qr_resp = await self.fetch_qrcode()
|
||||
if not qr_resp.qrcode or not qr_resp.qrcode_img_content:
|
||||
raise ApiError('Failed to get QR code from server', status=0)
|
||||
|
||||
# Convert QR image to base64 and notify caller
|
||||
last_qr_base64 = await self._fetch_qr_image_base64(qr_resp.qrcode_img_content)
|
||||
if on_qrcode:
|
||||
try:
|
||||
result = on_qrcode(last_qr_base64, qr_resp.qrcode_img_content)
|
||||
if asyncio.iscoroutine(result) or asyncio.isfuture(result):
|
||||
await result
|
||||
except Exception as e:
|
||||
logger.warning('on_qrcode callback error: %s', e)
|
||||
|
||||
# Poll until confirmed / expired / timeout
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + poll_timeout_ms / 1000.0
|
||||
|
||||
while loop.time() < deadline:
|
||||
try:
|
||||
status_resp = await self.poll_qrcode_status(qr_resp.qrcode)
|
||||
except Exception as e:
|
||||
logger.error('Error polling QR status: %s', e)
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
|
||||
if on_status:
|
||||
try:
|
||||
cb_result = on_status(status_resp.status or 'unknown')
|
||||
if asyncio.iscoroutine(cb_result) or asyncio.isfuture(cb_result):
|
||||
await cb_result
|
||||
except Exception as e:
|
||||
logger.warning('on_status callback error: %s', e)
|
||||
|
||||
if status_resp.status == 'confirmed' and status_resp.bot_token:
|
||||
new_base_url = status_resp.baseurl or self.base_url
|
||||
# Update this client instance as well
|
||||
self.token = status_resp.bot_token
|
||||
self.base_url = new_base_url.rstrip('/')
|
||||
return LoginResult(
|
||||
token=status_resp.bot_token,
|
||||
base_url=new_base_url,
|
||||
account_id=status_resp.ilink_bot_id or '',
|
||||
qr_image_base64=last_qr_base64,
|
||||
)
|
||||
|
||||
if status_resp.status == 'expired':
|
||||
break # retry with a new QR code
|
||||
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
# While-loop ended without break → poll timeout, treat as expired
|
||||
pass
|
||||
|
||||
remaining = max_retries - attempt - 1
|
||||
if remaining > 0:
|
||||
logger.info('QR code expired, refreshing... (%d retries left)', remaining)
|
||||
else:
|
||||
raise ApiError('QR code login failed: max retries exceeded', status=0)
|
||||
|
||||
# Should not reach here, but just in case
|
||||
raise ApiError('QR code login failed', status=0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parsing helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_cdn_media(data: Optional[dict]) -> Optional[CDNMedia]:
|
||||
if not data:
|
||||
return None
|
||||
return CDNMedia(
|
||||
encrypt_query_param=data.get('encrypt_query_param'),
|
||||
aes_key=data.get('aes_key'),
|
||||
encrypt_type=data.get('encrypt_type'),
|
||||
)
|
||||
|
||||
|
||||
def _parse_message_item(data: dict) -> MessageItem:
|
||||
item = MessageItem(
|
||||
type=data.get('type'),
|
||||
create_time_ms=data.get('create_time_ms'),
|
||||
update_time_ms=data.get('update_time_ms'),
|
||||
is_completed=data.get('is_completed'),
|
||||
msg_id=data.get('msg_id'),
|
||||
)
|
||||
|
||||
if data.get('text_item'):
|
||||
item.text_item = TextItem(text=data['text_item'].get('text'))
|
||||
|
||||
if data.get('image_item'):
|
||||
img = data['image_item']
|
||||
item.image_item = ImageItem(
|
||||
media=_parse_cdn_media(img.get('media')),
|
||||
thumb_media=_parse_cdn_media(img.get('thumb_media')),
|
||||
aeskey=img.get('aeskey'),
|
||||
url=img.get('url'),
|
||||
mid_size=img.get('mid_size'),
|
||||
)
|
||||
|
||||
if data.get('voice_item'):
|
||||
v = data['voice_item']
|
||||
item.voice_item = VoiceItem(
|
||||
media=_parse_cdn_media(v.get('media')),
|
||||
encode_type=v.get('encode_type'),
|
||||
playtime=v.get('playtime'),
|
||||
text=v.get('text'),
|
||||
)
|
||||
|
||||
if data.get('file_item'):
|
||||
f = data['file_item']
|
||||
item.file_item = FileItem(
|
||||
media=_parse_cdn_media(f.get('media')),
|
||||
file_name=f.get('file_name'),
|
||||
md5=f.get('md5'),
|
||||
len=f.get('len'),
|
||||
)
|
||||
|
||||
if data.get('video_item'):
|
||||
vid = data['video_item']
|
||||
item.video_item = VideoItem(
|
||||
media=_parse_cdn_media(vid.get('media')),
|
||||
video_size=vid.get('video_size'),
|
||||
play_length=vid.get('play_length'),
|
||||
video_md5=vid.get('video_md5'),
|
||||
thumb_media=_parse_cdn_media(vid.get('thumb_media')),
|
||||
)
|
||||
|
||||
if data.get('ref_msg'):
|
||||
ref = data['ref_msg']
|
||||
item.ref_msg = RefMessage(
|
||||
title=ref.get('title'),
|
||||
message_item=_parse_message_item(ref['message_item']) if ref.get('message_item') else None,
|
||||
)
|
||||
|
||||
return item
|
||||
|
||||
|
||||
def _parse_weixin_message(data: dict) -> WeixinMessage:
|
||||
msg = WeixinMessage(
|
||||
seq=data.get('seq'),
|
||||
message_id=data.get('message_id'),
|
||||
from_user_id=data.get('from_user_id'),
|
||||
to_user_id=data.get('to_user_id'),
|
||||
client_id=data.get('client_id'),
|
||||
create_time_ms=data.get('create_time_ms'),
|
||||
session_id=data.get('session_id'),
|
||||
group_id=data.get('group_id'),
|
||||
message_type=data.get('message_type'),
|
||||
message_state=data.get('message_state'),
|
||||
context_token=data.get('context_token'),
|
||||
)
|
||||
if data.get('item_list'):
|
||||
msg.item_list = [_parse_message_item(item) for item in data['item_list']]
|
||||
return msg
|
||||
|
||||
|
||||
def _parse_get_updates_response(data: dict) -> GetUpdatesResponse:
|
||||
resp = GetUpdatesResponse(
|
||||
ret=data.get('ret'),
|
||||
errcode=data.get('errcode'),
|
||||
errmsg=data.get('errmsg'),
|
||||
get_updates_buf=data.get('get_updates_buf'),
|
||||
longpolling_timeout_ms=data.get('longpolling_timeout_ms'),
|
||||
)
|
||||
if data.get('msgs'):
|
||||
resp.msgs = [_parse_weixin_message(m) for m in data['msgs']]
|
||||
return resp
|
||||
|
||||
|
||||
def _cdn_media_to_dict(media: Optional[CDNMedia]) -> Optional[dict]:
|
||||
if not media:
|
||||
return None
|
||||
d: dict = {}
|
||||
if media.encrypt_query_param is not None:
|
||||
d['encrypt_query_param'] = media.encrypt_query_param
|
||||
if media.aes_key is not None:
|
||||
d['aes_key'] = media.aes_key
|
||||
if media.encrypt_type is not None:
|
||||
d['encrypt_type'] = media.encrypt_type
|
||||
return d or None
|
||||
|
||||
|
||||
def _message_item_to_dict(item: MessageItem) -> dict:
|
||||
d: dict = {'type': item.type}
|
||||
|
||||
if item.text_item:
|
||||
d['text_item'] = {'text': item.text_item.text}
|
||||
|
||||
if item.image_item:
|
||||
img_d: dict = {}
|
||||
if item.image_item.media:
|
||||
img_d['media'] = _cdn_media_to_dict(item.image_item.media)
|
||||
if item.image_item.mid_size is not None:
|
||||
img_d['mid_size'] = item.image_item.mid_size
|
||||
d['image_item'] = img_d
|
||||
|
||||
if item.voice_item:
|
||||
voice_d: dict = {}
|
||||
if item.voice_item.media:
|
||||
voice_d['media'] = _cdn_media_to_dict(item.voice_item.media)
|
||||
if item.voice_item.playtime is not None:
|
||||
voice_d['playtime'] = item.voice_item.playtime
|
||||
d['voice_item'] = voice_d
|
||||
|
||||
if item.file_item:
|
||||
file_d: dict = {}
|
||||
if item.file_item.media:
|
||||
file_d['media'] = _cdn_media_to_dict(item.file_item.media)
|
||||
if item.file_item.file_name:
|
||||
file_d['file_name'] = item.file_item.file_name
|
||||
if item.file_item.len:
|
||||
file_d['len'] = item.file_item.len
|
||||
d['file_item'] = file_d
|
||||
|
||||
if item.video_item:
|
||||
vid_d: dict = {}
|
||||
if item.video_item.media:
|
||||
vid_d['media'] = _cdn_media_to_dict(item.video_item.media)
|
||||
if item.video_item.video_size is not None:
|
||||
vid_d['video_size'] = item.video_item.video_size
|
||||
d['video_item'] = vid_d
|
||||
|
||||
return d
|
||||
200
src/langbot/libs/openclaw_weixin_api/types.py
Normal file
200
src/langbot/libs/openclaw_weixin_api/types.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""Type definitions for the OpenClaw WeChat API, mirroring the upstream protocol."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
SESSION_EXPIRED_ERRCODE = -14
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
"""Structured error raised by the OpenClaw WeChat API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
status: int = 0,
|
||||
code: int | None = None,
|
||||
payload: Any = None,
|
||||
):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.code = code
|
||||
self.payload = payload
|
||||
|
||||
@property
|
||||
def is_session_expired(self) -> bool:
|
||||
return self.code == SESSION_EXPIRED_ERRCODE
|
||||
|
||||
|
||||
@dataclass
|
||||
class CDNMedia:
|
||||
encrypt_query_param: Optional[str] = None
|
||||
aes_key: Optional[str] = None
|
||||
encrypt_type: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextItem:
|
||||
text: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageItem:
|
||||
media: Optional[CDNMedia] = None
|
||||
thumb_media: Optional[CDNMedia] = None
|
||||
aeskey: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
mid_size: Optional[int] = None
|
||||
thumb_size: Optional[int] = None
|
||||
thumb_height: Optional[int] = None
|
||||
thumb_width: Optional[int] = None
|
||||
hd_size: Optional[int] = None
|
||||
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VoiceItem:
|
||||
media: Optional[CDNMedia] = None
|
||||
encode_type: Optional[int] = None
|
||||
bits_per_sample: Optional[int] = None
|
||||
sample_rate: Optional[int] = None
|
||||
playtime: Optional[int] = None
|
||||
text: Optional[str] = None
|
||||
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileItem:
|
||||
media: Optional[CDNMedia] = None
|
||||
file_name: Optional[str] = None
|
||||
md5: Optional[str] = None
|
||||
len: Optional[str] = None
|
||||
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VideoItem:
|
||||
media: Optional[CDNMedia] = None
|
||||
video_size: Optional[int] = None
|
||||
play_length: Optional[int] = None
|
||||
video_md5: Optional[str] = None
|
||||
thumb_media: Optional[CDNMedia] = None
|
||||
thumb_size: Optional[int] = None
|
||||
thumb_height: Optional[int] = None
|
||||
thumb_width: Optional[int] = None
|
||||
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RefMessage:
|
||||
message_item: Optional[MessageItem] = None
|
||||
title: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MessageItem:
|
||||
"""A single content item inside a WeixinMessage."""
|
||||
|
||||
# Item types
|
||||
NONE = 0
|
||||
TEXT = 1
|
||||
IMAGE = 2
|
||||
VOICE = 3
|
||||
FILE = 4
|
||||
VIDEO = 5
|
||||
|
||||
type: Optional[int] = None
|
||||
create_time_ms: Optional[int] = None
|
||||
update_time_ms: Optional[int] = None
|
||||
is_completed: Optional[bool] = None
|
||||
msg_id: Optional[str] = None
|
||||
ref_msg: Optional[RefMessage] = None
|
||||
text_item: Optional[TextItem] = None
|
||||
image_item: Optional[ImageItem] = None
|
||||
voice_item: Optional[VoiceItem] = None
|
||||
file_item: Optional[FileItem] = None
|
||||
video_item: Optional[VideoItem] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class WeixinMessage:
|
||||
"""Unified message from getUpdates or for sendMessage."""
|
||||
|
||||
# Message types
|
||||
TYPE_USER = 1
|
||||
TYPE_BOT = 2
|
||||
|
||||
# Message states
|
||||
STATE_NEW = 0
|
||||
STATE_GENERATING = 1
|
||||
STATE_FINISH = 2
|
||||
|
||||
seq: Optional[int] = None
|
||||
message_id: Optional[int] = None
|
||||
from_user_id: Optional[str] = None
|
||||
to_user_id: Optional[str] = None
|
||||
client_id: Optional[str] = None
|
||||
create_time_ms: Optional[int] = None
|
||||
update_time_ms: Optional[int] = None
|
||||
delete_time_ms: Optional[int] = None
|
||||
session_id: Optional[str] = None
|
||||
group_id: Optional[str] = None
|
||||
message_type: Optional[int] = None
|
||||
message_state: Optional[int] = None
|
||||
item_list: Optional[list[MessageItem]] = None
|
||||
context_token: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class GetUpdatesResponse:
|
||||
ret: Optional[int] = None
|
||||
errcode: Optional[int] = None
|
||||
errmsg: Optional[str] = None
|
||||
msgs: list[WeixinMessage] = field(default_factory=list)
|
||||
get_updates_buf: Optional[str] = None
|
||||
longpolling_timeout_ms: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class GetConfigResponse:
|
||||
ret: Optional[int] = None
|
||||
errmsg: Optional[str] = None
|
||||
typing_ticket: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class GetUploadUrlResponse:
|
||||
upload_param: Optional[str] = None
|
||||
thumb_upload_param: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class QRCodeResponse:
|
||||
"""Response from get_bot_qrcode endpoint."""
|
||||
|
||||
qrcode: Optional[str] = None
|
||||
qrcode_img_content: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class QRStatusResponse:
|
||||
"""Response from get_qrcode_status endpoint."""
|
||||
|
||||
status: Optional[str] = None # "wait" | "scaned" | "confirmed" | "expired"
|
||||
bot_token: Optional[str] = None
|
||||
ilink_bot_id: Optional[str] = None
|
||||
baseurl: Optional[str] = None
|
||||
ilink_user_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoginResult:
|
||||
"""Result returned by the login flow."""
|
||||
|
||||
token: str
|
||||
base_url: str
|
||||
account_id: str
|
||||
qr_image_base64: Optional[str] = None # data URI of the last QR code shown
|
||||
@@ -4,6 +4,7 @@ import base64
|
||||
import binascii
|
||||
import httpx
|
||||
import traceback
|
||||
from urllib.parse import quote
|
||||
from quart import Quart
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Callable, Dict, Any
|
||||
@@ -67,6 +68,31 @@ class WecomClient:
|
||||
await self.logger.error(f'获取accesstoken失败:{response.json()}')
|
||||
raise Exception(f'未获取access token: {data}')
|
||||
|
||||
async def get_user_info(self, userid: str) -> dict:
|
||||
"""
|
||||
Get user information by user ID using the application secret.
|
||||
|
||||
Args:
|
||||
userid: The user ID to look up.
|
||||
|
||||
Returns:
|
||||
dict: User information including 'name' field.
|
||||
"""
|
||||
if not await self.check_access_token():
|
||||
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:
|
||||
response = await client.get(url)
|
||||
data = response.json()
|
||||
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)
|
||||
if data.get('errcode', 0) != 0:
|
||||
await self.logger.error(f'获取用户信息失败:{data}')
|
||||
return {}
|
||||
return data
|
||||
|
||||
async def get_users(self):
|
||||
if not self.check_access_token_for_contacts():
|
||||
self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts)
|
||||
|
||||
@@ -13,9 +13,9 @@ from .. import group
|
||||
@group.group_class('files', '/api/v1/files')
|
||||
class FilesRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/image/<image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
@self.route('/image/<path:image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
async def _(image_key: str) -> quart.Response:
|
||||
if '/' in image_key or '\\' in image_key:
|
||||
if '..' in image_key or '\\' in image_key:
|
||||
return quart.Response(status=404)
|
||||
|
||||
if not await self.ap.storage_mgr.storage_provider.exists(image_key):
|
||||
|
||||
577
src/langbot/pkg/platform/sources/openclaw_weixin.py
Normal file
577
src/langbot/pkg/platform/sources/openclaw_weixin.py
Normal file
@@ -0,0 +1,577 @@
|
||||
"""OpenClaw WeChat adapter for LangBot.
|
||||
|
||||
Uses the OpenClaw WeChat HTTP JSON API (long-poll getUpdates + sendMessage)
|
||||
to integrate personal WeChat accounts with LangBot.
|
||||
|
||||
Reference: https://github.com/epiral/weixin-bot
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import traceback
|
||||
import typing
|
||||
|
||||
import pydantic
|
||||
import sqlalchemy
|
||||
|
||||
from langbot.libs.openclaw_weixin_api.client import (
|
||||
DEFAULT_BASE_URL,
|
||||
SESSION_EXPIRED_ERRCODE,
|
||||
OpenClawWeixinClient,
|
||||
)
|
||||
from langbot.libs.openclaw_weixin_api.types import (
|
||||
MessageItem,
|
||||
WeixinMessage,
|
||||
)
|
||||
from langbot.pkg.entity.persistence import bot as persistence_bot
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
|
||||
class OpenClawWeixinMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
"""Converts between LangBot MessageChain and OpenClaw WeChat message items."""
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain) -> list[dict]:
|
||||
"""Convert LangBot MessageChain to a list of OpenClaw message item dicts."""
|
||||
items = []
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.Plain):
|
||||
items.append({'type': MessageItem.TEXT, 'text_item': {'text': component.text}})
|
||||
elif isinstance(component, platform_message.Image):
|
||||
# OpenClaw WeChat only supports text messages without CDN upload.
|
||||
# For images, we send a placeholder text with the URL if available.
|
||||
if component.url:
|
||||
items.append(
|
||||
{
|
||||
'type': MessageItem.TEXT,
|
||||
'text_item': {'text': f'[Image: {component.url}]'},
|
||||
}
|
||||
)
|
||||
elif component.base64:
|
||||
items.append(
|
||||
{
|
||||
'type': MessageItem.TEXT,
|
||||
'text_item': {'text': '[Image]'},
|
||||
}
|
||||
)
|
||||
elif isinstance(component, platform_message.File):
|
||||
if component.name:
|
||||
items.append(
|
||||
{
|
||||
'type': MessageItem.TEXT,
|
||||
'text_item': {'text': f'[File: {component.name}]'},
|
||||
}
|
||||
)
|
||||
elif isinstance(component, platform_message.Forward):
|
||||
for node in component.node_list:
|
||||
if node.message_chain:
|
||||
items.extend(await OpenClawWeixinMessageConverter.yiri2target(node.message_chain))
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(
|
||||
msg: WeixinMessage,
|
||||
) -> platform_message.MessageChain:
|
||||
"""Convert an OpenClaw WeixinMessage to LangBot MessageChain."""
|
||||
components: list[platform_message.MessageComponent] = []
|
||||
|
||||
if not msg.item_list:
|
||||
return platform_message.MessageChain(components)
|
||||
|
||||
for item in msg.item_list:
|
||||
if item.type == MessageItem.TEXT and item.text_item and item.text_item.text:
|
||||
text = item.text_item.text
|
||||
|
||||
# Handle quoted messages
|
||||
if item.ref_msg:
|
||||
ref_parts = []
|
||||
if item.ref_msg.title:
|
||||
ref_parts.append(item.ref_msg.title)
|
||||
if item.ref_msg.message_item:
|
||||
ref_item = item.ref_msg.message_item
|
||||
if ref_item.text_item and ref_item.text_item.text:
|
||||
ref_parts.append(ref_item.text_item.text)
|
||||
if ref_parts:
|
||||
components.append(
|
||||
platform_message.Quote(
|
||||
sender_id='',
|
||||
origin=platform_message.MessageChain(
|
||||
[platform_message.Plain(text=' | '.join(ref_parts))]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
components.append(platform_message.Plain(text=text))
|
||||
|
||||
elif item.type == MessageItem.IMAGE and item.image_item:
|
||||
if hasattr(item.image_item, '_downloaded_bytes') and item.image_item._downloaded_bytes:
|
||||
b64 = base64.b64encode(item.image_item._downloaded_bytes).decode('utf-8')
|
||||
components.append(platform_message.Image(base64=f'data:image/jpeg;base64,{b64}'))
|
||||
else:
|
||||
components.append(platform_message.Unknown(text='[Image]'))
|
||||
|
||||
elif item.type == MessageItem.VOICE and item.voice_item:
|
||||
# Voice with speech-to-text: use the transcribed text
|
||||
if item.voice_item.text:
|
||||
components.append(platform_message.Plain(text=item.voice_item.text))
|
||||
else:
|
||||
components.append(platform_message.Unknown(text='[Voice]'))
|
||||
|
||||
# TODO: enable after full testing
|
||||
# elif item.type == MessageItem.VOICE and item.voice_item:
|
||||
# if item.voice_item.text:
|
||||
# components.append(platform_message.Plain(text=item.voice_item.text))
|
||||
# elif hasattr(item.voice_item, '_downloaded_bytes') and item.voice_item._downloaded_bytes:
|
||||
# b64 = base64.b64encode(item.voice_item._downloaded_bytes).decode('utf-8')
|
||||
# components.append(
|
||||
# platform_message.Voice(
|
||||
# base64=b64,
|
||||
# length=item.voice_item.playtime or 0,
|
||||
# )
|
||||
# )
|
||||
# else:
|
||||
# components.append(
|
||||
# platform_message.Voice(
|
||||
# length=item.voice_item.playtime or 0,
|
||||
# )
|
||||
# )
|
||||
|
||||
elif item.type == MessageItem.FILE and item.file_item:
|
||||
components.append(platform_message.Unknown(text=f'[File: {item.file_item.file_name or ""}]'))
|
||||
|
||||
# TODO: enable after full testing
|
||||
# elif item.type == MessageItem.FILE and item.file_item:
|
||||
# file_name = item.file_item.file_name or ''
|
||||
# file_size = int(item.file_item.len) if item.file_item.len else 0
|
||||
# if hasattr(item.file_item, '_downloaded_bytes') and item.file_item._downloaded_bytes:
|
||||
# b64 = base64.b64encode(item.file_item._downloaded_bytes).decode('utf-8')
|
||||
# components.append(
|
||||
# platform_message.File(
|
||||
# name=file_name,
|
||||
# size=file_size,
|
||||
# base64=b64,
|
||||
# )
|
||||
# )
|
||||
# else:
|
||||
# components.append(
|
||||
# platform_message.File(
|
||||
# name=file_name,
|
||||
# size=file_size,
|
||||
# )
|
||||
# )
|
||||
|
||||
elif item.type == MessageItem.VIDEO and item.video_item:
|
||||
components.append(platform_message.Unknown(text='[Video]'))
|
||||
|
||||
# TODO: enable after full testing
|
||||
# elif item.type == MessageItem.VIDEO and item.video_item:
|
||||
# if hasattr(item.video_item, '_downloaded_bytes') and item.video_item._downloaded_bytes:
|
||||
# b64 = base64.b64encode(item.video_item._downloaded_bytes).decode('utf-8')
|
||||
# components.append(
|
||||
# platform_message.File(
|
||||
# name='video.mp4',
|
||||
# size=item.video_item.video_size or 0,
|
||||
# base64=b64,
|
||||
# )
|
||||
# )
|
||||
# else:
|
||||
# components.append(
|
||||
# platform_message.File(
|
||||
# name='video.mp4',
|
||||
# size=item.video_item.video_size or 0,
|
||||
# )
|
||||
# )
|
||||
|
||||
else:
|
||||
components.append(platform_message.Unknown(text='[Unknown message type]'))
|
||||
|
||||
return platform_message.MessageChain(components)
|
||||
|
||||
|
||||
class OpenClawWeixinEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
"""Converts OpenClaw WeChat messages to LangBot events."""
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.MessageEvent) -> dict:
|
||||
return event.source_platform_object
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(msg: WeixinMessage) -> typing.Optional[platform_events.MessageEvent]:
|
||||
"""Convert an inbound WeixinMessage to a LangBot event."""
|
||||
if msg.message_type != WeixinMessage.TYPE_USER:
|
||||
return None
|
||||
|
||||
from_user_id = msg.from_user_id or ''
|
||||
if not from_user_id:
|
||||
return None
|
||||
|
||||
message_chain = await OpenClawWeixinMessageConverter.target2yiri(msg)
|
||||
if not message_chain:
|
||||
return None
|
||||
|
||||
timestamp = (msg.create_time_ms or 0) / 1000.0
|
||||
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=from_user_id,
|
||||
nickname=from_user_id,
|
||||
remark='',
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=timestamp,
|
||||
source_platform_object=msg,
|
||||
)
|
||||
|
||||
|
||||
class OpenClawWeixinAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
"""LangBot adapter for OpenClaw WeChat (long-poll based)."""
|
||||
|
||||
name: str = 'openclaw-weixin'
|
||||
|
||||
client: OpenClawWeixinClient = pydantic.Field(exclude=True)
|
||||
|
||||
config: dict
|
||||
|
||||
message_converter: OpenClawWeixinMessageConverter = OpenClawWeixinMessageConverter()
|
||||
event_converter: OpenClawWeixinEventConverter = OpenClawWeixinEventConverter()
|
||||
|
||||
# context_token cache: from_user_id -> context_token
|
||||
_context_tokens: dict[str, str] = pydantic.PrivateAttr(default_factory=dict)
|
||||
|
||||
_polling: bool = pydantic.PrivateAttr(default=False)
|
||||
_poll_task: typing.Optional[asyncio.Task] = pydantic.PrivateAttr(default=None)
|
||||
_bot_uuid: typing.Optional[str] = pydantic.PrivateAttr(default=None)
|
||||
|
||||
listeners: typing.Dict[
|
||||
typing.Type[platform_events.Event],
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = {}
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
client = OpenClawWeixinClient(
|
||||
base_url=config.get('base_url', DEFAULT_BASE_URL),
|
||||
token=config.get('token', ''),
|
||||
)
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
client=client,
|
||||
bot_account_id='',
|
||||
listeners={},
|
||||
name='openclaw-weixin',
|
||||
)
|
||||
|
||||
def set_bot_uuid(self, bot_uuid: str):
|
||||
"""Called by BotManager to provide the bot's UUID for config persistence."""
|
||||
self._bot_uuid = bot_uuid
|
||||
|
||||
async def _persist_config(self) -> None:
|
||||
"""Persist current self.config to the database so token survives restart."""
|
||||
if not self._bot_uuid:
|
||||
return
|
||||
try:
|
||||
ap = self.logger.ap
|
||||
await ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_bot.Bot)
|
||||
.where(persistence_bot.Bot.uuid == self._bot_uuid)
|
||||
.values(adapter_config=self.config)
|
||||
)
|
||||
except Exception as e:
|
||||
await self.logger.warning(f'Failed to persist adapter config: {e}')
|
||||
|
||||
async def _do_login(self) -> None:
|
||||
"""Run the QR code login flow via client.login() and update config."""
|
||||
adapter_logger = self.logger
|
||||
|
||||
async def _on_qrcode(qr_base64: str, _qr_url: str):
|
||||
await adapter_logger.info(
|
||||
f'Please scan the QR code to login WeChat: {_qr_url}',
|
||||
images=[platform_message.Image(base64=qr_base64)],
|
||||
)
|
||||
|
||||
login_result = await self.client.login(
|
||||
on_qrcode=_on_qrcode,
|
||||
)
|
||||
|
||||
# client.login() already updates client.token and client.base_url
|
||||
self.config['token'] = login_result.token
|
||||
self.config['base_url'] = login_result.base_url
|
||||
if login_result.account_id:
|
||||
self.config['account_id'] = login_result.account_id
|
||||
|
||||
await self.logger.info(f'WeChat login successful! account_id={login_result.account_id}')
|
||||
|
||||
# Persist token to database so it survives restart
|
||||
await self._persist_config()
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
message: platform_message.MessageChain,
|
||||
):
|
||||
"""Send a message to a user."""
|
||||
context_token = self._context_tokens.get(target_id, '')
|
||||
|
||||
for component in message:
|
||||
try:
|
||||
if isinstance(component, platform_message.Plain):
|
||||
if component.text:
|
||||
await self.client.send_text(target_id, component.text, context_token)
|
||||
|
||||
elif isinstance(component, platform_message.Image):
|
||||
img_bytes, _ = await component.get_bytes()
|
||||
await self.client.send_image(target_id, img_bytes, context_token)
|
||||
|
||||
elif isinstance(component, platform_message.File):
|
||||
file_bytes = await self._get_component_bytes(component)
|
||||
if file_bytes:
|
||||
await self.client.send_file(target_id, file_bytes, component.name or 'file', context_token)
|
||||
|
||||
elif isinstance(component, platform_message.Voice):
|
||||
voice_bytes = await self._get_component_bytes(component)
|
||||
if voice_bytes:
|
||||
await self.client.send_voice(target_id, voice_bytes, component.length or 0, context_token)
|
||||
|
||||
elif isinstance(component, platform_message.Forward):
|
||||
for node in component.node_list:
|
||||
if node.message_chain:
|
||||
await self.send_message(target_type, target_id, node.message_chain)
|
||||
|
||||
except Exception:
|
||||
await self.logger.error(
|
||||
f'Failed to send component {type(component).__name__}: {traceback.format_exc()}'
|
||||
)
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
"""Reply to a received message."""
|
||||
source_msg = message_source.source_platform_object
|
||||
if isinstance(source_msg, WeixinMessage):
|
||||
target_id = source_msg.from_user_id or ''
|
||||
if target_id:
|
||||
await self.send_message('friend', target_id, message)
|
||||
|
||||
async def is_muted(self, group_id: int) -> bool:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def _get_component_bytes(component: platform_message.MessageComponent) -> typing.Optional[bytes]:
|
||||
"""Extract raw bytes from a File or Voice component."""
|
||||
b64_val = getattr(component, 'base64', None)
|
||||
url_val = getattr(component, 'url', None)
|
||||
path_val = getattr(component, 'path', None)
|
||||
|
||||
if b64_val:
|
||||
return base64.b64decode(b64_val)
|
||||
elif url_val and url_val.startswith(('http://', 'https://')):
|
||||
import aiohttp
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url_val) as resp:
|
||||
if resp.status == 200:
|
||||
return await resp.read()
|
||||
elif path_val:
|
||||
import asyncio
|
||||
|
||||
with open(path_val, 'rb') as f:
|
||||
return await asyncio.to_thread(f.read)
|
||||
return None
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter],
|
||||
None,
|
||||
],
|
||||
):
|
||||
self.listeners[event_type] = callback
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter],
|
||||
None,
|
||||
],
|
||||
):
|
||||
self.listeners.pop(event_type, None)
|
||||
|
||||
async def run_async(self):
|
||||
"""Start the adapter. If no token is configured, trigger QR code login first."""
|
||||
base_url = self.config.get('base_url', DEFAULT_BASE_URL)
|
||||
token = self.config.get('token', '')
|
||||
|
||||
await self.logger.info('OpenClaw WeChat adapter starting...')
|
||||
|
||||
# QR code login flow when no token is provided
|
||||
if not token:
|
||||
await self.logger.info('No token configured, starting QR code login...')
|
||||
try:
|
||||
await self._do_login()
|
||||
except Exception as e:
|
||||
await self.logger.error(f'QR code login failed: {e}')
|
||||
raise
|
||||
|
||||
# Rebuild client with the (possibly updated) config
|
||||
self.client = OpenClawWeixinClient(
|
||||
base_url=self.config.get('base_url', base_url),
|
||||
token=self.config.get('token', token),
|
||||
)
|
||||
self.bot_account_id = self.config.get('account_id', 'openclaw-weixin')
|
||||
self._polling = True
|
||||
|
||||
# Start the long-poll loop
|
||||
self._poll_task = asyncio.create_task(self._poll_loop())
|
||||
await self.logger.info('OpenClaw WeChat adapter running')
|
||||
|
||||
try:
|
||||
await self._poll_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _poll_loop(self):
|
||||
"""Long-poll loop: call getUpdates continuously.
|
||||
|
||||
Error handling follows the weixin-bot SDK pattern:
|
||||
- Exponential backoff (1s -> 10s max) on failures
|
||||
- Session expired (errcode -14) triggers automatic re-login
|
||||
"""
|
||||
get_updates_buf = ''
|
||||
poll_timeout = float(self.config.get('poll_timeout', 35))
|
||||
|
||||
backoff_delay = 1.0
|
||||
max_backoff = 10.0
|
||||
|
||||
while self._polling:
|
||||
try:
|
||||
resp = await self.client.get_updates(
|
||||
get_updates_buf=get_updates_buf,
|
||||
timeout=poll_timeout + 5,
|
||||
)
|
||||
|
||||
if resp.longpolling_timeout_ms and resp.longpolling_timeout_ms > 0:
|
||||
poll_timeout = resp.longpolling_timeout_ms / 1000.0
|
||||
|
||||
is_api_error = (resp.ret is not None and resp.ret != 0) or (
|
||||
resp.errcode is not None and resp.errcode != 0
|
||||
)
|
||||
if is_api_error:
|
||||
is_session_expired = resp.errcode == SESSION_EXPIRED_ERRCODE or resp.ret == SESSION_EXPIRED_ERRCODE
|
||||
|
||||
if is_session_expired:
|
||||
await self.logger.error('OpenClaw WeChat session expired, attempting re-login...')
|
||||
try:
|
||||
await self._do_login()
|
||||
# Rebuild client with new credentials
|
||||
self.client = OpenClawWeixinClient(
|
||||
base_url=self.config.get('base_url', DEFAULT_BASE_URL),
|
||||
token=self.config.get('token', ''),
|
||||
)
|
||||
self._context_tokens.clear()
|
||||
get_updates_buf = ''
|
||||
backoff_delay = 1.0
|
||||
continue
|
||||
except Exception:
|
||||
await self.logger.error(f'Re-login failed: {traceback.format_exc()}')
|
||||
break
|
||||
|
||||
await self.logger.error(
|
||||
f'OpenClaw getUpdates failed: ret={resp.ret} errcode={resp.errcode} errmsg={resp.errmsg}'
|
||||
)
|
||||
await asyncio.sleep(backoff_delay)
|
||||
backoff_delay = min(backoff_delay * 2, max_backoff)
|
||||
continue
|
||||
|
||||
backoff_delay = 1.0
|
||||
|
||||
if resp.get_updates_buf:
|
||||
get_updates_buf = resp.get_updates_buf
|
||||
|
||||
for msg in resp.msgs:
|
||||
try:
|
||||
await self._handle_inbound_message(msg)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error handling message: {traceback.format_exc()}')
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
await self.logger.error(f'OpenClaw poll error: {traceback.format_exc()}')
|
||||
await asyncio.sleep(backoff_delay)
|
||||
backoff_delay = min(backoff_delay * 2, max_backoff)
|
||||
|
||||
async def _handle_inbound_message(self, msg: WeixinMessage):
|
||||
"""Process a single inbound message from getUpdates."""
|
||||
if msg.context_token and msg.from_user_id:
|
||||
self._context_tokens[msg.from_user_id] = msg.context_token
|
||||
|
||||
# Download CDN media (files, images) before converting to LangBot events
|
||||
await self._download_media_items(msg)
|
||||
|
||||
event = await OpenClawWeixinEventConverter.target2yiri(msg)
|
||||
if event is None:
|
||||
return
|
||||
|
||||
if type(event) in self.listeners:
|
||||
await self.listeners[type(event)](event, self)
|
||||
|
||||
async def _download_media_items(self, msg: WeixinMessage):
|
||||
"""Download CDN media for image items in the message."""
|
||||
if not msg.item_list:
|
||||
return
|
||||
|
||||
for item in msg.item_list:
|
||||
try:
|
||||
if item.type == MessageItem.IMAGE and item.image_item:
|
||||
if (
|
||||
item.image_item.media
|
||||
and item.image_item.media.encrypt_query_param
|
||||
and item.image_item.media.aes_key
|
||||
):
|
||||
img_bytes = await self.client.download_media(item.image_item.media)
|
||||
item.image_item._downloaded_bytes = img_bytes
|
||||
|
||||
# TODO: enable after full testing
|
||||
# elif item.type == MessageItem.FILE and item.file_item and item.file_item.media:
|
||||
# if item.file_item.media.encrypt_query_param and item.file_item.media.aes_key:
|
||||
# file_bytes = await self.client.download_media(item.file_item.media)
|
||||
# item.file_item._downloaded_bytes = file_bytes
|
||||
#
|
||||
# elif item.type == MessageItem.VOICE and item.voice_item and item.voice_item.media:
|
||||
# if item.voice_item.media.encrypt_query_param and item.voice_item.media.aes_key:
|
||||
# voice_bytes = await self.client.download_media(item.voice_item.media)
|
||||
# item.voice_item._downloaded_bytes = voice_bytes
|
||||
#
|
||||
# elif item.type == MessageItem.VIDEO and item.video_item and item.video_item.media:
|
||||
# if item.video_item.media.encrypt_query_param and item.video_item.media.aes_key:
|
||||
# video_bytes = await self.client.download_media(item.video_item.media)
|
||||
# item.video_item._downloaded_bytes = video_bytes
|
||||
|
||||
except Exception:
|
||||
await self.logger.warning(f'Failed to download CDN media: {traceback.format_exc()}')
|
||||
|
||||
async def kill(self) -> bool:
|
||||
"""Stop the adapter."""
|
||||
self._polling = False
|
||||
if self._poll_task and not self._poll_task.done():
|
||||
self._poll_task.cancel()
|
||||
try:
|
||||
await self._poll_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await self.client.close()
|
||||
await self.logger.info('OpenClaw WeChat adapter stopped')
|
||||
return True
|
||||
57
src/langbot/pkg/platform/sources/openclaw_weixin.yaml
Normal file
57
src/langbot/pkg/platform/sources/openclaw_weixin.yaml
Normal file
@@ -0,0 +1,57 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: openclaw-weixin
|
||||
label:
|
||||
en_US: OpenClaw WeChat
|
||||
zh_Hans: OpenClaw 微信
|
||||
description:
|
||||
en_US: OpenClaw WeChat adapter, supports personal WeChat via QR code login
|
||||
zh_Hans: OpenClaw 微信适配器,通过扫码登录支持个人微信
|
||||
icon: wechat.png
|
||||
spec:
|
||||
config:
|
||||
- name: base_url
|
||||
label:
|
||||
en_US: API Base URL
|
||||
zh_Hans: API 基础地址
|
||||
description:
|
||||
en_US: The base URL of the OpenClaw WeChat backend API
|
||||
zh_Hans: OpenClaw 微信后端 API 的基础地址
|
||||
type: string
|
||||
required: true
|
||||
default: "https://ilinkai.weixin.qq.com"
|
||||
- name: token
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
description:
|
||||
en_US: Bearer token obtained after QR code login authorization. Leave empty to trigger QR code login on startup.
|
||||
zh_Hans: 扫码登录授权后获取的 Bearer 令牌。留空则启动时自动触发扫码登录。
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
- name: account_id
|
||||
label:
|
||||
en_US: Account ID
|
||||
zh_Hans: 账号标识
|
||||
description:
|
||||
en_US: A label for this WeChat account (used for display purposes)
|
||||
zh_Hans: 此微信账号的标识(用于显示)
|
||||
type: string
|
||||
required: false
|
||||
default: "openclaw-weixin"
|
||||
- name: poll_timeout
|
||||
label:
|
||||
en_US: Poll Timeout (seconds)
|
||||
zh_Hans: 轮询超时(秒)
|
||||
description:
|
||||
en_US: Long-poll timeout for getUpdates, the server may hold the request up to this duration
|
||||
zh_Hans: getUpdates 长轮询超时时间,服务端最多持有请求的时长
|
||||
type: integer
|
||||
required: false
|
||||
default: 35
|
||||
execution:
|
||||
python:
|
||||
path: ./openclaw_weixin.py
|
||||
attr: OpenClawWeixinAdapter
|
||||
BIN
src/langbot/pkg/platform/sources/wechat.png
Normal file
BIN
src/langbot/pkg/platform/sources/wechat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 466 KiB |
@@ -148,51 +148,54 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
pass
|
||||
|
||||
if type(event) is platform_events.FriendMessage:
|
||||
payload = {
|
||||
'MsgType': 'text',
|
||||
'Content': '',
|
||||
'FromUserName': event.sender.id,
|
||||
'ToUserName': bot_account_id,
|
||||
'CreateTime': int(datetime.datetime.now().timestamp()),
|
||||
'AgentID': event.sender.nickname,
|
||||
}
|
||||
wecom_event = WecomEvent.from_payload(payload=payload)
|
||||
if not wecom_event:
|
||||
raise ValueError('无法从 message_data 构造 WecomEvent 对象')
|
||||
|
||||
return wecom_event
|
||||
return event.source_platform_object
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(event: WecomEvent):
|
||||
async def target2yiri(event: WecomEvent, bot: WecomClient = None):
|
||||
"""
|
||||
将 WecomEvent 转换为平台的 FriendMessage 对象。
|
||||
|
||||
Args:
|
||||
event (WecomEvent): 企业微信事件。
|
||||
bot (WecomClient): 企业微信客户端,用于获取用户信息。
|
||||
|
||||
Returns:
|
||||
platform_events.FriendMessage: 转换后的 FriendMessage 对象。
|
||||
"""
|
||||
# Try to get the user's real name from the WeCom API
|
||||
nickname = str(event.user_id)
|
||||
if bot and event.user_id:
|
||||
try:
|
||||
user_info = await bot.get_user_info(event.user_id)
|
||||
if user_info and user_info.get('name'):
|
||||
nickname = user_info.get('name')
|
||||
except Exception:
|
||||
pass # Fall back to user_id as nickname
|
||||
|
||||
# 转换消息链
|
||||
if event.type == 'text':
|
||||
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
||||
friend = platform_entities.Friend(
|
||||
id=f'u{event.user_id}',
|
||||
nickname=str(event.agent_id),
|
||||
nickname=nickname,
|
||||
remark='',
|
||||
)
|
||||
|
||||
return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, time=event.timestamp)
|
||||
return platform_events.FriendMessage(
|
||||
sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event
|
||||
)
|
||||
elif event.type == 'image':
|
||||
friend = platform_entities.Friend(
|
||||
id=f'u{event.user_id}',
|
||||
nickname=str(event.agent_id),
|
||||
nickname=nickname,
|
||||
remark='',
|
||||
)
|
||||
|
||||
yiri_chain = await WecomMessageConverter.target2yiri_image(picurl=event.picurl, message_id=event.message_id)
|
||||
|
||||
return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, time=event.timestamp)
|
||||
return platform_events.FriendMessage(
|
||||
sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event
|
||||
)
|
||||
|
||||
|
||||
class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
@@ -210,7 +213,6 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
'secret',
|
||||
'token',
|
||||
'EncodingAESKey',
|
||||
'contacts_secret',
|
||||
]
|
||||
|
||||
missing_keys = [key for key in required_keys if key not in config]
|
||||
@@ -223,7 +225,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
secret=config['secret'],
|
||||
token=config['token'],
|
||||
EncodingAESKey=config['EncodingAESKey'],
|
||||
contacts_secret=config['contacts_secret'],
|
||||
contacts_secret=config.get('contacts_secret', ''), # Optional, kept for backward compatibility
|
||||
logger=logger,
|
||||
unified_mode=True,
|
||||
api_base_url=config.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin'),
|
||||
@@ -248,18 +250,17 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
):
|
||||
Wecom_event = await WecomEventConverter.yiri2target(message_source, self.bot_account_id, self.bot)
|
||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||
fixed_user_id = Wecom_event.user_id
|
||||
# 删掉开头的u
|
||||
fixed_user_id = fixed_user_id[1:]
|
||||
# user_id is the original FromUserName from WecomEvent
|
||||
user_id = Wecom_event.user_id
|
||||
for content in content_list:
|
||||
if content['type'] == 'text':
|
||||
await self.bot.send_private_msg(fixed_user_id, Wecom_event.agent_id, content['content'])
|
||||
await self.bot.send_private_msg(user_id, Wecom_event.agent_id, content['content'])
|
||||
elif content['type'] == 'image':
|
||||
await self.bot.send_image(fixed_user_id, Wecom_event.agent_id, content['media_id'])
|
||||
await self.bot.send_image(user_id, Wecom_event.agent_id, content['media_id'])
|
||||
elif content['type'] == 'voice':
|
||||
await self.bot.send_voice(fixed_user_id, Wecom_event.agent_id, content['media_id'])
|
||||
await self.bot.send_voice(user_id, Wecom_event.agent_id, content['media_id'])
|
||||
elif content['type'] == 'file':
|
||||
await self.bot.send_file(fixed_user_id, Wecom_event.agent_id, content['media_id'])
|
||||
await self.bot.send_file(user_id, Wecom_event.agent_id, content['media_id'])
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||
@@ -287,7 +288,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
async def on_message(event: WecomEvent):
|
||||
self.bot_account_id = event.receiver_id
|
||||
try:
|
||||
return await callback(await self.event_converter.target2yiri(event), self)
|
||||
return await callback(await self.event_converter.target2yiri(event, self.bot), self)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in wecom callback: {traceback.format_exc()}')
|
||||
|
||||
|
||||
@@ -39,13 +39,6 @@ spec:
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: contacts_secret
|
||||
label:
|
||||
en_US: Contacts Secret
|
||||
zh_Hans: 通讯录密钥
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: api_base_url
|
||||
label:
|
||||
en_US: API Base URL
|
||||
|
||||
@@ -314,11 +314,11 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
|
||||
@self.action(PluginToRuntimeAction.GET_LLM_MODELS)
|
||||
async def get_llm_models(data: dict[str, Any]) -> handler.ActionResponse:
|
||||
"""Get llm models"""
|
||||
"""Get llm models, returns list of UUID strings"""
|
||||
llm_models = await self.ap.llm_model_service.get_llm_models(include_secret=False)
|
||||
return handler.ActionResponse.success(
|
||||
data={
|
||||
'llm_models': llm_models,
|
||||
'llm_models': [m['uuid'] for m in llm_models],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -531,6 +531,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
filters = data.get('filters')
|
||||
search_type = data.get('search_type', 'vector')
|
||||
query_text = data.get('query_text', '')
|
||||
vector_weight = data.get('vector_weight')
|
||||
try:
|
||||
results = await self.ap.rag_runtime_service.vector_search(
|
||||
collection_id,
|
||||
@@ -539,6 +540,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
filters,
|
||||
search_type,
|
||||
query_text,
|
||||
vector_weight=vector_weight,
|
||||
)
|
||||
return handler.ActionResponse.success(data={'results': results})
|
||||
except Exception as e:
|
||||
@@ -613,6 +615,47 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
|
||||
# ================= Knowledge Base Query APIs =================
|
||||
|
||||
@self.action(PluginToRuntimeAction.LIST_KNOWLEDGE_BASES)
|
||||
async def list_knowledge_bases(data: dict[str, Any]) -> handler.ActionResponse:
|
||||
"""List all knowledge bases available in the LangBot instance (unrestricted)."""
|
||||
knowledge_bases = []
|
||||
for kb_uuid, kb in self.ap.rag_mgr.knowledge_bases.items():
|
||||
knowledge_bases.append(
|
||||
{
|
||||
'uuid': kb.get_uuid(),
|
||||
'name': kb.get_name(),
|
||||
'description': kb.knowledge_base_entity.description or '',
|
||||
}
|
||||
)
|
||||
return handler.ActionResponse.success(data={'knowledge_bases': knowledge_bases})
|
||||
|
||||
@self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE)
|
||||
async def retrieve_knowledge(data: dict[str, Any]) -> handler.ActionResponse:
|
||||
"""Retrieve documents from any knowledge base (unrestricted)."""
|
||||
kb_id = data['kb_id']
|
||||
query_text = data['query_text']
|
||||
top_k = data.get('top_k', 5)
|
||||
filters = data.get('filters', {})
|
||||
|
||||
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_id)
|
||||
if not kb:
|
||||
return handler.ActionResponse.error(
|
||||
message=f'Knowledge base {kb_id} not found',
|
||||
)
|
||||
|
||||
try:
|
||||
entries = await kb.retrieve(
|
||||
query_text,
|
||||
settings={
|
||||
'top_k': top_k,
|
||||
'filters': filters,
|
||||
},
|
||||
)
|
||||
results = [entry.model_dump(mode='json') for entry in entries]
|
||||
return handler.ActionResponse.success(data={'results': results})
|
||||
except Exception as e:
|
||||
return _make_rag_error_response(e, 'RetrievalError', kb_id=kb_id)
|
||||
|
||||
@self.action(PluginToRuntimeAction.LIST_PIPELINE_KNOWLEDGE_BASES)
|
||||
async def list_pipeline_knowledge_bases(data: dict[str, Any]) -> handler.ActionResponse:
|
||||
"""List knowledge bases configured for the current query's pipeline."""
|
||||
|
||||
@@ -41,6 +41,7 @@ class RAGRuntimeService:
|
||||
filters: dict[str, Any] | None = None,
|
||||
search_type: str = 'vector',
|
||||
query_text: str = '',
|
||||
vector_weight: float | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Handle VECTOR_SEARCH action."""
|
||||
return await self.ap.vector_db_mgr.search(
|
||||
@@ -50,6 +51,7 @@ class RAGRuntimeService:
|
||||
filter=filters,
|
||||
search_type=search_type,
|
||||
query_text=query_text,
|
||||
vector_weight=vector_weight,
|
||||
)
|
||||
|
||||
async def vector_delete(
|
||||
|
||||
@@ -97,6 +97,7 @@ class VectorDBManager:
|
||||
filter: dict | None = None,
|
||||
search_type: str = 'vector',
|
||||
query_text: str = '',
|
||||
vector_weight: float | None = None,
|
||||
) -> list[dict]:
|
||||
"""Proxy: Search vectors.
|
||||
|
||||
@@ -111,6 +112,7 @@ class VectorDBManager:
|
||||
search_type=search_type,
|
||||
query_text=query_text,
|
||||
filter=filter,
|
||||
vector_weight=vector_weight,
|
||||
)
|
||||
|
||||
if not results or 'ids' not in results or not results['ids']:
|
||||
|
||||
@@ -53,6 +53,7 @@ class VectorDatabase(abc.ABC):
|
||||
search_type: str = 'vector',
|
||||
query_text: str = '',
|
||||
filter: dict[str, Any] | None = None,
|
||||
vector_weight: float | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for the most similar vectors in the specified collection.
|
||||
|
||||
@@ -70,6 +71,8 @@ class VectorDatabase(abc.ABC):
|
||||
{"file_id": "abc"}
|
||||
{"created_at": {"$gte": 1700000000}}
|
||||
{"file_type": {"$in": ["pdf", "docx"]}}
|
||||
vector_weight: Weight for vector search in hybrid mode (0.0–1.0).
|
||||
``None`` means use equal weights (backward compatible).
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@@ -52,13 +52,16 @@ class ChromaVectorDatabase(VectorDatabase):
|
||||
search_type: str = 'vector',
|
||||
query_text: str = '',
|
||||
filter: dict[str, Any] | None = None,
|
||||
vector_weight: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
col = await self.get_or_create_collection(collection)
|
||||
|
||||
if search_type == SearchType.FULL_TEXT:
|
||||
return await self._full_text_search(col, collection, k, query_text, filter)
|
||||
elif search_type == SearchType.HYBRID:
|
||||
return await self._hybrid_search(col, collection, query_embedding, k, query_text, filter)
|
||||
return await self._hybrid_search(
|
||||
col, collection, query_embedding, k, query_text, filter, vector_weight=vector_weight
|
||||
)
|
||||
|
||||
# Default: vector search
|
||||
return await self._vector_search(col, collection, query_embedding, k, filter)
|
||||
@@ -127,6 +130,7 @@ class ChromaVectorDatabase(VectorDatabase):
|
||||
k: int,
|
||||
query_text: str,
|
||||
filter: dict[str, Any] | None,
|
||||
vector_weight: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
# Fall back to pure vector search when no text is provided
|
||||
if not query_text:
|
||||
@@ -144,7 +148,15 @@ class ChromaVectorDatabase(VectorDatabase):
|
||||
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
|
||||
|
||||
# RRF fusion
|
||||
fused = self._rrf_fuse([vector_ids, text_ids], k)
|
||||
weights = None
|
||||
if vector_weight is not None:
|
||||
weights = [vector_weight, 1.0 - vector_weight]
|
||||
self.ap.logger.info(
|
||||
f"Chroma hybrid fusion config in '{collection}': "
|
||||
f'vector_weight={vector_weight}, weights={weights or [1.0, 1.0]}, '
|
||||
f'vector_hits={len(vector_ids)}, text_hits={len(text_ids)}'
|
||||
)
|
||||
fused = self._rrf_fuse([vector_ids, text_ids], k, weights=weights)
|
||||
if not fused:
|
||||
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
|
||||
|
||||
@@ -197,16 +209,24 @@ class ChromaVectorDatabase(VectorDatabase):
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _rrf_fuse(result_lists: list[list[str]], k: int) -> list[tuple[str, float]]:
|
||||
def _rrf_fuse(result_lists: list[list[str]], k: int, weights: list[float] | None = None) -> list[tuple[str, float]]:
|
||||
"""Reciprocal Rank Fusion over multiple ranked ID lists.
|
||||
|
||||
Returns a list of (doc_id, rrf_score) sorted by descending score,
|
||||
truncated to *k* entries.
|
||||
|
||||
Args:
|
||||
result_lists: Ranked ID lists from different search methods.
|
||||
k: Number of results to return.
|
||||
weights: Per-list weights. ``None`` means equal weight (1.0 each).
|
||||
"""
|
||||
if weights is None:
|
||||
weights = [1.0] * len(result_lists)
|
||||
scores: dict[str, float] = {}
|
||||
for ranked_ids in result_lists:
|
||||
for list_idx, ranked_ids in enumerate(result_lists):
|
||||
w = weights[list_idx]
|
||||
for rank, doc_id in enumerate(ranked_ids):
|
||||
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (_RRF_K + rank + 1)
|
||||
scores[doc_id] = scores.get(doc_id, 0.0) + w / (_RRF_K + rank + 1)
|
||||
sorted_results = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
||||
return sorted_results[:k]
|
||||
|
||||
|
||||
@@ -255,6 +255,7 @@ class MilvusVectorDatabase(VectorDatabase):
|
||||
search_type: str = 'vector',
|
||||
query_text: str = '',
|
||||
filter: dict[str, Any] | None = None,
|
||||
vector_weight: float | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for similar vectors in Milvus collection
|
||||
|
||||
|
||||
@@ -192,6 +192,7 @@ class PgVectorDatabase(VectorDatabase):
|
||||
search_type: str = 'vector',
|
||||
query_text: str = '',
|
||||
filter: dict[str, Any] | None = None,
|
||||
vector_weight: float | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for similar vectors using cosine distance
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ class QdrantVectorDatabase(VectorDatabase):
|
||||
search_type: str = 'vector',
|
||||
query_text: str = '',
|
||||
filter: dict[str, Any] | None = None,
|
||||
vector_weight: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
exists = await self.client.collection_exists(collection)
|
||||
if not exists:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from decimal import Decimal
|
||||
import re
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
@@ -101,8 +103,28 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
}
|
||||
)
|
||||
|
||||
def _normalize_collection_name(self, collection: str) -> str:
|
||||
"""SeekDB only accepts [a-zA-Z0-9_], while LangBot uses UUID-like KB IDs."""
|
||||
normalized = re.sub(r'[^A-Za-z0-9_]', '_', collection)
|
||||
if normalized != collection:
|
||||
self.ap.logger.info(f"Normalized SeekDB collection name: '{collection}' -> '{normalized}'")
|
||||
return normalized
|
||||
|
||||
def _json_safe(self, value: Any) -> Any:
|
||||
"""Convert SeekDB result values into JSON-serializable Python primitives."""
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if isinstance(value, dict):
|
||||
return {k: self._json_safe(v) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [self._json_safe(v) for v in value]
|
||||
if isinstance(value, tuple):
|
||||
return [self._json_safe(v) for v in value]
|
||||
return value
|
||||
|
||||
async def _get_or_create_collection_internal(self, collection: str, vector_size: int = None) -> Any:
|
||||
"""Internal method to get or create a collection with proper configuration."""
|
||||
collection = self._normalize_collection_name(collection)
|
||||
if collection in self._collections:
|
||||
return self._collections[collection]
|
||||
|
||||
@@ -173,6 +195,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
if not embeddings_list:
|
||||
return
|
||||
|
||||
collection = self._normalize_collection_name(collection)
|
||||
# Ensure collection exists with correct dimension
|
||||
vector_size = len(embeddings_list[0])
|
||||
coll = await self._get_or_create_collection_internal(collection, vector_size)
|
||||
@@ -194,6 +217,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
search_type: str = 'vector',
|
||||
query_text: str = '',
|
||||
filter: Dict[str, Any] | None = None,
|
||||
vector_weight: float | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for the most similar vectors in the specified collection.
|
||||
|
||||
@@ -210,6 +234,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
Returns:
|
||||
Dictionary with 'ids', 'metadatas', 'distances' keys
|
||||
"""
|
||||
collection = self._normalize_collection_name(collection)
|
||||
# Check if collection exists
|
||||
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
||||
if not exists:
|
||||
@@ -271,6 +296,17 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
query_cfg['where'] = filter
|
||||
knn_cfg['where'] = filter
|
||||
|
||||
# Apply vector_weight via pyseekdb's native boost parameter
|
||||
if vector_weight is not None:
|
||||
knn_cfg['boost'] = vector_weight
|
||||
query_cfg['boost'] = 1.0 - vector_weight
|
||||
self.ap.logger.info(
|
||||
f"SeekDB hybrid fusion config in '{collection}': "
|
||||
f'vector_weight={vector_weight}, '
|
||||
f'knn_boost={knn_cfg.get("boost", 1.0)}, '
|
||||
f'query_boost={query_cfg.get("boost", 1.0)}'
|
||||
)
|
||||
|
||||
results = await asyncio.to_thread(
|
||||
coll.hybrid_search,
|
||||
query=query_cfg,
|
||||
@@ -279,6 +315,9 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
n_results=k,
|
||||
include=['documents', 'metadatas'],
|
||||
)
|
||||
self.ap.logger.info(
|
||||
f"SeekDB hybrid search in '{collection}' returned {len(results.get('ids', [[]])[0])} results."
|
||||
)
|
||||
else:
|
||||
# Default: vector search via query()
|
||||
query_kwargs = {'n_results': k, 'query_embeddings': query_embedding}
|
||||
@@ -286,6 +325,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
query_kwargs['where'] = filter
|
||||
results = await asyncio.to_thread(coll.query, **query_kwargs)
|
||||
|
||||
results = self._json_safe(results)
|
||||
self.ap.logger.info(
|
||||
f"SeekDB {search_type} search in '{collection}' returned {len(results.get('ids', [[]])[0])} results"
|
||||
)
|
||||
@@ -299,6 +339,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
collection: Collection name
|
||||
file_id: File ID to delete
|
||||
"""
|
||||
collection = self._normalize_collection_name(collection)
|
||||
# Check if collection exists
|
||||
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
||||
if not exists:
|
||||
@@ -325,6 +366,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
collection: Collection name
|
||||
filter: Chroma-style ``where`` filter dict
|
||||
"""
|
||||
collection = self._normalize_collection_name(collection)
|
||||
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
||||
if not exists:
|
||||
self.ap.logger.warning(f"SeekDB collection '{collection}' not found for deletion")
|
||||
@@ -347,6 +389,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[Dict[str, Any]], int]:
|
||||
collection = self._normalize_collection_name(collection)
|
||||
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
||||
if not exists:
|
||||
return [], 0
|
||||
@@ -367,6 +410,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
|
||||
results = await asyncio.to_thread(coll.get, **get_kwargs)
|
||||
|
||||
results = self._json_safe(results)
|
||||
ids = results.get('ids', [])
|
||||
metadatas = results.get('metadatas', []) or [None] * len(ids)
|
||||
documents = results.get('documents', []) or [None] * len(ids)
|
||||
@@ -390,6 +434,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
Args:
|
||||
collection: Collection name
|
||||
"""
|
||||
collection = self._normalize_collection_name(collection)
|
||||
# Remove from cache
|
||||
if collection in self._collections:
|
||||
del self._collections[collection]
|
||||
|
||||
10
uv.lock
generated
10
uv.lock
generated
@@ -1832,7 +1832,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot"
|
||||
version = "4.9.3"
|
||||
version = "4.9.4"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocqhttp" },
|
||||
@@ -1937,7 +1937,7 @@ requires-dist = [
|
||||
{ name = "ebooklib", specifier = ">=0.18" },
|
||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||
{ name = "langbot-plugin", specifier = "==0.3.3" },
|
||||
{ name = "langbot-plugin", specifier = "==0.3.5" },
|
||||
{ name = "langchain", specifier = ">=0.2.0" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
|
||||
{ name = "lark-oapi", specifier = ">=1.4.15" },
|
||||
@@ -1993,7 +1993,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot-plugin"
|
||||
version = "0.3.3"
|
||||
version = "0.3.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
@@ -2011,9 +2011,9 @@ dependencies = [
|
||||
{ name = "watchdog" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/08/be/78a0375aec6aad34bc2f00e2b4d2511ec597e8143cf8596d0736e8767904/langbot_plugin-0.3.3.tar.gz", hash = "sha256:5b07609b9b08f8b24fefcf29b97b9882838c140143de6d4bac2f320511ef4374", size = 170709, upload-time = "2026-03-19T12:40:23.877Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/8f/0a22e4461b0893ac2afb1b6aaebafe04c921df6dbbf4b8bd6c83cf6a97b2/langbot_plugin-0.3.5.tar.gz", hash = "sha256:79c7feb08f788f480435de8cdefc3cfed4de2dfb03978a460251b8c9d1c271d3", size = 171927, upload-time = "2026-03-25T13:53:18.334Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/92/53/c708f3ef9459420974f1b131cffd43cfb2f97220350c26a6b5fbadbd6211/langbot_plugin-0.3.3-py3-none-any.whl", hash = "sha256:cca94a2ed07c1d3ec4be33f97268746908ed304b528d0eb7f23308677fe619ca", size = 145188, upload-time = "2026-03-19T12:40:25.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/93/fdd4eb54434a358a3917aec74190e2e1b64351a5bb955677f634d29fc4fd/langbot_plugin-0.3.5-py3-none-any.whl", hash = "sha256:4d31f92338e1e2dc343ae00982e4facbe7abae84f4d1c4e1375cdcac9d7155d7", size = 146575, upload-time = "2026-03-25T13:53:16.987Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
EmbeddingCall,
|
||||
} from '../types/monitoring';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
import { parseUTCTimestamp } from '../utils/dateUtils';
|
||||
|
||||
/**
|
||||
* Custom hook for fetching and managing monitoring data
|
||||
@@ -120,7 +121,7 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
variables?: string;
|
||||
}) => ({
|
||||
id: msg.id,
|
||||
timestamp: new Date(msg.timestamp),
|
||||
timestamp: parseUTCTimestamp(msg.timestamp),
|
||||
botId: msg.bot_id,
|
||||
botName: msg.bot_name,
|
||||
pipelineId: msg.pipeline_id,
|
||||
@@ -154,7 +155,7 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
message_id?: string;
|
||||
}) => ({
|
||||
id: call.id,
|
||||
timestamp: new Date(call.timestamp),
|
||||
timestamp: parseUTCTimestamp(call.timestamp),
|
||||
modelName: call.model_name,
|
||||
tokens: {
|
||||
input: call.input_tokens,
|
||||
@@ -190,7 +191,7 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
call_type?: string;
|
||||
}) => ({
|
||||
id: call.id,
|
||||
timestamp: new Date(call.timestamp),
|
||||
timestamp: parseUTCTimestamp(call.timestamp),
|
||||
modelName: call.model_name,
|
||||
promptTokens: call.prompt_tokens,
|
||||
totalTokens: call.total_tokens,
|
||||
@@ -227,10 +228,10 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
pipelineName: session.pipeline_name,
|
||||
messageCount: session.message_count,
|
||||
duration:
|
||||
new Date(session.last_activity).getTime() -
|
||||
new Date(session.start_time).getTime(),
|
||||
lastActivity: new Date(session.last_activity),
|
||||
startTime: new Date(session.start_time),
|
||||
parseUTCTimestamp(session.last_activity).getTime() -
|
||||
parseUTCTimestamp(session.start_time).getTime(),
|
||||
lastActivity: parseUTCTimestamp(session.last_activity),
|
||||
startTime: parseUTCTimestamp(session.start_time),
|
||||
platform: session.platform,
|
||||
userId: session.user_id,
|
||||
}),
|
||||
@@ -250,7 +251,7 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
message_id?: string;
|
||||
}) => ({
|
||||
id: error.id,
|
||||
timestamp: new Date(error.timestamp),
|
||||
timestamp: parseUTCTimestamp(error.timestamp),
|
||||
errorType: error.error_type,
|
||||
errorMessage: error.error_message,
|
||||
botId: error.bot_id,
|
||||
|
||||
@@ -97,3 +97,22 @@ export function isDateInRange(date: Date, range: DateRange | null): boolean {
|
||||
export function parseDate(dateStr: string): Date {
|
||||
return new Date(dateStr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a UTC timestamp string from the backend into a Date object.
|
||||
*
|
||||
* The backend stores all monitoring timestamps in UTC but serializes them
|
||||
* as naive ISO strings (e.g. "2026-03-25T14:30:00") without a timezone
|
||||
* designator. JavaScript's `new Date()` would treat such strings as local
|
||||
* time, causing the displayed time to be off by the user's UTC offset.
|
||||
*
|
||||
* This function appends 'Z' when the string has no timezone info, so that
|
||||
* `new Date()` correctly interprets it as UTC.
|
||||
*/
|
||||
export function parseUTCTimestamp(timestamp: string): Date {
|
||||
// If the string already contains timezone info ('Z', '+', or '-' offset), parse as-is
|
||||
if (/Z|[+-]\d{2}:\d{2}$/.test(timestamp)) {
|
||||
return new Date(timestamp);
|
||||
}
|
||||
return new Date(timestamp + 'Z');
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { MessageContentRenderer } from '@/app/home/monitoring/components/Message
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { MessageDetails } from '@/app/home/monitoring/types/monitoring';
|
||||
import { parseUTCTimestamp } from '@/app/home/monitoring/utils/dateUtils';
|
||||
|
||||
interface PipelineMonitoringTabProps {
|
||||
pipelineId: string;
|
||||
@@ -120,7 +121,7 @@ export default function PipelineMonitoringTab({
|
||||
message: result.message
|
||||
? {
|
||||
id: result.message.id,
|
||||
timestamp: new Date(result.message.timestamp),
|
||||
timestamp: parseUTCTimestamp(result.message.timestamp),
|
||||
botId: result.message.bot_id,
|
||||
botName: result.message.bot_name,
|
||||
pipelineId: result.message.pipeline_id,
|
||||
@@ -137,7 +138,7 @@ export default function PipelineMonitoringTab({
|
||||
: undefined,
|
||||
llmCalls: result.llm_calls.map((call: RawLLMCallData) => ({
|
||||
id: call.id,
|
||||
timestamp: new Date(call.timestamp),
|
||||
timestamp: parseUTCTimestamp(call.timestamp),
|
||||
modelName: call.model_name,
|
||||
status: call.status,
|
||||
duration: call.duration,
|
||||
@@ -150,7 +151,7 @@ export default function PipelineMonitoringTab({
|
||||
})),
|
||||
errors: result.errors.map((error: RawErrorData) => ({
|
||||
id: error.id,
|
||||
timestamp: new Date(error.timestamp),
|
||||
timestamp: parseUTCTimestamp(error.timestamp),
|
||||
errorType: error.error_type,
|
||||
errorMessage: error.error_message,
|
||||
stackTrace: error.stack_trace,
|
||||
|
||||
@@ -38,12 +38,10 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||
(props, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [pluginList, setPluginList] = useState<PluginCardVO[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
||||
const [detailModalOpen, setDetailModalOpen] = useState<boolean>(false);
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginCardVO | null>(
|
||||
null,
|
||||
);
|
||||
const [readmeModalOpen, setReadmeModalOpen] = useState<boolean>(false);
|
||||
const [readmePlugin, setReadmePlugin] = useState<PluginCardVO | null>(null);
|
||||
const [showOperationModal, setShowOperationModal] = useState(false);
|
||||
const [operationType, setOperationType] = useState<PluginOperationType>(
|
||||
PluginOperationType.DELETE,
|
||||
@@ -166,12 +164,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||
|
||||
function handlePluginClick(plugin: PluginCardVO) {
|
||||
setSelectedPlugin(plugin);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function handleViewReadme(plugin: PluginCardVO) {
|
||||
setReadmePlugin(plugin);
|
||||
setReadmeModalOpen(true);
|
||||
setDetailModalOpen(true);
|
||||
}
|
||||
|
||||
function handlePluginDelete(plugin: PluginCardVO) {
|
||||
@@ -355,52 +348,46 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${styles.pluginListContainer}`}>
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogContent className="w-[700px] max-h-[80vh] p-0 flex flex-col">
|
||||
<DialogHeader className="px-6 pt-6 pb-2">
|
||||
<DialogTitle>{t('plugins.pluginConfig')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6">
|
||||
{selectedPlugin && (
|
||||
<PluginForm
|
||||
pluginAuthor={selectedPlugin.author}
|
||||
pluginName={selectedPlugin.name}
|
||||
onFormSubmit={(timeout?: number) => {
|
||||
setModalOpen(false);
|
||||
if (timeout) {
|
||||
setTimeout(() => {
|
||||
getPluginList();
|
||||
}, timeout);
|
||||
} else {
|
||||
getPluginList();
|
||||
}
|
||||
}}
|
||||
onFormCancel={() => {
|
||||
setModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={readmeModalOpen} onOpenChange={setReadmeModalOpen}>
|
||||
<DialogContent className="sm:max-w-[900px] max-w-[90vw] max-h-[85vh] p-0 flex flex-col">
|
||||
<Dialog open={detailModalOpen} onOpenChange={setDetailModalOpen}>
|
||||
<DialogContent className="sm:max-w-[1100px] max-w-[95vw] max-h-[85vh] p-0 flex flex-col">
|
||||
<DialogHeader className="px-6 pt-6 pb-2 border-b">
|
||||
<DialogTitle>
|
||||
{readmePlugin &&
|
||||
`${readmePlugin.author}/${readmePlugin.name} - ${t(
|
||||
'plugins.readme',
|
||||
)}`}
|
||||
{selectedPlugin &&
|
||||
`${selectedPlugin.author}/${selectedPlugin.name}`}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{readmePlugin && (
|
||||
<PluginReadme
|
||||
pluginAuthor={readmePlugin.author}
|
||||
pluginName={readmePlugin.name}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 flex flex-row overflow-hidden min-h-0">
|
||||
{/* Left side - Readme */}
|
||||
<div className="flex-1 overflow-y-auto border-r min-w-0">
|
||||
{selectedPlugin && (
|
||||
<PluginReadme
|
||||
pluginAuthor={selectedPlugin.author}
|
||||
pluginName={selectedPlugin.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Right side - Config */}
|
||||
<div className="w-[380px] flex-shrink-0 overflow-y-auto px-4">
|
||||
{selectedPlugin && (
|
||||
<PluginForm
|
||||
pluginAuthor={selectedPlugin.author}
|
||||
pluginName={selectedPlugin.name}
|
||||
onFormSubmit={(timeout?: number) => {
|
||||
setDetailModalOpen(false);
|
||||
if (timeout) {
|
||||
setTimeout(() => {
|
||||
getPluginList();
|
||||
}, timeout);
|
||||
} else {
|
||||
getPluginList();
|
||||
}
|
||||
}}
|
||||
onFormCancel={() => {
|
||||
setDetailModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -413,7 +400,6 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||
onCardClick={() => handlePluginClick(vo)}
|
||||
onDeleteClick={() => handlePluginDelete(vo)}
|
||||
onUpgradeClick={() => handlePluginUpdate(vo)}
|
||||
onViewReadme={() => handleViewReadme(vo)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,15 +2,7 @@ import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/Plu
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
BugIcon,
|
||||
ExternalLink,
|
||||
Ellipsis,
|
||||
Trash,
|
||||
ArrowUp,
|
||||
Settings,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { BugIcon, ExternalLink, Ellipsis, Trash, ArrowUp } from 'lucide-react';
|
||||
import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -27,28 +19,20 @@ export default function PluginCardComponent({
|
||||
onCardClick,
|
||||
onDeleteClick,
|
||||
onUpgradeClick,
|
||||
onViewReadme,
|
||||
}: {
|
||||
cardVO: PluginCardVO;
|
||||
onCardClick: () => void;
|
||||
onDeleteClick: (cardVO: PluginCardVO) => void;
|
||||
onUpgradeClick: (cardVO: PluginCardVO) => void;
|
||||
onViewReadme: (cardVO: PluginCardVO) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] p-[1.2rem] cursor-pointer dark:bg-[#1f1f22] relative transition-all duration-200 hover:shadow-[0px_3px_6px_0_rgba(0,0,0,0.12)] hover:scale-[1.005]"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => {
|
||||
if (!dropdownOpen) {
|
||||
setIsHovered(false);
|
||||
}
|
||||
}}
|
||||
onClick={() => onCardClick()}
|
||||
>
|
||||
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
|
||||
{/* Icon - fixed width */}
|
||||
@@ -145,7 +129,10 @@ export default function PluginCardComponent({
|
||||
</div>
|
||||
|
||||
{/* Menu button - fixed width and position */}
|
||||
<div className="flex flex-col items-center justify-between h-full relative z-20 flex-shrink-0">
|
||||
<div
|
||||
className="flex flex-col items-center justify-between h-full relative z-20 flex-shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-center"></div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
@@ -153,9 +140,6 @@ export default function PluginCardComponent({
|
||||
open={dropdownOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDropdownOpen(open);
|
||||
if (!open) {
|
||||
setIsHovered(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -233,45 +217,6 @@ export default function PluginCardComponent({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover overlay with action buttons */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-gray-100/55 dark:bg-black/35 rounded-[10px] flex items-center justify-center gap-3 transition-all duration-200 z-10 ${
|
||||
isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewReadme(cardVO);
|
||||
}}
|
||||
className={`bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
|
||||
isHovered
|
||||
? 'translate-y-0 opacity-100'
|
||||
: 'translate-y-1 opacity-0'
|
||||
}`}
|
||||
style={{ transitionDelay: isHovered ? '10ms' : '0ms' }}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
{t('plugins.readme')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCardClick();
|
||||
}}
|
||||
variant="outline"
|
||||
className={`bg-white hover:bg-gray-100 text-gray-900 dark:bg-white dark:hover:bg-gray-100 dark:text-gray-900 px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
|
||||
isHovered
|
||||
? 'translate-y-0 opacity-100'
|
||||
: 'translate-y-1 opacity-0'
|
||||
}`}
|
||||
style={{ transitionDelay: isHovered ? '20ms' : '0ms' }}
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
{t('plugins.config')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -66,6 +66,7 @@ function MarketPageContent({
|
||||
const pageSize = 12; // 每页12个
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const isComposingRef = useRef(false);
|
||||
|
||||
// 排序选项
|
||||
const sortOptions: SortOption[] = [
|
||||
@@ -250,10 +251,14 @@ function MarketPageContent({
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (isComposingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置新的定时器
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
handleSearch(value);
|
||||
}, 300);
|
||||
}, 500);
|
||||
},
|
||||
[handleSearch],
|
||||
);
|
||||
@@ -398,6 +403,13 @@ function MarketPageContent({
|
||||
placeholder={t('market.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchInputChange(e.target.value)}
|
||||
onCompositionStart={() => {
|
||||
isComposingRef.current = true;
|
||||
}}
|
||||
onCompositionEnd={(e) => {
|
||||
isComposingRef.current = false;
|
||||
handleSearchInputChange((e.target as HTMLInputElement).value);
|
||||
}}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
// Immediately search, clear debounce timer
|
||||
|
||||
Reference in New Issue
Block a user