Compare commits

..

4 Commits

Author SHA1 Message Date
fdc310
60579f7b3a refactor(gewechat): remove redundant set_webhook_url and login logic
Remove the unnecessary set_webhook_url method and its caller in botmgr,
instead inject webhook_prefix via adapter config for self-assembly.
Remove login logic from run_async since login is handled elsewhere.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 06:15:16 +08:00
fdc310
c4d4c90930 fix: add set_webhook_url call in botmgr for auto callback URL
Pass webhook_full_url to adapters that implement set_webhook_url(),
built from api.webhook_prefix config + bot_uuid.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 05:57:32 +08:00
fdc310
1a3ef217d9 fix: resolve pydantic init error and auto-generate webhook callback URL
- Fix AttributeError by removing self.config assignment before super().__init__()
- Remove callback_base_url from adapter config (no longer needed)
- Add set_webhook_url() method, called by botmgr with auto-built URL
- Add gewechat to webhook URL display list in bot.py
- botmgr now passes webhook_prefix + bot_uuid to adapters via set_webhook_url()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 05:52:50 +08:00
fdc310
6e9fcccd7d feat: migrate gewechat adapter to unified webhook architecture
Move gewechat adapter from legacy (self-hosted Quart server) to the
unified webhook entry point at /api/bots/{bot_uuid}. This removes the
need for a dedicated port and aligns with the modern adapter pattern.

Key changes:
- Add set_bot_uuid() and handle_unified_webhook() methods
- Remove self-hosted Quart server from run_async()
- Auto-generate callback URL from callback_base_url + bot_uuid
- Update constructor signature to (config, logger)
- Rename legacy gewechat.yaml to prevent name conflict

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 04:59:29 +08:00
35 changed files with 844 additions and 1924 deletions

View File

@@ -34,6 +34,8 @@
---
## 什么是 LangBot
LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时通信机器人。它将大语言模型LLM连接到各种聊天平台帮助你创建能够对话、执行任务、并集成到现有工作流程中的智能 Agent。
### 核心能力
@@ -41,7 +43,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
- **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG知识库深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。
- **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。
- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。
- **插件生态** — 数百个插件,跨进程的事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
- **插件生态** — 数百个插件,事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。

View File

@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.9.4"
version = "4.9.3"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
@@ -64,7 +64,7 @@ dependencies = [
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3",
"langbot-plugin==0.3.5",
"langbot-plugin==0.3.3",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10",

View File

@@ -1,3 +1,3 @@
"""LangBot - Production-grade platform for building agentic IM bots"""
__version__ = '4.9.4'
__version__ = '4.9.3'

View File

@@ -1,3 +0,0 @@
from .client import OpenClawWeixinClient as OpenClawWeixinClient
from .types import ApiError as ApiError
from .types import LoginResult as LoginResult

View File

@@ -1,807 +0,0 @@
"""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

View File

@@ -1,200 +0,0 @@
"""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

View File

@@ -4,7 +4,6 @@ import base64
import binascii
import httpx
import traceback
from urllib.parse import quote
from quart import Quart
import xml.etree.ElementTree as ET
from typing import Callable, Dict, Any
@@ -68,31 +67,6 @@ class WecomClient:
await self.logger.error(f'获取accesstoken失败:{response.json()}')
raise Exception(f'未获取access token: {data}')
async def get_user_info(self, userid: str) -> dict:
"""
Get user information by user ID using the application secret.
Args:
userid: The user ID to look up.
Returns:
dict: User information including 'name' field.
"""
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/user/get?access_token=' + self.access_token + '&userid=' + quote(userid)
async with httpx.AsyncClient() as client:
response = await client.get(url)
data = response.json()
if data.get('errcode') == 40014 or data.get('errcode') == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.get_user_info(userid)
if data.get('errcode', 0) != 0:
await self.logger.error(f'获取用户信息失败:{data}')
return {}
return data
async def get_users(self):
if not self.check_access_token_for_contacts():
self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts)

View File

@@ -13,9 +13,9 @@ from .. import group
@group.group_class('files', '/api/v1/files')
class FilesRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/image/<path:image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
@self.route('/image/<image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
async def _(image_key: str) -> quart.Response:
if '..' in image_key or '\\' in image_key:
if '/' in image_key or '\\' in image_key:
return quart.Response(status=404)
if not await self.ap.storage_mgr.storage_provider.exists(image_key):

View File

@@ -68,6 +68,7 @@ class BotService:
'wecomcs',
'LINE',
'lark',
'gewechat',
]:
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')

View File

