mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-13 01:06:03 +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。
|
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 平台。
|
- **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 等平台。
|
- **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。
|
||||||
- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。
|
- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。
|
||||||
- **插件生态** — 数百个插件,事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
|
- **插件生态** — 数百个插件,跨进程的事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
|
||||||
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
|
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
|
||||||
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
|
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.3"
|
version = "4.9.4"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -64,7 +64,7 @@ dependencies = [
|
|||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin==0.3.3",
|
"langbot-plugin==0.3.5",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"tboxsdk>=0.0.10",
|
"tboxsdk>=0.0.10",
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
"""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 binascii
|
||||||
import httpx
|
import httpx
|
||||||
import traceback
|
import traceback
|
||||||
|
from urllib.parse import quote
|
||||||
from quart import Quart
|
from quart import Quart
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from typing import Callable, Dict, Any
|
from typing import Callable, Dict, Any
|
||||||
@@ -67,6 +68,31 @@ class WecomClient:
|
|||||||
await self.logger.error(f'获取accesstoken失败:{response.json()}')
|
await self.logger.error(f'获取accesstoken失败:{response.json()}')
|
||||||
raise Exception(f'未获取access token: {data}')
|
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):
|
async def get_users(self):
|
||||||
if not self.check_access_token_for_contacts():
|
if not self.check_access_token_for_contacts():
|
||||||
self.access_token_for_contacts = await self.get_access_token(self.secret_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')
|
@group.group_class('files', '/api/v1/files')
|
||||||
class FilesRouterGroup(group.RouterGroup):
|
class FilesRouterGroup(group.RouterGroup):
|
||||||
async def initialize(self) -> None:
|
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:
|
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)
|
return quart.Response(status=404)
|
||||||
|
|
||||||
if not await self.ap.storage_mgr.storage_provider.exists(image_key):
|
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
|
pass
|
||||||
|
|
||||||
if type(event) is platform_events.FriendMessage:
|
if type(event) is platform_events.FriendMessage:
|
||||||
payload = {
|
return event.source_platform_object
|
||||||
'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
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def target2yiri(event: WecomEvent):
|
async def target2yiri(event: WecomEvent, bot: WecomClient = None):
|
||||||
"""
|
"""
|
||||||
将 WecomEvent 转换为平台的 FriendMessage 对象。
|
将 WecomEvent 转换为平台的 FriendMessage 对象。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
event (WecomEvent): 企业微信事件。
|
event (WecomEvent): 企业微信事件。
|
||||||
|
bot (WecomClient): 企业微信客户端,用于获取用户信息。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
platform_events.FriendMessage: 转换后的 FriendMessage 对象。
|
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':
|
if event.type == 'text':
|
||||||
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'u{event.user_id}',
|
||||||
nickname=str(event.agent_id),
|
nickname=nickname,
|
||||||
remark='',
|
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':
|
elif event.type == 'image':
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'u{event.user_id}',
|
||||||
nickname=str(event.agent_id),
|
nickname=nickname,
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
|
|
||||||
yiri_chain = await WecomMessageConverter.target2yiri_image(picurl=event.picurl, message_id=event.message_id)
|
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):
|
class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
@@ -210,7 +213,6 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
'secret',
|
'secret',
|
||||||
'token',
|
'token',
|
||||||
'EncodingAESKey',
|
'EncodingAESKey',
|
||||||
'contacts_secret',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
missing_keys = [key for key in required_keys if key not in config]
|
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'],
|
secret=config['secret'],
|
||||||
token=config['token'],
|
token=config['token'],
|
||||||
EncodingAESKey=config['EncodingAESKey'],
|
EncodingAESKey=config['EncodingAESKey'],
|
||||||
contacts_secret=config['contacts_secret'],
|
contacts_secret=config.get('contacts_secret', ''), # Optional, kept for backward compatibility
|
||||||
logger=logger,
|
logger=logger,
|
||||||
unified_mode=True,
|
unified_mode=True,
|
||||||
api_base_url=config.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin'),
|
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)
|
Wecom_event = await WecomEventConverter.yiri2target(message_source, self.bot_account_id, self.bot)
|
||||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||||
fixed_user_id = Wecom_event.user_id
|
# user_id is the original FromUserName from WecomEvent
|
||||||
# 删掉开头的u
|
user_id = Wecom_event.user_id
|
||||||
fixed_user_id = fixed_user_id[1:]
|
|
||||||
for content in content_list:
|
for content in content_list:
|
||||||
if content['type'] == 'text':
|
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':
|
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':
|
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':
|
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):
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||||
@@ -287,7 +288,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
async def on_message(event: WecomEvent):
|
async def on_message(event: WecomEvent):
|
||||||
self.bot_account_id = event.receiver_id
|
self.bot_account_id = event.receiver_id
|
||||||
try:
|
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:
|
except Exception:
|
||||||
await self.logger.error(f'Error in wecom callback: {traceback.format_exc()}')
|
await self.logger.error(f'Error in wecom callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
|||||||
@@ -39,13 +39,6 @@ spec:
|
|||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
- name: contacts_secret
|
|
||||||
label:
|
|
||||||
en_US: Contacts Secret
|
|
||||||
zh_Hans: 通讯录密钥
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
default: ""
|
|
||||||
- name: api_base_url
|
- name: api_base_url
|
||||||
label:
|
label:
|
||||||
en_US: API Base URL
|
en_US: API Base URL
|
||||||
|
|||||||
@@ -314,11 +314,11 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
|
|
||||||
@self.action(PluginToRuntimeAction.GET_LLM_MODELS)
|
@self.action(PluginToRuntimeAction.GET_LLM_MODELS)
|
||||||
async def get_llm_models(data: dict[str, Any]) -> handler.ActionResponse:
|
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)
|
llm_models = await self.ap.llm_model_service.get_llm_models(include_secret=False)
|
||||||
return handler.ActionResponse.success(
|
return handler.ActionResponse.success(
|
||||||
data={
|
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')
|
filters = data.get('filters')
|
||||||
search_type = data.get('search_type', 'vector')
|
search_type = data.get('search_type', 'vector')
|
||||||
query_text = data.get('query_text', '')
|
query_text = data.get('query_text', '')
|
||||||
|
vector_weight = data.get('vector_weight')
|
||||||
try:
|
try:
|
||||||
results = await self.ap.rag_runtime_service.vector_search(
|
results = await self.ap.rag_runtime_service.vector_search(
|
||||||
collection_id,
|
collection_id,
|
||||||
@@ -539,6 +540,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
filters,
|
filters,
|
||||||
search_type,
|
search_type,
|
||||||
query_text,
|
query_text,
|
||||||
|
vector_weight=vector_weight,
|
||||||
)
|
)
|
||||||
return handler.ActionResponse.success(data={'results': results})
|
return handler.ActionResponse.success(data={'results': results})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -613,6 +615,47 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
|
|
||||||
# ================= Knowledge Base Query APIs =================
|
# ================= 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)
|
@self.action(PluginToRuntimeAction.LIST_PIPELINE_KNOWLEDGE_BASES)
|
||||||
async def list_pipeline_knowledge_bases(data: dict[str, Any]) -> handler.ActionResponse:
|
async def list_pipeline_knowledge_bases(data: dict[str, Any]) -> handler.ActionResponse:
|
||||||
"""List knowledge bases configured for the current query's pipeline."""
|
"""List knowledge bases configured for the current query's pipeline."""
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class RAGRuntimeService:
|
|||||||
filters: dict[str, Any] | None = None,
|
filters: dict[str, Any] | None = None,
|
||||||
search_type: str = 'vector',
|
search_type: str = 'vector',
|
||||||
query_text: str = '',
|
query_text: str = '',
|
||||||
|
vector_weight: float | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Handle VECTOR_SEARCH action."""
|
"""Handle VECTOR_SEARCH action."""
|
||||||
return await self.ap.vector_db_mgr.search(
|
return await self.ap.vector_db_mgr.search(
|
||||||
@@ -50,6 +51,7 @@ class RAGRuntimeService:
|
|||||||
filter=filters,
|
filter=filters,
|
||||||
search_type=search_type,
|
search_type=search_type,
|
||||||
query_text=query_text,
|
query_text=query_text,
|
||||||
|
vector_weight=vector_weight,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def vector_delete(
|
async def vector_delete(
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ class VectorDBManager:
|
|||||||
filter: dict | None = None,
|
filter: dict | None = None,
|
||||||
search_type: str = 'vector',
|
search_type: str = 'vector',
|
||||||
query_text: str = '',
|
query_text: str = '',
|
||||||
|
vector_weight: float | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Proxy: Search vectors.
|
"""Proxy: Search vectors.
|
||||||
|
|
||||||
@@ -111,6 +112,7 @@ class VectorDBManager:
|
|||||||
search_type=search_type,
|
search_type=search_type,
|
||||||
query_text=query_text,
|
query_text=query_text,
|
||||||
filter=filter,
|
filter=filter,
|
||||||
|
vector_weight=vector_weight,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not results or 'ids' not in results or not results['ids']:
|
if not results or 'ids' not in results or not results['ids']:
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class VectorDatabase(abc.ABC):
|
|||||||
search_type: str = 'vector',
|
search_type: str = 'vector',
|
||||||
query_text: str = '',
|
query_text: str = '',
|
||||||
filter: dict[str, Any] | None = None,
|
filter: dict[str, Any] | None = None,
|
||||||
|
vector_weight: float | None = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Search for the most similar vectors in the specified collection.
|
"""Search for the most similar vectors in the specified collection.
|
||||||
|
|
||||||
@@ -70,6 +71,8 @@ class VectorDatabase(abc.ABC):
|
|||||||
{"file_id": "abc"}
|
{"file_id": "abc"}
|
||||||
{"created_at": {"$gte": 1700000000}}
|
{"created_at": {"$gte": 1700000000}}
|
||||||
{"file_type": {"$in": ["pdf", "docx"]}}
|
{"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
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -52,13 +52,16 @@ class ChromaVectorDatabase(VectorDatabase):
|
|||||||
search_type: str = 'vector',
|
search_type: str = 'vector',
|
||||||
query_text: str = '',
|
query_text: str = '',
|
||||||
filter: dict[str, Any] | None = None,
|
filter: dict[str, Any] | None = None,
|
||||||
|
vector_weight: float | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
col = await self.get_or_create_collection(collection)
|
col = await self.get_or_create_collection(collection)
|
||||||
|
|
||||||
if search_type == SearchType.FULL_TEXT:
|
if search_type == SearchType.FULL_TEXT:
|
||||||
return await self._full_text_search(col, collection, k, query_text, filter)
|
return await self._full_text_search(col, collection, k, query_text, filter)
|
||||||
elif search_type == SearchType.HYBRID:
|
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
|
# Default: vector search
|
||||||
return await self._vector_search(col, collection, query_embedding, k, filter)
|
return await self._vector_search(col, collection, query_embedding, k, filter)
|
||||||
@@ -127,6 +130,7 @@ class ChromaVectorDatabase(VectorDatabase):
|
|||||||
k: int,
|
k: int,
|
||||||
query_text: str,
|
query_text: str,
|
||||||
filter: dict[str, Any] | None,
|
filter: dict[str, Any] | None,
|
||||||
|
vector_weight: float | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
# Fall back to pure vector search when no text is provided
|
# Fall back to pure vector search when no text is provided
|
||||||
if not query_text:
|
if not query_text:
|
||||||
@@ -144,7 +148,15 @@ class ChromaVectorDatabase(VectorDatabase):
|
|||||||
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
|
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
|
||||||
|
|
||||||
# RRF fusion
|
# 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:
|
if not fused:
|
||||||
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
|
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
|
||||||
|
|
||||||
@@ -197,16 +209,24 @@ class ChromaVectorDatabase(VectorDatabase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@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.
|
"""Reciprocal Rank Fusion over multiple ranked ID lists.
|
||||||
|
|
||||||
Returns a list of (doc_id, rrf_score) sorted by descending score,
|
Returns a list of (doc_id, rrf_score) sorted by descending score,
|
||||||
truncated to *k* entries.
|
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] = {}
|
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):
|
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)
|
sorted_results = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
||||||
return sorted_results[:k]
|
return sorted_results[:k]
|
||||||
|
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ class MilvusVectorDatabase(VectorDatabase):
|
|||||||
search_type: str = 'vector',
|
search_type: str = 'vector',
|
||||||
query_text: str = '',
|
query_text: str = '',
|
||||||
filter: dict[str, Any] | None = None,
|
filter: dict[str, Any] | None = None,
|
||||||
|
vector_weight: float | None = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Search for similar vectors in Milvus collection
|
"""Search for similar vectors in Milvus collection
|
||||||
|
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ class PgVectorDatabase(VectorDatabase):
|
|||||||
search_type: str = 'vector',
|
search_type: str = 'vector',
|
||||||
query_text: str = '',
|
query_text: str = '',
|
||||||
filter: dict[str, Any] | None = None,
|
filter: dict[str, Any] | None = None,
|
||||||
|
vector_weight: float | None = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Search for similar vectors using cosine distance
|
"""Search for similar vectors using cosine distance
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ class QdrantVectorDatabase(VectorDatabase):
|
|||||||
search_type: str = 'vector',
|
search_type: str = 'vector',
|
||||||
query_text: str = '',
|
query_text: str = '',
|
||||||
filter: dict[str, Any] | None = None,
|
filter: dict[str, Any] | None = None,
|
||||||
|
vector_weight: float | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
exists = await self.client.collection_exists(collection)
|
exists = await self.client.collection_exists(collection)
|
||||||
if not exists:
|
if not exists:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from decimal import Decimal
|
||||||
|
import re
|
||||||
from typing import Any, Dict, List
|
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:
|
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."""
|
"""Internal method to get or create a collection with proper configuration."""
|
||||||
|
collection = self._normalize_collection_name(collection)
|
||||||
if collection in self._collections:
|
if collection in self._collections:
|
||||||
return self._collections[collection]
|
return self._collections[collection]
|
||||||
|
|
||||||
@@ -173,6 +195,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
if not embeddings_list:
|
if not embeddings_list:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
collection = self._normalize_collection_name(collection)
|
||||||
# Ensure collection exists with correct dimension
|
# Ensure collection exists with correct dimension
|
||||||
vector_size = len(embeddings_list[0])
|
vector_size = len(embeddings_list[0])
|
||||||
coll = await self._get_or_create_collection_internal(collection, vector_size)
|
coll = await self._get_or_create_collection_internal(collection, vector_size)
|
||||||
@@ -194,6 +217,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
search_type: str = 'vector',
|
search_type: str = 'vector',
|
||||||
query_text: str = '',
|
query_text: str = '',
|
||||||
filter: Dict[str, Any] | None = None,
|
filter: Dict[str, Any] | None = None,
|
||||||
|
vector_weight: float | None = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Search for the most similar vectors in the specified collection.
|
"""Search for the most similar vectors in the specified collection.
|
||||||
|
|
||||||
@@ -210,6 +234,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary with 'ids', 'metadatas', 'distances' keys
|
Dictionary with 'ids', 'metadatas', 'distances' keys
|
||||||
"""
|
"""
|
||||||
|
collection = self._normalize_collection_name(collection)
|
||||||
# Check if collection exists
|
# Check if collection exists
|
||||||
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
||||||
if not exists:
|
if not exists:
|
||||||
@@ -271,6 +296,17 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
query_cfg['where'] = filter
|
query_cfg['where'] = filter
|
||||||
knn_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(
|
results = await asyncio.to_thread(
|
||||||
coll.hybrid_search,
|
coll.hybrid_search,
|
||||||
query=query_cfg,
|
query=query_cfg,
|
||||||
@@ -279,6 +315,9 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
n_results=k,
|
n_results=k,
|
||||||
include=['documents', 'metadatas'],
|
include=['documents', 'metadatas'],
|
||||||
)
|
)
|
||||||
|
self.ap.logger.info(
|
||||||
|
f"SeekDB hybrid search in '{collection}' returned {len(results.get('ids', [[]])[0])} results."
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Default: vector search via query()
|
# Default: vector search via query()
|
||||||
query_kwargs = {'n_results': k, 'query_embeddings': query_embedding}
|
query_kwargs = {'n_results': k, 'query_embeddings': query_embedding}
|
||||||
@@ -286,6 +325,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
query_kwargs['where'] = filter
|
query_kwargs['where'] = filter
|
||||||
results = await asyncio.to_thread(coll.query, **query_kwargs)
|
results = await asyncio.to_thread(coll.query, **query_kwargs)
|
||||||
|
|
||||||
|
results = self._json_safe(results)
|
||||||
self.ap.logger.info(
|
self.ap.logger.info(
|
||||||
f"SeekDB {search_type} search in '{collection}' returned {len(results.get('ids', [[]])[0])} results"
|
f"SeekDB {search_type} search in '{collection}' returned {len(results.get('ids', [[]])[0])} results"
|
||||||
)
|
)
|
||||||
@@ -299,6 +339,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
collection: Collection name
|
collection: Collection name
|
||||||
file_id: File ID to delete
|
file_id: File ID to delete
|
||||||
"""
|
"""
|
||||||
|
collection = self._normalize_collection_name(collection)
|
||||||
# Check if collection exists
|
# Check if collection exists
|
||||||
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
||||||
if not exists:
|
if not exists:
|
||||||
@@ -325,6 +366,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
collection: Collection name
|
collection: Collection name
|
||||||
filter: Chroma-style ``where`` filter dict
|
filter: Chroma-style ``where`` filter dict
|
||||||
"""
|
"""
|
||||||
|
collection = self._normalize_collection_name(collection)
|
||||||
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
||||||
if not exists:
|
if not exists:
|
||||||
self.ap.logger.warning(f"SeekDB collection '{collection}' not found for deletion")
|
self.ap.logger.warning(f"SeekDB collection '{collection}' not found for deletion")
|
||||||
@@ -347,6 +389,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
) -> tuple[list[Dict[str, Any]], int]:
|
) -> tuple[list[Dict[str, Any]], int]:
|
||||||
|
collection = self._normalize_collection_name(collection)
|
||||||
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
||||||
if not exists:
|
if not exists:
|
||||||
return [], 0
|
return [], 0
|
||||||
@@ -367,6 +410,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
|
|
||||||
results = await asyncio.to_thread(coll.get, **get_kwargs)
|
results = await asyncio.to_thread(coll.get, **get_kwargs)
|
||||||
|
|
||||||
|
results = self._json_safe(results)
|
||||||
ids = results.get('ids', [])
|
ids = results.get('ids', [])
|
||||||
metadatas = results.get('metadatas', []) or [None] * len(ids)
|
metadatas = results.get('metadatas', []) or [None] * len(ids)
|
||||||
documents = results.get('documents', []) or [None] * len(ids)
|
documents = results.get('documents', []) or [None] * len(ids)
|
||||||
@@ -390,6 +434,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
Args:
|
Args:
|
||||||
collection: Collection name
|
collection: Collection name
|
||||||
"""
|
"""
|
||||||
|
collection = self._normalize_collection_name(collection)
|
||||||
# Remove from cache
|
# Remove from cache
|
||||||
if collection in self._collections:
|
if collection in self._collections:
|
||||||
del self._collections[collection]
|
del self._collections[collection]
|
||||||
|
|||||||
10
uv.lock
generated
10
uv.lock
generated
@@ -1832,7 +1832,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.3"
|
version = "4.9.4"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiocqhttp" },
|
{ name = "aiocqhttp" },
|
||||||
@@ -1937,7 +1937,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ 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", specifier = ">=0.2.0" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
|
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
|
||||||
{ name = "lark-oapi", specifier = ">=1.4.15" },
|
{ name = "lark-oapi", specifier = ">=1.4.15" },
|
||||||
@@ -1993,7 +1993,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.3.3"
|
version = "0.3.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
@@ -2011,9 +2011,9 @@ dependencies = [
|
|||||||
{ name = "watchdog" },
|
{ name = "watchdog" },
|
||||||
{ name = "websockets" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
EmbeddingCall,
|
EmbeddingCall,
|
||||||
} from '../types/monitoring';
|
} from '../types/monitoring';
|
||||||
import { backendClient } from '@/app/infra/http';
|
import { backendClient } from '@/app/infra/http';
|
||||||
|
import { parseUTCTimestamp } from '../utils/dateUtils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for fetching and managing monitoring data
|
* Custom hook for fetching and managing monitoring data
|
||||||
@@ -120,7 +121,7 @@ export function useMonitoringData(filterState: FilterState) {
|
|||||||
variables?: string;
|
variables?: string;
|
||||||
}) => ({
|
}) => ({
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
timestamp: new Date(msg.timestamp),
|
timestamp: parseUTCTimestamp(msg.timestamp),
|
||||||
botId: msg.bot_id,
|
botId: msg.bot_id,
|
||||||
botName: msg.bot_name,
|
botName: msg.bot_name,
|
||||||
pipelineId: msg.pipeline_id,
|
pipelineId: msg.pipeline_id,
|
||||||
@@ -154,7 +155,7 @@ export function useMonitoringData(filterState: FilterState) {
|
|||||||
message_id?: string;
|
message_id?: string;
|
||||||
}) => ({
|
}) => ({
|
||||||
id: call.id,
|
id: call.id,
|
||||||
timestamp: new Date(call.timestamp),
|
timestamp: parseUTCTimestamp(call.timestamp),
|
||||||
modelName: call.model_name,
|
modelName: call.model_name,
|
||||||
tokens: {
|
tokens: {
|
||||||
input: call.input_tokens,
|
input: call.input_tokens,
|
||||||
@@ -190,7 +191,7 @@ export function useMonitoringData(filterState: FilterState) {
|
|||||||
call_type?: string;
|
call_type?: string;
|
||||||
}) => ({
|
}) => ({
|
||||||
id: call.id,
|
id: call.id,
|
||||||
timestamp: new Date(call.timestamp),
|
timestamp: parseUTCTimestamp(call.timestamp),
|
||||||
modelName: call.model_name,
|
modelName: call.model_name,
|
||||||
promptTokens: call.prompt_tokens,
|
promptTokens: call.prompt_tokens,
|
||||||
totalTokens: call.total_tokens,
|
totalTokens: call.total_tokens,
|
||||||
@@ -227,10 +228,10 @@ export function useMonitoringData(filterState: FilterState) {
|
|||||||
pipelineName: session.pipeline_name,
|
pipelineName: session.pipeline_name,
|
||||||
messageCount: session.message_count,
|
messageCount: session.message_count,
|
||||||
duration:
|
duration:
|
||||||
new Date(session.last_activity).getTime() -
|
parseUTCTimestamp(session.last_activity).getTime() -
|
||||||
new Date(session.start_time).getTime(),
|
parseUTCTimestamp(session.start_time).getTime(),
|
||||||
lastActivity: new Date(session.last_activity),
|
lastActivity: parseUTCTimestamp(session.last_activity),
|
||||||
startTime: new Date(session.start_time),
|
startTime: parseUTCTimestamp(session.start_time),
|
||||||
platform: session.platform,
|
platform: session.platform,
|
||||||
userId: session.user_id,
|
userId: session.user_id,
|
||||||
}),
|
}),
|
||||||
@@ -250,7 +251,7 @@ export function useMonitoringData(filterState: FilterState) {
|
|||||||
message_id?: string;
|
message_id?: string;
|
||||||
}) => ({
|
}) => ({
|
||||||
id: error.id,
|
id: error.id,
|
||||||
timestamp: new Date(error.timestamp),
|
timestamp: parseUTCTimestamp(error.timestamp),
|
||||||
errorType: error.error_type,
|
errorType: error.error_type,
|
||||||
errorMessage: error.error_message,
|
errorMessage: error.error_message,
|
||||||
botId: error.bot_id,
|
botId: error.bot_id,
|
||||||
|
|||||||
@@ -97,3 +97,22 @@ export function isDateInRange(date: Date, range: DateRange | null): boolean {
|
|||||||
export function parseDate(dateStr: string): Date {
|
export function parseDate(dateStr: string): Date {
|
||||||
return new Date(dateStr);
|
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 { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { MessageDetails } from '@/app/home/monitoring/types/monitoring';
|
import { MessageDetails } from '@/app/home/monitoring/types/monitoring';
|
||||||
|
import { parseUTCTimestamp } from '@/app/home/monitoring/utils/dateUtils';
|
||||||
|
|
||||||
interface PipelineMonitoringTabProps {
|
interface PipelineMonitoringTabProps {
|
||||||
pipelineId: string;
|
pipelineId: string;
|
||||||
@@ -120,7 +121,7 @@ export default function PipelineMonitoringTab({
|
|||||||
message: result.message
|
message: result.message
|
||||||
? {
|
? {
|
||||||
id: result.message.id,
|
id: result.message.id,
|
||||||
timestamp: new Date(result.message.timestamp),
|
timestamp: parseUTCTimestamp(result.message.timestamp),
|
||||||
botId: result.message.bot_id,
|
botId: result.message.bot_id,
|
||||||
botName: result.message.bot_name,
|
botName: result.message.bot_name,
|
||||||
pipelineId: result.message.pipeline_id,
|
pipelineId: result.message.pipeline_id,
|
||||||
@@ -137,7 +138,7 @@ export default function PipelineMonitoringTab({
|
|||||||
: undefined,
|
: undefined,
|
||||||
llmCalls: result.llm_calls.map((call: RawLLMCallData) => ({
|
llmCalls: result.llm_calls.map((call: RawLLMCallData) => ({
|
||||||
id: call.id,
|
id: call.id,
|
||||||
timestamp: new Date(call.timestamp),
|
timestamp: parseUTCTimestamp(call.timestamp),
|
||||||
modelName: call.model_name,
|
modelName: call.model_name,
|
||||||
status: call.status,
|
status: call.status,
|
||||||
duration: call.duration,
|
duration: call.duration,
|
||||||
@@ -150,7 +151,7 @@ export default function PipelineMonitoringTab({
|
|||||||
})),
|
})),
|
||||||
errors: result.errors.map((error: RawErrorData) => ({
|
errors: result.errors.map((error: RawErrorData) => ({
|
||||||
id: error.id,
|
id: error.id,
|
||||||
timestamp: new Date(error.timestamp),
|
timestamp: parseUTCTimestamp(error.timestamp),
|
||||||
errorType: error.error_type,
|
errorType: error.error_type,
|
||||||
errorMessage: error.error_message,
|
errorMessage: error.error_message,
|
||||||
stackTrace: error.stack_trace,
|
stackTrace: error.stack_trace,
|
||||||
|
|||||||
@@ -38,12 +38,10 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
|||||||
(props, ref) => {
|
(props, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [pluginList, setPluginList] = useState<PluginCardVO[]>([]);
|
const [pluginList, setPluginList] = useState<PluginCardVO[]>([]);
|
||||||
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
const [detailModalOpen, setDetailModalOpen] = useState<boolean>(false);
|
||||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginCardVO | null>(
|
const [selectedPlugin, setSelectedPlugin] = useState<PluginCardVO | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [readmeModalOpen, setReadmeModalOpen] = useState<boolean>(false);
|
|
||||||
const [readmePlugin, setReadmePlugin] = useState<PluginCardVO | null>(null);
|
|
||||||
const [showOperationModal, setShowOperationModal] = useState(false);
|
const [showOperationModal, setShowOperationModal] = useState(false);
|
||||||
const [operationType, setOperationType] = useState<PluginOperationType>(
|
const [operationType, setOperationType] = useState<PluginOperationType>(
|
||||||
PluginOperationType.DELETE,
|
PluginOperationType.DELETE,
|
||||||
@@ -166,12 +164,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
|||||||
|
|
||||||
function handlePluginClick(plugin: PluginCardVO) {
|
function handlePluginClick(plugin: PluginCardVO) {
|
||||||
setSelectedPlugin(plugin);
|
setSelectedPlugin(plugin);
|
||||||
setModalOpen(true);
|
setDetailModalOpen(true);
|
||||||
}
|
|
||||||
|
|
||||||
function handleViewReadme(plugin: PluginCardVO) {
|
|
||||||
setReadmePlugin(plugin);
|
|
||||||
setReadmeModalOpen(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePluginDelete(plugin: PluginCardVO) {
|
function handlePluginDelete(plugin: PluginCardVO) {
|
||||||
@@ -355,52 +348,46 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={`${styles.pluginListContainer}`}>
|
<div className={`${styles.pluginListContainer}`}>
|
||||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
<Dialog open={detailModalOpen} onOpenChange={setDetailModalOpen}>
|
||||||
<DialogContent className="w-[700px] max-h-[80vh] p-0 flex flex-col">
|
<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">
|
|
||||||
<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">
|
|
||||||
<DialogHeader className="px-6 pt-6 pb-2 border-b">
|
<DialogHeader className="px-6 pt-6 pb-2 border-b">
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{readmePlugin &&
|
{selectedPlugin &&
|
||||||
`${readmePlugin.author}/${readmePlugin.name} - ${t(
|
`${selectedPlugin.author}/${selectedPlugin.name}`}
|
||||||
'plugins.readme',
|
|
||||||
)}`}
|
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 flex flex-row overflow-hidden min-h-0">
|
||||||
{readmePlugin && (
|
{/* Left side - Readme */}
|
||||||
<PluginReadme
|
<div className="flex-1 overflow-y-auto border-r min-w-0">
|
||||||
pluginAuthor={readmePlugin.author}
|
{selectedPlugin && (
|
||||||
pluginName={readmePlugin.name}
|
<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>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -413,7 +400,6 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
|||||||
onCardClick={() => handlePluginClick(vo)}
|
onCardClick={() => handlePluginClick(vo)}
|
||||||
onDeleteClick={() => handlePluginDelete(vo)}
|
onDeleteClick={() => handlePluginDelete(vo)}
|
||||||
onUpgradeClick={() => handlePluginUpdate(vo)}
|
onUpgradeClick={() => handlePluginUpdate(vo)}
|
||||||
onViewReadme={() => handleViewReadme(vo)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,15 +2,7 @@ import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/Plu
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import { BugIcon, ExternalLink, Ellipsis, Trash, ArrowUp } from 'lucide-react';
|
||||||
BugIcon,
|
|
||||||
ExternalLink,
|
|
||||||
Ellipsis,
|
|
||||||
Trash,
|
|
||||||
ArrowUp,
|
|
||||||
Settings,
|
|
||||||
FileText,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http';
|
import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -27,28 +19,20 @@ export default function PluginCardComponent({
|
|||||||
onCardClick,
|
onCardClick,
|
||||||
onDeleteClick,
|
onDeleteClick,
|
||||||
onUpgradeClick,
|
onUpgradeClick,
|
||||||
onViewReadme,
|
|
||||||
}: {
|
}: {
|
||||||
cardVO: PluginCardVO;
|
cardVO: PluginCardVO;
|
||||||
onCardClick: () => void;
|
onCardClick: () => void;
|
||||||
onDeleteClick: (cardVO: PluginCardVO) => void;
|
onDeleteClick: (cardVO: PluginCardVO) => void;
|
||||||
onUpgradeClick: (cardVO: PluginCardVO) => void;
|
onUpgradeClick: (cardVO: PluginCardVO) => void;
|
||||||
onViewReadme: (cardVO: PluginCardVO) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<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]"
|
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)}
|
onClick={() => onCardClick()}
|
||||||
onMouseLeave={() => {
|
|
||||||
if (!dropdownOpen) {
|
|
||||||
setIsHovered(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
|
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
|
||||||
{/* Icon - fixed width */}
|
{/* Icon - fixed width */}
|
||||||
@@ -145,7 +129,10 @@ export default function PluginCardComponent({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Menu button - fixed width and position */}
|
{/* 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"></div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
@@ -153,9 +140,6 @@ export default function PluginCardComponent({
|
|||||||
open={dropdownOpen}
|
open={dropdownOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
setDropdownOpen(open);
|
setDropdownOpen(open);
|
||||||
if (!open) {
|
|
||||||
setIsHovered(false);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -233,45 +217,6 @@ export default function PluginCardComponent({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ function MarketPageContent({
|
|||||||
const pageSize = 12; // 每页12个
|
const pageSize = 12; // 每页12个
|
||||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const isComposingRef = useRef(false);
|
||||||
|
|
||||||
// 排序选项
|
// 排序选项
|
||||||
const sortOptions: SortOption[] = [
|
const sortOptions: SortOption[] = [
|
||||||
@@ -250,10 +251,14 @@ function MarketPageContent({
|
|||||||
clearTimeout(searchTimeoutRef.current);
|
clearTimeout(searchTimeoutRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isComposingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 设置新的定时器
|
// 设置新的定时器
|
||||||
searchTimeoutRef.current = setTimeout(() => {
|
searchTimeoutRef.current = setTimeout(() => {
|
||||||
handleSearch(value);
|
handleSearch(value);
|
||||||
}, 300);
|
}, 500);
|
||||||
},
|
},
|
||||||
[handleSearch],
|
[handleSearch],
|
||||||
);
|
);
|
||||||
@@ -398,6 +403,13 @@ function MarketPageContent({
|
|||||||
placeholder={t('market.searchPlaceholder')}
|
placeholder={t('market.searchPlaceholder')}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => handleSearchInputChange(e.target.value)}
|
onChange={(e) => handleSearchInputChange(e.target.value)}
|
||||||
|
onCompositionStart={() => {
|
||||||
|
isComposingRef.current = true;
|
||||||
|
}}
|
||||||
|
onCompositionEnd={(e) => {
|
||||||
|
isComposingRef.current = false;
|
||||||
|
handleSearchInputChange((e.target as HTMLInputElement).value);
|
||||||
|
}}
|
||||||
onKeyPress={(e) => {
|
onKeyPress={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
// Immediately search, clear debounce timer
|
// Immediately search, clear debounce timer
|
||||||
|
|||||||
Reference in New Issue
Block a user