from __future__ import annotations import asyncio import base64 import json import time import traceback import uuid import xml.etree.ElementTree as ET from dataclasses import dataclass, field import re from typing import TYPE_CHECKING, Any, Callable, Optional, Tuple from urllib.parse import unquote from Crypto.Cipher import AES from quart import Quart, request, Response, jsonify from langbot.libs.wecom_ai_bot_api import wecombotevent from langbot.libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt if TYPE_CHECKING: from langbot.pkg.platform.logger import EventLogger from langbot.pkg.utils import httpclient _CLIENT_TRANSIENT_CACHE_MAX = 4096 _MAX_STREAM_CONTENT_CHARS = 200000 @dataclass class StreamChunk: """描述单次推送给企业微信的流式片段。""" # 需要返回给企业微信的文本内容 content: str # 标记是否为最终片段,对应企业微信协议里的 finish 字段 is_final: bool = False # 预留额外元信息,未来支持多模态扩展时可使用 meta: dict[str, Any] = field(default_factory=dict) @dataclass class StreamSession: """维护一次企业微信流式会话的上下文。""" # 企业微信要求的 stream_id,用于标识后续刷新请求 stream_id: str # 原始消息的 msgid,便于与流水线消息对应 msg_id: str # 群聊会话标识(单聊时为空) chat_id: Optional[str] # 触发消息的发送者 user_id: Optional[str] # 会话创建时间 created_at: float = field(default_factory=time.time) # 最近一次被访问的时间,cleanup 依据该值判断过期 last_access: float = field(default_factory=time.time) # 将流水线增量结果缓存到队列,刷新请求逐条消费 queue: asyncio.Queue = field(default_factory=lambda: asyncio.Queue(maxsize=1)) # 是否已经完成(收到最终片段) finished: bool = False # 缓存最近一次片段,处理重试或超时兜底 last_chunk: Optional[StreamChunk] = None # 反馈 ID,用于接收用户点赞/点踩反馈 feedback_id: Optional[str] = None # Dify 人工输入暂停态:runner 把 _form_data 传过来时填充。 # 一旦设置,下次企微 followup 请求时返回 button_interaction 模板卡 # 替代 stream chunk。点击按钮会回调 template_card_event,EventKey # 就是 Dify 的 action_id。 pending_form: Optional[dict] = None # template_card task_id(企微要求 button_interaction 必填且不可重复)。 # 创建 pending_form 时生成;按钮点击回调里用来反查 session。 pending_form_task_id: Optional[str] = None class StreamSessionManager: """管理 stream 会话的生命周期,并负责队列的生产消费。""" # Sessions with registered feedback_ids use a longer TTL to survive the # full like → cancel → dislike feedback flow. Must align with the adapter's # _stream_to_monitoring_msg TTL (wecombot.py). _FEEDBACK_SESSION_TTL = 600 # 10 minutes _MAX_SESSIONS = 4096 def __init__(self, logger: EventLogger, ttl: int = 60) -> None: self.logger = logger self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射 self._msg_index: dict[str, str] = {} # msgid -> stream_id 映射,便于流水线根据消息 ID 找到会话 self._feedback_index: dict[str, str] = {} # feedback_id -> stream_id 映射 # task_id (button_interaction template_card 的) -> stream_id 映射, # 用于按钮点击回调里反查 pending_form。 self._task_index: dict[str, str] = {} def get_stream_id_by_msg(self, msg_id: str) -> Optional[str]: if not msg_id: return None return self._msg_index.get(msg_id) def get_session(self, stream_id: str) -> Optional[StreamSession]: return self._sessions.get(stream_id) def get_session_by_feedback_id(self, feedback_id: str) -> Optional[StreamSession]: """根据 feedback_id 查找会话。 Args: feedback_id: 企业微信反馈事件中的反馈 ID。 Returns: Optional[StreamSession]: 找到的会话实例,未找到返回 None。 """ if not feedback_id: return None stream_id = self._feedback_index.get(feedback_id) if stream_id: return self._sessions.get(stream_id) return None def register_feedback_id(self, stream_id: str, feedback_id: str) -> None: """注册 feedback_id 与 stream_id 的映射。 Args: stream_id: 企业微信流式会话 ID。 feedback_id: 反馈 ID。 """ if feedback_id and stream_id: self._feedback_index[feedback_id] = stream_id def set_pending_form(self, stream_id: str, form_data: dict, task_id: str) -> None: """把 Dify 人工输入暂停态绑定到 stream session。 下一次企微 followup 请求时,adapter 检测到 pending_form, 返回 button_interaction 模板卡而不是 stream chunk。 """ session = self._sessions.get(stream_id) if not session: return session.pending_form = form_data session.pending_form_task_id = task_id if task_id: self._task_index[task_id] = stream_id def get_session_by_task_id(self, task_id: str) -> Optional[StreamSession]: """按按钮点击回调里的 TaskId 反查 session。""" if not task_id: return None stream_id = self._task_index.get(task_id) if not stream_id: return None return self._sessions.get(stream_id) def clear_pending_form(self, stream_id: str) -> None: """按钮点击消费完后清掉 pending_form,避免重复弹卡。""" session = self._sessions.get(stream_id) if not session: return task_id = session.pending_form_task_id session.pending_form = None session.pending_form_task_id = None if task_id: self._task_index.pop(task_id, None) def clear(self) -> None: """Release every retained stream and reverse index.""" self._sessions.clear() self._msg_index.clear() self._feedback_index.clear() self._task_index.clear() def _drop_session(self, stream_id: str) -> StreamSession | None: session = self._sessions.pop(stream_id, None) if session is None: return None if session.msg_id and self._msg_index.get(session.msg_id) == stream_id: self._msg_index.pop(session.msg_id, None) if session.feedback_id: self._feedback_index.pop(session.feedback_id, None) if session.pending_form_task_id: self._task_index.pop(session.pending_form_task_id, None) return session def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]: """根据企业微信回调创建或获取会话。 Args: msg_json: 企业微信解密后的回调 JSON。 Returns: Tuple[StreamSession, bool]: `StreamSession` 为会话实例,`bool` 指示是否为新建会话。 Example: 在首次回调中调用,得到 `is_new=True` 后再触发流水线。 """ msg_id = msg_json.get('msgid', '') if msg_id and msg_id in self._msg_index: stream_id = self._msg_index[msg_id] session = self._sessions.get(stream_id) if session: session.last_access = time.time() return session, False self.cleanup() while len(self._sessions) >= self._MAX_SESSIONS: oldest_stream_id = min( self._sessions, key=lambda candidate: self._sessions[candidate].last_access, ) self._drop_session(oldest_stream_id) stream_id = str(uuid.uuid4()) session = StreamSession( stream_id=stream_id, msg_id=msg_id, chat_id=msg_json.get('chatid'), user_id=msg_json.get('from', {}).get('userid'), ) if msg_id: self._msg_index[msg_id] = stream_id self._sessions[stream_id] = session return session, True async def publish(self, stream_id: str, chunk: StreamChunk) -> bool: """向 stream 队列写入新的增量片段。 Args: stream_id: 企业微信分配的流式会话 ID。 chunk: 待发送的增量片段。 Returns: bool: 当流式队列存在并成功入队时返回 True。 Example: 在收到模型增量后调用 `await manager.publish('sid', StreamChunk('hello'))`。 """ session = self._sessions.get(stream_id) if not session: return False session.last_access = time.time() session.last_chunk = chunk try: session.queue.put_nowait(chunk) except asyncio.QueueFull: # Each chunk is a complete snapshot. Coalesce a slow consumer to # the newest value instead of retaining every intermediate body. try: session.queue.get_nowait() except asyncio.QueueEmpty: pass session.queue.put_nowait(chunk) if chunk.is_final: session.finished = True return True async def consume(self, stream_id: str, timeout: float = 0.5) -> Optional[StreamChunk]: """从队列中取出一个片段,若超时返回 None。 Args: stream_id: 企业微信流式会话 ID。 timeout: 取片段的最长等待时间(秒)。 Returns: Optional[StreamChunk]: 成功时返回片段,超时或会话不存在时返回 None。 Example: 企业微信刷新到达时调用,若队列有数据则立即返回 `StreamChunk`。 """ session = self._sessions.get(stream_id) if not session: return None session.last_access = time.time() try: chunk = await asyncio.wait_for(session.queue.get(), timeout) session.last_access = time.time() if chunk.is_final: session.finished = True return chunk except asyncio.TimeoutError: if session.finished and session.last_chunk: return session.last_chunk return None def mark_finished(self, stream_id: str) -> None: session = self._sessions.get(stream_id) if session: session.finished = True session.last_access = time.time() def cleanup(self) -> list[str]: """定期清理过期会话,防止队列与映射无上限累积。 已注册 feedback_id 的会话使用更长的 TTL,确保用户在点赞/取消/点踩流程中 不会因为 session 被提前清除而丢失上下文信息。 """ now = time.time() expired: list[str] = [] for stream_id, session in self._sessions.items(): # Sessions with registered feedback_ids use a longer TTL effective_ttl = self._FEEDBACK_SESSION_TTL if session.feedback_id else self.ttl if now - session.last_access > effective_ttl: expired.append(stream_id) removed_msg_ids: list[str] = [] for stream_id in expired: session = self._drop_session(stream_id) if not session: continue if session.msg_id: removed_msg_ids.append(session.msg_id) return removed_msg_ids def _decrypt_file(encrypted_data: bytes, aes_key_str: str) -> bytes: """Decrypt AES-256-CBC encrypted file data. Aligned with the official WeCom AI Bot Python SDK (crypto_utils.py). Args: encrypted_data: The raw encrypted bytes. aes_key_str: Base64-encoded AES key (may lack padding). Returns: Decrypted bytes with PKCS#7 padding removed. """ if not encrypted_data: raise ValueError('encrypted_data is empty') if not aes_key_str: raise ValueError('aes_key is empty') # Python's base64.b64decode requires proper padding (length % 4 == 0). # 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) iv = key[:16] 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] if pad_len < 1 or pad_len > 32 or pad_len > len(decrypted): raise ValueError(f'Invalid PKCS#7 padding value: {pad_len}') # 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' elif data.startswith(b'\x89PNG'): mime_type = 'image/png' elif data.startswith((b'GIF87a', b'GIF89a')): mime_type = 'image/gif' elif data.startswith(b'BM'): mime_type = 'image/bmp' elif data.startswith(b'II*\x00') or data.startswith(b'MM\x00*'): 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: mime_type = 'application/octet-stream' base64_str = base64.b64encode(data).decode('utf-8') 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: client = httpclient.get_session() async with client.get(download_url, timeout=30.0) as response: if response.status != 200: await logger.error(f'Failed to download file (HTTP {response.status})') return None, None encrypted_bytes = await httpclient.read_limited(response) 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 = await asyncio.to_thread(_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( msg_json: dict[str, Any], encoding_aes_key: str, logger: EventLogger ) -> dict[str, Any]: """Parse a decrypted WeChat Work AI Bot message JSON into a unified message dict. This is the shared message parsing logic used by both webhook and WebSocket modes. Args: msg_json: The decrypted message JSON from WeChat Work. encoding_aes_key: AES key for file decryption. logger: Logger instance. Returns: A dict suitable for constructing a WecomBotEvent. """ message_data: dict[str, Any] = {} msg_type = msg_json.get('msgtype', '') if msg_type: message_data['msgtype'] = msg_type if msg_json.get('chattype', '') == 'single': message_data['type'] = 'single' elif msg_json.get('chattype', '') == 'group': message_data['type'] = 'group' max_inline_file_size = 5 * 1024 * 1024 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: return None, None 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 await asyncio.to_thread(_bytes_to_data_uri, data) return None if msg_type == 'text': message_data['content'] = msg_json.get('text', {}).get('content') elif msg_type == 'markdown': message_data['content'] = msg_json.get('markdown', {}).get('content') or msg_json.get('text', {}).get( 'content', '' ) elif msg_type == 'image': image_info = msg_json.get('image', {}) 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: message_data['picurl'] = base64_data message_data['images'] = [base64_data] elif msg_type == 'voice': voice_info = msg_json.get('voice', {}) or {} download_url = voice_info.get('url') per_msg_aeskey = voice_info.get('aeskey', '') message_data['voice'] = { 'url': download_url, 'md5sum': voice_info.get('md5sum') or voice_info.get('md5'), 'filesize': voice_info.get('filesize') or voice_info.get('size'), 'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'), } if voice_info.get('content'): message_data['content'] = voice_info.get('content') # if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size: # voice_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey) # if voice_base64: # message_data['voice']['base64'] = voice_base64 elif msg_type == 'video': video_info = msg_json.get('video', {}) or {} download_url = video_info.get('url') per_msg_aeskey = video_info.get('aeskey', '') video_data = { 'url': download_url, 'filesize': video_info.get('filesize') or video_info.get('size'), 'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'), 'md5sum': video_info.get('md5sum') or video_info.get('md5'), 'filename': video_info.get('filename') or video_info.get('name'), } # if (video_data.get('filesize') or 0) <= max_inline_file_size: # video_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey) # if video_base64: # video_data['base64'] = video_base64 # 应为需要解密,但是目前暂时不能下载到内部进行解密,所以先将下载链接拼接aeskey返回给用户,由插件去处理该链接的下载和解密逻辑 video_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}' message_data['video'] = video_data elif msg_type == 'file': file_info = msg_json.get('file', {}) or {} download_url = file_info.get('url') or file_info.get('fileurl') per_msg_aeskey = file_info.get('aeskey', '') file_data = { 'filename': file_info.get('filename') or file_info.get('name'), 'filesize': file_info.get('filesize') or file_info.get('size'), 'md5sum': file_info.get('md5sum') or file_info.get('md5'), 'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'), 'download_url': download_url, 'extra': file_info, } # if (file_data.get('filesize') or 0) <= max_inline_file_size: # file_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey) # if file_bytes: # file_data['base64'] = _bytes_to_data_uri(file_bytes) # if dl_filename and not file_data.get('filename'): # file_data['filename'] = dl_filename # 应为需要解密,但是目前暂时不能下载到内部进行解密,所以先将下载链接拼接aeskey返回给用户,由插件去处理该链接的下载和解密逻辑 file_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}' message_data['file'] = file_data elif msg_type == 'link': message_data['link'] = msg_json.get('link', {}) if not message_data.get('content'): title = message_data['link'].get('title', '') desc = message_data['link'].get('description') or message_data['link'].get('digest', '') message_data['content'] = '\n'.join(filter(None, [title, desc])) elif msg_type == 'mixed': items = msg_json.get('mixed', {}).get('msg_item', []) texts = [] images = [] files = [] voices = [] videos = [] links = [] for item in items: item_type = item.get('msgtype') if item_type == 'text': texts.append(item.get('text', {}).get('content', '')) elif item_type == 'image': img_info = item.get('image', {}) 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: images.append(base64_data) elif item_type == 'file': file_info = item.get('file', {}) or {} download_url = file_info.get('url') or file_info.get('fileurl') item_aeskey = file_info.get('aeskey', '') file_data = { 'filename': file_info.get('filename') or file_info.get('name'), 'filesize': file_info.get('filesize') or file_info.get('size'), 'md5sum': file_info.get('md5sum') or file_info.get('md5'), 'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'), 'download_url': download_url, 'extra': file_info, } if (file_data.get('filesize') or 0) <= max_inline_file_size: file_bytes, dl_filename = await _safe_download(download_url, item_aeskey) if file_bytes: file_data['base64'] = await asyncio.to_thread( _bytes_to_data_uri, file_bytes, ) if dl_filename and not file_data.get('filename'): file_data['filename'] = dl_filename files.append(file_data) elif item_type == 'voice': voice_info = item.get('voice', {}) or {} download_url = voice_info.get('url') item_aeskey = voice_info.get('aeskey', '') voice_data = { 'url': download_url, 'md5sum': voice_info.get('md5sum') or voice_info.get('md5'), 'filesize': voice_info.get('filesize') or voice_info.get('size'), 'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'), } if voice_info.get('content'): texts.append(voice_info.get('content')) if (voice_data.get('filesize') or 0) <= max_inline_file_size: voice_base64 = await _safe_download_as_data_uri(download_url, item_aeskey) if voice_base64: voice_data['base64'] = voice_base64 voices.append(voice_data) elif item_type == 'video': video_info = item.get('video', {}) or {} download_url = video_info.get('url') item_aeskey = video_info.get('aeskey', '') video_data = { 'url': download_url, 'filesize': video_info.get('filesize') or video_info.get('size'), 'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'), 'md5sum': video_info.get('md5sum') or video_info.get('md5'), 'filename': video_info.get('filename') or video_info.get('name'), } if (video_data.get('filesize') or 0) <= max_inline_file_size: video_base64 = await _safe_download_as_data_uri(download_url, item_aeskey) if video_base64: video_data['base64'] = video_base64 videos.append(video_data) elif item_type == 'link': links.append(item.get('link', {})) if texts: message_data['content'] = ' '.join(texts) if images: message_data['images'] = images message_data['picurl'] = images[0] if files: message_data['files'] = files message_data['file'] = files[0] if voices: message_data['voices'] = voices message_data['voice'] = voices[0] if videos: message_data['videos'] = videos message_data['video'] = videos[0] if links: message_data['link'] = links[0] if items: message_data['attachments'] = items else: message_data['raw_msg'] = msg_json from_info = msg_json.get('from', {}) message_data['userid'] = from_info.get('userid', '') message_data['username'] = from_info.get('alias', '') or from_info.get('name', '') or from_info.get('userid', '') if msg_json.get('chattype', '') == 'group': message_data['chatid'] = msg_json.get('chatid', '') message_data['chatname'] = msg_json.get('chatname', '') or msg_json.get('chatid', '') message_data['msgid'] = msg_json.get('msgid', '') if msg_json.get('aibotid'): message_data['aibotid'] = msg_json.get('aibotid', '') # Handle quote (referenced message) - important for group chat file references quote_info = msg_json.get('quote') if quote_info: quote_data: dict[str, Any] = {} quote_type = quote_info.get('msgtype', '') quote_data['msgtype'] = quote_type if quote_type == 'text': quote_data['content'] = quote_info.get('text', {}).get('content', '') elif quote_type == 'image': img_info = quote_info.get('image', {}) 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: quote_data['picurl'] = base64_data quote_data['images'] = [base64_data] elif quote_type == 'file': file_info = quote_info.get('file', {}) or {} download_url = file_info.get('url') or file_info.get('fileurl') item_aeskey = file_info.get('aeskey', '') file_data = { 'filename': file_info.get('filename') or file_info.get('name'), 'filesize': file_info.get('filesize') or file_info.get('size'), 'md5sum': file_info.get('md5sum') or file_info.get('md5'), 'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'), 'download_url': download_url, 'extra': file_info, } # Same as private chat: append aeskey to download_url for plugin processing if download_url and item_aeskey: file_data['download_url'] = download_url + f'?aeskey={item_aeskey}' quote_data['file'] = file_data elif quote_type == 'voice': voice_info = quote_info.get('voice', {}) or {} download_url = voice_info.get('url') item_aeskey = voice_info.get('aeskey', '') voice_data = { 'url': download_url, 'md5sum': voice_info.get('md5sum') or voice_info.get('md5'), 'filesize': voice_info.get('filesize') or voice_info.get('size'), 'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'), } if voice_info.get('content'): quote_data['content'] = voice_info.get('content') # Same as private chat: append aeskey to url for plugin processing if download_url and item_aeskey: voice_data['url'] = download_url + f'?aeskey={item_aeskey}' quote_data['voice'] = voice_data elif quote_type == 'video': video_info = quote_info.get('video', {}) or {} download_url = video_info.get('url') item_aeskey = video_info.get('aeskey', '') video_data = { 'url': download_url, 'filesize': video_info.get('filesize') or video_info.get('size'), 'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'), 'md5sum': video_info.get('md5sum') or video_info.get('md5'), 'filename': video_info.get('filename') or video_info.get('name'), } # Same as private chat: append aeskey to download_url for plugin processing if download_url and item_aeskey: video_data['download_url'] = download_url + f'?aeskey={item_aeskey}' quote_data['video'] = video_data elif quote_type == 'link': quote_data['link'] = quote_info.get('link', {}) link = quote_data['link'] title = link.get('title', '') desc = link.get('description') or link.get('digest', '') quote_data['content'] = '\n'.join(filter(None, [title, desc])) elif quote_type == 'mixed': # Handle mixed type in quote (text + images + files etc.) items = quote_info.get('mixed', {}).get('msg_item', []) texts = [] images = [] files = [] for item in items: item_type = item.get('msgtype') if item_type == 'text': texts.append(item.get('text', {}).get('content', '')) elif item_type == 'image': img_info = item.get('image', {}) 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: images.append(base64_data) elif item_type == 'file': file_info = item.get('file', {}) or {} download_url = file_info.get('url') or file_info.get('fileurl') item_aeskey = file_info.get('aeskey', '') file_data = { 'filename': file_info.get('filename') or file_info.get('name'), 'filesize': file_info.get('filesize') or file_info.get('size'), 'md5sum': file_info.get('md5sum') or file_info.get('md5'), 'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'), 'download_url': download_url, 'extra': file_info, } # Same as private chat: append aeskey to download_url for plugin processing if download_url and item_aeskey: file_data['download_url'] = download_url + f'?aeskey={item_aeskey}' files.append(file_data) if texts: quote_data['content'] = ' '.join(texts) if images: quote_data['images'] = images quote_data['picurl'] = images[0] if files: quote_data['files'] = files quote_data['file'] = files[0] message_data['quote'] = quote_data return message_data def _wecom_button_style(action: dict, *, selected: bool = False) -> int: """Map Dify button style to WeCom button style.""" if not selected: return 2 return 1 def _wecom_field_display_name(field: dict, fallback: str = '') -> str: label = ( field.get('label') or field.get('title') or field.get('name') or field.get('output_variable_name') or fallback ) return str(label or fallback).strip() def _wecom_input_hint_lines(form_data: dict) -> list[str]: lines: list[str] = [] current_field = str(form_data.get('_current_input_field') or '').strip() for field in form_data.get('input_defs') or []: field_name = str(field.get('output_variable_name') or '').strip() field_type = str(field.get('type') or 'text').strip().lower() field_label = _wecom_field_display_name(field, field_name) if current_field and field_name != current_field: continue if not field_name: continue if field_type in {'file', 'file-list'}: limit = field.get('number_limits') if field_type == 'file-list' else 1 allowed_types = ', '.join(field.get('allowed_file_types') or []) suffix = f', up to {limit}' if field_type == 'file-list' and limit else '' allowed = f' ({allowed_types})' if allowed_types else '' lines.append(f'- {field_label}: upload file(s){allowed}{suffix} or reply `{field_name}: `') return lines def _wecom_pending_input_defs(form_data: dict) -> list[dict]: if form_data.get('_action_select_only'): return [] inputs = form_data.get('inputs') or {} current_field = str(form_data.get('_current_input_field') or '').strip() pending = [] for field in form_data.get('input_defs') or []: field_name = str(field.get('output_variable_name') or '').strip() if not field_name: continue if current_field and field_name != current_field: continue if str(field.get('type') or '').strip().lower() in {'file', 'file-list'}: continue if inputs.get(field_name) in (None, '', []): pending.append(field) return pending def _wecom_select_options(field: dict) -> list[str]: source = field.get('option_source') or {} options = source.get('value') if isinstance(source, dict) else [] if not isinstance(options, list): return [] return [str(option) for option in options] def _wecom_select_option_id(index: int) -> str: return f'opt_{index + 1}' def _wecom_pending_select_defs(form_data: dict) -> list[dict]: return [ field for field in _wecom_pending_input_defs(form_data) if str(field.get('type') or '').strip().lower() == 'select' and _wecom_select_options(field) ] def _wecom_field_title(field: dict, fallback: str) -> str: title = _wecom_field_display_name(field, fallback) return str(title or fallback).strip()[:13] or fallback def _wecom_form_desc(form_data: dict) -> str: form_content = _wecom_clean_form_content(form_data) return form_content[:512] if form_content else '' def build_human_input_text_prompt(form_data: dict) -> Optional[str]: """Build a plain-text prompt for a current non-select input field.""" current_field = str(form_data.get('_current_input_field') or '').strip() if not current_field: return None for field in form_data.get('input_defs') or form_data.get('all_input_defs') or []: if str(field.get('output_variable_name') or '').strip() != current_field: continue field_type = str(field.get('type') or 'text').strip().lower() if field_type == 'select': return None form_content = _wecom_clean_form_content(form_data) if not form_content: form_content = _wecom_field_display_name(field, current_field) node_title = str(form_data.get('node_title') or '人工介入').strip() return f'{node_title}\n\n{form_content}' if form_content else node_title return None def build_multiple_interaction_payload( form_data: dict, task_id: str, *, source: Optional[dict] = None, ) -> dict[str, Any]: """Build a WeCom multiple_interaction card for pending select fields.""" select_fields = _wecom_pending_select_defs(form_data) node_title = (form_data.get('node_title') or '').strip() or 'Human Input' inputs = form_data.get('inputs') or {} select_list = [] for field_index, field in enumerate(select_fields[:10]): field_name = str(field.get('output_variable_name') or '').strip() if not field_name: continue options = _wecom_select_options(field)[:10] option_list = [ { 'id': _wecom_select_option_id(idx), 'text': option_text[:10] or _wecom_select_option_id(idx), } for idx, option_text in enumerate(options) ] selected_id = _wecom_select_option_id(0) current_value = inputs.get(field_name) if current_value not in (None, '', []): for idx, option_text in enumerate(options): if str(current_value) == option_text: selected_id = _wecom_select_option_id(idx) break select_list.append( { 'question_key': field_name, 'title': _wecom_field_title(field, f'Select {field_index + 1}'), 'selected_id': selected_id, 'option_list': option_list, } ) card: dict[str, Any] = { 'card_type': 'multiple_interaction', 'main_title': { 'title': node_title, 'desc': _wecom_form_desc(form_data), }, 'select_list': select_list, 'submit_button': { 'text': 'Submit', 'key': 'submit_human_input', }, 'task_id': task_id, } if source: card['source'] = source return { 'msgtype': 'template_card', 'template_card': card, } _SELECT_BUTTON_KEY_PREFIX = '__dify_select__' def _encode_select_button_key(field_name: str, option_index: int) -> str: data = json.dumps({'f': field_name, 'i': option_index}, ensure_ascii=False, separators=(',', ':')) encoded = base64.urlsafe_b64encode(data.encode('utf-8')).decode('ascii').rstrip('=') return f'{_SELECT_BUTTON_KEY_PREFIX}:{encoded}' def parse_select_button_action(action_id: str, form_data: dict) -> dict[str, str]: """Decode a select option represented as a button_interaction click.""" action_id = str(action_id or '').strip() prefix = f'{_SELECT_BUTTON_KEY_PREFIX}:' if not action_id.startswith(prefix): return {} encoded = action_id[len(prefix) :] try: padded = encoded + '=' * (-len(encoded) % 4) data = json.loads(base64.urlsafe_b64decode(padded.encode('ascii')).decode('utf-8')) except Exception: return {} field_name = str(data.get('f') or '').strip() option_index = data.get('i') if not field_name or not isinstance(option_index, int): return {} for field in form_data.get('input_defs') or form_data.get('all_input_defs') or []: if str(field.get('output_variable_name') or '').strip() != field_name: continue options = _wecom_select_options(field) if 0 <= option_index < len(options): return {field_name: options[option_index]} return {} def build_select_button_interaction_payload( form_data: dict, task_id: str, *, source: Optional[dict] = None, ) -> dict[str, Any]: """Build a button_interaction card that emulates a select field. WeCom AI Bot long-connection callbacks are reliable for button clicks, so this is used as a fallback when multiple_interaction submit callbacks are not delivered by the platform. """ select_fields = _wecom_pending_select_defs(form_data) field = select_fields[0] if select_fields else {} field_name = str(field.get('output_variable_name') or '').strip() options = _wecom_select_options(field)[:10] if field else [] visible_options = options[:6] overflow_options = options[6:] node_title = (form_data.get('node_title') or '').strip() or 'Human Input' form_content = _wecom_clean_form_content(form_data) sub_title_parts: list[str] = [] if form_content: sub_title_parts.append(form_content) if overflow_options: extra_lines = [f' - {idx + 7}. {option}' for idx, option in enumerate(overflow_options)] sub_title_parts.append( 'More options can be entered by replying with the option text:\n' + '\n'.join(extra_lines) ) button_list = [ { 'text': option_text[:10] or f'Option {idx + 1}', 'style': 2 if idx == 0 else 0, 'key': _encode_select_button_key(field_name, idx), } for idx, option_text in enumerate(visible_options) ] card: dict[str, Any] = { 'card_type': 'button_interaction', 'main_title': { 'title': node_title, }, 'sub_title_text': '\n\n'.join(sub_title_parts), 'button_list': button_list, 'task_id': task_id, } if source: card['source'] = source return { 'msgtype': 'template_card', 'template_card': card, } def build_human_input_template_card_payload( form_data: dict, task_id: str, *, source: Optional[dict] = None, select_as_buttons: bool = False, ) -> dict[str, Any]: """Build the best WeCom template card for a Dify human-input form.""" if _wecom_pending_select_defs(form_data): if select_as_buttons: return build_select_button_interaction_payload(form_data, task_id, source=source) return build_multiple_interaction_payload(form_data, task_id, source=source) return build_button_interaction_payload(form_data, task_id, source=source) def _wecom_clean_form_content(form_data: dict) -> str: is_field_step = bool(form_data.get('_current_input_field')) and not form_data.get('_action_select_only') raw_content = str(form_data.get('raw_form_content') or '') content = form_data.get('form_content') or raw_content input_defs = list(form_data.get('all_input_defs') or form_data.get('input_defs') or []) fields = { str(field.get('output_variable_name') or '').strip(): field for field in input_defs if str(field.get('output_variable_name') or '').strip() } if form_data.get('_action_select_only') and raw_content: placeholders = [ match for match in re.finditer(r'\{\{#\$output\.([^#{}]+)#\}\}', raw_content) if match.group(1).strip() in fields ] if placeholders: content = raw_content[placeholders[-1].end() :] if is_field_step: inputs = form_data.get('inputs') or {} def replace_placeholder(match: re.Match[str]) -> str: field_name = match.group(1).strip() field = fields.get(field_name) value = inputs.get(field_name) if not field or value in (None, '', []): return '' return f'✅ {field_name}:{_wecom_display_input_value(field, value)}' content = re.sub(r'\{\{#\$output\.([^#{}]+)#\}\}', replace_placeholder, str(content)) kept_lines: list[str] = [] for line in str(content).splitlines(): placeholder = re.fullmatch(r'\s*\{\{#\$output\.([^#{}]+)#\}\}\s*', line) if placeholder and placeholder.group(1).strip() in fields: continue kept_lines.append(line) return re.sub(r'\n{3,}', '\n\n', '\n'.join(kept_lines).strip()) def _wecom_display_input_value(field: dict, value: Any) -> str: field_type = str(field.get('type') or 'text').strip().lower() if field_type == 'file': if isinstance(value, dict): return str(value.get('url') or value.get('upload_file_id') or '1 file') elif field_type == 'file-list' and isinstance(value, list): return f'{len(value)} file(s)' return str(value) def build_button_interaction_payload( form_data: dict, task_id: str, *, source: Optional[dict] = None, ) -> dict[str, Any]: """Build a `template_card` (button_interaction) WeCom payload. Shared by both the webhook-mode client (returns the payload as the response to a stream-followup callback) and the ws_client (sends it as a reply frame). Output shape is `{"msgtype": "template_card", "template_card": {...}}` per the WeCom spec. Args: form_data: Dify human-input form data with keys ``actions`` (list of ``{id, title, button_style}``), ``node_title``, ``form_content``. task_id: Unique per-card identifier. WeCom requires this for button_interaction. The click callback returns it as TaskId so we can find the originating session. source: Optional source header dict ``{icon_url, desc, desc_color}`` shown at the top of the card. WeCom accepts arbitrary HTTPS URLs for ``icon_url`` (unlike DingTalk Avatar which requires a uploaded media id), so the LangBot logo URL can be passed straight through. Notes: * ``button.key`` is set directly to the Dify ``action_id``. The click callback's ``EventKey`` carries this back unchanged (1024-byte limit per the spec, far more than we ever need). * WeCom caps the button list at 6. Extra actions are appended to ``sub_title_text`` so users can still reply with the id as text. * Styles map ``primary``→1 (blue), ``danger``→2 (red), default→0 (gray). First button is auto-promoted to primary when no style. """ actions = list(form_data.get('actions') or []) node_title = (form_data.get('node_title') or '').strip() or '人工介入' form_content = _wecom_clean_form_content(form_data) should_show_actions = not _wecom_pending_input_defs(form_data) visible_actions = actions[:6] if should_show_actions else [] overflow = actions[6:] if should_show_actions else [] sub_title_parts: list[str] = [] if form_content: sub_title_parts.append(form_content) input_hint_lines = _wecom_input_hint_lines(form_data) if input_hint_lines: sub_title_parts.append('Fill these fields in chat before choosing an action:\n' + '\n'.join(input_hint_lines)) if overflow: extra_lines = [f' - {a.get("title") or a.get("id") or ""} (回复 id: {a.get("id") or ""})' for a in overflow] sub_title_parts.append(f'另有 {len(overflow)} 个选项不在按钮列表中,可直接回复 id:\n' + '\n'.join(extra_lines)) sub_title_text = '\n\n'.join(sub_title_parts) button_list = [] for idx, action in enumerate(visible_actions): action_id = str(action.get('id') or '') title = str(action.get('title') or action_id or f'选项 {idx + 1}') button_list.append( { 'text': title, 'style': _wecom_button_style(action), 'key': action_id, } ) card: dict[str, Any] = { 'card_type': 'button_interaction', 'main_title': { 'title': node_title, }, 'sub_title_text': sub_title_text, 'button_list': button_list, 'task_id': task_id, } if source: card['source'] = source return { 'msgtype': 'template_card', 'template_card': card, } def extract_template_card_action(tce: dict[str, Any]) -> tuple[str, str, str]: """Extract task id, clicked button key, and card type from a WeCom callback.""" task_id = tce.get('TaskId') or tce.get('task_id') or tce.get('taskid') or tce.get('taskId') or '' event_key = ( tce.get('EventKey') or tce.get('event_key') or tce.get('eventkey') or tce.get('eventKey') or tce.get('key') or tce.get('Key') or '' ) card_type = tce.get('CardType') or tce.get('card_type') or tce.get('cardtype') or tce.get('cardType') or '' for button_key in ('button', 'Button', 'selected_button', 'selectedButton'): button = tce.get(button_key) if isinstance(button, dict): if not event_key: event_key = ( button.get('key') or button.get('Key') or button.get('event_key') or button.get('EventKey') or button.get('id') or button.get('Id') or '' ) break return str(task_id or ''), str(event_key or ''), str(card_type or '') def extract_wecom_event_type(payload: dict[str, Any]) -> str: """Extract eventtype from common WeCom callback wrapper shapes.""" event = payload.get('event') if isinstance(payload, dict) else {} if not isinstance(event, dict): event = {} event_type = ( event.get('eventtype') or event.get('event_type') or event.get('eventType') or event.get('EventType') or payload.get('eventtype') or payload.get('event_type') or payload.get('eventType') or payload.get('EventType') or '' ) if event_type: return str(event_type) tce = extract_template_card_event_payload(payload) task_id, event_key, card_type = extract_template_card_action(tce) if task_id or event_key or card_type or extract_template_card_selections(tce): return 'template_card_event' return '' def extract_template_card_event_payload(payload: dict[str, Any]) -> dict[str, Any]: """Extract template_card_event from common WeCom callback wrapper shapes.""" if not isinstance(payload, dict): return {} event = payload.get('event') if isinstance(payload.get('event'), dict) else {} candidates = ( event.get('template_card_event'), event.get('templateCardEvent'), event.get('TemplateCardEvent'), event.get('template_card'), payload.get('template_card_event'), payload.get('templateCardEvent'), payload.get('TemplateCardEvent'), payload.get('template_card'), ) for candidate in candidates: if isinstance(candidate, dict): return candidate if any( key in payload for key in ( 'TaskId', 'task_id', 'taskId', 'EventKey', 'event_key', 'eventKey', 'CardType', 'card_type', 'cardType', 'ResponseData', 'response_data', 'select_list', 'SelectList', ) ): return payload if any( key in event for key in ( 'TaskId', 'task_id', 'taskId', 'EventKey', 'event_key', 'eventKey', 'CardType', 'card_type', 'cardType', 'ResponseData', 'response_data', 'select_list', 'SelectList', ) ): return event return {} def extract_template_card_selections(tce: dict[str, Any], form_data: Optional[dict] = None) -> dict[str, str]: """Extract multiple_interaction select values from a WeCom callback. WeCom callback examples differ between webhook and websocket docs, so this parser accepts common snake_case/camelCase/PascalCase variants and maps the selected option id back to the Dify select option text when form_data is available. """ fields_by_name: dict[str, dict] = {} if form_data: for field in form_data.get('input_defs') or form_data.get('all_input_defs') or []: field_name = str(field.get('output_variable_name') or '').strip() if field_name: fields_by_name[field_name] = field def _maybe_decode_json(value: Any) -> Any: if not isinstance(value, str): return value text = value.strip() if not text or text[0] not in '[{': return value try: return json.loads(text) except json.JSONDecodeError: return value def _walk(value: Any) -> list[dict]: value = _maybe_decode_json(value) found: list[dict] = [] if isinstance(value, dict): found.append(value) for child in value.values(): found.extend(_walk(child)) elif isinstance(value, list): for child in value: found.extend(_walk(child)) return found def _lookup(item: dict, *keys: str) -> Any: for key in keys: if key in item: return item.get(key) lower_map = {str(key).lower(): value for key, value in item.items()} for key in keys: lowered = key.lower() if lowered in lower_map: return lower_map[lowered] return '' def _normalise_selected_value(question_key: str, selected: Any) -> str: selected = _maybe_decode_json(selected) if isinstance(selected, dict): selected = _lookup( selected, 'selected_id', 'SelectedId', 'selected_option_id', 'SelectedOptionId', 'option_id', 'OptionId', 'id', 'Id', 'value', 'Value', ) selected_id = str(selected or '').strip() if not selected_id: return '' selected_value = selected_id field = fields_by_name.get(question_key) if field: options = _wecom_select_options(field) for idx, option_text in enumerate(options): if selected_id in {_wecom_select_option_id(idx), option_text}: selected_value = option_text break return selected_value selections: dict[str, str] = {} for item in _walk(tce): question_key = _lookup( item, 'question_key', 'questionKey', 'QuestionKey', 'question', 'Question', 'key', 'Key', ) selected_id = _lookup( item, 'selected_id', 'selectedId', 'SelectedId', 'selected_option_id', 'selectedOptionId', 'SelectedOptionId', 'option_id', 'optionId', 'OptionId', 'value', 'Value', ) question_key = str(question_key or '').strip() selected_id = str(selected_id or '').strip() if question_key not in fields_by_name or not selected_id: continue selected_value = _normalise_selected_value(question_key, selected_id) if not selected_value: continue selections[question_key] = selected_value # Some WeCom callbacks encode ResponseData as a direct mapping: # {"xiala": "id_two"} rather than a select_list item array. for item in _walk(tce): if not isinstance(item, dict): continue for question_key, selected in item.items(): question_key = str(question_key or '').strip() if question_key not in fields_by_name or question_key in selections: continue selected_value = _normalise_selected_value(question_key, selected) if selected_value: selections[question_key] = selected_value return selections def resolve_form_action_title(form_data: dict, action_id: str) -> str: """Resolve a Dify form action title from its id.""" clean_action_id = str(action_id or '').strip() for action in form_data.get('actions') or []: if str(action.get('id', '')) == clean_action_id: return str(action.get('title') or clean_action_id) return clean_action_id def build_button_interaction_update_card( form_data: dict, task_id: str, action_id: str, source: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: """Build the template_card body used to update a clicked form card.""" node_title = str(form_data.get('node_title') or '').strip() or '人工介入' form_content = _wecom_clean_form_content(form_data) action_title = resolve_form_action_title(form_data, action_id) clean_action_id = str(action_id or '').strip() button_list = [] matched = False for idx, action in enumerate(list(form_data.get('actions') or [])[:6]): action_key = str(action.get('id') or '') button_title = str(action.get('title') or action_key or f'Option {idx + 1}') button = { 'text': button_title, 'style': _wecom_button_style(action), 'key': action_key, } if action_key == clean_action_id: button['style'] = _wecom_button_style(action, selected=True) button['text'] = f'✅ {button_title}' button['replace_text'] = f'✅ {button_title}' matched = True button_list.append(button) if clean_action_id and not matched: button_list.append( { 'text': action_title or clean_action_id, 'style': 1, 'key': clean_action_id, 'replace_text': f'✅ {action_title or clean_action_id}', } ) card: dict[str, Any] = { 'card_type': 'button_interaction', 'main_title': { 'title': node_title, }, 'sub_title_text': form_content, 'button_list': button_list, 'task_id': task_id, } if source: card['source'] = source return card def build_multiple_interaction_update_card( form_data: dict, task_id: str, selections: Optional[dict[str, str]] = None, source: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: """Build an update card that freezes submitted select values.""" node_title = str(form_data.get('node_title') or '').strip() or 'Human Input' selected_values = dict(form_data.get('inputs') or {}) selected_values.update(selections or {}) select_list = [] fields = _wecom_pending_select_defs(form_data) if not fields: fields = [ field for field in (form_data.get('input_defs') or form_data.get('all_input_defs') or []) if str(field.get('type') or '').strip().lower() == 'select' ] for field_index, field in enumerate(fields[:10]): field_name = str(field.get('output_variable_name') or '').strip() if not field_name: continue options = _wecom_select_options(field)[:10] option_list = [ { 'id': _wecom_select_option_id(idx), 'text': option_text[:10] or _wecom_select_option_id(idx), } for idx, option_text in enumerate(options) ] selected_id = _wecom_select_option_id(0) current_value = selected_values.get(field_name) if current_value not in (None, '', []): for idx, option_text in enumerate(options): if str(current_value) == option_text or str(current_value) == _wecom_select_option_id(idx): selected_id = _wecom_select_option_id(idx) break select_list.append( { 'question_key': field_name, 'title': _wecom_field_title(field, f'Select {field_index + 1}'), 'disable': True, 'selected_id': selected_id, 'option_list': option_list, } ) display_form_data = dict(form_data) display_form_data['inputs'] = selected_values form_content = _wecom_clean_form_content(display_form_data) card: dict[str, Any] = { 'card_type': 'multiple_interaction', 'main_title': { 'title': node_title, 'desc': form_content, }, 'select_list': select_list, 'submit_button': { 'text': '✅', 'key': 'submit_human_input', }, 'task_id': task_id, } if source: card['source'] = source return card class WecomBotClient: _MAX_DISPATCH_TASKS = 100 def __init__( self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger, unified_mode: bool = False, ): """企业微信智能机器人客户端。 Args: Token: 企业微信回调验证使用的 token。 EnCodingAESKey: 企业微信消息加解密密钥。 Corpid: 企业 ID。 logger: 日志记录器。 unified_mode: 是否使用统一 webhook 模式(默认 False)。 Example: >>> client = WecomBotClient(Token='token', EnCodingAESKey='aeskey', Corpid='corp', logger=logger) """ self.Token = Token self.EnCodingAESKey = EnCodingAESKey self.Corpid = Corpid self.ReceiveId = '' self.unified_mode = unified_mode self.app = Quart(__name__) # 只有在非统一模式下才注册独立路由 if not self.unified_mode: self.app.add_url_rule( '/callback/command', 'handle_callback', self.handle_callback_request, methods=['POST', 'GET'] ) self._message_handlers = { 'example': [], } self.logger = logger self.generated_content: dict[str, str] = {} self.msg_id_map: dict[str, int] = {} self.stream_sessions = StreamSessionManager(logger=logger) self.stream_poll_timeout = 0.5 self._feedback_callback: Optional[Callable] = None self._card_action_callback: Optional[Callable] = None self._stream_last_content: dict[str, str] = {} self._dispatch_tasks: set[asyncio.Task] = set() # Optional `source` block injected into every interactive template_card # the client builds. Set via `set_card_source` from the adapter after # reading config. Format: {icon_url, desc, desc_color}. self.card_source: Optional[dict] = None def set_card_source(self, source: Optional[dict]) -> None: """Set the `source` header dict injected into every button_interaction template_card. Pass None to clear.""" self.card_source = source def set_feedback_callback(self, callback: Callable) -> None: """设置反馈回调函数。 Args: callback: 反馈回调函数,签名: async def callback(feedback_id, feedback_type, feedback_content, inaccurate_reasons, session) """ self._feedback_callback = callback def set_card_action_callback(self, callback: Callable) -> None: """设置按钮卡片点击回调函数。 Signature: ``async def callback(session, action_id, task_id, raw_event) -> None`` ``session`` is the StreamSession the card was attached to; ``action_id`` is the Dify action_id reflected back via the button's ``key`` field; ``task_id`` is the card's task_id (matches ``session.pending_form_task_id``); ``raw_event`` is the decoded callback JSON for any extra fields the adapter wants. """ self._card_action_callback = callback @staticmethod def _build_stream_payload( stream_id: str, content: str, finish: bool, feedback_id: Optional[str] = None ) -> dict[str, Any]: """按照企业微信协议拼装返回报文。 Args: stream_id: 企业微信会话 ID。 content: 推送的文本内容。 finish: 是否为最终片段。 feedback_id: 反馈 ID,用于接收用户点赞/点踩反馈。 Returns: dict[str, Any]: 可直接加密返回的 payload。 Example: 组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。 """ stream_payload = { 'id': stream_id, 'finish': finish, 'content': content, } if feedback_id: stream_payload['feedback'] = {'id': feedback_id} return { 'msgtype': 'stream', 'stream': stream_payload, } def _build_button_interaction_payload(self, form_data: dict, task_id: str) -> dict[str, Any]: """Class-level shim — delegates to module-level builder and auto- injects the client's configured `source` block so every card emitted through this client carries the LangBot header.""" return build_human_input_template_card_payload(form_data, task_id, source=self.card_source) async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]: """对响应进行加密封装并返回给企业微信。 Args: payload: 待加密的响应内容。 nonce: 企业微信回调参数中的 nonce。 Returns: Tuple[Response, int]: Quart Response 对象及状态码。 Example: 在首包或刷新场景中调用以生成加密响应。 """ reply_plain_str = json.dumps(payload, ensure_ascii=False) reply_timestamp = str(int(time.time())) ret, encrypt_text = await asyncio.to_thread( self.wxcpt.EncryptMsg, reply_plain_str, nonce, reply_timestamp, ) if ret != 0: await self.logger.error(f'加密失败: {ret}') return jsonify({'error': 'encrypt_failed'}), 500 root = ET.fromstring(encrypt_text) encrypt = root.find('Encrypt').text resp = { 'encrypt': encrypt, } return jsonify(resp), 200 async def _dispatch_event(self, event: wecombotevent.WecomBotEvent) -> None: """异步触发流水线处理,避免阻塞首包响应。 Args: event: 由企业微信消息转换的内部事件对象。 """ try: await self._handle_message(event) except Exception: await self.logger.error(traceback.format_exc()) def _start_dispatch_task(self, event: wecombotevent.WecomBotEvent) -> bool: """Start one bounded pipeline dispatch task.""" for task in tuple(self._dispatch_tasks): if task.done(): self._dispatch_tasks.discard(task) if len(self._dispatch_tasks) >= self._MAX_DISPATCH_TASKS: return False task = asyncio.create_task(self._dispatch_event(event)) self._dispatch_tasks.add(task) def done(done_task: asyncio.Task) -> None: self._dispatch_tasks.discard(done_task) if not done_task.cancelled(): done_task.exception() task.add_done_callback(done) return True async def close(self) -> None: """Cancel callbacks and release retained webhook state.""" dispatch_tasks = list(self._dispatch_tasks) for task in dispatch_tasks: if not task.done(): task.cancel() if dispatch_tasks: await asyncio.gather(*dispatch_tasks, return_exceptions=True) self._dispatch_tasks.clear() self.generated_content.clear() self.msg_id_map.clear() self._stream_last_content.clear() self.stream_sessions.clear() async def _handle_post_initial_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: """处理企业微信首次推送的消息,返回 stream_id 并开启流水线。 Args: msg_json: 解密后的企业微信消息 JSON。 nonce: 企业微信回调参数 nonce。 Returns: Tuple[Response, int]: Quart Response 及状态码。 Example: 首次回调时调用,立即返回带 `stream_id` 的响应。 """ session, is_new = self.stream_sessions.create_or_get(msg_json) feedback_id = str(uuid.uuid4()) session.feedback_id = feedback_id self.stream_sessions.register_feedback_id(session.stream_id, feedback_id) message_data = await self.get_message(msg_json) if message_data: message_data['stream_id'] = session.stream_id message_data['feedback_id'] = feedback_id try: event = wecombotevent.WecomBotEvent(message_data) except Exception: await self.logger.error(traceback.format_exc()) else: if is_new: if not self._start_dispatch_task(event): await self.logger.warning('WeCom webhook dispatch capacity reached; dropping message') payload = self._build_stream_payload(session.stream_id, '', False, feedback_id) return await self._encrypt_and_reply(payload, nonce) async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: """处理企业微信的流式刷新请求,按需返回增量片段。 Args: msg_json: 解密后的企业微信刷新请求。 nonce: 企业微信回调参数 nonce。 Returns: Tuple[Response, int]: Quart Response 及状态码。 Example: 在刷新请求中调用,按需返回增量片段。 """ stream_info = msg_json.get('stream', {}) stream_id = stream_info.get('id', '') if not stream_id: await self.logger.error('刷新请求缺少 stream.id') return await self._encrypt_and_reply(self._build_stream_payload('', '', True), nonce) session = self.stream_sessions.get_session(stream_id) # If a Dify human-input pause arrived during this stream, switch # the response from `msgtype: stream` to `msgtype: template_card` # (button_interaction). The session's stream is also marked # finished so future followups aren't expected (assuming the # WeCom client treats template_card as the terminal response — # we'll know from the next callback whether it kept polling). if session and session.pending_form and session.pending_form_task_id: await self.logger.info( f'WeComBot: returning button_interaction for stream_id={stream_id} ' f'task_id={session.pending_form_task_id} actions={len(session.pending_form.get("actions") or [])}' ) card_payload = self._build_button_interaction_payload(session.pending_form, session.pending_form_task_id) self.stream_sessions.mark_finished(stream_id) return await self._encrypt_and_reply(card_payload, nonce) chunk = await self.stream_sessions.consume(stream_id, timeout=self.stream_poll_timeout) if not chunk: cached_content = None if session and session.msg_id: cached_content = self.generated_content.pop(session.msg_id, None) if cached_content is not None: chunk = StreamChunk(content=cached_content, is_final=True) else: payload = self._build_stream_payload(stream_id, '', False) return await self._encrypt_and_reply(payload, nonce) payload = self._build_stream_payload(stream_id, chunk.content, chunk.is_final) if chunk.is_final: self.stream_sessions.mark_finished(stream_id) return await self._encrypt_and_reply(payload, nonce) async def handle_callback_request(self): """企业微信回调入口(独立端口模式,使用全局 request)。 Returns: Quart Response: 根据请求类型返回验证、首包或刷新结果。 Example: 作为 Quart 路由处理函数直接注册并使用。 """ return await self._handle_callback_internal(request) async def handle_unified_webhook(self, req): """处理回调请求(统一 webhook 模式,显式传递 request)。 Args: req: Quart Request 对象 Returns: 响应数据 """ return await self._handle_callback_internal(req) async def _handle_callback_internal(self, req): """处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。 Args: req: Quart Request 对象 """ try: self.wxcpt = WXBizMsgCrypt(self.Token, self.EnCodingAESKey, '') if req.method == 'GET': return await self._handle_get_callback(req) if req.method == 'POST': return await self._handle_post_callback(req) return Response('', status=405) except Exception: await self.logger.error(traceback.format_exc()) return Response('Internal Server Error', status=500) async def _handle_get_callback(self, req) -> tuple[Response, int] | Response: """处理企业微信的 GET 验证请求。""" msg_signature = unquote(req.args.get('msg_signature', '')) timestamp = unquote(req.args.get('timestamp', '')) nonce = unquote(req.args.get('nonce', '')) echostr = unquote(req.args.get('echostr', '')) if not all([msg_signature, timestamp, nonce, echostr]): await self.logger.error('请求参数缺失') return Response('缺少参数', status=400) ret, decrypted_str = self.wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) if ret != 0: await self.logger.error('验证URL失败') return Response('验证失败', status=403) return Response(decrypted_str, mimetype='text/plain') async def _handle_post_callback(self, req) -> tuple[Response, int] | Response: """处理企业微信的 POST 回调请求。""" for expired_msg_id in self.stream_sessions.cleanup(): self.generated_content.pop(expired_msg_id, None) self._stream_last_content.pop(expired_msg_id, None) self.msg_id_map.pop(expired_msg_id, None) msg_signature = unquote(req.args.get('msg_signature', '')) timestamp = unquote(req.args.get('timestamp', '')) nonce = unquote(req.args.get('nonce', '')) encrypted_json = await req.get_json() encrypted_msg = (encrypted_json or {}).get('encrypt', '') if not encrypted_msg: await self.logger.error("请求体中缺少 'encrypt' 字段") return Response('Bad Request', status=400) xml_post_data = f'' ret, decrypted_xml = await asyncio.to_thread( self.wxcpt.DecryptMsg, xml_post_data, msg_signature, timestamp, nonce, ) if ret != 0: await self.logger.error('解密失败') return Response('解密失败', status=400) msg_json = await asyncio.to_thread(json.loads, decrypted_xml) event_type = extract_wecom_event_type(msg_json) if event_type == 'feedback_event': return await self._handle_feedback_event(msg_json, nonce) # Button click on a button_interaction template_card. The WeCom doc # calls this `template_card_event`; some routes wrap the button # event payload inside `event.template_card_event`. if event_type == 'template_card_event': return await self._handle_template_card_event(msg_json, nonce) if msg_json.get('msgtype') == 'stream': return await self._handle_post_followup_response(msg_json, nonce) return await self._handle_post_initial_response(msg_json, nonce) async def _handle_template_card_event(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: """Handle a button click on a button_interaction template_card. WeCom carries the click info in ``event.template_card_event`` with ``TaskId`` matching the card we created and ``EventKey`` carrying the button's ``key`` (which we set to the Dify ``action_id``). """ try: tce = extract_template_card_event_payload(msg_json) task_id, event_key, card_type = extract_template_card_action(tce) await self.logger.info(f'收到按钮点击: task_id={task_id} event_key={event_key!r} card_type={card_type}') session = self.stream_sessions.get_session_by_task_id(task_id) if session is None: await self.logger.warning(f'未找到 task_id={task_id} 对应的 session,按钮点击被丢弃') else: if self._card_action_callback is not None: try: await self._card_action_callback(session, event_key, task_id, msg_json) except Exception: await self.logger.error(f'card action callback raised: {traceback.format_exc()}') # Drop the form so a fresh chunk/followup doesn't re-render # the same card (and so the task_id can be GC'd). self.stream_sessions.clear_pending_form(session.stream_id) except Exception: await self.logger.error(f'_handle_template_card_event error: {traceback.format_exc()}') # WeCom expects an empty success ack for event callbacks. return await self._encrypt_and_reply({}, nonce) async def _handle_feedback_event(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: """处理企业微信用户反馈事件(点赞/点踩)。 Args: msg_json: 解密后的企业微信反馈事件 JSON。 nonce: 企业微信回调参数 nonce。 Returns: Tuple[Response, int]: Quart Response 及状态码。 Note: 企业微信协议要求:反馈事件目前仅支持回复空包。 """ try: feedback_event = msg_json.get('event', {}).get('feedback_event', {}) feedback_id = feedback_event.get('id', '') feedback_type = feedback_event.get('type', 0) feedback_content = feedback_event.get('content', '') inaccurate_reasons = feedback_event.get('inaccurate_reason_list', []) await self.logger.info( f'收到用户反馈事件: feedback_id={feedback_id}, type={feedback_type}, ' f'content={feedback_content}, reasons={inaccurate_reasons}' ) session = self.stream_sessions.get_session_by_feedback_id(feedback_id) if session: await self.logger.info( f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}' ) else: await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话,仍将记录反馈') # Dispatch feedback event regardless of session availability for handler in self._message_handlers.get('feedback', []): try: await handler( feedback_id=feedback_id, feedback_type=feedback_type, feedback_content=feedback_content, inaccurate_reasons=inaccurate_reasons, session=session, ) except Exception: await self.logger.error(traceback.format_exc()) if self._feedback_callback: try: await self._feedback_callback( feedback_id=feedback_id, feedback_type=feedback_type, feedback_content=feedback_content, inaccurate_reasons=inaccurate_reasons, session=session, ) except Exception: await self.logger.error(traceback.format_exc()) except Exception: await self.logger.error(traceback.format_exc()) return await self._encrypt_and_reply({}, nonce) async def get_message(self, msg_json): return await parse_wecom_bot_message(msg_json, self.EnCodingAESKey, self.logger) async def _handle_message(self, event: wecombotevent.WecomBotEvent): """ 处理消息事件。 """ try: message_id = event.message_id if message_id in self.msg_id_map.keys(): self.msg_id_map[message_id] += 1 return self.msg_id_map[message_id] = 1 while len(self.msg_id_map) > _CLIENT_TRANSIENT_CACHE_MAX: self.msg_id_map.pop(next(iter(self.msg_id_map)), None) msg_type = event.type if msg_type in self._message_handlers: for handler in self._message_handlers[msg_type]: await handler(event) except Exception: print(traceback.format_exc()) async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool: """将流水线片段推送到 stream 会话。 Args: msg_id: 原始企业微信消息 ID。 content: 流水线传入的累计正文快照。 is_final: 是否为最终片段。 Returns: bool: 当成功写入流式队列时返回 True。 Example: 在流水线 `reply_message_chunk` 中调用,将增量推送至企业微信。 """ # 根据 msg_id 找到对应 stream 会话,如果不存在说明当前消息非流式 stream_id = self.stream_sessions.get_stream_id_by_msg(msg_id) if not stream_id: return False previous_content = self._stream_last_content.get(msg_id, '') # Pipeline delivery provides a snapshot, not a provider delta. next_content = content or previous_content if len(next_content) > _MAX_STREAM_CONTENT_CHARS: next_content = next_content[-_MAX_STREAM_CONTENT_CHARS:] if not is_final and next_content == previous_content: return True # Follow-up responses replace the displayed stream body in WeCom. # Publish the complete snapshot so earlier chunks remain visible. chunk = StreamChunk(content=next_content, is_final=is_final) await self.stream_sessions.publish(stream_id, chunk) self._stream_last_content[msg_id] = next_content if is_final: self._stream_last_content.pop(msg_id, None) self.stream_sessions.mark_finished(stream_id) return True async def push_form_pause( self, msg_id: str, form_data: dict, task_id: Optional[str] = None ) -> tuple[bool, Optional[str], Optional[str]]: """Attach a Dify human-input pause to the active stream session. On the next WeCom followup poll, the response switches from ``msgtype: stream`` to ``msgtype: template_card`` (button_interaction) carrying the buttons. ``task_id`` is auto-generated if not provided and is what the button-click callback uses to look the session back up. Returns: ``(ok, stream_id, task_id)``. ``ok`` is False if the adapter's msg_id maps to no stream session (e.g. non-stream mode). """ stream_id = self.stream_sessions.get_stream_id_by_msg(msg_id) if not stream_id: return False, None, None if not task_id: # WeCom requires task_id [A-Za-z0-9_-@], <= 128 bytes, unique per bot. task_id = f'dify-{uuid.uuid4().hex[:24]}' self.stream_sessions.set_pending_form(stream_id, form_data, task_id) return True, stream_id, task_id async def set_message(self, msg_id: str, content: str): """兼容旧逻辑:若无法流式返回则缓存最终结果。 Args: msg_id: 企业微信消息 ID。 content: 最终回复的文本内容。 Example: 在非流式场景下缓存最终结果以备刷新时返回。 """ handled = await self.push_stream_chunk(msg_id, content, is_final=True) if not handled: self.generated_content[msg_id] = content[-_MAX_STREAM_CONTENT_CHARS:] while len(self.generated_content) > _CLIENT_TRANSIENT_CACHE_MAX: self.generated_content.pop(next(iter(self.generated_content)), None) def on_message(self, msg_type: str): def decorator(func: Callable[[wecombotevent.WecomBotEvent], None]): if msg_type not in self._message_handlers: self._message_handlers[msg_type] = [] self._message_handlers[msg_type].append(func) return func return decorator def on_feedback(self): def decorator(func: Callable): if 'feedback' not in self._message_handlers: self._message_handlers['feedback'] = [] self._message_handlers['feedback'].append(func) return func return decorator async def download_url_to_base64(self, download_url, encoding_aes_key): data, _filename = await download_encrypted_file(download_url, encoding_aes_key, self.logger) if data: return await asyncio.to_thread(_bytes_to_data_uri, data) return None async def run_task(self, host: str, port: int, *args, **kwargs): """ 启动 Quart 应用。 """ await self.app.run_task(host=host, port=port, *args, **kwargs)