@@ -272,6 +272,9 @@ class PlatformManager:
# 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid用于统一 webhook
if hasattr(adapter_inst, 'set_bot_uuid'):
adapter_inst.set_bot_uuid(bot_entity.uuid)
adapter_inst.config['_webhook_prefix'] = self.ap.instance_config.data['api'].get(
'webhook_prefix', 'http://127.0.0.1:5300'
)
runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst, logger=logger)

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,609 @@
import gewechat_client
import typing
import asyncio
import traceback
import re
import copy
import threading
from langbot.pkg.utils import httpclient
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
from ...utils import image
import xml.etree.ElementTree as ET
from typing import Optional, Tuple
from functools import partial
from ..logger import EventLogger
class GewechatMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
def __init__(self, config: dict):
self.config = config
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain) -> list[dict]:
content_list = []
for component in message_chain:
if isinstance(component, platform_message.At):
content_list.append({'type': 'at', 'target': component.target})
elif isinstance(component, platform_message.Plain):
content_list.append({'type': 'text', 'content': component.text})
elif isinstance(component, platform_message.Image):
if not component.url:
pass
content_list.append({'type': 'image', 'image': component.url})
elif isinstance(component, platform_message.Voice):
content_list.append({'type': 'voice', 'url': component.url, 'length': component.length})
elif isinstance(component, platform_message.Forward):
for node in component.node_list:
content_list.extend(await GewechatMessageConverter.yiri2target(node.message_chain))
content_list.append({'type': 'image', 'image': component.url})
elif isinstance(component, platform_message.WeChatMiniPrograms):
content_list.append(
{
'type': 'WeChatMiniPrograms',
'mini_app_id': component.mini_app_id,
'display_name': component.display_name,
'page_path': component.page_path,
'cover_img_url': component.image_url,
'title': component.title,
'user_name': component.user_name,
}
)
elif isinstance(component, platform_message.WeChatForwardMiniPrograms):
content_list.append(
{
'type': 'WeChatForwardMiniPrograms',
'xml_data': component.xml_data,
'image_url': component.image_url,
}
)
elif isinstance(component, platform_message.WeChatEmoji):
content_list.append(
{
'type': 'WeChatEmoji',
'emoji_md5': component.emoji_md5,
'emoji_size': component.emoji_size,
}
)
elif isinstance(component, platform_message.WeChatLink):
content_list.append(
{
'type': 'WeChatLink',
'link_title': component.link_title,
'link_desc': component.link_desc,
'link_thumb_url': component.link_thumb_url,
'link_url': component.link_url,
}
)
elif isinstance(component, platform_message.WeChatForwardLink):
content_list.append({'type': 'WeChatForwardLink', 'xml_data': component.xml_data})
elif isinstance(component, platform_message.WeChatForwardImage):
content_list.append({'type': 'WeChatForwardImage', 'xml_data': component.xml_data})
elif isinstance(component, platform_message.WeChatForwardFile):
content_list.append({'type': 'WeChatForwardFile', 'xml_data': component.xml_data})
elif isinstance(component, platform_message.WeChatAppMsg):
content_list.append({'type': 'WeChatAppMsg', 'app_msg': component.app_msg})
elif isinstance(component, platform_message.WeChatForwardQuote):
content_list.append({'type': 'WeChatAppMsg', 'app_msg': component.app_msg})
elif isinstance(component, platform_message.Forward):
for node in component.node_list:
if node.message_chain:
content_list.extend(await GewechatMessageConverter.yiri2target(node.message_chain))
return content_list
async def target2yiri(self, message: dict, bot_account_id: str) -> platform_message.MessageChain:
message_list = []
ats_bot = False
content = message['Data']['Content']['string']
content_no_preifx = content
is_group_message = self._is_group_message(message)
if is_group_message:
ats_bot = self._ats_bot(message, bot_account_id)
if '@所有人' in content:
message_list.append(platform_message.AtAll())
elif ats_bot:
message_list.append(platform_message.At(target=bot_account_id))
content_no_preifx, _ = self._extract_content_and_sender(content)
msg_type = message['Data']['MsgType']
handler_map = {
1: self._handler_text,
3: self._handler_image,
34: self._handler_voice,
49: self._handler_compound,
}
handler = handler_map.get(msg_type, self._handler_default)
handler_result = await handler(
message=message,
content_no_preifx=content_no_preifx,
)
if handler_result and len(handler_result) > 0:
message_list.extend(handler_result)
return platform_message.MessageChain(message_list)
async def _handler_text(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:
if message and self._is_group_message(message):
pattern = r'@\S{1,20}'
content_no_preifx = re.sub(pattern, '', content_no_preifx)
return platform_message.MessageChain([platform_message.Plain(content_no_preifx)])
async def _handler_image(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:
try:
image_xml = content_no_preifx
if not image_xml:
return platform_message.MessageChain([platform_message.Unknown('[图片内容为空]')])
base64_str, image_format = await image.get_gewechat_image_base64(
gewechat_url=self.config['gewechat_url'],
gewechat_file_url=self.config['gewechat_file_url'],
app_id=self.config['app_id'],
xml_content=image_xml,
token=self.config['token'],
image_type=2,
)
elements = [
platform_message.Image(base64=f'data:image/{image_format};base64,{base64_str}'),
platform_message.WeChatForwardImage(xml_data=image_xml),
]
return platform_message.MessageChain(elements)
except Exception as e:
print(f'处理图片失败: {str(e)}')
return platform_message.MessageChain([platform_message.Unknown('[图片处理失败]')])
async def _handler_voice(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:
message_List = []
try:
audio_base64 = message['Data']['ImgBuf']['buffer']
if not audio_base64:
message_List.append(platform_message.Unknown(text='[语音内容为空]'))
return platform_message.MessageChain(message_List)
voice_element = platform_message.Voice(base64=f'data:audio/silk;base64,{audio_base64}')
message_List.append(voice_element)
except KeyError as e:
print(f'语音数据字段缺失: {str(e)}')
message_List.append(platform_message.Unknown(text='[语音数据解析失败]'))
except Exception as e:
print(f'处理语音消息异常: {str(e)}')
message_List.append(platform_message.Unknown(text='[语音处理失败]'))
return platform_message.MessageChain(message_List)
async def _handler_compound(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:
try:
xml_data = ET.fromstring(content_no_preifx)
appmsg_data = xml_data.find('.//appmsg')
if appmsg_data:
data_type = appmsg_data.findtext('.//type', '')
sub_handler_map = {
'57': self._handler_compound_quote,
'5': self._handler_compound_link,
'6': self._handler_compound_file,
'33': self._handler_compound_mini_program,
'36': self._handler_compound_mini_program,
'2000': partial(self._handler_compound_unsupported, text='[转账消息]'),
'2001': partial(self._handler_compound_unsupported, text='[红包消息]'),
'51': partial(self._handler_compound_unsupported, text='[视频号消息]'),
}
handler = sub_handler_map.get(data_type, self._handler_compound_unsupported)
return await handler(
message=message,
xml_data=xml_data,
)
else:
return platform_message.MessageChain([platform_message.Unknown(text=content_no_preifx)])
except Exception as e:
print(f'解析复合消息失败: {str(e)}')
return platform_message.MessageChain([platform_message.Unknown(text=content_no_preifx)])
async def _handler_compound_quote(
self, message: Optional[dict], xml_data: ET.Element
) -> platform_message.MessageChain:
message_list = []
appmsg_data = xml_data.find('.//appmsg')
quote_data = ''
user_data = ''
sender_id = xml_data.findtext('.//fromusername')
if appmsg_data:
user_data = appmsg_data.findtext('.//title') or ''
quote_data = appmsg_data.find('.//refermsg').findtext('.//content')
message_list.append(
platform_message.WeChatForwardQuote(app_msg=ET.tostring(appmsg_data, encoding='unicode'))
)
if quote_data:
quote_data_message_list = platform_message.MessageChain()
try:
if '<msg>' not in quote_data:
quote_data_message_list.append(platform_message.Plain(quote_data))
else:
quote_data_xml = ET.fromstring(quote_data)
if quote_data_xml.find('img'):
quote_data_message_list.extend(await self._handler_image(None, quote_data))
elif quote_data_xml.find('voicemsg'):
quote_data_message_list.extend(await self._handler_voice(None, quote_data))
elif quote_data_xml.find('videomsg'):
quote_data_message_list.extend(await self._handler_default(None, quote_data))
else:
quote_data_message_list.extend(await self._handler_compound(None, quote_data))
except Exception as e:
print(f'处理引用消息异常 expcetion:{e}')
quote_data_message_list.append(platform_message.Plain(quote_data))
message_list.append(
platform_message.Quote(
sender_id=sender_id,
origin=quote_data_message_list,
)
)
if len(user_data) > 0:
pattern = r'@\S{1,20}'
user_data = re.sub(pattern, '', user_data)
message_list.append(platform_message.Plain(user_data))
return platform_message.MessageChain(message_list)
async def _handler_compound_file(self, message: dict, xml_data: ET.Element) -> platform_message.MessageChain:
xml_data_str = ET.tostring(xml_data, encoding='unicode')
return platform_message.MessageChain([platform_message.WeChatForwardFile(xml_data=xml_data_str)])
async def _handler_compound_link(self, message: dict, xml_data: ET.Element) -> platform_message.MessageChain:
message_list = []
try:
appmsg = xml_data.find('.//appmsg')
if appmsg is None:
return platform_message.MessageChain()
message_list.append(
platform_message.WeChatLink(
link_title=appmsg.findtext('title', ''),
link_desc=appmsg.findtext('des', ''),
link_url=appmsg.findtext('url', ''),
link_thumb_url=appmsg.findtext('thumburl', ''),
)
)
xml_data_str = ET.tostring(xml_data, encoding='unicode')
message_list.append(platform_message.WeChatForwardLink(xml_data=xml_data_str))
except Exception as e:
print(f'解析链接消息失败: {str(e)}')
return platform_message.MessageChain(message_list)
async def _handler_compound_mini_program(
self, message: dict, xml_data: ET.Element
) -> platform_message.MessageChain:
xml_data_str = ET.tostring(xml_data, encoding='unicode')
return platform_message.MessageChain([platform_message.WeChatForwardMiniPrograms(xml_data=xml_data_str)])
async def _handler_default(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:
if message:
msg_type = message['Data']['MsgType']
else:
msg_type = ''
return platform_message.MessageChain([platform_message.Unknown(text=f'[未知消息类型 msg_type:{msg_type}]')])
def _handler_compound_unsupported(
self, message: dict, xml_data: str, text: Optional[str] = None
) -> platform_message.MessageChain:
if not text:
text = f'[xml_data={xml_data}]'
content_list = []
content_list.append(platform_message.Unknown(text=f'[处理未支持复合消息类型[msg_type=49]|{text}'))
return platform_message.MessageChain(content_list)
def _ats_bot(self, message: dict, bot_account_id: str) -> bool:
ats_bot = False
try:
to_user_name = message['Wxid']
raw_content = message['Data']['Content']['string']
content_no_prefix, _ = self._extract_content_and_sender(raw_content)
push_content = message.get('Data', {}).get('PushContent', '')
ats_bot = ats_bot or ('在群聊中@了你' in push_content)
msg_source = message.get('Data', {}).get('MsgSource', '') or ''
if len(msg_source) > 0:
msg_source_data = ET.fromstring(msg_source)
at_user_list = msg_source_data.findtext('atuserlist') or ''
ats_bot = ats_bot or (to_user_name in at_user_list)
if message.get('Data', {}).get('MsgType', 0) == 49:
xml_data = ET.fromstring(content_no_prefix)
appmsg_data = xml_data.find('.//appmsg')
tousername = message['Wxid']
if appmsg_data:
quote_id = appmsg_data.find('.//refermsg').findtext('.//chatusr')
ats_bot = ats_bot or (quote_id == tousername)
except Exception as e:
print(f'Error in gewechat _ats_bot: {e}')
finally:
return ats_bot
def _extract_content_and_sender(self, raw_content: str) -> Tuple[str, Optional[str]]:
try:
regex = re.compile(r'^[a-zA-Z0-9_\-]{5,20}:')
line_split = raw_content.split('\n')
if len(line_split) > 0 and regex.match(line_split[0]):
raw_content = '\n'.join(line_split[1:])
sender_id = line_split[0].strip(':')
return raw_content, sender_id
except Exception as e:
print(f'_extract_content_and_sender got except: {e}')
finally:
return raw_content, None
def _is_group_message(self, message: dict) -> bool:
from_user_name = message['Data']['FromUserName']['string']
return from_user_name.endswith('@chatroom')
class GewechatEventConverter(abstract_platform_adapter.AbstractEventConverter):
def __init__(self, config: dict):
self.config = config
self.message_converter = GewechatMessageConverter(config)
@staticmethod
async def yiri2target(event: platform_events.MessageEvent) -> dict:
pass
async def target2yiri(self, event: dict, bot_account_id: str) -> platform_events.MessageEvent:
if event['Wxid'] == event['Data']['FromUserName']['string']:
return None
if event['Data']['FromUserName']['string'].startswith('gh_') or event['Data']['FromUserName'][
'string'
].startswith('weixin'):
return None
message_chain = await self.message_converter.target2yiri(copy.deepcopy(event), bot_account_id)
if not message_chain:
return None
if '@chatroom' in event['Data']['FromUserName']['string']:
sender_wxid = event['Data']['Content']['string'].split(':')[0]
return platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=sender_wxid,
member_name=event['Data']['FromUserName']['string'],
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
id=event['Data']['FromUserName']['string'],
name=event['Data']['FromUserName']['string'],
permission=platform_entities.Permission.Member,
),
special_title='',
),
message_chain=message_chain,
time=event['Data']['CreateTime'],
source_platform_object=event,
)
else:
return platform_events.FriendMessage(
sender=platform_entities.Friend(
id=event['Data']['FromUserName']['string'],
nickname=event['Data']['FromUserName']['string'],
remark='',
),
message_chain=message_chain,
time=event['Data']['CreateTime'],
source_platform_object=event,
)
class GeWeChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot: gewechat_client.GewechatClient = None
bot_uuid: str = None
message_converter: GewechatMessageConverter = None
event_converter: GewechatEventConverter = 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: EventLogger):
super().__init__(
config=config,
logger=logger,
)
self.message_converter = GewechatMessageConverter(config)
self.event_converter = GewechatEventConverter(config)
def set_bot_uuid(self, bot_uuid: str):
self.bot_uuid = bot_uuid
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
data = await request.json
await self.logger.debug(f'Gewechat callback event: {data}')
if 'data' in data:
data['Data'] = data['data']
if 'type_name' in data:
data['TypeName'] = data['type_name']
if 'testMsg' in data:
return 'ok'
elif 'TypeName' in data and data['TypeName'] == 'AddMsg':
try:
event = await self.event_converter.target2yiri(data.copy(), self.bot_account_id)
except Exception:
await self.logger.error(f'Error in gewechat callback: {traceback.format_exc()}')
return 'ok'
if event and event.__class__ in self.listeners:
await self.listeners[event.__class__](event, self)
return 'ok'
return 'ok'
async def _handle_message(self, message: platform_message.MessageChain, target_id: str):
content_list = await self.message_converter.yiri2target(message)
at_targets = [item['target'] for item in content_list if item['type'] == 'at']
at_targets = at_targets or []
member_info = []
if at_targets:
member_info = self.bot.get_chatroom_member_detail(self.config['app_id'], target_id, at_targets[::-1])[
'data'
]
for msg in content_list:
if msg['type'] == 'text' and at_targets:
for member in member_info:
msg['content'] = f'@{member["nickName"]} {msg["content"]}'
handler_map = {
'text': lambda msg: self.bot.post_text(
app_id=self.config['app_id'],
to_wxid=target_id,
content=msg['content'],
ats=','.join(at_targets),
),
'image': lambda msg: self.bot.post_image(
app_id=self.config['app_id'],
to_wxid=target_id,
img_url=msg['image'],
),
'WeChatForwardMiniPrograms': lambda msg: self.bot.forward_mini_app(
app_id=self.config['app_id'],
to_wxid=target_id,
xml=msg['xml_data'],
cover_img_url=msg.get('image_url'),
),
'WeChatEmoji': lambda msg: self.bot.post_emoji(
app_id=self.config['app_id'],
to_wxid=target_id,
emoji_md5=msg['emoji_md5'],
emoji_size=msg['emoji_size'],
),
'WeChatLink': lambda msg: self.bot.post_link(
app_id=self.config['app_id'],
to_wxid=target_id,
title=msg['link_title'],
desc=msg['link_desc'],
link_url=msg['link_url'],
thumb_url=msg['link_thumb_url'],
),
'WeChatMiniPrograms': lambda msg: self.bot.post_mini_app(
app_id=self.config['app_id'],
to_wxid=target_id,
mini_app_id=msg['mini_app_id'],
display_name=msg['display_name'],
page_path=msg['page_path'],
cover_img_url=msg['cover_img_url'],
title=msg['title'],
user_name=msg['user_name'],
),
'WeChatForwardLink': lambda msg: self.bot.forward_url(
app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data']
),
'WeChatForwardImage': lambda msg: self.bot.forward_image(
app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data']
),
'WeChatForwardFile': lambda msg: self.bot.forward_file(
app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data']
),
'voice': lambda msg: self.bot.post_voice(
app_id=self.config['app_id'],
to_wxid=target_id,
voice_url=msg['url'],
voice_duration=msg['length'],
),
'WeChatAppMsg': lambda msg: self.bot.post_app_msg(
app_id=self.config['app_id'],
to_wxid=target_id,
appmsg=msg['app_msg'],
),
'at': lambda msg: None,
}
if handler := handler_map.get(msg['type']):
handler(msg)
else:
await self.logger.warning(f'未处理的消息类型: {msg["type"]}')
continue
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
return await self._handle_message(message, target_id)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
):
if message_source.source_platform_object:
target_id = message_source.source_platform_object['Data']['FromUserName']['string']
return await self._handle_message(message, target_id)
async def is_muted(self, group_id: int) -> bool:
pass
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
],
):
pass
def _build_callback_url(self) -> str:
webhook_prefix = self.config.get('_webhook_prefix', 'http://127.0.0.1:5300').rstrip('/')
return f'{webhook_prefix}/bots/{self.bot_uuid}'
async def run_async(self):
if not self.config['token']:
session = httpclient.get_session()
async with session.post(
f'{self.config["gewechat_url"]}/v2/api/tools/getTokenId',
json={'app_id': self.config['app_id']},
) as response:
if response.status != 200:
raise Exception(f'获取gewechat token失败: {await response.text()}')
self.config['token'] = (await response.json())['data']
self.bot = gewechat_client.GewechatClient(f'{self.config["gewechat_url"]}/v2/api', self.config['token'])
def gewechat_init_process():
profile = self.bot.get_profile(self.config['app_id'])
self.bot_account_id = profile['data']['nickName']
try:
callback_url = self._build_callback_url()
self.bot.set_callback(self.config['token'], callback_url)
print(f'Gewechat 回调地址已设置: {callback_url}')
except Exception as e:
raise Exception(f'设置 Gewechat 回调失败token失效{e}')
threading.Thread(target=gewechat_init_process).start()
# 统一 webhook 模式下,不启动独立的 HTTP 服务
# 保持适配器运行
while True:
await asyncio.sleep(1)
async def kill(self) -> bool:
return False

