diff --git a/src/langbot/libs/openclaw_weixin_api/__init__.py b/src/langbot/libs/openclaw_weixin_api/__init__.py new file mode 100644 index 00000000..a8b7c940 --- /dev/null +++ b/src/langbot/libs/openclaw_weixin_api/__init__.py @@ -0,0 +1,3 @@ +from .client import OpenClawWeixinClient as OpenClawWeixinClient +from .types import ApiError as ApiError +from .types import LoginResult as LoginResult diff --git a/src/langbot/libs/openclaw_weixin_api/client.py b/src/langbot/libs/openclaw_weixin_api/client.py new file mode 100644 index 00000000..d713f299 --- /dev/null +++ b/src/langbot/libs/openclaw_weixin_api/client.py @@ -0,0 +1,807 @@ +"""Async HTTP client for the OpenClaw WeChat API. + +Implements the iLink Bot API protocol. +Reference: https://github.com/epiral/weixin-bot + +Endpoints: getUpdates (long-poll), sendMessage, getUploadUrl, getConfig, sendTyping. +""" + +from __future__ import annotations + +import asyncio +import base64 +import io +import logging +import os +import struct +import typing +import uuid +from typing import Optional +from urllib.parse import quote + +import aiohttp + +from .types import ( + ApiError, + CDNMedia, + FileItem, + GetConfigResponse, + GetUpdatesResponse, + GetUploadUrlResponse, + ImageItem, + LoginResult, + MessageItem, + QRCodeResponse, + QRStatusResponse, + RefMessage, + TextItem, + VideoItem, + VoiceItem, + WeixinMessage, +) + +logger = logging.getLogger('openclaw-weixin-sdk') + +DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com' +CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c' + +CHANNEL_VERSION = '1.0.0' + +DEFAULT_API_TIMEOUT = 15 +DEFAULT_LONG_POLL_TIMEOUT = 40 +DEFAULT_CONFIG_TIMEOUT = 10 +DEFAULT_QR_POLL_TIMEOUT = 35 + +SESSION_EXPIRED_ERRCODE = -14 + +DEFAULT_BOT_TYPE = '3' + +# Maximum text length per message chunk (WeChat limit) +MAX_TEXT_CHUNK_SIZE = 2000 + + +def _random_wechat_uin() -> str: + """Generate the X-WECHAT-UIN header: random uint32 -> decimal string -> base64.""" + rand_bytes = os.urandom(4) + uint32_val = struct.unpack('>I', rand_bytes)[0] + return base64.b64encode(str(uint32_val).encode('utf-8')).decode('utf-8') + + +def _build_base_info() -> dict: + """Build the base_info payload included in every API request.""" + return {'channel_version': CHANNEL_VERSION} + + +def _chunk_text(text: str, max_size: int = MAX_TEXT_CHUNK_SIZE) -> list[str]: + """Split long text into chunks that fit within WeChat's message size limit.""" + if len(text) <= max_size: + return [text] + chunks = [] + while text: + chunks.append(text[:max_size]) + text = text[max_size:] + return chunks + + +class OpenClawWeixinClient: + """Async client for the OpenClaw WeChat HTTP JSON API.""" + + def __init__(self, base_url: str, token: str): + self.base_url = base_url.rstrip('/') + self.token = token + self._session: Optional[aiohttp.ClientSession] = None + + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession() + return self._session + + async def close(self): + if self._session and not self._session.closed: + await self._session.close() + + def _build_headers(self) -> dict[str, str]: + headers = { + 'Content-Type': 'application/json', + 'AuthorizationType': 'ilink_bot_token', + 'X-WECHAT-UIN': _random_wechat_uin(), + } + if self.token: + headers['Authorization'] = f'Bearer {self.token}' + return headers + + async def _post(self, endpoint: str, payload: dict, timeout: float = DEFAULT_API_TIMEOUT) -> dict: + """Make a POST request and return the JSON response. + + Raises ApiError on HTTP errors or when the response contains a non-zero errcode. + """ + payload['base_info'] = _build_base_info() + + session = await self._get_session() + url = f'{self.base_url}/{endpoint}' + headers = self._build_headers() + + async with session.post( + url, json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=timeout) + ) as resp: + if resp.status != 200: + text = await resp.text() + raise ApiError( + f'OpenClaw API error {resp.status}: {text}', + status=resp.status, + ) + data = await resp.json(content_type=None) + + # Check for application-level errors in the response body + errcode = data.get('errcode') or data.get('ret') + if errcode and errcode != 0: + raise ApiError( + data.get('errmsg') or f'API errcode {errcode}', + status=200, + code=errcode, + payload=data, + ) + + return data + + async def get_updates( + self, get_updates_buf: str = '', timeout: float = DEFAULT_LONG_POLL_TIMEOUT + ) -> GetUpdatesResponse: + """Long-poll for new messages. + + Note: This method does NOT raise ApiError for errcode responses — + it returns them in the GetUpdatesResponse so the caller can handle + session expiry and other errors with full context. + """ + try: + # Bypass the errcode check in _post since get_updates needs + # to return error info (e.g. session expired) to the caller. + payload: dict = {'get_updates_buf': get_updates_buf} + payload['base_info'] = _build_base_info() + + session = await self._get_session() + url = f'{self.base_url}/ilink/bot/getupdates' + headers = self._build_headers() + + async with session.post( + url, + json=payload, + headers=headers, + timeout=aiohttp.ClientTimeout(total=timeout), + ) as resp: + if resp.status != 200: + text = await resp.text() + raise ApiError( + f'OpenClaw API error {resp.status}: {text}', + status=resp.status, + ) + data = await resp.json(content_type=None) + + except (asyncio.TimeoutError, aiohttp.ServerTimeoutError): + return GetUpdatesResponse(ret=0, msgs=[], get_updates_buf=get_updates_buf) + except ApiError: + raise + except Exception as e: + if 'timeout' in str(e).lower(): + return GetUpdatesResponse(ret=0, msgs=[], get_updates_buf=get_updates_buf) + raise + + return _parse_get_updates_response(data) + + async def send_message( + self, + to_user_id: str, + item_list: list[MessageItem], + context_token: str = '', + ) -> None: + """Send a message to a user.""" + items_payload = [_message_item_to_dict(item) for item in item_list] + + payload = { + 'msg': { + 'from_user_id': '', + 'to_user_id': to_user_id, + 'client_id': f'langbot-{uuid.uuid4().hex[:16]}', + 'message_type': WeixinMessage.TYPE_BOT, + 'message_state': WeixinMessage.STATE_FINISH, + 'item_list': items_payload, + 'context_token': context_token or None, + } + } + await self._post('ilink/bot/sendmessage', payload) + + async def send_text(self, to_user_id: str, text: str, context_token: str = '') -> None: + """Send a plain text message, automatically chunking if too long.""" + chunks = _chunk_text(text) + for chunk in chunks: + item = MessageItem(type=MessageItem.TEXT, text_item=TextItem(text=chunk)) + await self.send_message(to_user_id, [item], context_token) + + async def get_config(self, ilink_user_id: str, context_token: str = '') -> GetConfigResponse: + """Get bot config including typing_ticket.""" + data = await self._post( + 'ilink/bot/getconfig', + {'ilink_user_id': ilink_user_id, 'context_token': context_token or None}, + timeout=DEFAULT_CONFIG_TIMEOUT, + ) + return GetConfigResponse( + ret=data.get('ret'), + errmsg=data.get('errmsg'), + typing_ticket=data.get('typing_ticket'), + ) + + async def send_typing(self, ilink_user_id: str, typing_ticket: str, status: int = 1) -> None: + """Send typing indicator. status: 1=typing, 2=cancel.""" + await self._post( + 'ilink/bot/sendtyping', + { + 'ilink_user_id': ilink_user_id, + 'typing_ticket': typing_ticket, + 'status': status, + }, + timeout=DEFAULT_CONFIG_TIMEOUT, + ) + + async def stop_typing(self, ilink_user_id: str, typing_ticket: str) -> None: + """Cancel the typing indicator for a user.""" + await self.send_typing(ilink_user_id, typing_ticket, status=2) + + async def download_media( + self, + media: CDNMedia, + ) -> bytes: + """Download and decrypt a file from the WeChat CDN. + + Args: + media: CDNMedia object with encrypt_query_param and aes_key. + + Returns: + Decrypted file bytes. + """ + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + from cryptography.hazmat.primitives.padding import PKCS7 + + if not media.encrypt_query_param: + raise ApiError('CDN media has no encrypt_query_param', status=0) + if not media.aes_key: + raise ApiError('CDN media has no aes_key', status=0) + + # Derive 16-byte AES key + # aes_key is base64-encoded; the decoded content may be: + # - raw 16 bytes (direct AES key) + # - 32-char hex string (decode hex to get 16 bytes) + raw = base64.b64decode(media.aes_key) + if len(raw) == 16: + aes_key = raw + elif len(raw) == 32: + # Hex-encoded 16-byte key + aes_key = bytes.fromhex(raw.decode('utf-8')) + else: + raise ApiError(f'Invalid AES key length: {len(raw)} (expected 16 or 32)', status=0) + + # Download encrypted bytes from CDN + session = await self._get_session() + cdn_url = f'{CDN_BASE_URL}/download?encrypted_query_param={quote(media.encrypt_query_param, safe="")}' + + async with session.get(cdn_url, timeout=aiohttp.ClientTimeout(total=120)) as resp: + if resp.status != 200: + text = await resp.text() + raise ApiError(f'CDN download failed: {resp.status} {text}', status=resp.status) + encrypted = await resp.read() + + # Decrypt AES-128-ECB with PKCS7 padding + cipher = Cipher(algorithms.AES(aes_key), modes.ECB()) + decryptor = cipher.decryptor() + padded = decryptor.update(encrypted) + decryptor.finalize() + + unpadder = PKCS7(128).unpadder() + return unpadder.update(padded) + unpadder.finalize() + + async def upload_media( + self, + file_bytes: bytes, + to_user_id: str, + media_type: int, + ) -> CDNMedia: + """Encrypt and upload media to WeChat CDN. + + Args: + file_bytes: Raw file bytes to upload. + to_user_id: Recipient user ID. + media_type: 1=IMAGE, 2=VIDEO, 3=FILE, 4=VOICE. + + Returns: + CDNMedia with encrypt_query_param and aes_key for use in sendMessage. + """ + import hashlib + + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + from cryptography.hazmat.primitives.padding import PKCS7 + + # 1. Generate random 16-byte AES key + raw_key = os.urandom(16) + aes_key_hex = raw_key.hex() # 32-char hex string + + # 2. Encode key for CDNMedia: base64(hex_string) — same for all media types + # Matches official SDK: Buffer.from(aeskey_hex).toString("base64") + encoded_key = base64.b64encode(aes_key_hex.encode('utf-8')).decode('utf-8') + + # 3. Encrypt file with AES-128-ECB + PKCS7 + padder = PKCS7(128).padder() + padded = padder.update(file_bytes) + padder.finalize() + cipher = Cipher(algorithms.AES(raw_key), modes.ECB()) + encryptor = cipher.encryptor() + encrypted = encryptor.update(padded) + encryptor.finalize() + + # 4. Get upload URL + raw_md5 = hashlib.md5(file_bytes).hexdigest() + filekey = os.urandom(16).hex() # 32-char hex, matches official SDK + + upload_resp = await self.get_upload_url( + filekey=filekey, + media_type=media_type, + to_user_id=to_user_id, + rawsize=len(file_bytes), + rawfilemd5=raw_md5, + filesize=len(encrypted), + aeskey=aes_key_hex, # hex string, as expected by the API + ) + + if not upload_resp.upload_param: + raise ApiError('Failed to get upload URL', status=0) + + # 5. Upload to CDN + # upload_param is an opaque token from the server — pass it as-is + session = await self._get_session() + cdn_url = f'{CDN_BASE_URL}/upload?encrypted_query_param={quote(upload_resp.upload_param, safe="")}&filekey={quote(filekey, safe="")}' + logger.debug( + 'CDN upload: url=%s raw_size=%d encrypted_size=%d md5=%s aeskey=%s', + cdn_url, + len(file_bytes), + len(encrypted), + raw_md5, + encoded_key, + ) + + async with session.post( + cdn_url, + data=encrypted, + headers={'Content-Type': 'application/octet-stream'}, + timeout=aiohttp.ClientTimeout(total=120), + ) as resp: + if resp.status != 200: + text = await resp.text() + logger.error('CDN upload failed: status=%d url=%s body=%s', resp.status, cdn_url, text[:500]) + raise ApiError(f'CDN upload failed: {resp.status} {text}', status=resp.status) + download_param = resp.headers.get('x-encrypted-param', '') + + if not download_param: + raise ApiError('CDN upload succeeded but no x-encrypted-param returned', status=0) + + return CDNMedia( + encrypt_query_param=download_param, + aes_key=encoded_key, + encrypt_type=1, + ) + + async def send_image( + self, + to_user_id: str, + image_bytes: bytes, + context_token: str = '', + ) -> None: + """Upload an image to CDN and send it.""" + media = await self.upload_media(image_bytes, to_user_id, media_type=1) + item = MessageItem( + type=MessageItem.IMAGE, + image_item=ImageItem( + media=media, + aeskey=media.aes_key, + ), + ) + await self.send_message(to_user_id, [item], context_token) + + async def send_file( + self, + to_user_id: str, + file_bytes: bytes, + file_name: str, + context_token: str = '', + ) -> None: + """Upload a file to CDN and send it.""" + import hashlib + + media = await self.upload_media(file_bytes, to_user_id, media_type=3) + item = MessageItem( + type=MessageItem.FILE, + file_item=FileItem( + media=media, + file_name=file_name, + md5=hashlib.md5(file_bytes).hexdigest(), + len=str(len(file_bytes)), + ), + ) + await self.send_message(to_user_id, [item], context_token) + + async def send_voice( + self, + to_user_id: str, + voice_bytes: bytes, + playtime: int = 0, + context_token: str = '', + ) -> None: + """Upload a voice message to CDN and send it.""" + media = await self.upload_media(voice_bytes, to_user_id, media_type=4) + item = MessageItem( + type=MessageItem.VOICE, + voice_item=VoiceItem( + media=media, + playtime=playtime, + ), + ) + await self.send_message(to_user_id, [item], context_token) + + async def get_upload_url( + self, + filekey: str, + media_type: int, + to_user_id: str, + rawsize: int, + rawfilemd5: str, + filesize: int, + thumb_rawsize: Optional[int] = None, + thumb_rawfilemd5: Optional[str] = None, + thumb_filesize: Optional[int] = None, + aeskey: Optional[str] = None, + ) -> GetUploadUrlResponse: + """Get a pre-signed CDN upload URL.""" + payload: dict = { + 'filekey': filekey, + 'media_type': media_type, + 'to_user_id': to_user_id, + 'rawsize': rawsize, + 'rawfilemd5': rawfilemd5, + 'filesize': filesize, + 'no_need_thumb': True, + } + if thumb_rawsize is not None: + payload['thumb_rawsize'] = thumb_rawsize + if thumb_rawfilemd5 is not None: + payload['thumb_rawfilemd5'] = thumb_rawfilemd5 + if thumb_filesize is not None: + payload['thumb_filesize'] = thumb_filesize + if aeskey is not None: + payload['aeskey'] = aeskey + + data = await self._post('ilink/bot/getuploadurl', payload) + logger.debug('get_upload_url response: %s', data) + return GetUploadUrlResponse( + upload_param=data.get('upload_param'), + thumb_upload_param=data.get('thumb_upload_param'), + ) + + # ----------------------------------------------------------------------- + # QR Code Login + # ----------------------------------------------------------------------- + + async def fetch_qrcode(self, bot_type: str = DEFAULT_BOT_TYPE) -> QRCodeResponse: + """Fetch a QR code for WeChat login authorization (GET, no auth needed).""" + session = await self._get_session() + url = f'{self.base_url}/ilink/bot/get_bot_qrcode?bot_type={bot_type}' + + async with session.get(url, timeout=aiohttp.ClientTimeout(total=DEFAULT_API_TIMEOUT)) as resp: + if resp.status != 200: + text = await resp.text() + raise ApiError( + f'Failed to fetch QR code: {resp.status} {text}', + status=resp.status, + ) + data = await resp.json(content_type=None) + + logger.debug( + 'fetch_qrcode response: qrcode=%s, img=%s', data.get('qrcode'), bool(data.get('qrcode_img_content')) + ) + + return QRCodeResponse( + qrcode=data.get('qrcode'), + qrcode_img_content=data.get('qrcode_img_content'), + ) + + async def _fetch_qr_image_base64(self, url: str) -> str: + """Generate a QR code image from the URL and return a data URI string. + + The qrcode_img_content URL points to an HTML page (not a raw image), + so we generate the QR code locally using the qrcode library. + """ + import qrcode + + qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L) + qr.add_data(url) + qr.make(fit=True) + img = qr.make_image(fill_color='black', back_color='white') + + buf = io.BytesIO() + img.save(buf, format='PNG') + b64 = base64.b64encode(buf.getvalue()).decode('utf-8') + return f'data:image/png;base64,{b64}' + + async def poll_qrcode_status(self, qrcode: str) -> QRStatusResponse: + """Long-poll the QR code scan status (GET with iLink-App-ClientVersion header).""" + session = await self._get_session() + url = f'{self.base_url}/ilink/bot/get_qrcode_status?qrcode={quote(qrcode, safe="")}' + headers = {'iLink-App-ClientVersion': '1'} + + try: + async with session.get( + url, headers=headers, timeout=aiohttp.ClientTimeout(total=DEFAULT_QR_POLL_TIMEOUT) + ) as resp: + if resp.status != 200: + text = await resp.text() + raise ApiError( + f'Failed to poll QR status: {resp.status} {text}', + status=resp.status, + ) + data = await resp.json(content_type=None) + logger.debug('QR status poll response: %s', data) + except (asyncio.TimeoutError, aiohttp.ServerTimeoutError): + return QRStatusResponse(status='wait') + + return QRStatusResponse( + status=data.get('status'), + bot_token=data.get('bot_token'), + ilink_bot_id=data.get('ilink_bot_id'), + baseurl=data.get('baseurl'), + ilink_user_id=data.get('ilink_user_id'), + ) + + async def login( + self, + max_retries: int = 5, + poll_timeout_ms: int = 480_000, + on_qrcode: Optional[typing.Callable[[str, str], typing.Any]] = None, + on_status: Optional[typing.Callable[[str], typing.Any]] = None, + ) -> LoginResult: + """Complete QR code login flow with auto-retry on expiry. + + Args: + max_retries: Max number of QR code refreshes on expiry. + poll_timeout_ms: Timeout per QR code in milliseconds. + on_qrcode: Callback(qr_image_base64, qr_url) called each time a + new QR code is fetched. Use this to display the QR code. + on_status: Callback(status_str) called on each status poll change. + + Returns: + LoginResult with token, base_url, and account_id. + + Raises: + ApiError: On unrecoverable API errors. + Exception: If all retries are exhausted. + """ + last_qr_base64: Optional[str] = None + + for attempt in range(max_retries): + qr_resp = await self.fetch_qrcode() + if not qr_resp.qrcode or not qr_resp.qrcode_img_content: + raise ApiError('Failed to get QR code from server', status=0) + + # Convert QR image to base64 and notify caller + last_qr_base64 = await self._fetch_qr_image_base64(qr_resp.qrcode_img_content) + if on_qrcode: + try: + result = on_qrcode(last_qr_base64, qr_resp.qrcode_img_content) + if asyncio.iscoroutine(result) or asyncio.isfuture(result): + await result + except Exception as e: + logger.warning('on_qrcode callback error: %s', e) + + # Poll until confirmed / expired / timeout + loop = asyncio.get_running_loop() + deadline = loop.time() + poll_timeout_ms / 1000.0 + + while loop.time() < deadline: + try: + status_resp = await self.poll_qrcode_status(qr_resp.qrcode) + except Exception as e: + logger.error('Error polling QR status: %s', e) + await asyncio.sleep(2) + continue + + if on_status: + try: + cb_result = on_status(status_resp.status or 'unknown') + if asyncio.iscoroutine(cb_result) or asyncio.isfuture(cb_result): + await cb_result + except Exception as e: + logger.warning('on_status callback error: %s', e) + + if status_resp.status == 'confirmed' and status_resp.bot_token: + new_base_url = status_resp.baseurl or self.base_url + # Update this client instance as well + self.token = status_resp.bot_token + self.base_url = new_base_url.rstrip('/') + return LoginResult( + token=status_resp.bot_token, + base_url=new_base_url, + account_id=status_resp.ilink_bot_id or '', + qr_image_base64=last_qr_base64, + ) + + if status_resp.status == 'expired': + break # retry with a new QR code + + await asyncio.sleep(1) + else: + # While-loop ended without break → poll timeout, treat as expired + pass + + remaining = max_retries - attempt - 1 + if remaining > 0: + logger.info('QR code expired, refreshing... (%d retries left)', remaining) + else: + raise ApiError('QR code login failed: max retries exceeded', status=0) + + # Should not reach here, but just in case + raise ApiError('QR code login failed', status=0) + + +# --------------------------------------------------------------------------- +# Parsing helpers +# --------------------------------------------------------------------------- + + +def _parse_cdn_media(data: Optional[dict]) -> Optional[CDNMedia]: + if not data: + return None + return CDNMedia( + encrypt_query_param=data.get('encrypt_query_param'), + aes_key=data.get('aes_key'), + encrypt_type=data.get('encrypt_type'), + ) + + +def _parse_message_item(data: dict) -> MessageItem: + item = MessageItem( + type=data.get('type'), + create_time_ms=data.get('create_time_ms'), + update_time_ms=data.get('update_time_ms'), + is_completed=data.get('is_completed'), + msg_id=data.get('msg_id'), + ) + + if data.get('text_item'): + item.text_item = TextItem(text=data['text_item'].get('text')) + + if data.get('image_item'): + img = data['image_item'] + item.image_item = ImageItem( + media=_parse_cdn_media(img.get('media')), + thumb_media=_parse_cdn_media(img.get('thumb_media')), + aeskey=img.get('aeskey'), + url=img.get('url'), + mid_size=img.get('mid_size'), + ) + + if data.get('voice_item'): + v = data['voice_item'] + item.voice_item = VoiceItem( + media=_parse_cdn_media(v.get('media')), + encode_type=v.get('encode_type'), + playtime=v.get('playtime'), + text=v.get('text'), + ) + + if data.get('file_item'): + f = data['file_item'] + item.file_item = FileItem( + media=_parse_cdn_media(f.get('media')), + file_name=f.get('file_name'), + md5=f.get('md5'), + len=f.get('len'), + ) + + if data.get('video_item'): + vid = data['video_item'] + item.video_item = VideoItem( + media=_parse_cdn_media(vid.get('media')), + video_size=vid.get('video_size'), + play_length=vid.get('play_length'), + video_md5=vid.get('video_md5'), + thumb_media=_parse_cdn_media(vid.get('thumb_media')), + ) + + if data.get('ref_msg'): + ref = data['ref_msg'] + item.ref_msg = RefMessage( + title=ref.get('title'), + message_item=_parse_message_item(ref['message_item']) if ref.get('message_item') else None, + ) + + return item + + +def _parse_weixin_message(data: dict) -> WeixinMessage: + msg = WeixinMessage( + seq=data.get('seq'), + message_id=data.get('message_id'), + from_user_id=data.get('from_user_id'), + to_user_id=data.get('to_user_id'), + client_id=data.get('client_id'), + create_time_ms=data.get('create_time_ms'), + session_id=data.get('session_id'), + group_id=data.get('group_id'), + message_type=data.get('message_type'), + message_state=data.get('message_state'), + context_token=data.get('context_token'), + ) + if data.get('item_list'): + msg.item_list = [_parse_message_item(item) for item in data['item_list']] + return msg + + +def _parse_get_updates_response(data: dict) -> GetUpdatesResponse: + resp = GetUpdatesResponse( + ret=data.get('ret'), + errcode=data.get('errcode'), + errmsg=data.get('errmsg'), + get_updates_buf=data.get('get_updates_buf'), + longpolling_timeout_ms=data.get('longpolling_timeout_ms'), + ) + if data.get('msgs'): + resp.msgs = [_parse_weixin_message(m) for m in data['msgs']] + return resp + + +def _cdn_media_to_dict(media: Optional[CDNMedia]) -> Optional[dict]: + if not media: + return None + d: dict = {} + if media.encrypt_query_param is not None: + d['encrypt_query_param'] = media.encrypt_query_param + if media.aes_key is not None: + d['aes_key'] = media.aes_key + if media.encrypt_type is not None: + d['encrypt_type'] = media.encrypt_type + return d or None + + +def _message_item_to_dict(item: MessageItem) -> dict: + d: dict = {'type': item.type} + + if item.text_item: + d['text_item'] = {'text': item.text_item.text} + + if item.image_item: + img_d: dict = {} + if item.image_item.media: + img_d['media'] = _cdn_media_to_dict(item.image_item.media) + if item.image_item.mid_size is not None: + img_d['mid_size'] = item.image_item.mid_size + d['image_item'] = img_d + + if item.voice_item: + voice_d: dict = {} + if item.voice_item.media: + voice_d['media'] = _cdn_media_to_dict(item.voice_item.media) + if item.voice_item.playtime is not None: + voice_d['playtime'] = item.voice_item.playtime + d['voice_item'] = voice_d + + if item.file_item: + file_d: dict = {} + if item.file_item.media: + file_d['media'] = _cdn_media_to_dict(item.file_item.media) + if item.file_item.file_name: + file_d['file_name'] = item.file_item.file_name + if item.file_item.len: + file_d['len'] = item.file_item.len + d['file_item'] = file_d + + if item.video_item: + vid_d: dict = {} + if item.video_item.media: + vid_d['media'] = _cdn_media_to_dict(item.video_item.media) + if item.video_item.video_size is not None: + vid_d['video_size'] = item.video_item.video_size + d['video_item'] = vid_d + + return d diff --git a/src/langbot/libs/openclaw_weixin_api/types.py b/src/langbot/libs/openclaw_weixin_api/types.py new file mode 100644 index 00000000..93bb2e3c --- /dev/null +++ b/src/langbot/libs/openclaw_weixin_api/types.py @@ -0,0 +1,200 @@ +"""Type definitions for the OpenClaw WeChat API, mirroring the upstream protocol.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Optional + +SESSION_EXPIRED_ERRCODE = -14 + + +class ApiError(Exception): + """Structured error raised by the OpenClaw WeChat API.""" + + def __init__( + self, + message: str, + *, + status: int = 0, + code: int | None = None, + payload: Any = None, + ): + super().__init__(message) + self.status = status + self.code = code + self.payload = payload + + @property + def is_session_expired(self) -> bool: + return self.code == SESSION_EXPIRED_ERRCODE + + +@dataclass +class CDNMedia: + encrypt_query_param: Optional[str] = None + aes_key: Optional[str] = None + encrypt_type: Optional[int] = None + + +@dataclass +class TextItem: + text: Optional[str] = None + + +@dataclass +class ImageItem: + media: Optional[CDNMedia] = None + thumb_media: Optional[CDNMedia] = None + aeskey: Optional[str] = None + url: Optional[str] = None + mid_size: Optional[int] = None + thumb_size: Optional[int] = None + thumb_height: Optional[int] = None + thumb_width: Optional[int] = None + hd_size: Optional[int] = None + _downloaded_bytes: Optional[bytes] = field(default=None, repr=False) + + +@dataclass +class VoiceItem: + media: Optional[CDNMedia] = None + encode_type: Optional[int] = None + bits_per_sample: Optional[int] = None + sample_rate: Optional[int] = None + playtime: Optional[int] = None + text: Optional[str] = None + _downloaded_bytes: Optional[bytes] = field(default=None, repr=False) + + +@dataclass +class FileItem: + media: Optional[CDNMedia] = None + file_name: Optional[str] = None + md5: Optional[str] = None + len: Optional[str] = None + _downloaded_bytes: Optional[bytes] = field(default=None, repr=False) + + +@dataclass +class VideoItem: + media: Optional[CDNMedia] = None + video_size: Optional[int] = None + play_length: Optional[int] = None + video_md5: Optional[str] = None + thumb_media: Optional[CDNMedia] = None + thumb_size: Optional[int] = None + thumb_height: Optional[int] = None + thumb_width: Optional[int] = None + _downloaded_bytes: Optional[bytes] = field(default=None, repr=False) + + +@dataclass +class RefMessage: + message_item: Optional[MessageItem] = None + title: Optional[str] = None + + +@dataclass +class MessageItem: + """A single content item inside a WeixinMessage.""" + + # Item types + NONE = 0 + TEXT = 1 + IMAGE = 2 + VOICE = 3 + FILE = 4 + VIDEO = 5 + + type: Optional[int] = None + create_time_ms: Optional[int] = None + update_time_ms: Optional[int] = None + is_completed: Optional[bool] = None + msg_id: Optional[str] = None + ref_msg: Optional[RefMessage] = None + text_item: Optional[TextItem] = None + image_item: Optional[ImageItem] = None + voice_item: Optional[VoiceItem] = None + file_item: Optional[FileItem] = None + video_item: Optional[VideoItem] = None + + +@dataclass +class WeixinMessage: + """Unified message from getUpdates or for sendMessage.""" + + # Message types + TYPE_USER = 1 + TYPE_BOT = 2 + + # Message states + STATE_NEW = 0 + STATE_GENERATING = 1 + STATE_FINISH = 2 + + seq: Optional[int] = None + message_id: Optional[int] = None + from_user_id: Optional[str] = None + to_user_id: Optional[str] = None + client_id: Optional[str] = None + create_time_ms: Optional[int] = None + update_time_ms: Optional[int] = None + delete_time_ms: Optional[int] = None + session_id: Optional[str] = None + group_id: Optional[str] = None + message_type: Optional[int] = None + message_state: Optional[int] = None + item_list: Optional[list[MessageItem]] = None + context_token: Optional[str] = None + + +@dataclass +class GetUpdatesResponse: + ret: Optional[int] = None + errcode: Optional[int] = None + errmsg: Optional[str] = None + msgs: list[WeixinMessage] = field(default_factory=list) + get_updates_buf: Optional[str] = None + longpolling_timeout_ms: Optional[int] = None + + +@dataclass +class GetConfigResponse: + ret: Optional[int] = None + errmsg: Optional[str] = None + typing_ticket: Optional[str] = None + + +@dataclass +class GetUploadUrlResponse: + upload_param: Optional[str] = None + thumb_upload_param: Optional[str] = None + + +@dataclass +class QRCodeResponse: + """Response from get_bot_qrcode endpoint.""" + + qrcode: Optional[str] = None + qrcode_img_content: Optional[str] = None + + +@dataclass +class QRStatusResponse: + """Response from get_qrcode_status endpoint.""" + + status: Optional[str] = None # "wait" | "scaned" | "confirmed" | "expired" + bot_token: Optional[str] = None + ilink_bot_id: Optional[str] = None + baseurl: Optional[str] = None + ilink_user_id: Optional[str] = None + + +@dataclass +class LoginResult: + """Result returned by the login flow.""" + + token: str + base_url: str + account_id: str + qr_image_base64: Optional[str] = None # data URI of the last QR code shown diff --git a/src/langbot/pkg/api/http/controller/groups/files.py b/src/langbot/pkg/api/http/controller/groups/files.py index 408fbfd0..439cc57e 100644 --- a/src/langbot/pkg/api/http/controller/groups/files.py +++ b/src/langbot/pkg/api/http/controller/groups/files.py @@ -13,9 +13,9 @@ from .. import group @group.group_class('files', '/api/v1/files') class FilesRouterGroup(group.RouterGroup): async def initialize(self) -> None: - @self.route('/image/', methods=['GET'], auth_type=group.AuthType.NONE) + @self.route('/image/', methods=['GET'], auth_type=group.AuthType.NONE) async def _(image_key: str) -> quart.Response: - if '/' in image_key or '\\' in image_key: + if '..' in image_key or '\\' in image_key: return quart.Response(status=404) if not await self.ap.storage_mgr.storage_provider.exists(image_key): diff --git a/src/langbot/pkg/platform/sources/openclaw_weixin.py b/src/langbot/pkg/platform/sources/openclaw_weixin.py new file mode 100644 index 00000000..9253f90e --- /dev/null +++ b/src/langbot/pkg/platform/sources/openclaw_weixin.py @@ -0,0 +1,577 @@ +"""OpenClaw WeChat adapter for LangBot. + +Uses the OpenClaw WeChat HTTP JSON API (long-poll getUpdates + sendMessage) +to integrate personal WeChat accounts with LangBot. + +Reference: https://github.com/epiral/weixin-bot +""" + +from __future__ import annotations + +import asyncio +import base64 +import traceback +import typing + +import pydantic +import sqlalchemy + +from langbot.libs.openclaw_weixin_api.client import ( + DEFAULT_BASE_URL, + SESSION_EXPIRED_ERRCODE, + OpenClawWeixinClient, +) +from langbot.libs.openclaw_weixin_api.types import ( + MessageItem, + WeixinMessage, +) +from langbot.pkg.entity.persistence import bot as persistence_bot + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.message as platform_message + + +class OpenClawWeixinMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + """Converts between LangBot MessageChain and OpenClaw WeChat message items.""" + + @staticmethod + async def yiri2target(message_chain: platform_message.MessageChain) -> list[dict]: + """Convert LangBot MessageChain to a list of OpenClaw message item dicts.""" + items = [] + for component in message_chain: + if isinstance(component, platform_message.Plain): + items.append({'type': MessageItem.TEXT, 'text_item': {'text': component.text}}) + elif isinstance(component, platform_message.Image): + # OpenClaw WeChat only supports text messages without CDN upload. + # For images, we send a placeholder text with the URL if available. + if component.url: + items.append( + { + 'type': MessageItem.TEXT, + 'text_item': {'text': f'[Image: {component.url}]'}, + } + ) + elif component.base64: + items.append( + { + 'type': MessageItem.TEXT, + 'text_item': {'text': '[Image]'}, + } + ) + elif isinstance(component, platform_message.File): + if component.name: + items.append( + { + 'type': MessageItem.TEXT, + 'text_item': {'text': f'[File: {component.name}]'}, + } + ) + elif isinstance(component, platform_message.Forward): + for node in component.node_list: + if node.message_chain: + items.extend(await OpenClawWeixinMessageConverter.yiri2target(node.message_chain)) + return items + + @staticmethod + async def target2yiri( + msg: WeixinMessage, + ) -> platform_message.MessageChain: + """Convert an OpenClaw WeixinMessage to LangBot MessageChain.""" + components: list[platform_message.MessageComponent] = [] + + if not msg.item_list: + return platform_message.MessageChain(components) + + for item in msg.item_list: + if item.type == MessageItem.TEXT and item.text_item and item.text_item.text: + text = item.text_item.text + + # Handle quoted messages + if item.ref_msg: + ref_parts = [] + if item.ref_msg.title: + ref_parts.append(item.ref_msg.title) + if item.ref_msg.message_item: + ref_item = item.ref_msg.message_item + if ref_item.text_item and ref_item.text_item.text: + ref_parts.append(ref_item.text_item.text) + if ref_parts: + components.append( + platform_message.Quote( + sender_id='', + origin=platform_message.MessageChain( + [platform_message.Plain(text=' | '.join(ref_parts))] + ), + ) + ) + + components.append(platform_message.Plain(text=text)) + + elif item.type == MessageItem.IMAGE and item.image_item: + if hasattr(item.image_item, '_downloaded_bytes') and item.image_item._downloaded_bytes: + b64 = base64.b64encode(item.image_item._downloaded_bytes).decode('utf-8') + components.append(platform_message.Image(base64=f'data:image/jpeg;base64,{b64}')) + else: + components.append(platform_message.Unknown(text='[Image]')) + + elif item.type == MessageItem.VOICE and item.voice_item: + # Voice with speech-to-text: use the transcribed text + if item.voice_item.text: + components.append(platform_message.Plain(text=item.voice_item.text)) + else: + components.append(platform_message.Unknown(text='[Voice]')) + + # TODO: enable after full testing + # elif item.type == MessageItem.VOICE and item.voice_item: + # if item.voice_item.text: + # components.append(platform_message.Plain(text=item.voice_item.text)) + # elif hasattr(item.voice_item, '_downloaded_bytes') and item.voice_item._downloaded_bytes: + # b64 = base64.b64encode(item.voice_item._downloaded_bytes).decode('utf-8') + # components.append( + # platform_message.Voice( + # base64=b64, + # length=item.voice_item.playtime or 0, + # ) + # ) + # else: + # components.append( + # platform_message.Voice( + # length=item.voice_item.playtime or 0, + # ) + # ) + + elif item.type == MessageItem.FILE and item.file_item: + components.append(platform_message.Unknown(text=f'[File: {item.file_item.file_name or ""}]')) + + # TODO: enable after full testing + # elif item.type == MessageItem.FILE and item.file_item: + # file_name = item.file_item.file_name or '' + # file_size = int(item.file_item.len) if item.file_item.len else 0 + # if hasattr(item.file_item, '_downloaded_bytes') and item.file_item._downloaded_bytes: + # b64 = base64.b64encode(item.file_item._downloaded_bytes).decode('utf-8') + # components.append( + # platform_message.File( + # name=file_name, + # size=file_size, + # base64=b64, + # ) + # ) + # else: + # components.append( + # platform_message.File( + # name=file_name, + # size=file_size, + # ) + # ) + + elif item.type == MessageItem.VIDEO and item.video_item: + components.append(platform_message.Unknown(text='[Video]')) + + # TODO: enable after full testing + # elif item.type == MessageItem.VIDEO and item.video_item: + # if hasattr(item.video_item, '_downloaded_bytes') and item.video_item._downloaded_bytes: + # b64 = base64.b64encode(item.video_item._downloaded_bytes).decode('utf-8') + # components.append( + # platform_message.File( + # name='video.mp4', + # size=item.video_item.video_size or 0, + # base64=b64, + # ) + # ) + # else: + # components.append( + # platform_message.File( + # name='video.mp4', + # size=item.video_item.video_size or 0, + # ) + # ) + + else: + components.append(platform_message.Unknown(text='[Unknown message type]')) + + return platform_message.MessageChain(components) + + +class OpenClawWeixinEventConverter(abstract_platform_adapter.AbstractEventConverter): + """Converts OpenClaw WeChat messages to LangBot events.""" + + @staticmethod + async def yiri2target(event: platform_events.MessageEvent) -> dict: + return event.source_platform_object + + @staticmethod + async def target2yiri(msg: WeixinMessage) -> typing.Optional[platform_events.MessageEvent]: + """Convert an inbound WeixinMessage to a LangBot event.""" + if msg.message_type != WeixinMessage.TYPE_USER: + return None + + from_user_id = msg.from_user_id or '' + if not from_user_id: + return None + + message_chain = await OpenClawWeixinMessageConverter.target2yiri(msg) + if not message_chain: + return None + + timestamp = (msg.create_time_ms or 0) / 1000.0 + + return platform_events.FriendMessage( + sender=platform_entities.Friend( + id=from_user_id, + nickname=from_user_id, + remark='', + ), + message_chain=message_chain, + time=timestamp, + source_platform_object=msg, + ) + + +class OpenClawWeixinAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): + """LangBot adapter for OpenClaw WeChat (long-poll based).""" + + name: str = 'openclaw-weixin' + + client: OpenClawWeixinClient = pydantic.Field(exclude=True) + + config: dict + + message_converter: OpenClawWeixinMessageConverter = OpenClawWeixinMessageConverter() + event_converter: OpenClawWeixinEventConverter = OpenClawWeixinEventConverter() + + # context_token cache: from_user_id -> context_token + _context_tokens: dict[str, str] = pydantic.PrivateAttr(default_factory=dict) + + _polling: bool = pydantic.PrivateAttr(default=False) + _poll_task: typing.Optional[asyncio.Task] = pydantic.PrivateAttr(default=None) + _bot_uuid: typing.Optional[str] = pydantic.PrivateAttr(default=None) + + listeners: typing.Dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] = {} + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): + client = OpenClawWeixinClient( + base_url=config.get('base_url', DEFAULT_BASE_URL), + token=config.get('token', ''), + ) + super().__init__( + config=config, + logger=logger, + client=client, + bot_account_id='', + listeners={}, + name='openclaw-weixin', + ) + + def set_bot_uuid(self, bot_uuid: str): + """Called by BotManager to provide the bot's UUID for config persistence.""" + self._bot_uuid = bot_uuid + + async def _persist_config(self) -> None: + """Persist current self.config to the database so token survives restart.""" + if not self._bot_uuid: + return + try: + ap = self.logger.ap + await ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_bot.Bot) + .where(persistence_bot.Bot.uuid == self._bot_uuid) + .values(adapter_config=self.config) + ) + except Exception as e: + await self.logger.warning(f'Failed to persist adapter config: {e}') + + async def _do_login(self) -> None: + """Run the QR code login flow via client.login() and update config.""" + adapter_logger = self.logger + + async def _on_qrcode(qr_base64: str, _qr_url: str): + await adapter_logger.info( + f'Please scan the QR code to login WeChat: {_qr_url}', + images=[platform_message.Image(base64=qr_base64)], + ) + + login_result = await self.client.login( + on_qrcode=_on_qrcode, + ) + + # client.login() already updates client.token and client.base_url + self.config['token'] = login_result.token + self.config['base_url'] = login_result.base_url + if login_result.account_id: + self.config['account_id'] = login_result.account_id + + await self.logger.info(f'WeChat login successful! account_id={login_result.account_id}') + + # Persist token to database so it survives restart + await self._persist_config() + + async def send_message( + self, + target_type: str, + target_id: str, + message: platform_message.MessageChain, + ): + """Send a message to a user.""" + context_token = self._context_tokens.get(target_id, '') + + for component in message: + try: + if isinstance(component, platform_message.Plain): + if component.text: + await self.client.send_text(target_id, component.text, context_token) + + elif isinstance(component, platform_message.Image): + img_bytes, _ = await component.get_bytes() + await self.client.send_image(target_id, img_bytes, context_token) + + elif isinstance(component, platform_message.File): + file_bytes = await self._get_component_bytes(component) + if file_bytes: + await self.client.send_file(target_id, file_bytes, component.name or 'file', context_token) + + elif isinstance(component, platform_message.Voice): + voice_bytes = await self._get_component_bytes(component) + if voice_bytes: + await self.client.send_voice(target_id, voice_bytes, component.length or 0, context_token) + + elif isinstance(component, platform_message.Forward): + for node in component.node_list: + if node.message_chain: + await self.send_message(target_type, target_id, node.message_chain) + + except Exception: + await self.logger.error( + f'Failed to send component {type(component).__name__}: {traceback.format_exc()}' + ) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ): + """Reply to a received message.""" + source_msg = message_source.source_platform_object + if isinstance(source_msg, WeixinMessage): + target_id = source_msg.from_user_id or '' + if target_id: + await self.send_message('friend', target_id, message) + + async def is_muted(self, group_id: int) -> bool: + return False + + @staticmethod + async def _get_component_bytes(component: platform_message.MessageComponent) -> typing.Optional[bytes]: + """Extract raw bytes from a File or Voice component.""" + b64_val = getattr(component, 'base64', None) + url_val = getattr(component, 'url', None) + path_val = getattr(component, 'path', None) + + if b64_val: + return base64.b64decode(b64_val) + elif url_val and url_val.startswith(('http://', 'https://')): + import aiohttp + + async with aiohttp.ClientSession() as session: + async with session.get(url_val) as resp: + if resp.status == 200: + return await resp.read() + elif path_val: + import asyncio + + with open(path_val, 'rb') as f: + return await asyncio.to_thread(f.read) + return None + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], + None, + ], + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], + None, + ], + ): + self.listeners.pop(event_type, None) + + async def run_async(self): + """Start the adapter. If no token is configured, trigger QR code login first.""" + base_url = self.config.get('base_url', DEFAULT_BASE_URL) + token = self.config.get('token', '') + + await self.logger.info('OpenClaw WeChat adapter starting...') + + # QR code login flow when no token is provided + if not token: + await self.logger.info('No token configured, starting QR code login...') + try: + await self._do_login() + except Exception as e: + await self.logger.error(f'QR code login failed: {e}') + raise + + # Rebuild client with the (possibly updated) config + self.client = OpenClawWeixinClient( + base_url=self.config.get('base_url', base_url), + token=self.config.get('token', token), + ) + self.bot_account_id = self.config.get('account_id', 'openclaw-weixin') + self._polling = True + + # Start the long-poll loop + self._poll_task = asyncio.create_task(self._poll_loop()) + await self.logger.info('OpenClaw WeChat adapter running') + + try: + await self._poll_task + except asyncio.CancelledError: + pass + + async def _poll_loop(self): + """Long-poll loop: call getUpdates continuously. + + Error handling follows the weixin-bot SDK pattern: + - Exponential backoff (1s -> 10s max) on failures + - Session expired (errcode -14) triggers automatic re-login + """ + get_updates_buf = '' + poll_timeout = float(self.config.get('poll_timeout', 35)) + + backoff_delay = 1.0 + max_backoff = 10.0 + + while self._polling: + try: + resp = await self.client.get_updates( + get_updates_buf=get_updates_buf, + timeout=poll_timeout + 5, + ) + + if resp.longpolling_timeout_ms and resp.longpolling_timeout_ms > 0: + poll_timeout = resp.longpolling_timeout_ms / 1000.0 + + is_api_error = (resp.ret is not None and resp.ret != 0) or ( + resp.errcode is not None and resp.errcode != 0 + ) + if is_api_error: + is_session_expired = resp.errcode == SESSION_EXPIRED_ERRCODE or resp.ret == SESSION_EXPIRED_ERRCODE + + if is_session_expired: + await self.logger.error('OpenClaw WeChat session expired, attempting re-login...') + try: + await self._do_login() + # Rebuild client with new credentials + self.client = OpenClawWeixinClient( + base_url=self.config.get('base_url', DEFAULT_BASE_URL), + token=self.config.get('token', ''), + ) + self._context_tokens.clear() + get_updates_buf = '' + backoff_delay = 1.0 + continue + except Exception: + await self.logger.error(f'Re-login failed: {traceback.format_exc()}') + break + + await self.logger.error( + f'OpenClaw getUpdates failed: ret={resp.ret} errcode={resp.errcode} errmsg={resp.errmsg}' + ) + await asyncio.sleep(backoff_delay) + backoff_delay = min(backoff_delay * 2, max_backoff) + continue + + backoff_delay = 1.0 + + if resp.get_updates_buf: + get_updates_buf = resp.get_updates_buf + + for msg in resp.msgs: + try: + await self._handle_inbound_message(msg) + except Exception: + await self.logger.error(f'Error handling message: {traceback.format_exc()}') + + except asyncio.CancelledError: + break + except Exception: + await self.logger.error(f'OpenClaw poll error: {traceback.format_exc()}') + await asyncio.sleep(backoff_delay) + backoff_delay = min(backoff_delay * 2, max_backoff) + + async def _handle_inbound_message(self, msg: WeixinMessage): + """Process a single inbound message from getUpdates.""" + if msg.context_token and msg.from_user_id: + self._context_tokens[msg.from_user_id] = msg.context_token + + # Download CDN media (files, images) before converting to LangBot events + await self._download_media_items(msg) + + event = await OpenClawWeixinEventConverter.target2yiri(msg) + if event is None: + return + + if type(event) in self.listeners: + await self.listeners[type(event)](event, self) + + async def _download_media_items(self, msg: WeixinMessage): + """Download CDN media for image items in the message.""" + if not msg.item_list: + return + + for item in msg.item_list: + try: + if item.type == MessageItem.IMAGE and item.image_item: + if ( + item.image_item.media + and item.image_item.media.encrypt_query_param + and item.image_item.media.aes_key + ): + img_bytes = await self.client.download_media(item.image_item.media) + item.image_item._downloaded_bytes = img_bytes + + # TODO: enable after full testing + # elif item.type == MessageItem.FILE and item.file_item and item.file_item.media: + # if item.file_item.media.encrypt_query_param and item.file_item.media.aes_key: + # file_bytes = await self.client.download_media(item.file_item.media) + # item.file_item._downloaded_bytes = file_bytes + # + # elif item.type == MessageItem.VOICE and item.voice_item and item.voice_item.media: + # if item.voice_item.media.encrypt_query_param and item.voice_item.media.aes_key: + # voice_bytes = await self.client.download_media(item.voice_item.media) + # item.voice_item._downloaded_bytes = voice_bytes + # + # elif item.type == MessageItem.VIDEO and item.video_item and item.video_item.media: + # if item.video_item.media.encrypt_query_param and item.video_item.media.aes_key: + # video_bytes = await self.client.download_media(item.video_item.media) + # item.video_item._downloaded_bytes = video_bytes + + except Exception: + await self.logger.warning(f'Failed to download CDN media: {traceback.format_exc()}') + + async def kill(self) -> bool: + """Stop the adapter.""" + self._polling = False + if self._poll_task and not self._poll_task.done(): + self._poll_task.cancel() + try: + await self._poll_task + except asyncio.CancelledError: + pass + await self.client.close() + await self.logger.info('OpenClaw WeChat adapter stopped') + return True diff --git a/src/langbot/pkg/platform/sources/openclaw_weixin.yaml b/src/langbot/pkg/platform/sources/openclaw_weixin.yaml new file mode 100644 index 00000000..c5400a59 --- /dev/null +++ b/src/langbot/pkg/platform/sources/openclaw_weixin.yaml @@ -0,0 +1,57 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: openclaw-weixin + label: + en_US: OpenClaw WeChat + zh_Hans: OpenClaw 微信 + description: + en_US: OpenClaw WeChat adapter, supports personal WeChat via QR code login + zh_Hans: OpenClaw 微信适配器,通过扫码登录支持个人微信 + icon: wechat.png +spec: + config: + - name: base_url + label: + en_US: API Base URL + zh_Hans: API 基础地址 + description: + en_US: The base URL of the OpenClaw WeChat backend API + zh_Hans: OpenClaw 微信后端 API 的基础地址 + type: string + required: true + default: "https://ilinkai.weixin.qq.com" + - name: token + label: + en_US: Token + zh_Hans: 令牌 + description: + en_US: Bearer token obtained after QR code login authorization. Leave empty to trigger QR code login on startup. + zh_Hans: 扫码登录授权后获取的 Bearer 令牌。留空则启动时自动触发扫码登录。 + type: string + required: false + default: "" + - name: account_id + label: + en_US: Account ID + zh_Hans: 账号标识 + description: + en_US: A label for this WeChat account (used for display purposes) + zh_Hans: 此微信账号的标识(用于显示) + type: string + required: false + default: "openclaw-weixin" + - name: poll_timeout + label: + en_US: Poll Timeout (seconds) + zh_Hans: 轮询超时(秒) + description: + en_US: Long-poll timeout for getUpdates, the server may hold the request up to this duration + zh_Hans: getUpdates 长轮询超时时间,服务端最多持有请求的时长 + type: integer + required: false + default: 35 +execution: + python: + path: ./openclaw_weixin.py + attr: OpenClawWeixinAdapter diff --git a/src/langbot/pkg/platform/sources/wechat.png b/src/langbot/pkg/platform/sources/wechat.png new file mode 100644 index 00000000..603d0d9a Binary files /dev/null and b/src/langbot/pkg/platform/sources/wechat.png differ