Fix/weconbot image and file (#2085)

* fix:wecombot file and image

* fix: add enable-stream-reply config
This commit is contained in:
fdc310
2026-03-28 01:24:54 +08:00
committed by GitHub
parent c111bf1714
commit 498d030da9
3 changed files with 173 additions and 52 deletions

View File

@@ -6,7 +6,8 @@ import traceback
import uuid import uuid
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Callable, Optional import re
from typing import Any, Callable, Optional, Tuple
from urllib.parse import unquote from urllib.parse import unquote
import httpx import httpx
@@ -199,52 +200,139 @@ class StreamSessionManager:
self._msg_index.pop(msg_id, None) self._msg_index.pop(msg_id, None)
async def download_encrypted_file(download_url: str, encoding_aes_key: str, logger: EventLogger) -> Optional[str]: def _decrypt_file(encrypted_data: bytes, aes_key_str: str) -> bytes:
"""Download an AES-encrypted file from WeChat Work and return as data URI. """Decrypt AES-256-CBC encrypted file data.
Aligned with the official WeCom AI Bot Python SDK (crypto_utils.py).
Args: Args:
download_url: The encrypted file download URL. encrypted_data: The raw encrypted bytes.
encoding_aes_key: The AES key used for decryption (base64-encoded, without trailing '='). aes_key_str: Base64-encoded AES key (may lack padding).
logger: Logger instance.
Returns: Returns:
A data URI string (e.g. 'data:image/jpeg;base64,...') or None on failure. Decrypted bytes with PKCS#7 padding removed.
""" """
if not download_url: if not encrypted_data:
return None raise ValueError('encrypted_data is empty')
async with httpx.AsyncClient() as client: if not aes_key_str:
response = await client.get(download_url) raise ValueError('aes_key is empty')
if response.status_code != 200:
await logger.error(f'failed to get file: {response.text}')
return None
encrypted_bytes = response.content
aes_key = base64.b64decode(encoding_aes_key + '=') # Python's base64.b64decode requires proper padding (length % 4 == 0).
iv = aes_key[:16] # Node.js Buffer.from tolerates missing '=', so we must pad manually.
remainder = len(aes_key_str) % 4
if remainder != 0:
aes_key_str = aes_key_str + '=' * (4 - remainder)
key = base64.b64decode(aes_key_str)
cipher = AES.new(aes_key, AES.MODE_CBC, iv) iv = key[:16]
decrypted = cipher.decrypt(encrypted_bytes)
cipher = AES.new(key, AES.MODE_CBC, iv)
# Ensure encrypted data is aligned to AES block size (16 bytes).
# Node.js setAutoPadding(false) silently handles unaligned data,
# but PyCryptodome will raise an error.
block_size = 16
data_remainder = len(encrypted_data) % block_size
if data_remainder != 0:
encrypted_data = encrypted_data + b'\x00' * (block_size - data_remainder)
decrypted = cipher.decrypt(encrypted_data)
# Remove PKCS#7 padding with validation
if len(decrypted) == 0:
raise ValueError('Decrypted data is empty')
pad_len = decrypted[-1] pad_len = decrypted[-1]
decrypted = decrypted[:-pad_len] if pad_len < 1 or pad_len > 32 or pad_len > len(decrypted):
raise ValueError(f'Invalid PKCS#7 padding value: {pad_len}')
if decrypted.startswith(b'\xff\xd8'): # Verify all padding bytes are consistent
for i in range(len(decrypted) - pad_len, len(decrypted)):
if decrypted[i] != pad_len:
raise ValueError('Invalid PKCS#7 padding: padding bytes mismatch')
return decrypted[: len(decrypted) - pad_len]
def _extract_filename(content_disposition: str) -> Optional[str]:
"""Extract filename from a Content-Disposition header value."""
if not content_disposition:
return None
# RFC 5987: filename*=UTF-8''xxx
utf8_match = re.search(r"filename\*=UTF-8''([^;\s]+)", content_disposition, re.IGNORECASE)
if utf8_match:
return unquote(utf8_match.group(1))
# Standard: filename="xxx" or filename=xxx
match = re.search(r'filename="?([^";\s]+)"?', content_disposition, re.IGNORECASE)
if match:
return unquote(match.group(1))
return None
def _bytes_to_data_uri(data: bytes) -> str:
"""Convert raw bytes to a data URI with auto-detected MIME type."""
if data.startswith(b'\xff\xd8'):
mime_type = 'image/jpeg' mime_type = 'image/jpeg'
elif decrypted.startswith(b'\x89PNG'): elif data.startswith(b'\x89PNG'):
mime_type = 'image/png' mime_type = 'image/png'
elif decrypted.startswith((b'GIF87a', b'GIF89a')): elif data.startswith((b'GIF87a', b'GIF89a')):
mime_type = 'image/gif' mime_type = 'image/gif'
elif decrypted.startswith(b'BM'): elif data.startswith(b'BM'):
mime_type = 'image/bmp' mime_type = 'image/bmp'
elif decrypted.startswith(b'II*\x00') or decrypted.startswith(b'MM\x00*'): elif data.startswith(b'II*\x00') or data.startswith(b'MM\x00*'):
mime_type = 'image/tiff' mime_type = 'image/tiff'
elif data[:4] == b'%PDF':
mime_type = 'application/pdf'
elif data[:4] == b'PK\x03\x04':
mime_type = 'application/zip'
else: else:
mime_type = 'application/octet-stream' mime_type = 'application/octet-stream'
base64_str = base64.b64encode(decrypted).decode('utf-8') base64_str = base64.b64encode(data).decode('utf-8')
return f'data:{mime_type};base64,{base64_str}' return f'data:{mime_type};base64,{base64_str}'
async def download_encrypted_file(
download_url: str, aes_key: str, logger: EventLogger
) -> Tuple[Optional[bytes], Optional[str]]:
"""Download an AES-encrypted file from WeChat Work and decrypt it.
Args:
download_url: The encrypted file download URL.
aes_key: The AES key for decryption (base64-encoded, per-message aeskey
or platform EncodingAESKey).
logger: Logger instance.
Returns:
A tuple of (decrypted_bytes, filename) or (None, None) on failure.
"""
if not download_url:
return None, None
if not aes_key:
await logger.error('download_encrypted_file: aes_key is empty, cannot decrypt')
return None, None
filename: Optional[str] = None
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(download_url)
if response.status_code != 200:
await logger.error(f'Failed to download file (HTTP {response.status_code}): {response.text[:200]}')
return None, None
encrypted_bytes = response.content
filename = _extract_filename(response.headers.get('content-disposition', ''))
except Exception:
await logger.error(f'Failed to download file: {traceback.format_exc()}')
return None, None
try:
decrypted = _decrypt_file(encrypted_bytes, aes_key)
return decrypted, filename
except Exception:
await logger.error(f'Failed to decrypt file: {traceback.format_exc()}')
return None, None
async def parse_wecom_bot_message( async def parse_wecom_bot_message(
msg_json: dict[str, Any], encoding_aes_key: str, logger: EventLogger msg_json: dict[str, Any], encoding_aes_key: str, logger: EventLogger
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -273,10 +361,22 @@ async def parse_wecom_bot_message(
max_inline_file_size = 5 * 1024 * 1024 max_inline_file_size = 5 * 1024 * 1024
async def _safe_download(url: str): async def _safe_download(url: str, per_msg_aeskey: str = '') -> Tuple[Optional[bytes], Optional[str]]:
"""Download and decrypt a file, preferring per-message aeskey over platform key."""
if not url: if not url:
return None return None, None
return await download_encrypted_file(url, encoding_aes_key, logger) key = per_msg_aeskey or encoding_aes_key
if not key:
await logger.warning('No AES key available for file decryption, skipping download')
return None, None
return await download_encrypted_file(url, key, logger)
async def _safe_download_as_data_uri(url: str, per_msg_aeskey: str = '') -> Optional[str]:
"""Download, decrypt, and convert to data URI for backward compatibility."""
data, _filename = await _safe_download(url, per_msg_aeskey)
if data:
return _bytes_to_data_uri(data)
return None
if msg_type == 'text': if msg_type == 'text':
message_data['content'] = msg_json.get('text', {}).get('content') message_data['content'] = msg_json.get('text', {}).get('content')
@@ -285,14 +385,17 @@ async def parse_wecom_bot_message(
'content', '' 'content', ''
) )
elif msg_type == 'image': elif msg_type == 'image':
picurl = msg_json.get('image', {}).get('url', '') image_info = msg_json.get('image', {})
base64_data = await _safe_download(picurl) picurl = image_info.get('url', '')
per_msg_aeskey = image_info.get('aeskey', '')
base64_data = await _safe_download_as_data_uri(picurl, per_msg_aeskey)
if base64_data: if base64_data:
message_data['picurl'] = base64_data message_data['picurl'] = base64_data
message_data['images'] = [base64_data] message_data['images'] = [base64_data]
elif msg_type == 'voice': elif msg_type == 'voice':
voice_info = msg_json.get('voice', {}) or {} voice_info = msg_json.get('voice', {}) or {}
download_url = voice_info.get('url') download_url = voice_info.get('url')
per_msg_aeskey = voice_info.get('aeskey', '')
message_data['voice'] = { message_data['voice'] = {
'url': download_url, 'url': download_url,
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'), 'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
@@ -302,12 +405,13 @@ async def parse_wecom_bot_message(
if voice_info.get('content'): if voice_info.get('content'):
message_data['content'] = voice_info.get('content') message_data['content'] = voice_info.get('content')
if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size: if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
voice_base64 = await _safe_download(download_url) voice_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
if voice_base64: if voice_base64:
message_data['voice']['base64'] = voice_base64 message_data['voice']['base64'] = voice_base64
elif msg_type == 'video': elif msg_type == 'video':
video_info = msg_json.get('video', {}) or {} video_info = msg_json.get('video', {}) or {}
download_url = video_info.get('url') download_url = video_info.get('url')
per_msg_aeskey = video_info.get('aeskey', '')
video_data = { video_data = {
'url': download_url, 'url': download_url,
'filesize': video_info.get('filesize') or video_info.get('size'), 'filesize': video_info.get('filesize') or video_info.get('size'),
@@ -316,13 +420,14 @@ async def parse_wecom_bot_message(
'filename': video_info.get('filename') or video_info.get('name'), 'filename': video_info.get('filename') or video_info.get('name'),
} }
if (video_data.get('filesize') or 0) <= max_inline_file_size: if (video_data.get('filesize') or 0) <= max_inline_file_size:
video_base64 = await _safe_download(download_url) video_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
if video_base64: if video_base64:
video_data['base64'] = video_base64 video_data['base64'] = video_base64
message_data['video'] = video_data message_data['video'] = video_data
elif msg_type == 'file': elif msg_type == 'file':
file_info = msg_json.get('file', {}) or {} file_info = msg_json.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl') download_url = file_info.get('url') or file_info.get('fileurl')
per_msg_aeskey = file_info.get('aeskey', '')
file_data = { file_data = {
'filename': file_info.get('filename') or file_info.get('name'), 'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'), 'filesize': file_info.get('filesize') or file_info.get('size'),
@@ -332,9 +437,11 @@ async def parse_wecom_bot_message(
'extra': file_info, 'extra': file_info,
} }
if (file_data.get('filesize') or 0) <= max_inline_file_size: if (file_data.get('filesize') or 0) <= max_inline_file_size:
file_base64 = await _safe_download(download_url) file_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey)
if file_base64: if file_bytes:
file_data['base64'] = file_base64 file_data['base64'] = _bytes_to_data_uri(file_bytes)
if dl_filename and not file_data.get('filename'):
file_data['filename'] = dl_filename
message_data['file'] = file_data message_data['file'] = file_data
elif msg_type == 'link': elif msg_type == 'link':
message_data['link'] = msg_json.get('link', {}) message_data['link'] = msg_json.get('link', {})
@@ -355,13 +462,16 @@ async def parse_wecom_bot_message(
if item_type == 'text': if item_type == 'text':
texts.append(item.get('text', {}).get('content', '')) texts.append(item.get('text', {}).get('content', ''))
elif item_type == 'image': elif item_type == 'image':
img_url = item.get('image', {}).get('url') img_info = item.get('image', {})
base64_data = await _safe_download(img_url) img_url = img_info.get('url')
img_aeskey = img_info.get('aeskey', '')
base64_data = await _safe_download_as_data_uri(img_url, img_aeskey)
if base64_data: if base64_data:
images.append(base64_data) images.append(base64_data)
elif item_type == 'file': elif item_type == 'file':
file_info = item.get('file', {}) or {} file_info = item.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl') download_url = file_info.get('url') or file_info.get('fileurl')
item_aeskey = file_info.get('aeskey', '')
file_data = { file_data = {
'filename': file_info.get('filename') or file_info.get('name'), 'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'), 'filesize': file_info.get('filesize') or file_info.get('size'),
@@ -371,13 +481,16 @@ async def parse_wecom_bot_message(
'extra': file_info, 'extra': file_info,
} }
if (file_data.get('filesize') or 0) <= max_inline_file_size: if (file_data.get('filesize') or 0) <= max_inline_file_size:
file_base64 = await _safe_download(download_url) file_bytes, dl_filename = await _safe_download(download_url, item_aeskey)
if file_base64: if file_bytes:
file_data['base64'] = file_base64 file_data['base64'] = _bytes_to_data_uri(file_bytes)
if dl_filename and not file_data.get('filename'):
file_data['filename'] = dl_filename
files.append(file_data) files.append(file_data)
elif item_type == 'voice': elif item_type == 'voice':
voice_info = item.get('voice', {}) or {} voice_info = item.get('voice', {}) or {}
download_url = voice_info.get('url') download_url = voice_info.get('url')
item_aeskey = voice_info.get('aeskey', '')
voice_data = { voice_data = {
'url': download_url, 'url': download_url,
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'), 'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
@@ -387,13 +500,14 @@ async def parse_wecom_bot_message(
if voice_info.get('content'): if voice_info.get('content'):
texts.append(voice_info.get('content')) texts.append(voice_info.get('content'))
if (voice_data.get('filesize') or 0) <= max_inline_file_size: if (voice_data.get('filesize') or 0) <= max_inline_file_size:
voice_base64 = await _safe_download(download_url) voice_base64 = await _safe_download_as_data_uri(download_url, item_aeskey)
if voice_base64: if voice_base64:
voice_data['base64'] = voice_base64 voice_data['base64'] = voice_base64
voices.append(voice_data) voices.append(voice_data)
elif item_type == 'video': elif item_type == 'video':
video_info = item.get('video', {}) or {} video_info = item.get('video', {}) or {}
download_url = video_info.get('url') download_url = video_info.get('url')
item_aeskey = video_info.get('aeskey', '')
video_data = { video_data = {
'url': download_url, 'url': download_url,
'filesize': video_info.get('filesize') or video_info.get('size'), 'filesize': video_info.get('filesize') or video_info.get('size'),
@@ -402,7 +516,7 @@ async def parse_wecom_bot_message(
'filename': video_info.get('filename') or video_info.get('name'), 'filename': video_info.get('filename') or video_info.get('name'),
} }
if (video_data.get('filesize') or 0) <= max_inline_file_size: if (video_data.get('filesize') or 0) <= max_inline_file_size:
video_base64 = await _safe_download(download_url) video_base64 = await _safe_download_as_data_uri(download_url, item_aeskey)
if video_base64: if video_base64:
video_data['base64'] = video_base64 video_data['base64'] = video_base64
videos.append(video_data) videos.append(video_data)
@@ -770,7 +884,10 @@ class WecomBotClient:
return decorator return decorator
async def download_url_to_base64(self, download_url, encoding_aes_key): async def download_url_to_base64(self, download_url, encoding_aes_key):
return await download_encrypted_file(download_url, encoding_aes_key, self.logger) data, _filename = await download_encrypted_file(download_url, encoding_aes_key, self.logger)
if data:
return _bytes_to_data_uri(data)
return None
async def run_task(self, host: str, port: int, *args, **kwargs): async def run_task(self, host: str, port: int, *args, **kwargs):
""" """

View File

@@ -277,14 +277,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return {'stream': success} return {'stream': success}
async def is_stream_output_supported(self) -> bool: async def is_stream_output_supported(self) -> bool:
"""智能机器人侧默认开启流式能力。 """Whether streaming output is enabled for this bot instance."""
return self.config.get('enable-stream-reply', True)
Returns:
bool: 恒定返回 True。
Example:
流水线执行阶段会调用此方法以确认是否启用流式。"""
return True
async def send_message(self, target_type, target_id, message): async def send_message(self, target_type, target_id, message):
_ws_mode = not self.config.get('enable-webhook', False) _ws_mode = not self.config.get('enable-webhook', False)

View File

@@ -75,6 +75,16 @@ spec:
type: string type: string
required: false required: false
default: "" default: ""
- name: enable-stream-reply
label:
en_US: Enable Stream Reply
zh_Hans: 启用流式回复
description:
en_US: If enabled, the bot will use streaming mode to reply messages
zh_Hans: 如果启用,机器人将使用流式模式回复消息
type: boolean
required: false
default: true
execution: execution:
python: python:
path: ./wecombot.py path: ./wecombot.py