View File

@@ -0,0 +1,51 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: gewechat
label:
en_US: GeWeChat
zh_Hans: GeWeChat个人微信
description:
en_US: GeWeChat Adapter (Unified Webhook)
zh_Hans: GeWeChat 适配器(统一 Webhook请查看文档了解使用方式
icon: gewechat.png
spec:
config:
- name: gewechat_url
label:
en_US: GeWeChat URL
zh_Hans: GeWeChat URL
description:
en_US: GeWeChat API server address, e.g. http://127.0.0.1:2531
zh_Hans: GeWeChat API 服务器地址,如 http://127.0.0.1:2531
type: string
required: true
default: ""
- name: gewechat_file_url
label:
en_US: GeWeChat file download URL
zh_Hans: GeWeChat 文件下载URL
description:
en_US: GeWeChat file download service address
zh_Hans: GeWeChat 文件下载服务地址
type: string
required: true
default: ""
- name: app_id
label:
en_US: App ID
zh_Hans: 应用ID
type: string
required: true
default: ""
- name: token
label:
en_US: Token
zh_Hans: 令牌
type: string
required: true
default: ""
execution:
python:
path: ./gewechat.py
attr: GeWeChatAdapter

View File

@@ -1,577 +0,0 @@
"""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

View File

@@ -1,57 +0,0 @@
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 466 KiB

View File

@@ -148,54 +148,51 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
pass
if type(event) is platform_events.FriendMessage:
return event.source_platform_object
payload = {
'MsgType': 'text',
'Content': '',
'FromUserName': event.sender.id,
'ToUserName': bot_account_id,
'CreateTime': int(datetime.datetime.now().timestamp()),
'AgentID': event.sender.nickname,
}
wecom_event = WecomEvent.from_payload(payload=payload)
if not wecom_event:
raise ValueError('无法从 message_data 构造 WecomEvent 对象')
return wecom_event
@staticmethod
async def target2yiri(event: WecomEvent, bot: WecomClient = None):
async def target2yiri(event: WecomEvent):
"""
将 WecomEvent 转换为平台的 FriendMessage 对象。
Args:
event (WecomEvent): 企业微信事件。
bot (WecomClient): 企业微信客户端,用于获取用户信息。
Returns:
platform_events.FriendMessage: 转换后的 FriendMessage 对象。
"""
# Try to get the user's real name from the WeCom API
nickname = str(event.user_id)
if bot and event.user_id:
try:
user_info = await bot.get_user_info(event.user_id)
if user_info and user_info.get('name'):
nickname = user_info.get('name')
except Exception:
pass # Fall back to user_id as nickname
# 转换消息链
if event.type == 'text':
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
friend = platform_entities.Friend(
id=f'u{event.user_id}',
nickname=nickname,
nickname=str(event.agent_id),
remark='',
)
return platform_events.FriendMessage(
sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event
)
return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, time=event.timestamp)
elif event.type == 'image':
friend = platform_entities.Friend(
id=f'u{event.user_id}',
nickname=nickname,
nickname=str(event.agent_id),
remark='',
)
yiri_chain = await WecomMessageConverter.target2yiri_image(picurl=event.picurl, message_id=event.message_id)
return platform_events.FriendMessage(
sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event
)
return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, time=event.timestamp)
class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
@@ -213,6 +210,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'secret',
'token',
'EncodingAESKey',
'contacts_secret',
]
missing_keys = [key for key in required_keys if key not in config]
@@ -225,7 +223,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
secret=config['secret'],
token=config['token'],
EncodingAESKey=config['EncodingAESKey'],
contacts_secret=config.get('contacts_secret', ''), # Optional, kept for backward compatibility
contacts_secret=config['contacts_secret'],
logger=logger,
unified_mode=True,
api_base_url=config.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin'),
@@ -250,17 +248,18 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
):
Wecom_event = await WecomEventConverter.yiri2target(message_source, self.bot_account_id, self.bot)
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
# user_id is the original FromUserName from WecomEvent
user_id = Wecom_event.user_id
fixed_user_id = Wecom_event.user_id
# 删掉开头的u
fixed_user_id = fixed_user_id[1:]
for content in content_list:
if content['type'] == 'text':
await self.bot.send_private_msg(user_id, Wecom_event.agent_id, content['content'])
await self.bot.send_private_msg(fixed_user_id, Wecom_event.agent_id, content['content'])
elif content['type'] == 'image':
await self.bot.send_image(user_id, Wecom_event.agent_id, content['media_id'])
await self.bot.send_image(fixed_user_id, Wecom_event.agent_id, content['media_id'])
elif content['type'] == 'voice':
await self.bot.send_voice(user_id, Wecom_event.agent_id, content['media_id'])
await self.bot.send_voice(fixed_user_id, Wecom_event.agent_id, content['media_id'])
elif content['type'] == 'file':
await self.bot.send_file(user_id, Wecom_event.agent_id, content['media_id'])
await self.bot.send_file(fixed_user_id, Wecom_event.agent_id, content['media_id'])
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
@@ -288,7 +287,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def on_message(event: WecomEvent):
self.bot_account_id = event.receiver_id
try:
return await callback(await self.event_converter.target2yiri(event, self.bot), self)
return await callback(await self.event_converter.target2yiri(event), self)
except Exception:
await self.logger.error(f'Error in wecom callback: {traceback.format_exc()}')

View File

@@ -39,6 +39,13 @@ spec:
type: string
required: true
default: ""
- name: contacts_secret
label:
en_US: Contacts Secret
zh_Hans: 通讯录密钥
type: string
required: true
default: ""
- name: api_base_url
label:
en_US: API Base URL

View File

@@ -314,11 +314,11 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.GET_LLM_MODELS)
async def get_llm_models(data: dict[str, Any]) -> handler.ActionResponse:
"""Get llm models, returns list of UUID strings"""
"""Get llm models"""
llm_models = await self.ap.llm_model_service.get_llm_models(include_secret=False)
return handler.ActionResponse.success(
data={
'llm_models': [m['uuid'] for m in llm_models],
'llm_models': llm_models,
},
)
@@ -531,7 +531,6 @@ class RuntimeConnectionHandler(handler.Handler):
filters = data.get('filters')
search_type = data.get('search_type', 'vector')
query_text = data.get('query_text', '')
vector_weight = data.get('vector_weight')
try:
results = await self.ap.rag_runtime_service.vector_search(
collection_id,
@@ -540,7 +539,6 @@ class RuntimeConnectionHandler(handler.Handler):
filters,
search_type,
query_text,
vector_weight=vector_weight,
)
return handler.ActionResponse.success(data={'results': results})
except Exception as e:
@@ -615,47 +613,6 @@ class RuntimeConnectionHandler(handler.Handler):
# ================= Knowledge Base Query APIs =================
@self.action(PluginToRuntimeAction.LIST_KNOWLEDGE_BASES)
async def list_knowledge_bases(data: dict[str, Any]) -> handler.ActionResponse:
"""List all knowledge bases available in the LangBot instance (unrestricted)."""
knowledge_bases = []
for kb_uuid, kb in self.ap.rag_mgr.knowledge_bases.items():
knowledge_bases.append(
{
'uuid': kb.get_uuid(),
'name': kb.get_name(),
'description': kb.knowledge_base_entity.description or '',
}
)
return handler.ActionResponse.success(data={'knowledge_bases': knowledge_bases})
@self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE)
async def retrieve_knowledge(data: dict[str, Any]) -> handler.ActionResponse:
"""Retrieve documents from any knowledge base (unrestricted)."""
kb_id = data['kb_id']
query_text = data['query_text']
top_k = data.get('top_k', 5)
filters = data.get('filters', {})
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_id)
if not kb:
return handler.ActionResponse.error(
message=f'Knowledge base {kb_id} not found',
)
try:
entries = await kb.retrieve(
query_text,
settings={
'top_k': top_k,
'filters': filters,
},
)
results = [entry.model_dump(mode='json') for entry in entries]
return handler.ActionResponse.success(data={'results': results})
except Exception as e:
return _make_rag_error_response(e, 'RetrievalError', kb_id=kb_id)
@self.action(PluginToRuntimeAction.LIST_PIPELINE_KNOWLEDGE_BASES)
async def list_pipeline_knowledge_bases(data: dict[str, Any]) -> handler.ActionResponse:
"""List knowledge bases configured for the current query's pipeline."""

View File

@@ -41,7 +41,6 @@ class RAGRuntimeService:
filters: dict[str, Any] | None = None,
search_type: str = 'vector',
query_text: str = '',
vector_weight: float | None = None,
) -> list[dict[str, Any]]:
"""Handle VECTOR_SEARCH action."""
return await self.ap.vector_db_mgr.search(
@@ -51,7 +50,6 @@ class RAGRuntimeService:
filter=filters,
search_type=search_type,
query_text=query_text,
vector_weight=vector_weight,
)
async def vector_delete(

View File

@@ -97,7 +97,6 @@ class VectorDBManager:
filter: dict | None = None,
search_type: str = 'vector',
query_text: str = '',
vector_weight: float | None = None,
) -> list[dict]:
"""Proxy: Search vectors.
@@ -112,7 +111,6 @@ class VectorDBManager:
search_type=search_type,
query_text=query_text,
filter=filter,
vector_weight=vector_weight,
)
if not results or 'ids' not in results or not results['ids']:

View File

@@ -53,7 +53,6 @@ class VectorDatabase(abc.ABC):
search_type: str = 'vector',
query_text: str = '',
filter: dict[str, Any] | None = None,
vector_weight: float | None = None,
) -> Dict[str, Any]:
"""Search for the most similar vectors in the specified collection.
@@ -71,8 +70,6 @@ class VectorDatabase(abc.ABC):
{"file_id": "abc"}
{"created_at": {"$gte": 1700000000}}
{"file_type": {"$in": ["pdf", "docx"]}}
vector_weight: Weight for vector search in hybrid mode (0.01.0).
``None`` means use equal weights (backward compatible).
"""
pass

View File

@@ -52,16 +52,13 @@ class ChromaVectorDatabase(VectorDatabase):
search_type: str = 'vector',
query_text: str = '',
filter: dict[str, Any] | None = None,
vector_weight: float | None = None,
) -> dict[str, Any]:
col = await self.get_or_create_collection(collection)
if search_type == SearchType.FULL_TEXT:
return await self._full_text_search(col, collection, k, query_text, filter)
elif search_type == SearchType.HYBRID:
return await self._hybrid_search(
col, collection, query_embedding, k, query_text, filter, vector_weight=vector_weight
)
return await self._hybrid_search(col, collection, query_embedding, k, query_text, filter)
# Default: vector search
return await self._vector_search(col, collection, query_embedding, k, filter)
@@ -130,7 +127,6 @@ class ChromaVectorDatabase(VectorDatabase):
k: int,
query_text: str,
filter: dict[str, Any] | None,
vector_weight: float | None = None,
) -> dict[str, Any]:
# Fall back to pure vector search when no text is provided
if not query_text:
@@ -148,15 +144,7 @@ class ChromaVectorDatabase(VectorDatabase):
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
# RRF fusion
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)
fused = self._rrf_fuse([vector_ids, text_ids], k)
if not fused:
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
@@ -209,24 +197,16 @@ class ChromaVectorDatabase(VectorDatabase):
}
@staticmethod
def _rrf_fuse(result_lists: list[list[str]], k: int, weights: list[float] | None = None) -> list[tuple[str, float]]:
def _rrf_fuse(result_lists: list[list[str]], k: int) -> list[tuple[str, float]]:
"""Reciprocal Rank Fusion over multiple ranked ID lists.
Returns a list of (doc_id, rrf_score) sorted by descending score,
truncated to *k* entries.
Args:
result_lists: Ranked ID lists from different search methods.
k: Number of results to return.
weights: Per-list weights. ``None`` means equal weight (1.0 each).
"""
if weights is None:
weights = [1.0] * len(result_lists)
scores: dict[str, float] = {}
for list_idx, ranked_ids in enumerate(result_lists):
w = weights[list_idx]
for ranked_ids in result_lists:
for rank, doc_id in enumerate(ranked_ids):
scores[doc_id] = scores.get(doc_id, 0.0) + w / (_RRF_K + rank + 1)
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (_RRF_K + rank + 1)
sorted_results = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return sorted_results[:k]

View File

@@ -255,7 +255,6 @@ class MilvusVectorDatabase(VectorDatabase):
search_type: str = 'vector',
query_text: str = '',
filter: dict[str, Any] | None = None,
vector_weight: float | None = None,
) -> Dict[str, Any]:
"""Search for similar vectors in Milvus collection

