mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-26 07:24:20 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60579f7b3a | |||
| c4d4c90930 | |||
| 1a3ef217d9 | |||
| 6e9fcccd7d |
+3
-1
@@ -34,6 +34,8 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 什么是 LangBot?
|
||||||
|
|
||||||
LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时通信机器人。它将大语言模型(LLM)连接到各种聊天平台,帮助你创建能够对话、执行任务、并集成到现有工作流程中的智能 Agent。
|
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 平台。
|
- **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG(知识库),深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。
|
||||||
- **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。
|
- **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。
|
||||||
- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。
|
- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。
|
||||||
- **插件生态** — 数百个插件,跨进程的事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
|
- **插件生态** — 数百个插件,事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
|
||||||
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
|
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
|
||||||
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
|
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.4"
|
version = "4.9.3"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -64,7 +64,7 @@ dependencies = [
|
|||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin==0.3.5",
|
"langbot-plugin==0.3.3",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"tboxsdk>=0.0.10",
|
"tboxsdk>=0.0.10",
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||||
|
|
||||||
__version__ = '4.9.4'
|
__version__ = '4.9.3'
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
from .client import OpenClawWeixinClient as OpenClawWeixinClient
|
|
||||||
from .types import ApiError as ApiError
|
|
||||||
from .types import LoginResult as LoginResult
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -4,7 +4,6 @@ import base64
|
|||||||
import binascii
|
import binascii
|
||||||
import httpx
|
import httpx
|
||||||
import traceback
|
import traceback
|
||||||
from urllib.parse import quote
|
|
||||||
from quart import Quart
|
from quart import Quart
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from typing import Callable, Dict, Any
|
from typing import Callable, Dict, Any
|
||||||
@@ -68,31 +67,6 @@ class WecomClient:
|
|||||||
await self.logger.error(f'获取accesstoken失败:{response.json()}')
|
await self.logger.error(f'获取accesstoken失败:{response.json()}')
|
||||||
raise Exception(f'未获取access token: {data}')
|
raise Exception(f'未获取access token: {data}')
|
||||||
|
|
||||||
async def get_user_info(self, userid: str) -> dict:
|
|
||||||
"""
|
|
||||||
Get user information by user ID using the application secret.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
userid: The user ID to look up.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: User information including 'name' field.
|
|
||||||
"""
|
|
||||||
if not await self.check_access_token():
|
|
||||||
self.access_token = await self.get_access_token(self.secret)
|
|
||||||
|
|
||||||
url = self.base_url + '/user/get?access_token=' + self.access_token + '&userid=' + quote(userid)
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.get(url)
|
|
||||||
data = response.json()
|
|
||||||
if data.get('errcode') == 40014 or data.get('errcode') == 42001:
|
|
||||||
self.access_token = await self.get_access_token(self.secret)
|
|
||||||
return await self.get_user_info(userid)
|
|
||||||
if data.get('errcode', 0) != 0:
|
|
||||||
await self.logger.error(f'获取用户信息失败:{data}')
|
|
||||||
return {}
|
|
||||||
return data
|
|
||||||
|
|
||||||
async def get_users(self):
|
async def get_users(self):
|
||||||
if not self.check_access_token_for_contacts():
|
if not self.check_access_token_for_contacts():
|
||||||
self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts)
|
self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts)
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ from .. import group
|
|||||||
@group.group_class('files', '/api/v1/files')
|
@group.group_class('files', '/api/v1/files')
|
||||||
class FilesRouterGroup(group.RouterGroup):
|
class FilesRouterGroup(group.RouterGroup):
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
@self.route('/image/<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:
|
async def _(image_key: str) -> quart.Response:
|
||||||
if '..' in image_key or '\\' in image_key:
|
if '/' in image_key or '\\' in image_key:
|
||||||
return quart.Response(status=404)
|
return quart.Response(status=404)
|
||||||
|
|
||||||
if not await self.ap.storage_mgr.storage_provider.exists(image_key):
|
if not await self.ap.storage_mgr.storage_provider.exists(image_key):
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ class BotService:
|
|||||||
'wecomcs',
|
'wecomcs',
|
||||||
'LINE',
|
'LINE',
|
||||||
'lark',
|
'lark',
|
||||||
|
'gewechat',
|
||||||
]:
|
]:
|
||||||
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
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', '')
|
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
|
||||||
|
|||||||
@@ -272,6 +272,9 @@ class PlatformManager:
|
|||||||
# 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid(用于统一 webhook)
|
# 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid(用于统一 webhook)
|
||||||
if hasattr(adapter_inst, 'set_bot_uuid'):
|
if hasattr(adapter_inst, 'set_bot_uuid'):
|
||||||
adapter_inst.set_bot_uuid(bot_entity.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)
|
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 |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
|
||||||
@@ -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 |
@@ -148,54 +148,51 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if type(event) is platform_events.FriendMessage:
|
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
|
@staticmethod
|
||||||
async def target2yiri(event: WecomEvent, bot: WecomClient = None):
|
async def target2yiri(event: WecomEvent):
|
||||||
"""
|
"""
|
||||||
将 WecomEvent 转换为平台的 FriendMessage 对象。
|
将 WecomEvent 转换为平台的 FriendMessage 对象。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
event (WecomEvent): 企业微信事件。
|
event (WecomEvent): 企业微信事件。
|
||||||
bot (WecomClient): 企业微信客户端,用于获取用户信息。
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
platform_events.FriendMessage: 转换后的 FriendMessage 对象。
|
platform_events.FriendMessage: 转换后的 FriendMessage 对象。
|
||||||
"""
|
"""
|
||||||
# Try to get the user's real name from the WeCom API
|
|
||||||
nickname = str(event.user_id)
|
|
||||||
if bot and event.user_id:
|
|
||||||
try:
|
|
||||||
user_info = await bot.get_user_info(event.user_id)
|
|
||||||
if user_info and user_info.get('name'):
|
|
||||||
nickname = user_info.get('name')
|
|
||||||
except Exception:
|
|
||||||
pass # Fall back to user_id as nickname
|
|
||||||
|
|
||||||
# 转换消息链
|
# 转换消息链
|
||||||
if event.type == 'text':
|
if event.type == 'text':
|
||||||
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'u{event.user_id}',
|
||||||
nickname=nickname,
|
nickname=str(event.agent_id),
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
|
|
||||||
return platform_events.FriendMessage(
|
return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, time=event.timestamp)
|
||||||
sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event
|
|
||||||
)
|
|
||||||
elif event.type == 'image':
|
elif event.type == 'image':
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'u{event.user_id}',
|
||||||
nickname=nickname,
|
nickname=str(event.agent_id),
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
|
|
||||||
yiri_chain = await WecomMessageConverter.target2yiri_image(picurl=event.picurl, message_id=event.message_id)
|
yiri_chain = await WecomMessageConverter.target2yiri_image(picurl=event.picurl, message_id=event.message_id)
|
||||||
|
|
||||||
return platform_events.FriendMessage(
|
return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, time=event.timestamp)
|
||||||
sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
@@ -213,6 +210,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
'secret',
|
'secret',
|
||||||
'token',
|
'token',
|
||||||
'EncodingAESKey',
|
'EncodingAESKey',
|
||||||
|
'contacts_secret',
|
||||||
]
|
]
|
||||||
|
|
||||||
missing_keys = [key for key in required_keys if key not in config]
|
missing_keys = [key for key in required_keys if key not in config]
|
||||||
@@ -225,7 +223,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
secret=config['secret'],
|
secret=config['secret'],
|
||||||
token=config['token'],
|
token=config['token'],
|
||||||
EncodingAESKey=config['EncodingAESKey'],
|
EncodingAESKey=config['EncodingAESKey'],
|
||||||
contacts_secret=config.get('contacts_secret', ''), # Optional, kept for backward compatibility
|
contacts_secret=config['contacts_secret'],
|
||||||
logger=logger,
|
logger=logger,
|
||||||
unified_mode=True,
|
unified_mode=True,
|
||||||
api_base_url=config.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin'),
|
api_base_url=config.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin'),
|
||||||
@@ -250,17 +248,18 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
):
|
):
|
||||||
Wecom_event = await WecomEventConverter.yiri2target(message_source, self.bot_account_id, self.bot)
|
Wecom_event = await WecomEventConverter.yiri2target(message_source, self.bot_account_id, self.bot)
|
||||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||||
# user_id is the original FromUserName from WecomEvent
|
fixed_user_id = Wecom_event.user_id
|
||||||
user_id = Wecom_event.user_id
|
# 删掉开头的u
|
||||||
|
fixed_user_id = fixed_user_id[1:]
|
||||||
for content in content_list:
|
for content in content_list:
|
||||||
if content['type'] == 'text':
|
if content['type'] == 'text':
|
||||||
await self.bot.send_private_msg(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':
|
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':
|
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':
|
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):
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||||
@@ -288,7 +287,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
async def on_message(event: WecomEvent):
|
async def on_message(event: WecomEvent):
|
||||||
self.bot_account_id = event.receiver_id
|
self.bot_account_id = event.receiver_id
|
||||||
try:
|
try:
|
||||||
return await callback(await self.event_converter.target2yiri(event, self.bot), self)
|
return await callback(await self.event_converter.target2yiri(event), self)
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(f'Error in wecom callback: {traceback.format_exc()}')
|
await self.logger.error(f'Error in wecom callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ spec:
|
|||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
- name: contacts_secret
|
||||||
|
label:
|
||||||
|
en_US: Contacts Secret
|
||||||
|
zh_Hans: 通讯录密钥
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: ""
|
||||||
- name: api_base_url
|
- name: api_base_url
|
||||||
label:
|
label:
|
||||||
en_US: API Base URL
|
en_US: API Base URL
|
||||||
|
|||||||
@@ -314,11 +314,11 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
|
|
||||||
@self.action(PluginToRuntimeAction.GET_LLM_MODELS)
|
@self.action(PluginToRuntimeAction.GET_LLM_MODELS)
|
||||||
async def get_llm_models(data: dict[str, Any]) -> handler.ActionResponse:
|
async def get_llm_models(data: dict[str, Any]) -> handler.ActionResponse:
|
||||||
"""Get llm models, returns list of UUID strings"""
|
"""Get llm models"""
|
||||||
llm_models = await self.ap.llm_model_service.get_llm_models(include_secret=False)
|
llm_models = await self.ap.llm_model_service.get_llm_models(include_secret=False)
|
||||||
return handler.ActionResponse.success(
|
return handler.ActionResponse.success(
|
||||||
data={
|
data={
|
||||||
'llm_models': [m['uuid'] for m in llm_models],
|
'llm_models': llm_models,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -531,7 +531,6 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
filters = data.get('filters')
|
filters = data.get('filters')
|
||||||
search_type = data.get('search_type', 'vector')
|
search_type = data.get('search_type', 'vector')
|
||||||
query_text = data.get('query_text', '')
|
query_text = data.get('query_text', '')
|
||||||
vector_weight = data.get('vector_weight')
|
|
||||||
try:
|
try:
|
||||||
results = await self.ap.rag_runtime_service.vector_search(
|
results = await self.ap.rag_runtime_service.vector_search(
|
||||||
collection_id,
|
collection_id,
|
||||||
@@ -540,7 +539,6 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
filters,
|
filters,
|
||||||
search_type,
|
search_type,
|
||||||
query_text,
|
query_text,
|
||||||
vector_weight=vector_weight,
|
|
||||||
)
|
)
|
||||||
return handler.ActionResponse.success(data={'results': results})
|
return handler.ActionResponse.success(data={'results': results})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -615,47 +613,6 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
|
|
||||||
# ================= Knowledge Base Query APIs =================
|
# ================= Knowledge Base Query APIs =================
|
||||||
|
|
||||||
@self.action(PluginToRuntimeAction.LIST_KNOWLEDGE_BASES)
|
|
||||||
async def list_knowledge_bases(data: dict[str, Any]) -> handler.ActionResponse:
|
|
||||||
"""List all knowledge bases available in the LangBot instance (unrestricted)."""
|
|
||||||
knowledge_bases = []
|
|
||||||
for kb_uuid, kb in self.ap.rag_mgr.knowledge_bases.items():
|
|
||||||
knowledge_bases.append(
|
|
||||||
{
|
|
||||||
'uuid': kb.get_uuid(),
|
|
||||||
'name': kb.get_name(),
|
|
||||||
'description': kb.knowledge_base_entity.description or '',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return handler.ActionResponse.success(data={'knowledge_bases': knowledge_bases})
|
|
||||||
|
|
||||||
@self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE)
|
|
||||||
async def retrieve_knowledge(data: dict[str, Any]) -> handler.ActionResponse:
|
|
||||||
"""Retrieve documents from any knowledge base (unrestricted)."""
|
|
||||||
kb_id = data['kb_id']
|
|
||||||
query_text = data['query_text']
|
|
||||||
top_k = data.get('top_k', 5)
|
|
||||||
filters = data.get('filters', {})
|
|
||||||
|
|
||||||
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_id)
|
|
||||||
if not kb:
|
|
||||||
return handler.ActionResponse.error(
|
|
||||||
message=f'Knowledge base {kb_id} not found',
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
entries = await kb.retrieve(
|
|
||||||
query_text,
|
|
||||||
settings={
|
|
||||||
'top_k': top_k,
|
|
||||||
'filters': filters,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
results = [entry.model_dump(mode='json') for entry in entries]
|
|
||||||
return handler.ActionResponse.success(data={'results': results})
|
|
||||||
except Exception as e:
|
|
||||||
return _make_rag_error_response(e, 'RetrievalError', kb_id=kb_id)
|
|
||||||
|
|
||||||
@self.action(PluginToRuntimeAction.LIST_PIPELINE_KNOWLEDGE_BASES)
|
@self.action(PluginToRuntimeAction.LIST_PIPELINE_KNOWLEDGE_BASES)
|
||||||
async def list_pipeline_knowledge_bases(data: dict[str, Any]) -> handler.ActionResponse:
|
async def list_pipeline_knowledge_bases(data: dict[str, Any]) -> handler.ActionResponse:
|
||||||
"""List knowledge bases configured for the current query's pipeline."""
|
"""List knowledge bases configured for the current query's pipeline."""
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ class RAGRuntimeService:
|
|||||||
filters: dict[str, Any] | None = None,
|
filters: dict[str, Any] | None = None,
|
||||||
search_type: str = 'vector',
|
search_type: str = 'vector',
|
||||||
query_text: str = '',
|
query_text: str = '',
|
||||||
vector_weight: float | None = None,
|
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Handle VECTOR_SEARCH action."""
|
"""Handle VECTOR_SEARCH action."""
|
||||||
return await self.ap.vector_db_mgr.search(
|
return await self.ap.vector_db_mgr.search(
|
||||||
@@ -51,7 +50,6 @@ class RAGRuntimeService:
|
|||||||
filter=filters,
|
filter=filters,
|
||||||
search_type=search_type,
|
search_type=search_type,
|
||||||
query_text=query_text,
|
query_text=query_text,
|
||||||
vector_weight=vector_weight,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def vector_delete(
|
async def vector_delete(
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ class VectorDBManager:
|
|||||||
filter: dict | None = None,
|
filter: dict | None = None,
|
||||||
search_type: str = 'vector',
|
search_type: str = 'vector',
|
||||||
query_text: str = '',
|
query_text: str = '',
|
||||||
vector_weight: float | None = None,
|
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Proxy: Search vectors.
|
"""Proxy: Search vectors.
|
||||||
|
|
||||||
@@ -112,7 +111,6 @@ class VectorDBManager:
|
|||||||
search_type=search_type,
|
search_type=search_type,
|
||||||
query_text=query_text,
|
query_text=query_text,
|
||||||
filter=filter,
|
filter=filter,
|
||||||
vector_weight=vector_weight,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not results or 'ids' not in results or not results['ids']:
|
if not results or 'ids' not in results or not results['ids']:
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ class VectorDatabase(abc.ABC):
|
|||||||
search_type: str = 'vector',
|
search_type: str = 'vector',
|
||||||
query_text: str = '',
|
query_text: str = '',
|
||||||
filter: dict[str, Any] | None = None,
|
filter: dict[str, Any] | None = None,
|
||||||
vector_weight: float | None = None,
|
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Search for the most similar vectors in the specified collection.
|
"""Search for the most similar vectors in the specified collection.
|
||||||
|
|
||||||
@@ -71,8 +70,6 @@ class VectorDatabase(abc.ABC):
|
|||||||
{"file_id": "abc"}
|
{"file_id": "abc"}
|
||||||
{"created_at": {"$gte": 1700000000}}
|
{"created_at": {"$gte": 1700000000}}
|
||||||
{"file_type": {"$in": ["pdf", "docx"]}}
|
{"file_type": {"$in": ["pdf", "docx"]}}
|
||||||
vector_weight: Weight for vector search in hybrid mode (0.0–1.0).
|
|
||||||
``None`` means use equal weights (backward compatible).
|
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -52,16 +52,13 @@ class ChromaVectorDatabase(VectorDatabase):
|
|||||||
search_type: str = 'vector',
|
search_type: str = 'vector',
|
||||||
query_text: str = '',
|
query_text: str = '',
|
||||||
filter: dict[str, Any] | None = None,
|
filter: dict[str, Any] | None = None,
|
||||||
vector_weight: float | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
col = await self.get_or_create_collection(collection)
|
col = await self.get_or_create_collection(collection)
|
||||||
|
|
||||||
if search_type == SearchType.FULL_TEXT:
|
if search_type == SearchType.FULL_TEXT:
|
||||||
return await self._full_text_search(col, collection, k, query_text, filter)
|
return await self._full_text_search(col, collection, k, query_text, filter)
|
||||||
elif search_type == SearchType.HYBRID:
|
elif search_type == SearchType.HYBRID:
|
||||||
return await self._hybrid_search(
|
return await self._hybrid_search(col, collection, query_embedding, k, query_text, filter)
|
||||||
col, collection, query_embedding, k, query_text, filter, vector_weight=vector_weight
|
|
||||||
)
|
|
||||||
|
|
||||||
# Default: vector search
|
# Default: vector search
|
||||||
return await self._vector_search(col, collection, query_embedding, k, filter)
|
return await self._vector_search(col, collection, query_embedding, k, filter)
|
||||||
@@ -130,7 +127,6 @@ class ChromaVectorDatabase(VectorDatabase):
|
|||||||
k: int,
|
k: int,
|
||||||
query_text: str,
|
query_text: str,
|
||||||
filter: dict[str, Any] | None,
|
filter: dict[str, Any] | None,
|
||||||
vector_weight: float | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
# Fall back to pure vector search when no text is provided
|
# Fall back to pure vector search when no text is provided
|
||||||
if not query_text:
|
if not query_text:
|
||||||
@@ -148,15 +144,7 @@ class ChromaVectorDatabase(VectorDatabase):
|
|||||||
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
|
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
|
||||||
|
|
||||||
# RRF fusion
|
# RRF fusion
|
||||||
weights = None
|
fused = self._rrf_fuse([vector_ids, text_ids], k)
|
||||||
if vector_weight is not None:
|
|
||||||
weights = [vector_weight, 1.0 - vector_weight]
|
|
||||||
self.ap.logger.info(
|
|
||||||
f"Chroma hybrid fusion config in '{collection}': "
|
|
||||||
f'vector_weight={vector_weight}, weights={weights or [1.0, 1.0]}, '
|
|
||||||
f'vector_hits={len(vector_ids)}, text_hits={len(text_ids)}'
|
|
||||||
)
|
|
||||||
fused = self._rrf_fuse([vector_ids, text_ids], k, weights=weights)
|
|
||||||
if not fused:
|
if not fused:
|
||||||
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
|
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
|
||||||
|
|
||||||
@@ -209,24 +197,16 @@ class ChromaVectorDatabase(VectorDatabase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@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.
|
"""Reciprocal Rank Fusion over multiple ranked ID lists.
|
||||||
|
|
||||||
Returns a list of (doc_id, rrf_score) sorted by descending score,
|
Returns a list of (doc_id, rrf_score) sorted by descending score,
|
||||||
truncated to *k* entries.
|
truncated to *k* entries.
|
||||||
|
|
||||||
Args:
|
|
||||||
result_lists: Ranked ID lists from different search methods.
|
|
||||||
k: Number of results to return.
|
|
||||||
weights: Per-list weights. ``None`` means equal weight (1.0 each).
|
|
||||||
"""
|
"""
|
||||||
if weights is None:
|
|
||||||
weights = [1.0] * len(result_lists)
|
|
||||||
scores: dict[str, float] = {}
|
scores: dict[str, float] = {}
|
||||||
for list_idx, ranked_ids in enumerate(result_lists):
|
for ranked_ids in result_lists:
|
||||||
w = weights[list_idx]
|
|
||||||
for rank, doc_id in enumerate(ranked_ids):
|
for rank, doc_id in enumerate(ranked_ids):
|
||||||
scores[doc_id] = scores.get(doc_id, 0.0) + 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)
|
sorted_results = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
||||||
return sorted_results[:k]
|
return sorted_results[:k]
|
||||||
|
|
||||||
|
|||||||
@@ -255,7 +255,6 @@ class MilvusVectorDatabase(VectorDatabase):
|
|||||||
search_type: str = 'vector',
|
search_type: str = 'vector',
|
||||||
query_text: str = '',
|
query_text: str = '',
|
||||||
filter: dict[str, Any] | None = None,
|
filter: dict[str, Any] | None = None,
|
||||||
vector_weight: float | None = None,
|
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Search for similar vectors in Milvus collection
|
"""Search for similar vectors in Milvus collection
|
||||||
|
|
||||||
|
|||||||
@@ -192,7 +192,6 @@ class PgVectorDatabase(VectorDatabase):
|
|||||||
search_type: str = 'vector',
|
search_type: str = 'vector',
|
||||||
query_text: str = '',
|
query_text: str = '',
|
||||||
filter: dict[str, Any] | None = None,
|
filter: dict[str, Any] | None = None,
|
||||||
vector_weight: float | None = None,
|
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Search for similar vectors using cosine distance
|
"""Search for similar vectors using cosine distance
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ class QdrantVectorDatabase(VectorDatabase):
|
|||||||
search_type: str = 'vector',
|
search_type: str = 'vector',
|
||||||
query_text: str = '',
|
query_text: str = '',
|
||||||
filter: dict[str, Any] | None = None,
|
filter: dict[str, Any] | None = None,
|
||||||
vector_weight: float | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
exists = await self.client.collection_exists(collection)
|
exists = await self.client.collection_exists(collection)
|
||||||
if not exists:
|
if not exists:
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from decimal import Decimal
|
|
||||||
import re
|
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
|
||||||
@@ -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:
|
async def _get_or_create_collection_internal(self, collection: str, vector_size: int = None) -> Any:
|
||||||
"""Internal method to get or create a collection with proper configuration."""
|
"""Internal method to get or create a collection with proper configuration."""
|
||||||
collection = self._normalize_collection_name(collection)
|
|
||||||
if collection in self._collections:
|
if collection in self._collections:
|
||||||
return self._collections[collection]
|
return self._collections[collection]
|
||||||
|
|
||||||
@@ -195,7 +173,6 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
if not embeddings_list:
|
if not embeddings_list:
|
||||||
return
|
return
|
||||||
|
|
||||||
collection = self._normalize_collection_name(collection)
|
|
||||||
# Ensure collection exists with correct dimension
|
# Ensure collection exists with correct dimension
|
||||||
vector_size = len(embeddings_list[0])
|
vector_size = len(embeddings_list[0])
|
||||||
coll = await self._get_or_create_collection_internal(collection, vector_size)
|
coll = await self._get_or_create_collection_internal(collection, vector_size)
|
||||||
@@ -217,7 +194,6 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
search_type: str = 'vector',
|
search_type: str = 'vector',
|
||||||
query_text: str = '',
|
query_text: str = '',
|
||||||
filter: Dict[str, Any] | None = None,
|
filter: Dict[str, Any] | None = None,
|
||||||
vector_weight: float | None = None,
|
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Search for the most similar vectors in the specified collection.
|
"""Search for the most similar vectors in the specified collection.
|
||||||
|
|
||||||
@@ -234,7 +210,6 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary with 'ids', 'metadatas', 'distances' keys
|
Dictionary with 'ids', 'metadatas', 'distances' keys
|
||||||
"""
|
"""
|
||||||
collection = self._normalize_collection_name(collection)
|
|
||||||
# Check if collection exists
|
# Check if collection exists
|
||||||
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
||||||
if not exists:
|
if not exists:
|
||||||
@@ -296,17 +271,6 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
query_cfg['where'] = filter
|
query_cfg['where'] = filter
|
||||||
knn_cfg['where'] = filter
|
knn_cfg['where'] = filter
|
||||||
|
|
||||||
# Apply vector_weight via pyseekdb's native boost parameter
|
|
||||||
if vector_weight is not None:
|
|
||||||
knn_cfg['boost'] = vector_weight
|
|
||||||
query_cfg['boost'] = 1.0 - vector_weight
|
|
||||||
self.ap.logger.info(
|
|
||||||
f"SeekDB hybrid fusion config in '{collection}': "
|
|
||||||
f'vector_weight={vector_weight}, '
|
|
||||||
f'knn_boost={knn_cfg.get("boost", 1.0)}, '
|
|
||||||
f'query_boost={query_cfg.get("boost", 1.0)}'
|
|
||||||
)
|
|
||||||
|
|
||||||
results = await asyncio.to_thread(
|
results = await asyncio.to_thread(
|
||||||
coll.hybrid_search,
|
coll.hybrid_search,
|
||||||
query=query_cfg,
|
query=query_cfg,
|
||||||
@@ -315,9 +279,6 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
n_results=k,
|
n_results=k,
|
||||||
include=['documents', 'metadatas'],
|
include=['documents', 'metadatas'],
|
||||||
)
|
)
|
||||||
self.ap.logger.info(
|
|
||||||
f"SeekDB hybrid search in '{collection}' returned {len(results.get('ids', [[]])[0])} results."
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# Default: vector search via query()
|
# Default: vector search via query()
|
||||||
query_kwargs = {'n_results': k, 'query_embeddings': query_embedding}
|
query_kwargs = {'n_results': k, 'query_embeddings': query_embedding}
|
||||||
@@ -325,7 +286,6 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
query_kwargs['where'] = filter
|
query_kwargs['where'] = filter
|
||||||
results = await asyncio.to_thread(coll.query, **query_kwargs)
|
results = await asyncio.to_thread(coll.query, **query_kwargs)
|
||||||
|
|
||||||
results = self._json_safe(results)
|
|
||||||
self.ap.logger.info(
|
self.ap.logger.info(
|
||||||
f"SeekDB {search_type} search in '{collection}' returned {len(results.get('ids', [[]])[0])} results"
|
f"SeekDB {search_type} search in '{collection}' returned {len(results.get('ids', [[]])[0])} results"
|
||||||
)
|
)
|
||||||
@@ -339,7 +299,6 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
collection: Collection name
|
collection: Collection name
|
||||||
file_id: File ID to delete
|
file_id: File ID to delete
|
||||||
"""
|
"""
|
||||||
collection = self._normalize_collection_name(collection)
|
|
||||||
# Check if collection exists
|
# Check if collection exists
|
||||||
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
||||||
if not exists:
|
if not exists:
|
||||||
@@ -366,7 +325,6 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
collection: Collection name
|
collection: Collection name
|
||||||
filter: Chroma-style ``where`` filter dict
|
filter: Chroma-style ``where`` filter dict
|
||||||
"""
|
"""
|
||||||
collection = self._normalize_collection_name(collection)
|
|
||||||
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
||||||
if not exists:
|
if not exists:
|
||||||
self.ap.logger.warning(f"SeekDB collection '{collection}' not found for deletion")
|
self.ap.logger.warning(f"SeekDB collection '{collection}' not found for deletion")
|
||||||
@@ -389,7 +347,6 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
) -> tuple[list[Dict[str, Any]], int]:
|
) -> tuple[list[Dict[str, Any]], int]:
|
||||||
collection = self._normalize_collection_name(collection)
|
|
||||||
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
||||||
if not exists:
|
if not exists:
|
||||||
return [], 0
|
return [], 0
|
||||||
@@ -410,7 +367,6 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
|
|
||||||
results = await asyncio.to_thread(coll.get, **get_kwargs)
|
results = await asyncio.to_thread(coll.get, **get_kwargs)
|
||||||
|
|
||||||
results = self._json_safe(results)
|
|
||||||
ids = results.get('ids', [])
|
ids = results.get('ids', [])
|
||||||
metadatas = results.get('metadatas', []) or [None] * len(ids)
|
metadatas = results.get('metadatas', []) or [None] * len(ids)
|
||||||
documents = results.get('documents', []) or [None] * len(ids)
|
documents = results.get('documents', []) or [None] * len(ids)
|
||||||
@@ -434,7 +390,6 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
Args:
|
Args:
|
||||||
collection: Collection name
|
collection: Collection name
|
||||||
"""
|
"""
|
||||||
collection = self._normalize_collection_name(collection)
|
|
||||||
# Remove from cache
|
# Remove from cache
|
||||||
if collection in self._collections:
|
if collection in self._collections:
|
||||||
del self._collections[collection]
|
del self._collections[collection]
|
||||||
|
|||||||
@@ -1832,7 +1832,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.4"
|
version = "4.9.3"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiocqhttp" },
|
{ name = "aiocqhttp" },
|
||||||
@@ -1937,7 +1937,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||||
{ name = "langbot-plugin", specifier = "==0.3.5" },
|
{ name = "langbot-plugin", specifier = "==0.3.3" },
|
||||||
{ name = "langchain", specifier = ">=0.2.0" },
|
{ name = "langchain", specifier = ">=0.2.0" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
|
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
|
||||||
{ name = "lark-oapi", specifier = ">=1.4.15" },
|
{ name = "lark-oapi", specifier = ">=1.4.15" },
|
||||||
@@ -1993,7 +1993,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.3.5"
|
version = "0.3.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
@@ -2011,9 +2011,9 @@ dependencies = [
|
|||||||
{ name = "watchdog" },
|
{ name = "watchdog" },
|
||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/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 = [
|
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]]
|
[[package]]
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
EmbeddingCall,
|
EmbeddingCall,
|
||||||
} from '../types/monitoring';
|
} from '../types/monitoring';
|
||||||
import { backendClient } from '@/app/infra/http';
|
import { backendClient } from '@/app/infra/http';
|
||||||
import { parseUTCTimestamp } from '../utils/dateUtils';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for fetching and managing monitoring data
|
* Custom hook for fetching and managing monitoring data
|
||||||
@@ -121,7 +120,7 @@ export function useMonitoringData(filterState: FilterState) {
|
|||||||
variables?: string;
|
variables?: string;
|
||||||
}) => ({
|
}) => ({
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
timestamp: parseUTCTimestamp(msg.timestamp),
|
timestamp: new Date(msg.timestamp),
|
||||||
botId: msg.bot_id,
|
botId: msg.bot_id,
|
||||||
botName: msg.bot_name,
|
botName: msg.bot_name,
|
||||||
pipelineId: msg.pipeline_id,
|
pipelineId: msg.pipeline_id,
|
||||||
@@ -155,7 +154,7 @@ export function useMonitoringData(filterState: FilterState) {
|
|||||||
message_id?: string;
|
message_id?: string;
|
||||||
}) => ({
|
}) => ({
|
||||||
id: call.id,
|
id: call.id,
|
||||||
timestamp: parseUTCTimestamp(call.timestamp),
|
timestamp: new Date(call.timestamp),
|
||||||
modelName: call.model_name,
|
modelName: call.model_name,
|
||||||
tokens: {
|
tokens: {
|
||||||
input: call.input_tokens,
|
input: call.input_tokens,
|
||||||
@@ -191,7 +190,7 @@ export function useMonitoringData(filterState: FilterState) {
|
|||||||
call_type?: string;
|
call_type?: string;
|
||||||
}) => ({
|
}) => ({
|
||||||
id: call.id,
|
id: call.id,
|
||||||
timestamp: parseUTCTimestamp(call.timestamp),
|
timestamp: new Date(call.timestamp),
|
||||||
modelName: call.model_name,
|
modelName: call.model_name,
|
||||||
promptTokens: call.prompt_tokens,
|
promptTokens: call.prompt_tokens,
|
||||||
totalTokens: call.total_tokens,
|
totalTokens: call.total_tokens,
|
||||||
@@ -228,10 +227,10 @@ export function useMonitoringData(filterState: FilterState) {
|
|||||||
pipelineName: session.pipeline_name,
|
pipelineName: session.pipeline_name,
|
||||||
messageCount: session.message_count,
|
messageCount: session.message_count,
|
||||||
duration:
|
duration:
|
||||||
parseUTCTimestamp(session.last_activity).getTime() -
|
new Date(session.last_activity).getTime() -
|
||||||
parseUTCTimestamp(session.start_time).getTime(),
|
new Date(session.start_time).getTime(),
|
||||||
lastActivity: parseUTCTimestamp(session.last_activity),
|
lastActivity: new Date(session.last_activity),
|
||||||
startTime: parseUTCTimestamp(session.start_time),
|
startTime: new Date(session.start_time),
|
||||||
platform: session.platform,
|
platform: session.platform,
|
||||||
userId: session.user_id,
|
userId: session.user_id,
|
||||||
}),
|
}),
|
||||||
@@ -251,7 +250,7 @@ export function useMonitoringData(filterState: FilterState) {
|
|||||||
message_id?: string;
|
message_id?: string;
|
||||||
}) => ({
|
}) => ({
|
||||||
id: error.id,
|
id: error.id,
|
||||||
timestamp: parseUTCTimestamp(error.timestamp),
|
timestamp: new Date(error.timestamp),
|
||||||
errorType: error.error_type,
|
errorType: error.error_type,
|
||||||
errorMessage: error.error_message,
|
errorMessage: error.error_message,
|
||||||
botId: error.bot_id,
|
botId: error.bot_id,
|
||||||
|
|||||||
@@ -97,22 +97,3 @@ export function isDateInRange(date: Date, range: DateRange | null): boolean {
|
|||||||
export function parseDate(dateStr: string): Date {
|
export function parseDate(dateStr: string): Date {
|
||||||
return new Date(dateStr);
|
return new Date(dateStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a UTC timestamp string from the backend into a Date object.
|
|
||||||
*
|
|
||||||
* The backend stores all monitoring timestamps in UTC but serializes them
|
|
||||||
* as naive ISO strings (e.g. "2026-03-25T14:30:00") without a timezone
|
|
||||||
* designator. JavaScript's `new Date()` would treat such strings as local
|
|
||||||
* time, causing the displayed time to be off by the user's UTC offset.
|
|
||||||
*
|
|
||||||
* This function appends 'Z' when the string has no timezone info, so that
|
|
||||||
* `new Date()` correctly interprets it as UTC.
|
|
||||||
*/
|
|
||||||
export function parseUTCTimestamp(timestamp: string): Date {
|
|
||||||
// If the string already contains timezone info ('Z', '+', or '-' offset), parse as-is
|
|
||||||
if (/Z|[+-]\d{2}:\d{2}$/.test(timestamp)) {
|
|
||||||
return new Date(timestamp);
|
|
||||||
}
|
|
||||||
return new Date(timestamp + 'Z');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { MessageContentRenderer } from '@/app/home/monitoring/components/Message
|
|||||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { MessageDetails } from '@/app/home/monitoring/types/monitoring';
|
import { MessageDetails } from '@/app/home/monitoring/types/monitoring';
|
||||||
import { parseUTCTimestamp } from '@/app/home/monitoring/utils/dateUtils';
|
|
||||||
|
|
||||||
interface PipelineMonitoringTabProps {
|
interface PipelineMonitoringTabProps {
|
||||||
pipelineId: string;
|
pipelineId: string;
|
||||||
@@ -121,7 +120,7 @@ export default function PipelineMonitoringTab({
|
|||||||
message: result.message
|
message: result.message
|
||||||
? {
|
? {
|
||||||
id: result.message.id,
|
id: result.message.id,
|
||||||
timestamp: parseUTCTimestamp(result.message.timestamp),
|
timestamp: new Date(result.message.timestamp),
|
||||||
botId: result.message.bot_id,
|
botId: result.message.bot_id,
|
||||||
botName: result.message.bot_name,
|
botName: result.message.bot_name,
|
||||||
pipelineId: result.message.pipeline_id,
|
pipelineId: result.message.pipeline_id,
|
||||||
@@ -138,7 +137,7 @@ export default function PipelineMonitoringTab({
|
|||||||
: undefined,
|
: undefined,
|
||||||
llmCalls: result.llm_calls.map((call: RawLLMCallData) => ({
|
llmCalls: result.llm_calls.map((call: RawLLMCallData) => ({
|
||||||
id: call.id,
|
id: call.id,
|
||||||
timestamp: parseUTCTimestamp(call.timestamp),
|
timestamp: new Date(call.timestamp),
|
||||||
modelName: call.model_name,
|
modelName: call.model_name,
|
||||||
status: call.status,
|
status: call.status,
|
||||||
duration: call.duration,
|
duration: call.duration,
|
||||||
@@ -151,7 +150,7 @@ export default function PipelineMonitoringTab({
|
|||||||
})),
|
})),
|
||||||
errors: result.errors.map((error: RawErrorData) => ({
|
errors: result.errors.map((error: RawErrorData) => ({
|
||||||
id: error.id,
|
id: error.id,
|
||||||
timestamp: parseUTCTimestamp(error.timestamp),
|
timestamp: new Date(error.timestamp),
|
||||||
errorType: error.error_type,
|
errorType: error.error_type,
|
||||||
errorMessage: error.error_message,
|
errorMessage: error.error_message,
|
||||||
stackTrace: error.stack_trace,
|
stackTrace: error.stack_trace,
|
||||||
|
|||||||
@@ -38,10 +38,12 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
|||||||
(props, ref) => {
|
(props, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [pluginList, setPluginList] = useState<PluginCardVO[]>([]);
|
const [pluginList, setPluginList] = useState<PluginCardVO[]>([]);
|
||||||
const [detailModalOpen, setDetailModalOpen] = useState<boolean>(false);
|
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
||||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginCardVO | null>(
|
const [selectedPlugin, setSelectedPlugin] = useState<PluginCardVO | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [readmeModalOpen, setReadmeModalOpen] = useState<boolean>(false);
|
||||||
|
const [readmePlugin, setReadmePlugin] = useState<PluginCardVO | null>(null);
|
||||||
const [showOperationModal, setShowOperationModal] = useState(false);
|
const [showOperationModal, setShowOperationModal] = useState(false);
|
||||||
const [operationType, setOperationType] = useState<PluginOperationType>(
|
const [operationType, setOperationType] = useState<PluginOperationType>(
|
||||||
PluginOperationType.DELETE,
|
PluginOperationType.DELETE,
|
||||||
@@ -164,7 +166,12 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
|||||||
|
|
||||||
function handlePluginClick(plugin: PluginCardVO) {
|
function handlePluginClick(plugin: PluginCardVO) {
|
||||||
setSelectedPlugin(plugin);
|
setSelectedPlugin(plugin);
|
||||||
setDetailModalOpen(true);
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleViewReadme(plugin: PluginCardVO) {
|
||||||
|
setReadmePlugin(plugin);
|
||||||
|
setReadmeModalOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePluginDelete(plugin: PluginCardVO) {
|
function handlePluginDelete(plugin: PluginCardVO) {
|
||||||
@@ -348,46 +355,52 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={`${styles.pluginListContainer}`}>
|
<div className={`${styles.pluginListContainer}`}>
|
||||||
<Dialog open={detailModalOpen} onOpenChange={setDetailModalOpen}>
|
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||||
<DialogContent className="sm:max-w-[1100px] max-w-[95vw] max-h-[85vh] p-0 flex flex-col">
|
<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">
|
<DialogHeader className="px-6 pt-6 pb-2 border-b">
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{selectedPlugin &&
|
{readmePlugin &&
|
||||||
`${selectedPlugin.author}/${selectedPlugin.name}`}
|
`${readmePlugin.author}/${readmePlugin.name} - ${t(
|
||||||
|
'plugins.readme',
|
||||||
|
)}`}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex-1 flex flex-row overflow-hidden min-h-0">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{/* Left side - Readme */}
|
{readmePlugin && (
|
||||||
<div className="flex-1 overflow-y-auto border-r min-w-0">
|
<PluginReadme
|
||||||
{selectedPlugin && (
|
pluginAuthor={readmePlugin.author}
|
||||||
<PluginReadme
|
pluginName={readmePlugin.name}
|
||||||
pluginAuthor={selectedPlugin.author}
|
/>
|
||||||
pluginName={selectedPlugin.name}
|
)}
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Right side - Config */}
|
|
||||||
<div className="w-[380px] flex-shrink-0 overflow-y-auto px-4">
|
|
||||||
{selectedPlugin && (
|
|
||||||
<PluginForm
|
|
||||||
pluginAuthor={selectedPlugin.author}
|
|
||||||
pluginName={selectedPlugin.name}
|
|
||||||
onFormSubmit={(timeout?: number) => {
|
|
||||||
setDetailModalOpen(false);
|
|
||||||
if (timeout) {
|
|
||||||
setTimeout(() => {
|
|
||||||
getPluginList();
|
|
||||||
}, timeout);
|
|
||||||
} else {
|
|
||||||
getPluginList();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onFormCancel={() => {
|
|
||||||
setDetailModalOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -400,6 +413,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
|||||||
onCardClick={() => handlePluginClick(vo)}
|
onCardClick={() => handlePluginClick(vo)}
|
||||||
onDeleteClick={() => handlePluginDelete(vo)}
|
onDeleteClick={() => handlePluginDelete(vo)}
|
||||||
onUpgradeClick={() => handlePluginUpdate(vo)}
|
onUpgradeClick={() => handlePluginUpdate(vo)}
|
||||||
|
onViewReadme={() => handleViewReadme(vo)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+61
-6
@@ -2,7 +2,15 @@ import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/Plu
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { 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 { getCloudServiceClientSync, systemInfo } from '@/app/infra/http';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -19,20 +27,28 @@ export default function PluginCardComponent({
|
|||||||
onCardClick,
|
onCardClick,
|
||||||
onDeleteClick,
|
onDeleteClick,
|
||||||
onUpgradeClick,
|
onUpgradeClick,
|
||||||
|
onViewReadme,
|
||||||
}: {
|
}: {
|
||||||
cardVO: PluginCardVO;
|
cardVO: PluginCardVO;
|
||||||
onCardClick: () => void;
|
onCardClick: () => void;
|
||||||
onDeleteClick: (cardVO: PluginCardVO) => void;
|
onDeleteClick: (cardVO: PluginCardVO) => void;
|
||||||
onUpgradeClick: (cardVO: PluginCardVO) => void;
|
onUpgradeClick: (cardVO: PluginCardVO) => void;
|
||||||
|
onViewReadme: (cardVO: PluginCardVO) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] p-[1.2rem] cursor-pointer dark:bg-[#1f1f22] relative transition-all duration-200 hover:shadow-[0px_3px_6px_0_rgba(0,0,0,0.12)] hover:scale-[1.005]"
|
className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] p-[1.2rem] cursor-pointer dark:bg-[#1f1f22] relative transition-all duration-200 hover:shadow-[0px_3px_6px_0_rgba(0,0,0,0.12)] hover:scale-[1.005]"
|
||||||
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]">
|
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
|
||||||
{/* Icon - fixed width */}
|
{/* Icon - fixed width */}
|
||||||
@@ -129,10 +145,7 @@ export default function PluginCardComponent({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Menu button - fixed width and position */}
|
{/* Menu button - fixed width and position */}
|
||||||
<div
|
<div className="flex flex-col items-center justify-between h-full relative z-20 flex-shrink-0">
|
||||||
className="flex flex-col items-center justify-between h-full relative z-20 flex-shrink-0"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center"></div>
|
<div className="flex items-center justify-center"></div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
@@ -140,6 +153,9 @@ export default function PluginCardComponent({
|
|||||||
open={dropdownOpen}
|
open={dropdownOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
setDropdownOpen(open);
|
setDropdownOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setIsHovered(false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -217,6 +233,45 @@ export default function PluginCardComponent({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hover overlay with action buttons */}
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 bg-gray-100/55 dark:bg-black/35 rounded-[10px] flex items-center justify-center gap-3 transition-all duration-200 z-10 ${
|
||||||
|
isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onViewReadme(cardVO);
|
||||||
|
}}
|
||||||
|
className={`bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
|
||||||
|
isHovered
|
||||||
|
? 'translate-y-0 opacity-100'
|
||||||
|
: 'translate-y-1 opacity-0'
|
||||||
|
}`}
|
||||||
|
style={{ transitionDelay: isHovered ? '10ms' : '0ms' }}
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
{t('plugins.readme')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCardClick();
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
className={`bg-white hover:bg-gray-100 text-gray-900 dark:bg-white dark:hover:bg-gray-100 dark:text-gray-900 px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
|
||||||
|
isHovered
|
||||||
|
? 'translate-y-0 opacity-100'
|
||||||
|
: 'translate-y-1 opacity-0'
|
||||||
|
}`}
|
||||||
|
style={{ transitionDelay: isHovered ? '20ms' : '0ms' }}
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
{t('plugins.config')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ function MarketPageContent({
|
|||||||
const pageSize = 12; // 每页12个
|
const pageSize = 12; // 每页12个
|
||||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const isComposingRef = useRef(false);
|
|
||||||
|
|
||||||
// 排序选项
|
// 排序选项
|
||||||
const sortOptions: SortOption[] = [
|
const sortOptions: SortOption[] = [
|
||||||
@@ -251,14 +250,10 @@ function MarketPageContent({
|
|||||||
clearTimeout(searchTimeoutRef.current);
|
clearTimeout(searchTimeoutRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isComposingRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置新的定时器
|
// 设置新的定时器
|
||||||
searchTimeoutRef.current = setTimeout(() => {
|
searchTimeoutRef.current = setTimeout(() => {
|
||||||
handleSearch(value);
|
handleSearch(value);
|
||||||
}, 500);
|
}, 300);
|
||||||
},
|
},
|
||||||
[handleSearch],
|
[handleSearch],
|
||||||
);
|
);
|
||||||
@@ -403,13 +398,6 @@ function MarketPageContent({
|
|||||||
placeholder={t('market.searchPlaceholder')}
|
placeholder={t('market.searchPlaceholder')}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => handleSearchInputChange(e.target.value)}
|
onChange={(e) => handleSearchInputChange(e.target.value)}
|
||||||
onCompositionStart={() => {
|
|
||||||
isComposingRef.current = true;
|
|
||||||
}}
|
|
||||||
onCompositionEnd={(e) => {
|
|
||||||
isComposingRef.current = false;
|
|
||||||
handleSearchInputChange((e.target as HTMLInputElement).value);
|
|
||||||
}}
|
|
||||||
onKeyPress={(e) => {
|
onKeyPress={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
// Immediately search, clear debounce timer
|
// Immediately search, clear debounce timer
|
||||||
|
|||||||
Reference in New Issue
Block a user