v0.0.5:补齐多模态的最后一块
多模态通信补齐的概念插画
Web 端最后一块短板
第三篇里,Mio 学会了看图、听语音、发自拍——在 Telegram 上。同一版本上线的 Web 聊天界面呢?只能打字。文本输入、流式回复,但发不了照片、录不了语音。
上一篇把 Web UI 从头重建之后,这个缺口就更扎眼了。界面看起来已经像正经的聊天 App——唯独不能发媒体。
像一台没装摄像头的手机。
v0.0.5 就是来补这个洞的。照片、视频、语音、表情——Telegram 用户从第一天就有的东西,现在浏览器里也全了。
38 个文件改动,新增 3,009 行代码,从开工到打 tag 发版,28 分钟。
先传文件,再发消息
整个方案的核心决策:两阶段上传。
你选了一张照片或者录完一段语音,客户端立刻把文件传到 POST /chat/media。服务端做校验、存进内存、返回一个 mediaId。你点发送的时候,消息体里带上 mediaIds[] 和文字内容。
为什么不一起发?因为上传和发送是两个完全不同的操作,失败原因也不一样。上传可能因为文件太大、格式不对、服务器内存不够而失败。消息发送可能因为认证或限流而失败。拆开之后,你可以显示上传进度、优雅处理上传错误、让用户在发送前预览附件。
服务端复用了已有的 processMedia() 管道——跟处理 Telegram 图片和语音的是同一套代码。唯一新写的是一个内存 web downloader,把上传的 buffer 包装成 Telegram 文件下载器暴露的同一个接口。
一条管道、两个输入源、零重复处理逻辑。
语音录制:跨浏览器兼容这个大坑
语音录制听起来简单。跨浏览器一试就知道有多少坑。
MediaRecorder API 不保证支持哪个编解码器。Chrome 原生支持 WebM/Opus。Safari 历史上不支持 WebM,只给 MP4。Firefox 有自己的想法。
方案:录制开始时做编解码器协商。 检查浏览器支不支持 audio/webm;codecs=opus,支持就用——文件更小、质量更好。不支持就退回 audio/mp4。服务端两种都收。
其他关键细节:5 分钟最大录制时长(没这个限制,用户可能忘关麦,传一个巨大的文件上来);sendingVoiceRef 防重复发送,用 try/finally 保护(录音到发送之间的异步步骤够多,按两下按钮在第一次发送完成之前是真实会发生的事);麦克风权限错误的正确处理——原来的代码是空的 catch 块,错误被默默吞掉了。现在弹 toast:"无法录音,请检查麦克风权限"。
消失的语音消息
这个 bug 花了最多时间。
语音消息在无声无息中消失了。录完、点发送、什么都不发生。没有报错、没有反馈,只有沉默。
两个 bug 合谋:
Bug 1:flushToServer 的提前返回。 发送消息到服务端的函数有个前置检查——文本不能为空。纯语音消息——有 mediaId 但没文字——被这个检查拦下来,悄悄丢弃了。
Bug 2:服务端 schema 校验。 ChatSchema 对文本字段用了 .min(1)。纯语音的空文本在服务端也过不了。修复:改用 .refine(),只要 mediaIds 非空就接受空文本。
两层静默失败。客户端根本没发出去。就算发出去了,服务端也会拒绝。
两个都修了,语音才活过来。
同一条链路上还有第三个问题:原来的代码用 setTimeout(500) 来等状态更新,然后再读 mediaIds。一个靠祈祷的竞态条件。修复:让 addVoiceRecording 直接返回 MediaAttachment 对象,立即使用——不依赖状态、不赌时序。
表情选择器
功能不大,细节比预想的多。
@emoji-mart/react 加懒加载——选择器组件很重,不能拖慢首屏。中文 locale 支持,表情分类名显示中文。选择器集成在输入栏,跟语音按钮和文件上传按钮并排。
技术上不复杂,但补齐了输入体验。没有表情选择器的聊天应用,用起来总觉得差点什么。
管理后台:从环境变量到数据库
v0.0.5 之前,用户白名单是一个环境变量。加人?改环境变量、重新部署。踢人?一样。
三个测试用户还能凑合。真要运营起来?不行。
v0.0.5 把白名单迁到了 telegram_allowlist 数据库表,加了 users.role 列(migration 0003)。内存缓存兜底数据库,启动时预加载。环境变量留着做 fallback——数据库是空的就退回读 ALLOWED_TELEGRAM_IDS。
管理 API:GET/POST/DELETE /api/admin/allowlist,admin guard 中间件保护。/admin 页面,中文界面,在网页上管理用户,不用碰代码不用碰环境变量。
范围不大,运营影响很大。"开发者工具"和"产品"的区别,就在这种地方。
两轮安全审计
v0.0.5 在安全上下了真功夫。
实现完成后跑了两轮完整安全审计——并行审计 agent 扫描每个新文件和改过的端点。找到并修了 17 个问题。
重点几个:
Magic byte MIME 校验。 不信 Content-Type header,不信文件扩展名。读文件的实际字节,用 file-type 库验证 magic byte 签名。有人把 malware.exe 改名成 photo.jpg?直接拒绝。
全局内存上限。 pendingUploads map 把上传文件存在内存里,等着跟消息一起发。没有上限的话,恶意用户可以无限上传把服务器内存打爆。修复:500MB 全局上限,加每用户最多 3 个待发文件。
所有权校验。 web downloader 闭包里捕获 userId,取文件时验证所有权。你不能通过猜 mediaId 引用别人上传的文件。
URL scheme 过滤。 消息气泡把上传的媒体渲染成内嵌图片。没有过滤的话,构造过的消息可以注入任意 URL。修复:只允许 blob: 和 https: 协议。
文件名消毒。 上传的文件名在处理前做 sanitize。通过文件名做路径遍历(../../etc/passwd.jpg)——挡住。
反射错误截断。 上传失败的错误消息不会把完整请求内容回显给客户端。截断以防信息泄露。
每一条单独看都不惊天动地。但 17 条叠在一起,就是"能跑"和"能安全上线"的区别。
心跳 bug
一个小 bug,影响比它看起来大得多。
Mio 的主动消息系统查询一段时间没活动的会话,发送场景感知的消息。查询条件是 lastMessageAt 小于某个阈值。
问题来了:刚完成 onboarding 但还没发过第一条消息的新用户,lastMessageAt 是 NULL。SQL 里 NULL < anything 的结果是 NULL,不是 true。这些用户被查询悄悄跳过了。
修复:加一个 OR lastMessageAt IS NULL。
一行代码。但没这一行,每个新用户的第一体验都是沉默——做完 onboarding,Mio 永远不会主动找你说话。
最差的第一印象。
数字
38 个文件改动,新增 3,009 行代码。两阶段媒体上传。带编解码器协商的语音录制。懒加载表情选择器。数据库驱动的管理后台。两轮安全审计修了 17 个问题。12/12 测试通过。
实现用时 18 分 30 秒。安全审计用时 8 分 42 秒。发版用时 1 分 14 秒。两个并行实现 agent,两个并行审计 agent。
总计:从"开始 v0.0.5"到打 tag 发版,28 分钟。
Web 端不再是二等公民了。Telegram 能做的,浏览器现在也都能做。
接下来
媒体支持是 Telegram 和 Web 端之间最后一个大缺口。v0.0.5 之后,两个渠道在核心交互上对齐了——文字、图片、语音、表情、流式回复。
地基打好了。接下来要在上面盖东西——那些只有两个渠道都说同一种语言之后才能做的功能。
但那是后面版本的故事了。