View File

@@ -192,7 +192,6 @@ class PgVectorDatabase(VectorDatabase):
search_type: str = 'vector',
query_text: str = '',
filter: dict[str, Any] | None = None,
vector_weight: float | None = None,
) -> Dict[str, Any]:
"""Search for similar vectors using cosine distance

View File

@@ -100,7 +100,6 @@ class QdrantVectorDatabase(VectorDatabase):
search_type: str = 'vector',
query_text: str = '',
filter: dict[str, Any] | None = None,
vector_weight: float | None = None,
) -> dict[str, Any]:
exists = await self.client.collection_exists(collection)
if not exists:

View File

@@ -1,8 +1,6 @@
from __future__ import annotations
import asyncio
from decimal import Decimal
import re
from typing import Any, Dict, List
@@ -103,28 +101,8 @@ class SeekDBVectorDatabase(VectorDatabase):
}
)
def _normalize_collection_name(self, collection: str) -> str:
"""SeekDB only accepts [a-zA-Z0-9_], while LangBot uses UUID-like KB IDs."""
normalized = re.sub(r'[^A-Za-z0-9_]', '_', collection)
if normalized != collection:
self.ap.logger.info(f"Normalized SeekDB collection name: '{collection}' -> '{normalized}'")
return normalized
def _json_safe(self, value: Any) -> Any:
"""Convert SeekDB result values into JSON-serializable Python primitives."""
if isinstance(value, Decimal):
return float(value)
if isinstance(value, dict):
return {k: self._json_safe(v) for k, v in value.items()}
if isinstance(value, list):
return [self._json_safe(v) for v in value]
if isinstance(value, tuple):
return [self._json_safe(v) for v in value]
return value
async def _get_or_create_collection_internal(self, collection: str, vector_size: int = None) -> Any:
"""Internal method to get or create a collection with proper configuration."""
collection = self._normalize_collection_name(collection)
if collection in self._collections:
return self._collections[collection]
@@ -195,7 +173,6 @@ class SeekDBVectorDatabase(VectorDatabase):
if not embeddings_list:
return
collection = self._normalize_collection_name(collection)
# Ensure collection exists with correct dimension
vector_size = len(embeddings_list[0])
coll = await self._get_or_create_collection_internal(collection, vector_size)
@@ -217,7 +194,6 @@ class SeekDBVectorDatabase(VectorDatabase):
search_type: str = 'vector',
query_text: str = '',
filter: Dict[str, Any] | None = None,
vector_weight: float | None = None,
) -> Dict[str, Any]:
"""Search for the most similar vectors in the specified collection.
@@ -234,7 +210,6 @@ class SeekDBVectorDatabase(VectorDatabase):
Returns:
Dictionary with 'ids', 'metadatas', 'distances' keys
"""
collection = self._normalize_collection_name(collection)
# Check if collection exists
exists = await asyncio.to_thread(self.client.has_collection, collection)
if not exists:
@@ -296,17 +271,6 @@ class SeekDBVectorDatabase(VectorDatabase):
query_cfg['where'] = filter
knn_cfg['where'] = filter
# Apply vector_weight via pyseekdb's native boost parameter
if vector_weight is not None:
knn_cfg['boost'] = vector_weight
query_cfg['boost'] = 1.0 - vector_weight
self.ap.logger.info(
f"SeekDB hybrid fusion config in '{collection}': "
f'vector_weight={vector_weight}, '
f'knn_boost={knn_cfg.get("boost", 1.0)}, '
f'query_boost={query_cfg.get("boost", 1.0)}'
)
results = await asyncio.to_thread(
coll.hybrid_search,
query=query_cfg,
@@ -315,9 +279,6 @@ class SeekDBVectorDatabase(VectorDatabase):
n_results=k,
include=['documents', 'metadatas'],
)
self.ap.logger.info(
f"SeekDB hybrid search in '{collection}' returned {len(results.get('ids', [[]])[0])} results."
)
else:
# Default: vector search via query()
query_kwargs = {'n_results': k, 'query_embeddings': query_embedding}
@@ -325,7 +286,6 @@ class SeekDBVectorDatabase(VectorDatabase):
query_kwargs['where'] = filter
results = await asyncio.to_thread(coll.query, **query_kwargs)
results = self._json_safe(results)
self.ap.logger.info(
f"SeekDB {search_type} search in '{collection}' returned {len(results.get('ids', [[]])[0])} results"
)
@@ -339,7 +299,6 @@ class SeekDBVectorDatabase(VectorDatabase):
collection: Collection name
file_id: File ID to delete
"""
collection = self._normalize_collection_name(collection)
# Check if collection exists
exists = await asyncio.to_thread(self.client.has_collection, collection)
if not exists:
@@ -366,7 +325,6 @@ class SeekDBVectorDatabase(VectorDatabase):
collection: Collection name
filter: Chroma-style ``where`` filter dict
"""
collection = self._normalize_collection_name(collection)
exists = await asyncio.to_thread(self.client.has_collection, collection)
if not exists:
self.ap.logger.warning(f"SeekDB collection '{collection}' not found for deletion")
@@ -389,7 +347,6 @@ class SeekDBVectorDatabase(VectorDatabase):
limit: int = 20,
offset: int = 0,
) -> tuple[list[Dict[str, Any]], int]:
collection = self._normalize_collection_name(collection)
exists = await asyncio.to_thread(self.client.has_collection, collection)
if not exists:
return [], 0
@@ -410,7 +367,6 @@ class SeekDBVectorDatabase(VectorDatabase):
results = await asyncio.to_thread(coll.get, **get_kwargs)
results = self._json_safe(results)
ids = results.get('ids', [])
metadatas = results.get('metadatas', []) or [None] * len(ids)
documents = results.get('documents', []) or [None] * len(ids)
@@ -434,7 +390,6 @@ class SeekDBVectorDatabase(VectorDatabase):
Args:
collection: Collection name
"""
collection = self._normalize_collection_name(collection)
# Remove from cache
if collection in self._collections:
del self._collections[collection]

