Skip to content

feat(channels): support WeChat and WeCom image and file attachments - #95

Open
orhuoxu wants to merge 3 commits into
OpenBMB:mainfrom
orhuoxu:wechat-wecom-attachment
Open

feat(channels): support WeChat and WeCom image and file attachments#95
orhuoxu wants to merge 3 commits into
OpenBMB:mainfrom
orhuoxu:wechat-wecom-attachment

Conversation

@orhuoxu

@orhuoxu orhuoxu commented Aug 12, 2026

Copy link
Copy Markdown

概述

StaffDeck 支持通过微信和企业微信接收入站图片、PDF 以及受支持的文本文件,并将附件接入既有对话链路。

用户可以直接在微信或企业微信会话中发送图片或文件。渠道适配器负责解析消息中的附件元数据、下载和解密媒体;统一附件桥接层负责将媒体暂存、解析为 ChatAttachment,并将其传递给既有的 AgentLoop、会话消息存储和模型输入流程。

控制台的渠道会话详情支持图片预览和非图片附件下载。中文文件名通过 UTF-8 文件名参数传递,避免浏览器因响应头编码问题无法下载。

本实现复用 Web 端已有的附件暂存、解析、模型输入和消息元数据能力,不改变 AgentLoop、会话模型和渠道出站投递的核心语义。

支持范围

微信

  • 解析 iLink 消息中的 image_itemfile_item
  • 图片消息归一化为 kind=image
  • 文件消息归一化为 kind=file,保留文件名和文件大小。
  • 纯附件消息会生成默认意图:
    • 图片:请识别并描述这张图片的内容。
    • 文件:请读取并概述这个文件。
  • 优先使用消息提供的 CDN full_url 下载媒体。
  • 没有 full_url 时,使用官方 downloadmedia 接口。
  • 支持 iLink 媒体的 AES-ECB/PKCS#7 解密。
  • 校验解密后的预期大小(消息提供该字段时)。
  • CDN 下载失败时依次尝试:
    • aiohttp,有限重试和流式读取;
    • httpx 回退;
    • 系统 curl 回退,使用独立 TLS 栈和 HTTP/1.1。
  • curl 回退兼容微信 CDN 的连接重置、传输超时以及异常 Content-Length

微信 CDN 回退依赖部署环境提供系统 curl。如果系统没有 curl,仅能使用前面的 Python HTTP 客户端。

企业微信

  • 支持 image 消息。
  • 支持 file 消息。
  • 支持 mixed 消息中的文本、图片。
  • 仅接受通过企业微信媒体域名校验的媒体 URL。
  • 支持企业微信智能机器人 URL 下载和 aeskey 解密。
  • 对传统 media/get 下载路径使用 access token 缓存、提前刷新和 token 失效重试。
  • 企业微信 durable inbox replay 时恢复为 ChannelInboundAttachment dataclass,保持持久化重放后的附件语义一致。

统一入站附件协议

ChannelInbound 增加 attachments 字段,携带当前入站消息的临时附件描述。

每个 ChannelInboundAttachment 包含:

  • media_id:渠道侧媒体标识或媒体 URL。
  • kindimagefile
  • filename:渠道提供的文件名或生成的回退文件名。
  • content_type:渠道提供的 MIME 类型或适配器推断值。
  • size:可选的媒体大小信息。
  • download_params:渠道下载所需的临时参数,例如 context_tokenfull_urlaes_key 和预期大小。

这些下载参数只用于处理当前入站消息,不作为长期对话附件元数据直接暴露给模型或前端。

微信媒体下载

微信图片和文件的 full_url 指向微信 CDN,响应是经过加密的媒体内容。下载流程如下:

getupdates
  -> normalize_wechat_message
  -> extract_message_attachments
  -> WeChatAdapter.download_media
  -> WeChatClient.download_media_url
  -> aiohttp / httpx / curl
  -> AES-ECB/PKCS#7 解密
  -> 统一附件桥接

微信媒体 URL 必须满足以下条件:

  • 使用 HTTPS。
  • 主机名属于受信任的微信官方域名范围。
  • 下载后的加密数据不超过加密媒体上限。
  • 解密后的有效数据不超过 25 MB。

aiohttp 路径使用流式读取和分块大小限制。httpx 和系统 curl 回退路径会在接收响应后执行大小限制,因此部署环境仍应使用可信网络边界,并避免将不受信任的 URL 直接传入适配器。

微信 CDN 可能在加密响应中包含尾随字节,或返回与实际传输内容不完全一致的 Content-Length。当前实现允许特定的 curl 传输结果继续进入 AES 解密,最终由解密、预期大小和图片 magic bytes 校验判断内容是否有效。

企业微信媒体下载

企业微信 URL 下载流程如下:

WebSocket 入站帧
  -> normalize_wecom_frame
  -> ChannelInboundAttachment
  -> WeComAdapter.download_media
  -> URL 下载或 media/get
  -> 企业微信 aeskey 解密
  -> 统一附件桥接

