ENZH
和 AI 讨论这篇文章
ChatGPTClaude

web 端也能发图片和语音了

📊 幻灯片

多模态通信补齐的概念插画多模态通信补齐的概念插画

web 端差在哪

Mio 在 telegram 上从 第三篇讲的 v0.0.2 开始就能看图、听语音、发自拍了。但同一个版本上线的 web 聊天界面一直只能打字:文本输入、流式回复,照片发不了,语音也录不了。

上一篇把 web UI 从头重建了一遍之后,这个缺口就更明显了——界面已经像个正经的聊天 app 了,但媒体一概发不出去。v0.0.5 就是干这一件事:把照片、视频、语音、表情这些 telegram 用户第一天就有的东西,在浏览器里全部补上。整个改动 38 个文件、新增 3,009 行代码,从开工到打 tag 发版 28 分钟。下面讲具体是怎么做的。

上传和发送拆成两步

整个方案里最核心的一个决策是两阶段上传。流程大概是这样的:你选了一张照片,或者录完一段语音,客户端先把文件传到 POST /chat/media;服务端做校验、存进内存、返回一个 mediaId。等你点发送的时候,消息体里带的是 mediaIds[] 加文字内容,文件本身已经在服务端了。

两阶段上传:先把文件传到服务端换回 mediaId,点发送时消息只带 mediaIds,把上传和发送拆成两个独立步骤两阶段上传:先把文件传到服务端换回 mediaId,点发送时消息只带 mediaIds,把上传和发送拆成两个独立步骤

为什么不把文件和消息一起发?因为上传和发送是两个不同的操作,失败的原因也完全不一样。上传会因为文件太大、格式不对、服务器内存不够而挂掉;发消息挂,一般是认证或者限流的问题。拆开之后能做的事情就多了:可以显示上传进度,可以单独处理上传错误,用户也可以在发送之前先预览一下附件。

服务端这边基本没写新东西,直接复用了已有的 processMedia() 管道——处理 telegram 图片和语音的就是这套代码。唯一新写的是一个内存里的 web downloader,把上传的 buffer 包装成 telegram 文件下载器暴露的那个接口的样子。这套管道两个输入源都能用,处理逻辑没重复。

语音录制的浏览器兼容

语音录制这块,听起来简单,跨浏览器一试全是坑。MediaRecorder API 不保证支持哪个 codec:Chrome 原生支持 WebM/Opus;Safari 历史上不支持 WebM,只给 MP4;Firefox 又有自己的想法。

做法是录制开始的时候先做 codec 协商:查一下浏览器支不支持 audio/webm;codecs=opus,支持就用它,文件更小、质量也更好;不支持就退回 audio/mp4。服务端两种都收,反正后面走的是同一条管道。

编解码器协商:录音开始先问浏览器支不支持 opus,支持走 webm、不支持退回 mp4,两条分支最后都汇进服务端的同一条管道编解码器协商:录音开始先问浏览器支不支持 opus,支持走 webm、不支持退回 mp4,两条分支最后都汇进服务端的同一条管道

除了 codec,还有几个细节:

  1. 最长录 5 分钟。没这个限制的话,用户忘了关麦,一个巨大的文件就传上来了。
  2. sendingVoiceRef 防重复发送,加 try/finally 保护。录音到发送中间的异步步骤挺多的,用户按两下按钮、第一次还没发完第二次就进来了,这个事是真实会发生的。
  3. 麦克风权限错误要真的处理。原来的代码是个空的 catch 块,错误直接被吞掉;现在会弹 toast 提示「无法录音,请检查麦克风权限」。

一个把语音消息全部吞掉的 bug

这个 bug 花的时间最多。现象是语音消息无声无息地消失:录完、点发送,然后什么都没发生。没有报错,也没有任何反馈。

查下来其实是两个 bug 叠在一起:

第一个在客户端。flushToServer 这个函数发消息之前有个前置检查,文本不能为空。纯语音消息——有 mediaId 但没文字——就被这个检查拦下来悄悄丢掉了,根本没发出去。

第二个在服务端。ChatSchema 对文本字段用了 .min(1),纯语音的空文本在这一层也过不了。就算客户端那个检查不拦,服务端也会拒绝。修法是改用 .refine():只要 mediaIds 非空,空文本就放行。

两层都在静默失败,所以两个都得修,只修一个没用。

两层静默拦截:一条纯语音消息先被客户端的非空文本检查拦下,即使放行也会被服务端的 .min(1) 校验拒掉,两道关卡都无声丢弃,所以必须两层都改两层静默拦截:一条纯语音消息先被客户端的非空文本检查拦下,即使放行也会被服务端的 .min(1) 校验拒掉,两道关卡都无声丢弃,所以必须两层都改

同一条链路上还挖出第三个问题:原来的代码用 setTimeout(500) 等状态更新,然后再去读 mediaIds。这就是个赌时序的竞态,500ms 够不够完全看运气。修法是让 addVoiceRecording 直接把 MediaAttachment 对象 return 出来,拿到就用,不再依赖状态更新的时机。

表情选择器

表情选择器这块细节比预想的多一点。用的是 @emoji-mart/react,加了懒加载——选择器组件挺重的,不能拖慢首屏。中文 locale 也配了,表情分类名显示中文。位置放在输入栏里,跟语音按钮和文件上传按钮并排。

技术上没什么可讲的,但一个聊天 app 没有表情选择器,用起来就是差点意思。

白名单从环境变量搬进数据库

v0.0.5 之前,用户白名单是一个环境变量。要加一个人,就得改环境变量、重新部署;踢人也一样。三个测试用户的时候能凑合,真要运营起来就不行了。

这次把白名单迁到了 telegram_allowlist 这张表,顺便给 users 加了 role 列(migration 0003)。读的时候走内存缓存,启动时预加载;环境变量留着做 fallback,数据库是空的就退回去读 ALLOWED_TELEGRAM_IDS

管理入口也一起做了:GET/POST/DELETE /api/admin/allowlist 三个 API,admin guard 中间件保护,再加一个中文界面的 /admin 页面。以后在网页上点两下就能管用户,不用碰代码也不用碰环境变量。这种后台的东西,真要运营起来是绕不开的。

两轮安全审计,修了 17 个问题

实现做完之后跑了两轮完整的安全审计:并行的 audit agent 把每个新文件和改过的端点都扫了一遍,找到并修掉 17 个问题。挑几个重点的讲:

  1. magic byte 校验 MIME。 不信 Content-Type header,也不信文件扩展名,直接读文件的实际字节,用 file-type 库验 magic byte 签名。有人把 malware.exe 改名成 photo.jpg 传上来,这一层直接拒掉。
  2. 全局内存上限。 pendingUploads 这个 map 把待发送的文件存在内存里,没有上限的话,恶意用户可以一直传文件把服务器内存打爆。修法:500MB 全局上限,外加每个用户最多 3 个待发文件。
  3. 所有权校验。 web downloader 的闭包里捕获 userId,取文件的时候验一遍所有权。就算猜到别人的 mediaId,也拿不到别人上传的文件。
  4. URL scheme 过滤。 消息气泡会把上传的媒体渲染成内嵌图片,不过滤的话,构造过的消息可以往里注入任意 URL。现在只允许 blob:https: 两种协议。
  5. 文件名 sanitize。 上传的文件名在处理之前先消毒,想用 ../../etc/passwd.jpg 这种文件名做路径遍历,走不通。
  6. 错误信息截断。 上传失败的报错不会把完整请求内容回显给客户端,做了截断,防止信息泄露。

这 17 条单独拎出来看都不算大事,但都是上线之前必须堵上的洞。

顺手修的一个心跳 bug

还有一个 bug 是顺手修的。Mio 的主动消息系统会查一段时间没活动的会话,给用户发场景感知的消息,查询条件是 lastMessageAt 小于某个阈值。问题在于:刚做完 onboarding、还没发过第一条消息的新用户,lastMessageAtNULL,而 SQL 里 NULL < anything 的结果是 NULL,不是 true。这批用户就被查询悄悄跳过了。

修复就一行,加个 OR lastMessageAt IS NULL。但没这一行的话,每个新用户做完 onboarding 之后 Mio 永远不会主动找他说话——刚注册、还没发过消息的用户,恰好全被这个查询跳过了。

下一步

整个 v0.0.5 是两个并行实现 agent 加两个并行 audit agent 干出来的:实现用了 18 分 30 秒,安全审计 8 分 42 秒,发版 1 分 14 秒,从「开始 v0.0.5」到打 tag 总共 28 分钟,12/12 测试通过。

媒体支持是 telegram 和 web 端之间最后一个大缺口。v0.0.5 之后,两个渠道在核心交互上算是对齐了:文字、图片、语音、表情、流式回复都有。接下来可以做的,是那些要等两个渠道能力一致之后才做得了的功能——这个后面几篇再讲。

和 AI 讨论这篇文章
ChatGPTClaude

订阅更新

新文章发布时发到你的邮箱,不发别的。


© Xingfan Xia 2024 - 2026 · CC BY-NC 4.0