10
uv.lock generated
View File

@@ -1832,7 +1832,7 @@ wheels = [
[[package]]
name = "langbot"
version = "4.9.4"
version = "4.9.3"
source = { editable = "." }
dependencies = [
{ name = "aiocqhttp" },
@@ -1937,7 +1937,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", specifier = "==0.3.5" },
{ name = "langbot-plugin", specifier = "==0.3.3" },
{ name = "langchain", specifier = ">=0.2.0" },
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
{ name = "lark-oapi", specifier = ">=1.4.15" },
@@ -1993,7 +1993,7 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.3.5"
version = "0.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
@@ -2011,9 +2011,9 @@ dependencies = [
{ name = "watchdog" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/8f/0a22e4461b0893ac2afb1b6aaebafe04c921df6dbbf4b8bd6c83cf6a97b2/langbot_plugin-0.3.5.tar.gz", hash = "sha256:79c7feb08f788f480435de8cdefc3cfed4de2dfb03978a460251b8c9d1c271d3", size = 171927, upload-time = "2026-03-25T13:53:18.334Z" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]

View File

@@ -7,7 +7,6 @@ import {
EmbeddingCall,
} from '../types/monitoring';
import { backendClient } from '@/app/infra/http';
import { parseUTCTimestamp } from '../utils/dateUtils';
/**
* Custom hook for fetching and managing monitoring data
@@ -121,7 +120,7 @@ export function useMonitoringData(filterState: FilterState) {
variables?: string;
}) => ({
id: msg.id,
timestamp: parseUTCTimestamp(msg.timestamp),
timestamp: new Date(msg.timestamp),
botId: msg.bot_id,
botName: msg.bot_name,
pipelineId: msg.pipeline_id,
@@ -155,7 +154,7 @@ export function useMonitoringData(filterState: FilterState) {
message_id?: string;
}) => ({
id: call.id,
timestamp: parseUTCTimestamp(call.timestamp),
timestamp: new Date(call.timestamp),
modelName: call.model_name,
tokens: {
input: call.input_tokens,
@@ -191,7 +190,7 @@ export function useMonitoringData(filterState: FilterState) {
call_type?: string;
}) => ({
id: call.id,
timestamp: parseUTCTimestamp(call.timestamp),
timestamp: new Date(call.timestamp),
modelName: call.model_name,
promptTokens: call.prompt_tokens,
totalTokens: call.total_tokens,
@@ -228,10 +227,10 @@ export function useMonitoringData(filterState: FilterState) {
pipelineName: session.pipeline_name,
messageCount: session.message_count,
duration:
parseUTCTimestamp(session.last_activity).getTime() -
parseUTCTimestamp(session.start_time).getTime(),
lastActivity: parseUTCTimestamp(session.last_activity),
startTime: parseUTCTimestamp(session.start_time),
new Date(session.last_activity).getTime() -
new Date(session.start_time).getTime(),
lastActivity: new Date(session.last_activity),
startTime: new Date(session.start_time),
platform: session.platform,
userId: session.user_id,
}),
@@ -251,7 +250,7 @@ export function useMonitoringData(filterState: FilterState) {
message_id?: string;
}) => ({
id: error.id,
timestamp: parseUTCTimestamp(error.timestamp),
timestamp: new Date(error.timestamp),
errorType: error.error_type,
errorMessage: error.error_message,
botId: error.bot_id,

View File

@@ -97,22 +97,3 @@ export function isDateInRange(date: Date, range: DateRange | null): boolean {
export function parseDate(dateStr: string): Date {
return new Date(dateStr);
}
/**
* Parse a UTC timestamp string from the backend into a Date object.
*
* The backend stores all monitoring timestamps in UTC but serializes them
* as naive ISO strings (e.g. "2026-03-25T14:30:00") without a timezone
* designator. JavaScript's `new Date()` would treat such strings as local
* time, causing the displayed time to be off by the user's UTC offset.
*
* This function appends 'Z' when the string has no timezone info, so that
* `new Date()` correctly interprets it as UTC.
*/
export function parseUTCTimestamp(timestamp: string): Date {
// If the string already contains timezone info ('Z', '+', or '-' offset), parse as-is
if (/Z|[+-]\d{2}:\d{2}$/.test(timestamp)) {
return new Date(timestamp);
}
return new Date(timestamp + 'Z');
}

View File

@@ -10,7 +10,6 @@ import { MessageContentRenderer } from '@/app/home/monitoring/components/Message
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { httpClient } from '@/app/infra/http/HttpClient';
import { MessageDetails } from '@/app/home/monitoring/types/monitoring';
import { parseUTCTimestamp } from '@/app/home/monitoring/utils/dateUtils';
interface PipelineMonitoringTabProps {
pipelineId: string;
@@ -121,7 +120,7 @@ export default function PipelineMonitoringTab({
message: result.message
? {
id: result.message.id,
timestamp: parseUTCTimestamp(result.message.timestamp),
timestamp: new Date(result.message.timestamp),
botId: result.message.bot_id,
botName: result.message.bot_name,
pipelineId: result.message.pipeline_id,
@@ -138,7 +137,7 @@ export default function PipelineMonitoringTab({
: undefined,
llmCalls: result.llm_calls.map((call: RawLLMCallData) => ({
id: call.id,
timestamp: parseUTCTimestamp(call.timestamp),
timestamp: new Date(call.timestamp),
modelName: call.model_name,
status: call.status,
duration: call.duration,
@@ -151,7 +150,7 @@ export default function PipelineMonitoringTab({
})),
errors: result.errors.map((error: RawErrorData) => ({
id: error.id,
timestamp: parseUTCTimestamp(error.timestamp),
timestamp: new Date(error.timestamp),
errorType: error.error_type,
errorMessage: error.error_message,
stackTrace: error.stack_trace,

View File

@@ -38,10 +38,12 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
(props, ref) => {
const { t } = useTranslation();
const [pluginList, setPluginList] = useState<PluginCardVO[]>([]);
const [detailModalOpen, setDetailModalOpen] = useState<boolean>(false);
const [modalOpen, setModalOpen] = useState<boolean>(false);
const [selectedPlugin, setSelectedPlugin] = useState<PluginCardVO | null>(
null,
);
const [readmeModalOpen, setReadmeModalOpen] = useState<boolean>(false);
const [readmePlugin, setReadmePlugin] = useState<PluginCardVO | null>(null);
const [showOperationModal, setShowOperationModal] = useState(false);
const [operationType, setOperationType] = useState<PluginOperationType>(
PluginOperationType.DELETE,
@@ -164,7 +166,12 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
function handlePluginClick(plugin: PluginCardVO) {
setSelectedPlugin(plugin);
setDetailModalOpen(true);
setModalOpen(true);
}
function handleViewReadme(plugin: PluginCardVO) {
setReadmePlugin(plugin);
setReadmeModalOpen(true);
}
function handlePluginDelete(plugin: PluginCardVO) {
@@ -348,46 +355,52 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
</div>
) : (
<div className={`${styles.pluginListContainer}`}>
<Dialog open={detailModalOpen} onOpenChange={setDetailModalOpen}>
<DialogContent className="sm:max-w-[1100px] max-w-[95vw] max-h-[85vh] p-0 flex flex-col">
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="w-[700px] max-h-[80vh] p-0 flex flex-col">
<DialogHeader className="px-6 pt-6 pb-2">
<DialogTitle>{t('plugins.pluginConfig')}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6">
{selectedPlugin && (
<PluginForm
pluginAuthor={selectedPlugin.author}
pluginName={selectedPlugin.name}
onFormSubmit={(timeout?: number) => {
setModalOpen(false);
if (timeout) {
setTimeout(() => {
getPluginList();
}, timeout);
} else {
getPluginList();
}
}}
onFormCancel={() => {
setModalOpen(false);
}}
/>
)}
</div>
</DialogContent>
</Dialog>
<Dialog open={readmeModalOpen} onOpenChange={setReadmeModalOpen}>
<DialogContent className="sm:max-w-[900px] max-w-[90vw] max-h-[85vh] p-0 flex flex-col">
<DialogHeader className="px-6 pt-6 pb-2 border-b">
<DialogTitle>
{selectedPlugin &&
`${selectedPlugin.author}/${selectedPlugin.name}`}
{readmePlugin &&
`${readmePlugin.author}/${readmePlugin.name} - ${t(
'plugins.readme',
)}`}
</DialogTitle>
</DialogHeader>
<div className="flex-1 flex flex-row overflow-hidden min-h-0">
{/* Left side - Readme */}
<div className="flex-1 overflow-y-auto border-r min-w-0">
{selectedPlugin && (
<PluginReadme
pluginAuthor={selectedPlugin.author}
pluginName={selectedPlugin.name}
/>
)}
</div>
{/* Right side - Config */}
<div className="w-[380px] flex-shrink-0 overflow-y-auto px-4">
{selectedPlugin && (
<PluginForm
pluginAuthor={selectedPlugin.author}
pluginName={selectedPlugin.name}
onFormSubmit={(timeout?: number) => {
setDetailModalOpen(false);
if (timeout) {
setTimeout(() => {
getPluginList();
}, timeout);
} else {
getPluginList();
}
}}
onFormCancel={() => {
setDetailModalOpen(false);
}}
/>
)}
</div>
<div className="flex-1 overflow-y-auto">
{readmePlugin && (
<PluginReadme
pluginAuthor={readmePlugin.author}
pluginName={readmePlugin.name}
/>
)}
</div>
</DialogContent>
</Dialog>
@@ -400,6 +413,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
onCardClick={() => handlePluginClick(vo)}
onDeleteClick={() => handlePluginDelete(vo)}
onUpgradeClick={() => handlePluginUpdate(vo)}
onViewReadme={() => handleViewReadme(vo)}
/>
</div>
);

View File

@@ -2,7 +2,15 @@ import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/Plu
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { useTranslation } from 'react-i18next';
import { BugIcon, ExternalLink, Ellipsis, Trash, ArrowUp } from 'lucide-react';
import {
BugIcon,
ExternalLink,
Ellipsis,
Trash,
ArrowUp,
Settings,
FileText,
} from 'lucide-react';
import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Button } from '@/components/ui/button';
@@ -19,20 +27,28 @@ export default function PluginCardComponent({
onCardClick,
onDeleteClick,
onUpgradeClick,
onViewReadme,
}: {
cardVO: PluginCardVO;
onCardClick: () => void;
onDeleteClick: (cardVO: PluginCardVO) => void;
onUpgradeClick: (cardVO: PluginCardVO) => void;
onViewReadme: (cardVO: PluginCardVO) => void;
}) {
const { t } = useTranslation();
const [dropdownOpen, setDropdownOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
return (
<>
<div
className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] p-[1.2rem] cursor-pointer dark:bg-[#1f1f22] relative transition-all duration-200 hover:shadow-[0px_3px_6px_0_rgba(0,0,0,0.12)] hover:scale-[1.005]"
onClick={() => onCardClick()}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => {
if (!dropdownOpen) {
setIsHovered(false);
}
}}
>
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
{/* Icon - fixed width */}
@@ -129,10 +145,7 @@ export default function PluginCardComponent({
</div>
{/* Menu button - fixed width and position */}
<div
className="flex flex-col items-center justify-between h-full relative z-20 flex-shrink-0"
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-col items-center justify-between h-full relative z-20 flex-shrink-0">
<div className="flex items-center justify-center"></div>
<div className="flex items-center justify-center">
@@ -140,6 +153,9 @@ export default function PluginCardComponent({
open={dropdownOpen}
onOpenChange={(open) => {
setDropdownOpen(open);
if (!open) {
setIsHovered(false);
}
}}
>
<DropdownMenuTrigger asChild>
@@ -217,6 +233,45 @@ export default function PluginCardComponent({
</div>
</div>
</div>
{/* Hover overlay with action buttons */}
<div
className={`absolute inset-0 bg-gray-100/55 dark:bg-black/35 rounded-[10px] flex items-center justify-center gap-3 transition-all duration-200 z-10 ${
isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
>
<Button
onClick={(e) => {
e.stopPropagation();
onViewReadme(cardVO);
}}
className={`bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
isHovered
? 'translate-y-0 opacity-100'
: 'translate-y-1 opacity-0'
}`}
style={{ transitionDelay: isHovered ? '10ms' : '0ms' }}
>
<FileText className="w-4 h-4" />
{t('plugins.readme')}
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
onCardClick();
}}
variant="outline"
className={`bg-white hover:bg-gray-100 text-gray-900 dark:bg-white dark:hover:bg-gray-100 dark:text-gray-900 px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
isHovered
? 'translate-y-0 opacity-100'
: 'translate-y-1 opacity-0'
}`}
style={{ transitionDelay: isHovered ? '20ms' : '0ms' }}
>
<Settings className="w-4 h-4" />
{t('plugins.config')}
</Button>
</div>
</div>
</>
);

View File

@@ -66,7 +66,6 @@ function MarketPageContent({
const pageSize = 12; // 每页12个
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const isComposingRef = useRef(false);
// 排序选项
const sortOptions: SortOption[] = [
@@ -251,14 +250,10 @@ function MarketPageContent({
clearTimeout(searchTimeoutRef.current);
}
if (isComposingRef.current) {
return;
}
// 设置新的定时器
searchTimeoutRef.current = setTimeout(() => {
handleSearch(value);
}, 500);
}, 300);
},
[handleSearch],
);
@@ -403,13 +398,6 @@ function MarketPageContent({
placeholder={t('market.searchPlaceholder')}
value={searchQuery}
onChange={(e) => handleSearchInputChange(e.target.value)}
onCompositionStart={() => {
isComposingRef.current = true;
}}
onCompositionEnd={(e) => {
isComposingRef.current = false;
handleSearchInputChange((e.target as HTMLInputElement).value);
}}
onKeyPress={(e) => {
if (e.key === 'Enter') {
// Immediately search, clear debounce timer