企微智能机器人 URL 使用 WebSocket 所属事件循环下载;传统 media/get 路径使用缓存的 access token。access token 失效时会强制刷新一次并重试,其他错误直接失败。

附件桥接与解析

渠道附件由 inbound_attachments_to_chat 统一桥接到 Web 附件管道:

  1. 调用对应渠道适配器下载媒体。
  2. 校验数据非空和解密后大小。
  3. 使用 magic bytes 识别 JPEG、PNG、GIF、WebP、BMP 和 SVG 等图片格式。
  4. 对图片校正 MIME 类型和文件扩展名,而不是只相信渠道上游声明。
  5. JPEG 会截断最后一个 FFD9 之后的渠道尾随字节。
  6. 调用既有 parse_chat_attachment 解析附件。
  7. 使用 stage_chat_attachment 将原始数据安全暂存到用户附件目录。
  8. 将包含 sha256 和 sandbox 路径的 ChatAttachmentRead 传递给对话请求。

统一附件解析支持:

  • 图片:构造 data URL,供支持视觉输入的模型使用。
  • PDF:提取前 30 页文本。
  • 文本文件:提取并截断正文,同时生成预览和文件摘要。
  • 其他二进制文件:保存附件元数据,并返回暂不支持直接读取内容的提示。

单个有效渠道附件上限为 25 MB。加密下载会预留 AES 分块所需的密文余量。

会话和模型链路

渠道附件进入既有 ChatTurnRequest.attachments 字段,不新增独立的 AgentLoop 分支:

渠道消息
  -> ChannelInbound.attachments
  -> inbound_attachments_to_chat
  -> ChatAttachmentRead
  -> ChatTurnRequest.attachments
  -> AgentLoop / Harness

附件元数据会随用户消息写入消息 metadata_json。Harness 会将附件物化到任务工作区,并在模型支持视觉输入时将有效图片作为视觉输入传递。

如果渠道附件下载或解析失败,当前实现会记录异常并跳过该附件;入站事件本身不保证因此自动重试。此时 Agent 可能只收到默认的附件处理提示,而没有实际附件内容。

控制台

控制台行为:

  • 图片通过 Blob URL 加载并预览。
  • 非图片附件通过 Blob URL 下载。
  • 下载链接被点击后延迟释放 Blob URL,避免浏览器尚未开始下载时 URL 已被撤销。
  • 下载响应同时提供 ASCII filename 和 UTF-8 filename*,支持中文文件名。
  • 组件卸载时释放图片 Blob URL,避免长期占用浏览器内存。

测试和验证

已覆盖的核心测试包括:

  • 微信图片和文件消息归一化。
  • 微信 AES-ECB/PKCS#7 解密。
  • 微信媒体请求和出站请求协议。
  • 企业微信图片、文件、mixed 消息归一化。
  • 企业微信 durable inbox replay 附件恢复。
  • 附件桥接、图片类型识别和文本提取。
  • 渠道会话附件元数据 API。
  • 中文文件名下载响应和前端生产构建。

@fadeoreo fadeoreo self-assigned this Aug 12, 2026
@fadeoreo

Copy link
Copy Markdown
Collaborator

有一个需要合并前修复的问题:

[P1] 微信/企微通过 media_id 的下载分支仍然没有执行 25 MB 限制

微信:

wechat.py:424

def download_media(self, context_token: str, media_id: str) -> bytes:
    ...
    return response.content

企微:

wecom.py:771

response = client.get(...)
...
return response.content

attachment_bridge.py 虽然之后会检查:

if not data or len(data) > MAX_CHANNEL_MEDIA_BYTES:

但这时整个响应已经通过 response.content 读入内存了。也就是说:

  • 微信/企微 media_id fallback 路径仍可能一次性加载超大文件;
  • 只有带 CDN URL 的部分路径使用了流式限制;
  • 第三方返回大文件时仍可能造成 worker 内存压力。

建议让这两个 media_id 下载路径也使用流式读取,并在读取过程中累计大小,超过 MAX_CHANNEL_MEDIA_BYTES 立即中止。当前的 CDN fallback 也需要注意:_download_wechat_cdn_httpx() 和 curl fallback 同样是先完整读取、之后才校验大小。

@orhuoxu

orhuoxu commented Aug 12, 2026

Copy link
Copy Markdown
Author

感谢你的细致审查和问题指正!确实是之前考虑得不够周全,我已经按照你的建议完成修改并补充了相关测试,再次感谢!
具体方案是把大小限制前移到各下载器:
微信 downloadmedia:改用 httpx.Client.stream(),二进制响应按块读取,超过 25 MB 立即终止。
企业微信 media/get:同样改成流式读取,保留 JSON 错误响应解析。
微信 CDN httpx fallback:改用 AsyncClient.stream(),按密文上限读取。
微信 CDN curl fallback:改用 Popen(stdout=PIPE) 分块读取,超过上限立即终止进程,同时保留现有的兼容逻辑。
不改变渠道协议、附件解析和其他渠道行为。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants