import { effect, inject, Injectable, OnDestroy, signal } from '@angular/core'; import type { MatrixClient, MatrixEvent, Room, RoomMember } from 'matrix-js-sdk'; import { environment } from '../../environments/environment'; import { FmodeApi } from './api/fmode-api'; import { SessionService } from './session.service'; import { setServerUploadLimit } from './upload-limits'; import { isAllowedMediaUrl } from './s3-link'; /** * Matrix 客户端 —— `matrix-js-sdk` 的**唯一收敛点** * * 依据《数据访问层设计.md》§五(每域唯一 owner)与 SPEC FR-04。 * * ## NFR-02:SDK 不得进主包 * * `matrix-js-sdk` **只在首次真正需要时动态导入**(`await import()`), * 顶层仅 `import type`(编译期擦除,零运行时依赖)。 * 验证:主包 chunk 中不得出现 `matrix-js-sdk` 字样。 * * ## 身份规则(fmode-hub v4.2 P3 约定) * * - 用户 `mxid` = `@<用户 objectId 小写>:fmode.cn` * - Agent `mxid` = `@:fmode.cn` * - 凭据一律来自 provision 云函数(`RP5Ht55NeX`)**服务端签发**; * 本服务**不派生任何密码**(黄金规则 9:前端零密钥)。 * * ## 不包含的能力 * * 语音通话(`m.call.*` / WebRTC)按 SPEC FR-11 裁决**暂不实现**, * 故不引入 TURN 配置,也不依赖 `navigator.mediaDevices`。 */ /** 一条归一化后的文本消息(视图层只见此结构) */ export interface MxMessage { /** 所属房间(未读计数需要知道消息来自哪个 Agent) */ roomId: string; eventId: string; sender: string; /** 发送者显示名(群聊需要区分多个 agent;取不到时回落为 Matrix ID 的本地部分) */ senderName?: string; /** 这条消息 @ 了谁(`m.mentions.user_ids`)。UI 据此高亮「这条是在叫我」 */ mentions?: string[]; /** * 所属线程(Matrix `m.thread`)。 * * 🔴 **这是 App 侧「多会话」的唯一依据**。容器 `build_session_key` 按 * `room + thread` 划分会话(`/opt/hermes/gateway/session.py:654`), * 同一个线程根下的消息共享同一段上下文历史。 * * - 线程根消息:`threadId === eventId`(它自己就是根) * - 线程内回复:`threadId === 根的 eventId` * - 无 thread:`undefined`(容器视为新会话 —— `MATRIX_AUTO_THREAD` 默认 true) */ threadId?: string; /** * 原生附件(`m.file` / `m.image` 等)。 * * `url` 已经由 `mxcUrlToHttp` 转成可访问的 HTTP 地址 —— UI 直接可用。 */ media?: { name: string; url: string; mimeType: string; size: number }; body: string; ts: number; kind: 'text' | 'notice' | 'emote'; /** * 是否**实时到达**的消息(`data.liveEvent === true`)。 * * 🔴 `/sync` 初次同步会把**历史消息**逐条塞进 timeline,这些也会触发 * `Room.timeline` 事件。若不加区分,未读计数会把整个历史都算成未读 * (实测曾出现「刚连接就有 39 条未读」)。 * * 未读计数**只计 `live === true`** 的消息;timeline 渲染两者都要。 */ live: boolean; /** * 群聊派活标记(2026-11-06)。 * * 用户在群聊里 @ 容器派活时,App 把归属信息写进消息 content 的 * `com.fmode.dispatch` 字段。这样**归属随事件本身持久化** —— * 刷新、换设备、重新同步都能读到,不依赖本地存储、也不依赖时钟。 * * 🔴 用途:`ChatService.belongsToSession` 见到它就**不把这个房间级消息 * 归入任何单聊会话**。群聊与单聊共用同一个 DM 房间,物理上分不开, * 只能在归属判定上隔离(用户裁决 2026-11-06:单聊永远看不到群聊派活)。 */ dispatch?: MxDispatchMeta; /** * 单聊会话归属标记(2026-11-06)。 * * 与 `dispatch` 对称:单聊里发的房间级消息(图片/文件不带 thread) * 打上会话 id,`belongsToSession` 据此**优先归属**, * 避免被「派活之后的房间级消息」规则误挡。 */ sessionId?: string; /** * 发送时写入的本地消息 id(`com.fmode.local`)。 * * 🔴 用于把「乐观消息」和它的服务端 echo 对上(2026-11-09): * 乐观消息 id 是 `local-*`,echo 的 id 是 `$eventId`,两者天然不同。 * 没有这个键,同一条消息会在「本地一条 + 回读一条」地重复出现 * (实测:发一句「你好」,`messages()` 里出现 3 条)。 */ localId?: string; /** * 这条消息**回复的是哪一条**(`m.relates_to.m.in_reply_to.event_id`)。 * * 🔴 2026-11-10:容器对每条用户消息分别回话(图文被拆成两条时各回一轮), * 靠它把回复归到**正确的那条提问**。此前没有解析,回合聚合只能按顺序猜, * 结果图片那条的回复被算到了文字那条下面。 */ replyTo?: string; /** * 容器声明的消息类别(`content["fmode.kind"]`)。 * * 2026-11-11:容器侧确认 `_interim_send` 标记没有进 Matrix 内容, * App 只能猜。已请容器把它落成这个自定义键(Matrix 允许 `m.` 以外的键)。 * 读不到就是 `undefined` → 调用方回退启发式。 */ fmodeKind?: 'process' | 'final'; } /** 群聊派活元信息(写入 Matrix content 的 `com.fmode.dispatch`) */ export interface MxDispatchMeta { /** 群会话 id */ groupId: string; /** 本轮提问 id(= GroupStore 的 turnId) */ turnId: string; /** 派给哪个容器 */ targetAgentId: string; } /** Matrix content 里存放派活标记的字段名(反向域名,避开规范与容器扩展) */ export const DISPATCH_KEY = 'com.fmode.dispatch'; /** Matrix content 里存放单聊归属的字段名 */ export const SESSION_KEY = 'com.fmode.session'; /** Matrix content 里存放本地消息 id 的字段名(把乐观消息和自己的 echo 对上) */ export const LOCAL_ID_KEY = 'com.fmode.local'; /** * Matrix content 里存放**云盘公网链接**的字段名(2026-11-13)。 * * ## 为什么要有它 * * 用户发的文件是**两份**:一份进 Matrix 媒体库(`mxc://`,给容器读), * 一份进云盘(`s3.fmode.cn`,给 App 长期展示与预览)。 * * 但回读历史时只能拿到 `mxc://`,App 需要的却是云盘链接: * · 预览/新标签打开要走云盘(Matrix 地址需要 blob 中转,Safari 兼容差) * · 云盘链接换设备、刷新后都还能用 * * 原先靠内存 Map 记住云盘地址,**刷新就丢**。现在写进事件本身,永久可回读。 */ export const CLOUD_URL_KEY = 'com.fmode.cloud_url'; /** * 从消息 content 取云盘公网链接。 * * 只接受 http(s) 且域名在白名单内 —— 防止历史/异常数据里塞进 `javascript:` 之类。 */ export function extractCloudUrl(content: Record | undefined): string | undefined { const raw = content?.[CLOUD_URL_KEY]; if (typeof raw !== 'string' || !raw) return undefined; try { const u = new URL(raw); if (u.protocol !== 'https:' && u.protocol !== 'http:') return undefined; return u.toString(); } catch { return undefined; } } /** * 从消息 content 解析群聊派活标记。 * * 字段缺失 / 形状不对一律返回 undefined —— 宁可当成普通消息,也不能误判归属。 */ export function extractDispatch(content: Record): MxDispatchMeta | undefined { const raw = content[DISPATCH_KEY]; if (!raw || typeof raw !== 'object') return undefined; const d = raw as Record; if (typeof d['groupId'] !== 'string' || !d['groupId']) return undefined; return { groupId: d['groupId'], turnId: typeof d['turnId'] === 'string' ? d['turnId'] : '', targetAgentId: typeof d['targetAgentId'] === 'string' ? d['targetAgentId'] : '', }; } /** 从消息 content 解析单聊会话归属 */ export function extractSessionId(content: Record): string | undefined { const raw = content[SESSION_KEY]; if (typeof raw === 'string' && raw) return raw; if (raw && typeof raw === 'object') { const id = (raw as Record)['sessionId']; if (typeof id === 'string' && id) return id; } return undefined; } /** * 从消息 content 解析发送时写入的本地消息 id。 * * 形状不对一律 undefined —— 宁可多显示一条,也不能认错人。 */ export function extractLocalId(content: Record): string | undefined { const raw = content[LOCAL_ID_KEY]; return typeof raw === 'string' && raw ? raw : undefined; } /** 同步连接状态(驱动 ChatService 的 ConnectionState) */ export type MxSyncState = 'connecting' | 'connected' | 'disconnected'; function kindOf(msgtype: string | undefined): MxMessage['kind'] { if (msgtype === 'm.notice') return 'notice'; if (msgtype === 'm.emote') return 'emote'; return 'text'; } /** * 发送者的展示名。 * * 优先用房间成员里的 displayName(群聊里每个 agent 都设了名字,如 `lj-test`); * 取不到时回落到 Matrix ID 的本地部分(`@zgdcmgnqcd:fmode.cn` → `zgdcmgnqcd`), * **不要**直接显示完整 ID —— 太长且对用户没有意义。 */ function senderDisplayName(room: { getMember(id: string): { name?: string } | null } | undefined, sender: string): string { try { const m = room?.getMember(sender); if (m?.name) return m.name; } catch { /* 房间还没就绪时 getMember 可能抛错 */ } const local = sender.replace(/^@/, '').split(':')[0]; return local || sender; } /** * 从消息内容里取出被 @ 的成员。 * * Matrix 的 mention 是结构化的(`m.mentions.user_ids`),不是从文本里正则匹配 —— * 文本里的 `@名字` 只是给人看的,真正被 @ 的人在这个数组里。 */ function extractMentions(content: Record): string[] | undefined { const m = content['m.mentions'] as { user_ids?: unknown } | undefined; if (!m || !Array.isArray(m.user_ids)) return undefined; const ids = m.user_ids.filter((x): x is string => typeof x === 'string'); return ids.length ? ids : undefined; } /** HTML 转义(formatted_body 里插入用户内容必须先转义) */ function escapeHtml(s: string): string { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } /** * 从消息内容里取出所属线程根 event_id。 * * Matrix 规范的 thread 关系: * ```json * { "m.relates_to": { "rel_type": "m.thread", "event_id": "$线程根" } } * ``` * 注意 `rel_type` 必须是 `m.thread` —— 其它关系类型(`m.replace` 编辑、 * `m.reference` 引用)都不是线程,不能混为一谈。 */ /** * 判断 sync 的错误是不是「token 失效」(2026-11-12)。 * * SDK 对 `M_UNKNOWN_TOKEN` 会**永久停止** sync(`sync.ts:586`), * 只有这一类错误值得自动重签凭据;网络类错误 SDK 自己会退避重连。 */ export function isUnknownToken(err: unknown): boolean { if (!err || typeof err !== 'object') return false; const e = err as { errcode?: unknown; data?: { errcode?: unknown } }; const code = e.errcode ?? e.data?.errcode; return code === 'M_UNKNOWN_TOKEN'; } export function extractThreadId(content: Record | undefined): string | undefined { const rel = content?.['m.relates_to'] as | { rel_type?: unknown; event_id?: unknown } | undefined; if (!rel || rel.rel_type !== 'm.thread') return undefined; return typeof rel.event_id === 'string' && rel.event_id ? rel.event_id : undefined; } /** * 提取「这条消息在回复哪一条」。 * * Matrix 里 thread 内回复带 `m.relates_to.m.in_reply_to.event_id`。 * 容器每收到一条用户消息都会回一轮(不合并),靠这个字段才能把回复 * 归到**它真正回应提问的那一条**上(2026-11-10)。 */ export function extractReplyTo(content: Record | undefined): string | undefined { const rel = content?.['m.relates_to'] as { 'm.in_reply_to'?: { event_id?: unknown } } | undefined; const id = rel?.['m.in_reply_to']?.event_id; return typeof id === 'string' && id ? id : undefined; } /** * 读取容器声明的消息类别(`content["fmode.kind"]`,2026-11-11)。 * * 容器改了 Matrix adapter 之后,过程行会带 `"process"`、正文带 `"final"`。 * 只认这两个值;其它(含缺失)返回 `undefined` → 调用方回退文本启发式, * 保证**未改造的容器照常工作**。 */ export function extractFmodeKind( content: Record | undefined, ): 'process' | 'final' | undefined { const v = content?.['fmode.kind']; return v === 'process' || v === 'final' ? v : undefined; } /** * 构造 `m.relates_to`(thread 关系)。 * * 依据容器实测回复(lj-test,2026-10-28):`event_id` 必须是**线程根**的 * event_id,后续每条消息都填同一个根,容器才不会把它切成新会话。 * * `is_falling_back` + `m.in_reply_to` 是给不支持 thread 的客户端做降级展示的 * (容器侧不依赖它们,但发出去能让其它 Matrix 客户端正常显示)。 * * @param threadRootId 线程根 event_id * @param lastEventId 该线程内最近一条消息的 event_id(降级用,可省略) */ export function buildThreadRelation( threadRootId: string, lastEventId?: string, ): Record { return { rel_type: 'm.thread', event_id: threadRootId, is_falling_back: true, ...(lastEventId ? { 'm.in_reply_to': { event_id: lastEventId } } : {}), }; } import { isMediaMsgType, isMxcUrl, parseNativeMedia } from './matrix-media'; const TEXT_MSGTYPES = ['m.text', 'm.notice', 'm.emote']; @Injectable({ providedIn: 'root' }) export class MatrixClientService implements OnDestroy { /** 懒加载的 SDK 模块(首次 `load()` 时填充) */ private sdk: typeof import('matrix-js-sdk') | null = null; /** startClient 是否已调用过(保证全局消息流只启一次) */ private started = false; private client: MatrixClient | null = null; // ---------- 连接状态(供 chat 页徽标消费) ---------- private readonly _syncState = signal('disconnected'); readonly syncState = this._syncState.asReadonly(); /** * 本地 store 版本号(每次 sync 完成 +1)。 * 聊天页 `effect` 订阅它 → 重读 timeline,解决「同步晚于首读 → 历史空白」。 */ private readonly _storeVersion = signal(0); readonly storeVersion = this._storeVersion.asReadonly(); private readonly _userId = signal(''); /** 当前登录的 Matrix 用户 id(`@xxx:fmode.cn`) */ readonly userId = this._userId.asReadonly(); private token = ''; private deviceId = ''; /** 显式停止标志:避免 disconnect() 后 sync 回调再改状态 */ private stopped = false; /** * 初次同步是否已完成。 * * 🔴 未读计数的**分界线**:初次 `/sync` 会把房间历史逐条回填(且按时间 * 升序),若直接计数会把整个历史都算成未读。故此标志为 false 期间, * 未读只推进基线、不计数。 */ private initialSyncDone = false; /** 初次同步是否完成(未读计数用于区分历史与实时) */ get hasInitialSync(): boolean { return this.initialSyncDone; } /** 等待初次同步完成的挂起者(sync 完成时统一放行) */ private syncWaiters: (() => void)[] = []; /** * 等到初次 `/sync` 完成(2026-11-07)。 * * 🔴 群聊重建必须先等它:首次同步前各房间 timeline 是空的, * 此时读出来的群历史会被判成「这个群没有任何消息」,覆盖掉本地缓存。 * 已同步则立即返回;断线且永不重连时由调用方的超时兜底。 */ async synced(timeoutMs = 15_000): Promise { if (this.initialSyncDone) return true; return new Promise((resolve) => { let done = false; const finish = (ok: boolean): void => { if (done) return; done = true; const i = this.syncWaiters.indexOf(wake); if (i >= 0) this.syncWaiters.splice(i, 1); resolve(ok); }; const wake = (): void => finish(true); this.syncWaiters.push(wake); setTimeout(() => finish(this.initialSyncDone), timeoutMs); }); } /** 当前登录的 Matrix 用户 id(实时消息判定「是不是我发的」用) */ currentUserId(): string { return this._userId() || this.client?.getUserId() || ''; } /** * 全局消息流 —— **全项目唯一一处** `Room.timeline` 订阅。 * * 多路复用给:① 聊天页(当前房间)② 未读计数(所有房间)。 * 这样 sync 常驻时也只有一个监听器,不会重复触发。 */ private readonly messageHandlers = new Set<(m: MxMessage) => void>(); private globalBound = false; private readonly session = inject(SessionService); private readonly api = inject(FmodeApi); /** token 失效自愈的尝试次数(成功后归零,见 Sync 事件处理) */ private recoveryAttempts = 0; constructor() { // 🔴 登出即断连:不依赖页面记得调用 disconnect()。 // 任一页面的 doLogout() 清掉 token 后,这里自动收敛。 effect(() => { if (!this.session.token() && this.client) this.disconnect(); }); } ngOnDestroy(): void { this.disconnect(); } // ---------- 生命周期(会话级常驻) ---------- /** * 建立**会话级**连接(整个登录会话只调用一次,由 SessionService 持有者驱动)。 * * 🔴 与旧实现的关键差异:这是**常驻**的 —— 不在聊天页 `close()` 时断开, * 否则离开会话后收不到消息,未读计数永远无法累加。 * 断开只发生在**退出登录**时(`disconnect()`)。 * * @param accessToken 云函数返回的 token(**不落 localStorage**,仅存内存) * @param userId 形如 `@sr2wipsdyq:fmode.cn` * @param deviceId 云函数返回的 deviceId(可空) */ async connectSession(accessToken: string, userId: string, deviceId?: string): Promise { if (!accessToken || !userId) { throw new Error('登录凭据不完整,请重新进入'); } // 同身份重复调用:幂等,不打断现有 sync if (this.client && this.token === accessToken && this._userId() === userId) { /* * 🔴 幂等返回前必须确认 **sync 真的在跑**(2026-11-05 修复)。 * * 场景:群聊页只调 `provisionMatrix` + `ensureRoom`,从不打开单聊。 * 若此时客户端已存在(别的页面建的)但没 startClient, * 这里直接 return → 全局消息流从未启动 → **一个回复都收不到** * (实测:单聊 12 秒收到,群聊 60 秒为空)。 */ this.ensureSyncing(); return; } this.disconnect(); const sdk = await this.load(); this.token = accessToken; this.deviceId = deviceId ?? ''; this._userId.set(userId); this.stopped = false; this.initialSyncDone = false; this._syncState.set('connecting'); this.client = sdk.createClient({ baseUrl: MATRIX_HS, accessToken, userId, ...(deviceId ? { deviceId } : {}), /* * 关闭 VoIP(2026-10-31)—— 本项目不做语音通话(FR-11「暂不实现」)。 * * 🔴 必须用 SDK 真实的选项名 `disableVoip`(源码 client.js:521): * 它会让 `supportsVoip()` 返回 false,从而跳过 turnServer 轮询。 * 之前误用 `fallbackICEServerAllowed`(不是 createClient 的选项)→ 完全无效, * 控制台仍每 10s 报一次 `GET /voip/turnServer 404 M_NOT_FOUND`。 */ disableVoip: true, }); /* * 压掉 client 自身的 info/warn 日志(2026-11-01)。 * * 🔴 控制台被大量「Missing/Adding default global push rule …」刷屏: * 这是 SDK 拿服务端 push rules 与自身默认表对比时的提示(`client.logger`), * 属于**服务端规则版本与 SDK 版本不一致**的既有状况,不影响收发消息。 * 之前只设了模块级 `logger`,而 client 有独立的 logger 实例,所以没生效。 * 这里把 client 的 logger 提到 error 级,只保留真正需要处理的错误。 */ try { const cl = this.client as unknown as { logger?: { setLevel?: (l: string) => void } }; cl.logger?.setLevel?.('error'); } catch { /* 结构不符时忽略:不影响主流程 */ } // sync 状态 → 连接状态(FR-04 AC-04.9:断线可自动重连) this.client.on(sdk.ClientEvent.Sync, (state: string, _prev: string | null, data?: { error?: unknown }) => { if (this.stopped) return; if (state === 'SYNCING' || state === 'PREPARED') { this._syncState.set('connected'); this.recoveryAttempts = 0; // 恢复正常 → 重置退避 } else if (state === 'ERROR') { this._syncState.set('disconnected'); /* * 🔴 token 失效会让 SDK **永久**停掉 sync(2026-11-12 实测)。 * * `matrix-js-sdk` 的 `SyncApi.shouldAbortSync()` 遇到 `M_UNKNOWN_TOKEN` * 会直接 `this.stop()`(`sync.ts:586-595`)—— 之后网络恢复、页面一直在, * sync 也都不会再起来,界面永远停在「连接已断开」, * 只能靠用户手点「重新连接」(用户截图实证)。 * * 这里做自愈:用 provision 的 `validate:1` 探活并重新签发凭据。 */ if (isUnknownToken(data?.error)) void this.recoverFromExpiredToken(); } else if (state === 'RECONNECTING') this._syncState.set('connecting'); // 🔴 首个 SYNCING = 初次同步完成 → 之后的消息才算「实时」 if (state === 'SYNCING') { this.initialSyncDone = true; // 放行等待「初次同步完成」的调用方(群聊重建,2026-11-07) for (const w of this.syncWaiters.splice(0)) w(); /* * 探测服务端上传上限(2026-11-13)。 * * 用户裁决「去掉 20MB 的写死限制,看 Matrix 最大支持多少」。 * 连上后问一次 `/media/v3/config`,把 `m.upload.size` 写进 * `upload-limits`,后续选文件时按真实上限判断。 * 失败不影响任何功能 —— 拿不到就视为「不限制」。 */ void this.probeUploadLimit(); /* * 🔴 通知「timeline 已就绪」。 * * `startClient()` 是异步的:`openReal()` 里紧接着读 `timelineToMessages()` * 时,房间的历史事件**往往还没落到本地 store** → 拿到空数组; * 而后续只依赖 realtime 事件补消息,历史内容永远补不上 * (表现为:刷新后会话空白、重开看不到之前的内容)。 * * 每次 SYNCING 递增版本号,聊天页据此重读一次 timeline。 */ this._storeVersion.update((n) => n + 1); } }); // 收到邀请自动接受(Agent 侧建房的场景) this.client.on(sdk.RoomMemberEvent.Membership, (_ev: MatrixEvent, member: RoomMember) => { if (this.stopped) return; if (member.membership === 'invite' && member.userId === userId) { void this.client?.joinRoom(member.roomId).catch(() => undefined); } }); // 全局消息流(未读计数 + 聊天页复用) this.bindGlobalMessageStream(); this.started = true; this.client.startClient({ initialSyncLimit: INITIAL_SYNC_LIMIT }); } /** 断开并清理(**仅退出登录时**调用) */ disconnect(): void { this.stopped = true; this.initialSyncDone = false; this.messageHandlers.clear(); this.globalBound = false; if (this.client) { try { this.client.stopClient(); } catch { /* 忽略:销毁期异常不阻塞清理 */ } try { this.client.removeAllListeners(); } catch { /* 忽略 */ } } this.client = null; this.started = false; this.token = ''; this.deviceId = ''; this._userId.set(''); this._syncState.set('disconnected'); } /** 是否已建立会话级连接 */ get connected(): boolean { return !!this.client; } /** * token 失效后自愈:重新签发凭据并重建连接(2026-11-12)。 * * ## 为什么需要 * * SDK 在 `M_UNKNOWN_TOKEN` 时会 `SyncApi.stop()` 永久停掉 sync, * 网络恢复也不会自己回来 —— 用户看到的就是一直挂着的「连接已断开」。 * * ## 做法 * * 用 `validateMatrix()`(云函数侧 `validate:1`)探活并在失效时重签凭据, * 拿到新 token 后 `connectSession()` 重建客户端。 * * 🔴 必须**用现有 userId 重连**,而不是直接重连旧 token; * 同时保留指数退避与次数上限,避免云函数故障时打爆服务端。 */ private async recoverFromExpiredToken(): Promise { if (this.stopped) return; const userId = this._userId(); if (!userId) return; const attempt = this.recoveryAttempts++; if (attempt >= 3) return; // 上限:连续 3 次失败就交给用户手动「重新连接」 /* 指数退避:0s / 3s / 9s */ if (attempt > 0) await new Promise((r) => setTimeout(r, 3000 * attempt)); if (this.stopped) return; this._syncState.set('connecting'); try { const agentId = this.recoveryAgentId ?? ''; const prov = await this.api.validateMatrix(agentId); if (!prov?.accessToken || prov.accessToken === this.token) return; // 没换到新凭据,别再重试 this.connectSession(prov.accessToken, prov.userMxId || userId, prov.deviceId); } catch { /* 网络/云函数故障:保持 disconnected,UI 仍有「重新连接」兜底 */ } } /** * 记录「当前连的是哪个容器的房间」,供 token 失效自愈时调 `validateMatrix`。 * * 取不到时传空串 —— `validateMatrix('')` 只校验用户账号级凭据,够用。 */ setRecoveryAgentId(agentId: string): void { this.recoveryAgentId = agentId; } private recoveryAgentId = ''; // ---------- 房间 ---------- /** * 确保与目标 Agent 的 DM 房间可用。 * * 优先用 provision 返回的 `roomId`(绑定表真相);未提供时按 * `m.direct` accountData 查找,仍无则创建。 */ async ensureRoom( agentMxId: string, opts?: { roomId?: string | null; agentName?: string }, ): Promise { const c = this.requireClient(); // ① 绑定表给的 roomId 优先(与容器侧同一房间) if (opts?.roomId) { const room = c.getRoom(opts.roomId); if (room && room.getMyMembership() === 'join') return opts.roomId; // 本地尚未 join → 尝试加入(可能是别的设备建的房) const joined = await c.joinRoom(opts.roomId).catch(() => null); if (joined) return opts.roomId; } // ② m.direct 里已有记录 const directEv = c.getAccountData('m.direct' as never); const direct = directEv?.getContent() as Record | undefined; for (const rid of direct?.[agentMxId] ?? []) { const room = c.getRoom(rid); if (room?.getMyMembership() === 'join') return rid; } // ③ 兜底:遍历已 join 房间找共同成员 for (const room of c.getRooms()) { if (room.getMyMembership() !== 'join') continue; if (room.getMember(agentMxId)) return room.roomId; } // ④ 都没有 → 新建 DM const sdk = await this.load(); const created = await c.createRoom({ invite: [agentMxId], is_direct: true, preset: sdk.Preset.TrustedPrivateChat, name: opts?.agentName, }); return created.room_id; } /** 该房间是否已是「已加入」状态 */ isJoined(roomId: string): boolean { return !!this.client?.getRoom(roomId)?.getMyMembership?.(); } /** * 为**一条新会话**创建独立房间(一房间一会话,2026-11-12)。 * * ## 为什么要这样 * * 老架构里 51 条会话共用一个 DM 房间,容器只能上报房间号 `!roomId`, * 而 App 无法据此区分「是 51 条里的哪一条」—— * 于是「一条会话在跑」会被误判成「同房间所有会话都在跑」(用户实测的乱转)。 * * 一房间一会话后,**房间号本身就是会话的唯一标识**,容器上报无歧义。 * * ## 实测依据(2026-11-12) * * · 客户端 `createRoom({invite:[agentMxId]})` → 容器 **3 秒内自动进房**; * · 同时往两个房间发消息 → 容器 `currentSessions` 出现 **2 条独立的 running**, * 各自回复正确内容,互不串台。 * * 也就是说:**容器侧零改造**,这是它原生支持的模式。 * * ⚠️ 代价:Matrix 房间数会增加(每个会话一个)。需要配套清理长期不活跃的房间, * 否则 `/sync` 首包会越来越慢(实测 173 房间 / 5013 事件已约 1 秒)。 */ async createSessionRoom(agentMxId: string, agentName?: string): Promise { const c = this.requireClient(); const sdk = await this.load(); const created = await c.createRoom({ invite: [agentMxId], is_direct: true, preset: sdk.Preset.TrustedPrivateChat, name: agentName, /* * 标记「这是一条会话房间」:便于日后按来源批量清理, * 不会与新老房间混淆(老房间没有这个标记)。 */ creation_content: { 'fmode.session_room': true }, }); return created.room_id; } /** * 等容器进入某个房间(最多 `timeoutMs`)。 * * 新建房间后必须等容器进房才能发消息,否则容器收不到 * (它只监听自己已加入的房间)。实测进房约 3 秒。 * * 🔴 2026-11-13 用户实测「新建对话很慢」后调整: * ① 轮询间隔 1000ms → **200ms**:原先最坏情况要等满 1 秒才发现已进房; * ② 默认超时 30s → **8s**:实测进房约 3 秒,8 秒没进基本就是不会进了。 * 原先 30 秒意味着容器异常时用户要干等半分钟才看到界面。 * 进房判断本身是**本地内存查表**(`getRoom().getMember()`), * 提高轮询频率没有网络开销。 */ async waitAgentJoined(roomId: string, agentMxId: string, timeoutMs = 8_000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const room = this.client?.getRoom(roomId); if (room?.getMember(agentMxId)?.membership === 'join') return true; await new Promise((r) => setTimeout(r, 200)); } return false; } // ---------- 消息收发 ---------- /** * 发文本(AC-04.2) * * @param threadRootId 线程根 event_id。**不传则容器视为新会话** * (`MATRIX_AUTO_THREAD=true` 会把每条无 thread 消息切成独立会话)。 * 多会话场景必须传同一个根。 * @param lastEventId 线程内最近一条消息,仅用于 `m.in_reply_to` 降级展示 */ async sendText( roomId: string, text: string, threadRootId?: string, lastEventId?: string, extra?: Record, ): Promise<{ event_id?: string }> { const c = this.requireClient(); const sdk = await this.load(); return c.sendEvent(roomId, sdk.EventType.RoomMessage, { msgtype: sdk.MsgType.Text, body: text, ...(threadRootId ? { 'm.relates_to': buildThreadRelation(threadRootId, lastEventId) } : {}), /* * App 私有字段(2026-11-06):群聊派活 / 单聊归属。 * Matrix 规范允许 `m.room.message` 携带任意额外字段,容器会忽略未知字段。 * 放在 `m.relates_to` 之后,不影响任何标准字段的解析。 */ ...(extra ?? {}), }); } /** * 房间当前最后一条消息事件的 eventId(无则 null)。 * * 🔴 群聊补读的**锚点**(2026-11-06):发送前记下它,之后凡是排在它后面的 * 事件才算「本轮的新回复」。用事件顺序判定,**完全不依赖两端时钟** —— * 此前用 `sentAt - 2000` 对比事件时间,容器与浏览器时钟一有偏差就漏收。 */ lastMessageEventId(roomId: string): string | null { const list = this.timelineToMessages(roomId); for (let i = list.length - 1; i >= 0; i--) { if (list[i].eventId) return list[i].eventId; } return null; } /** * 房间 timeline 的 `eventId → 下标` 索引。 * * 供「某个锚点之后有哪些事件」的判定使用。每次同步后重建, * 避免逐条 `indexOf` 的 O(n²)。 */ timelineIndex(roomId: string): Map { const map = new Map(); const list = this.timelineToMessages(roomId); for (let i = 0; i < list.length; i++) { if (list[i].eventId) map.set(list[i].eventId, i); } return map; } /** * 发一条 **@ 指定成员** 的消息。 * * ## 🔴 为什么不能只在文本里写「@名字」 * * Matrix 的 mention 是**结构化**的:消息内容里要有 * `m.mentions: { user_ids: [...] }`,客户端/机器人据此判断「这条是不是在叫我」。 * 只写文本 `@lj-test 你好` 对方**收不到任何 mention 信号**。 * * P0 实测:容器靠 mention 判断是否需要回应(`MATRIX_REQUIRE_MENTION=true`), * 所以这里必须同时做三件事: * ① 正文里插入可读的 `@名字` * ② `format: org.matrix.custom.html` + `formatted_body` 里放真正的链接 * ③ `m.mentions.user_ids` 列出被 @ 的人 */ async sendMention( roomId: string, userId: string, text: string, displayName?: string, ): Promise<{ event_id?: string }> { return this.sendMentionMany(roomId, [userId], text, displayName ? [displayName] : undefined); } /** 同时 @ 多个成员(群内协作) */ async sendMentionMany( roomId: string, userIds: string[], text: string, displayNames?: string[], threadRootId?: string, lastEventId?: string, ): Promise<{ event_id?: string }> { const c = this.requireClient(); const sdk = await this.load(); const names = userIds.map((id, i) => displayNames?.[i] || id.replace(/^@/, '').split(':')[0]); const prefix = names.map((n) => `@${n}`).join(' '); const body = `${prefix} ${text}`; const htmlPrefix = userIds .map( (id, i) => `${escapeHtml(names[i])}`, ) .join(' '); return c.sendEvent(roomId, sdk.EventType.RoomMessage, { msgtype: sdk.MsgType.Text, body, format: 'org.matrix.custom.html', formatted_body: `${htmlPrefix} ${escapeHtml(text)}`, 'm.mentions': { user_ids: userIds }, ...(threadRootId ? { 'm.relates_to': buildThreadRelation(threadRootId, lastEventId) } : {}), } as never); } /** 发文件卡片(`m.text` + JSON body,与容器侧解析约定一致) */ /** * 发送文件(**Matrix 原生附件**)。 * * 🔴 为什么不用 `m.text + JSON body`: * 容器解析附件只认 Matrix 原生事件 —— * `{ msgtype: 'm.file', body: '文件名', url: 'mxc://...' }` * (见 `matrix-media.ts` 的实测记录:容器交付成果用的就是这种形态)。 * * 早期实现把文件信息塞进 `m.text` 的 JSON body 里发出去 —— * **容器完全不认**,用户发了图,容器回「我这边只看到文字,没收到附件」。 * * 流程:先把 `blob` 上传到 Matrix 媒体库拿 `mxc://` 地址,再发 `m.file` 事件。 * * @param blob 文件内容 * @param card 展示用的元信息(名称/大小/类型) */ async sendFile( roomId: string, blob: Blob, card: { name: string; size: number; mimeType: string }, threadRootId?: string, lastEventId?: string, /** * App 私有字段(2026-11-07):群聊发附件时写入 `com.fmode.dispatch`, * 让这条文件事件也能被「派活切段」识别为**用户在本 turn 发的内容**。 * 容器会忽略未知字段。 */ extra?: Record, ): Promise<{ event_id?: string }> { const c = this.requireClient(); const sdk = await this.load(); // ① 上传到媒体库 → mxc:// const upload = await c.uploadContent(blob, { name: card.name, type: card.mimeType }); const mxc = upload.content_uri; if (!mxc) throw new Error('附件上传失败(没有返回地址)'); // ② 发原生附件事件。图片用 m.image(客户端可直接内联显示),其余用 m.file const isImage = card.mimeType.startsWith('image/'); const content: Record = { msgtype: isImage ? 'm.image' : 'm.file', body: card.name, url: mxc, info: { mimetype: card.mimeType, size: card.size, }, ...(threadRootId ? { 'm.relates_to': buildThreadRelation(threadRootId, lastEventId) } : {}), ...(extra ?? {}), }; return c.sendEvent(roomId, sdk.EventType.RoomMessage, content as never); } /** * 发「已上传到个人存储」的附件事件(2026-11-13,附件 `'url'` 模式)。 * * ## 与 `sendFile()` 的区别 * * `sendFile()` 会把**文件本体 `uploadContent` 进 Matrix 媒体库**拿 `mxc://` —— * 文件因此进出一趟 homeserver。用户裁决:**只允许收发 cdn/obs 地址**, * 所以本方法**不做任何上传**,把外部拿到的云盘地址直接写进 `content.url`。 * * ## 事件形态 * * 仍是标准的 `m.image`/`m.file`(容器侧按 msgtype 分发,不能改成 `m.text`), * 唯一差别是 `url` 为 `https://s3.fmode.cn/...`。 * 实测(2026-11-13)`matrix.fmode.cn` **接受并原样读回**非 mxc 的 url。 * * @param url 云盘公网地址(必须是 https) */ async sendFileByUrl( roomId: string, url: string, card: { name: string; size: number; mimeType: string }, threadRootId?: string, lastEventId?: string, extra?: Record, ): Promise<{ event_id?: string }> { /* * 🔴 只放行本平台云盘域名(2026-11-13 评审 G1): * 只判 https 太宽 —— `https://evil.example.com/x` 也会发进事件, * 而容器侧白名单只认 s3/file-cloud,等于两边口径不一致。 */ if (!isAllowedMediaUrl(url)) throw new Error('附件地址无效(必须为云盘 https 地址)'); const c = this.requireClient(); const sdk = await this.load(); const isImage = card.mimeType.startsWith('image/'); const content: Record = { msgtype: isImage ? 'm.image' : 'm.file', body: card.name, url, info: { mimetype: card.mimeType, size: card.size, }, // 与 url 同值:兼容只认 `com.fmode.cloud_url` 的存量回读逻辑 [CLOUD_URL_KEY]: url, ...(threadRootId ? { 'm.relates_to': buildThreadRelation(threadRootId, lastEventId) } : {}), ...(extra ?? {}), }; return c.sendEvent(roomId, sdk.EventType.RoomMessage, content as never); } /** 当前 timeline → 归一消息(时间正序) */ timelineToMessages(roomId: string): MxMessage[] { const room = this.client?.getRoom(roomId); if (!room) return []; return this.collectRoomEvents(room) .filter((e) => e.getType() === 'm.room.message') /* * 🔴 也要接受原生附件(m.file / m.image …)。 * 实测:容器交付成果用的是 m.file,此前只认 m.text, * 导致交付物在 App 里完全看不到。 */ .filter((e) => { const mt = String(e.getContent()['msgtype'] ?? ''); return TEXT_MSGTYPES.includes(mt) || isMediaMsgType(mt); }) .map((e) => ({ roomId, eventId: e.getId() ?? '', sender: e.getSender() ?? '', senderName: senderDisplayName(room, e.getSender() ?? ''), mentions: extractMentions(e.getContent()), // 线程根 = 自身 event_id,线程内回复 = 根 event_id,无 thread = undefined threadId: extractThreadId(e.getContent()) ?? undefined, // 🔴 派活 / 单聊归属标记必须在这里解析(2026-11-06): // 与实时流那条路径**两处都要加**,否则刷新后归属信息丢失。 dispatch: extractDispatch(e.getContent()), sessionId: extractSessionId(e.getContent()), localId: extractLocalId(e.getContent()), replyTo: extractReplyTo(e.getContent()), fmodeKind: extractFmodeKind(e.getContent()), body: (e.getContent()['body'] as string) ?? '', /* * 原生附件 → 转成 FileCard。 * * 🔴 **优先用云盘链接**(2026-11-13 用户实测反馈): * 原先一律用 `mxc://` 转出来的 `/_matrix/media/...`, * 但那个地址 ① 预览/新标签要用 blob 中转(Safari 兼容差) * ② 平台清理媒体后就失效。用户要的是**云盘公网链接**。 * 事件里带了 `com.fmode.cloud_url` 就直接用它; * 老消息没这个字段才退回 Matrix 地址。 */ media: (() => { const m = parseNativeMedia(e.getContent()); if (!m) return undefined; const http = this.mediaHttpUrl(e.getContent(), m.mxcUrl); return http ? { name: m.name, url: http, mimeType: m.mimeType, size: m.size ?? -1 } : undefined; })(), ts: e.getTs(), kind: kindOf(String(e.getContent()['msgtype'] ?? '')), // 从 timeline 读出的都视为历史(未读不依赖此值) live: false, })); } /** * 收集房间的**全部**消息事件:顶层 timeline + 各 thread 内的回复。 * * ## 为什么需要(2026-11-03 修复) * * `getLiveTimeline().getEvents()` **只返回房间顶层事件**。而用户在 App 里 * 发图/发文件时,消息带 `m.relates_to{rel_type:'m.thread', event_id:<会话根>}`, * 属于**线程内回复** —— 只读顶层 timeline 就完全看不到这些附件。 * * 实测:某会话 sync 里有 33 条媒体消息(含 jpg),界面 0 张。 * * 这里把每个顶层事件的 thread 一并展开,再按 eventId 去重、按时间排序。 */ private collectRoomEvents(room: Room): MatrixEvent[] { const top = room.getLiveTimeline().getEvents(); const seen = new Set(); const out: MatrixEvent[] = []; const push = (e: MatrixEvent): void => { const id = e.getId() ?? ''; if (id && seen.has(id)) return; if (id) seen.add(id); out.push(e); }; for (const e of top) { push(e); /* * `Thread` 只在**该事件确实是线程根**且 SDK 已建索引时才存在, * 且 `thread.events` 可能为空(`/sync` 携带的 thread 回复不一定挂到根上)。 * 所以这里做双保险:能拿到就用,拿不到再从房间事件池里按 relates_to 过滤。 */ try { const thread = e.getThread?.(); for (const te of thread?.events ?? []) push(te); } catch { /* 个别事件结构异常不影响整体 */ } const rootId = e.getId(); if (!rootId) continue; try { /* * `room.getThread(rootId)` 返回已建的线程;没有则退回全量事件里筛。 * 后者是唯一保证不漏的手段 —— 线程回复就在房间事件池里。 */ const t = room.getThread?.(rootId); for (const te of t?.events ?? []) push(te); } catch { /* 同上 */ } } /* * 兜底:把房间里**所有**事件(含未挂到根的线程回复)扫一遍。 * `getLiveTimeline` 只给顶层,故改从 `room.getUnfilteredTimelineSet()` * 拿全部 set;仍取不到时退回顶层,保证不抛错。 */ try { const sets = (room as unknown as { getUnfilteredTimelineSet?: () => { getLiveTimeline?: () => { getEvents?: () => MatrixEvent[] } } }) .getUnfilteredTimelineSet?.(); for (const te of sets?.getLiveTimeline?.()?.getEvents?.() ?? []) push(te); } catch { /* 结构不符时忽略 */ } return out.sort((a, b) => a.getTs() - b.getTs()); } /** * 确保 sync 已启动(幂等)。 * * `startClient()` 是异步的且只需一次;重复调用 SDK 会忽略, * 但这里用标志位避免重复触发,也方便排查「到底有没有起来」。 */ /** * 探测 homeserver 的单文件上传上限(2026-11-13)。 * * `GET /_matrix/media/v3/config` → `{ "m.upload.size": 33554432 }` * (实测 `matrix.fmode.cn` 为 **32MB**)。 * * 拿到后写进 `upload-limits`,选文件时按**真实上限**判断; * 拿不到就保持「不限制」——宁可让服务端报错,也不误伤用户。 * * 失败静默:这是体验优化,不是功能依赖。 */ private async probeUploadLimit(): Promise { try { const token = this.token; if (!token) return; const res = await fetch(`${MATRIX_HS}/_matrix/media/v3/config`, { headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) return; const body = (await res.json()) as { 'm.upload.size'?: unknown }; const size = body['m.upload.size']; if (typeof size === 'number') setServerUploadLimit(size); } catch { /* 探测失败:保持不限制 */ } } ensureSyncing(): void { if (!this.client || this.stopped) return; /* * 🔴 先补绑全局消息流(2026-11-05)。 * `bindGlobalMessageStream` 是幂等的,但**只在首次建客户端时调用过**; * 若客户端由别的路径创建、绑定被跳过,`onMessage` 永远收不到东西。 */ this.bindGlobalMessageStream(); if (this.started) return; this.started = true; try { this.client.startClient({ initialSyncLimit: INITIAL_SYNC_LIMIT }); } catch { /* 已在运行等情况:忽略 */ } } /** * 列出**群房间**(房间里有多个成员,且不是 1:1 DM)。 * * 群聊功能需要用这个发现「我参加了哪些工作组」。 * 判定:成员数 > 2 或房间未标记 is_direct。 */ /** * 实时拉房间成员(绕过 SDK 缓存)。 * * 🔴 实测:`room.getJoinedMembers()` 返回的是 SDK 本地缓存, * 可能是旧的 —— 组长明明已不在群里,App 仍显示它在。 * 查「组长在不在群」这种关键判断必须用实时数据。 */ async fetchRoomMembers(roomId: string): Promise { const c = this.client; if (!c) return []; try { const res = (await c.getJoinedRoomMembers(roomId)) as unknown; /* * 🔴 SDK 返回的是 `{ joined: { "@a:x": {...}, "@b:x": {...} } }`, * 不是直接的成员 map。 * 实测踩坑:直接 `Object.keys(res)` 拿到的是 `["joined"]` —— * 于是「组长在不在群」永远判成「不在」,误报。 */ const obj = res as Record; const inner = (obj && typeof obj['joined'] === 'object' ? obj['joined'] : obj) as | Record | undefined; if (!inner || typeof inner !== 'object') return []; return Object.keys(inner); } catch { return []; } } listGroupRooms(): { roomId: string; name: string; memberIds: string[] }[] { const c = this.client; if (!c) return []; const out: { roomId: string; name: string; memberIds: string[] }[] = []; for (const room of c.getRooms()) { if (room.getMyMembership() !== 'join') continue; /* * 🔴 群的判定是「成员 ≥ 3」,不是「不是 DM」。 * * 实测:`getDMInviter()` 对我们由 owner 自己建的那些 1:1 房间返回 null * (它们不是「别人发起的 DM」),于是 2 人房间全被当成了群 * (实测列出 4 个,其中 3 个是 DM)。 * * 群必然 ≥ 3 人(我 + 至少两个 agent);DM 恒为 2 人。 * 用人数判断最稳,不依赖 is_direct 标记。 */ const members = room.getJoinedMemberCount(); if (members < 3) continue; out.push({ roomId: room.roomId, name: room.name || '(未命名群)', memberIds: room.getJoinedMembers().map((m) => m.userId), }); } return out; } /** * 读房间状态事件(如自定义的 `fmode.workgroup.roles`)。 * 状态不存在时返回 null,**不抛错** —— 新群本来就没有这个状态。 */ async getRoomState(roomId: string, eventType: string): Promise { const c = this.client; if (!c) return null; try { const ev = await c.getStateEvent(roomId, eventType, ''); return ev ?? null; } catch { // M_NOT_FOUND:状态还没设置过,属正常 return null; } } /** 写房间状态事件(需要足够 power level,通常由群创建者设置) */ async setRoomState(roomId: string, eventType: string, content: unknown): Promise { const c = this.requireClient(); await c.sendStateEvent(roomId, eventType as never, content as never, ''); } /** * `mxc://` → 可访问的 HTTP 地址。 * * 🔴 Matrix 的附件地址是内部协议(`mxc://server/mediaId`), * 浏览器直接打开不了,必须经 SDK 转成 `/_matrix/media/...` 的 HTTP 地址。 * * 🔴 参数含义(第 5 个 = useAuthenticatedApi/直接下载): * 之前传 true 生成的是 `/_matrix/media/v3/download/...` —— 该接口带 * `Content-Disposition: attachment`,浏览器**只会下载、不会显示**, * 于是「在新标签页打开」变成了「下载文件」(用户实测)。 * 传 false 生成 `/_matrix/media/v3/preview_url/...`(内联展示), * 图片/PDF/HTML 才能直接在标签页里打开。 */ /** * 给 Matrix 媒体地址补上访问令牌,供「在新标签页打开」使用。 * * 🔴 背景(2026-11-01):媒体接口要鉴权,直接 window.open 会 401; * 而页面内 fetch 该地址又被 CORS 拦截(实测 Failed to fetch), * 所以没法先抓成 blob。带上 access_token 查询参数的直链是唯一能 * "浏览器新标签页里直接显示"的方式(homeserver 支持该参数)。 * 仅用于用户主动点击打开,不做缓存、不落库。 */ withAccessToken(url: string): string { if (!this.token || !url.includes('/_matrix/')) return url; if (/[?&]access_token=/.test(url)) return url; const sep = url.includes('?') ? '&' : '?'; return `${url}${sep}access_token=${encodeURIComponent(this.token)}`; } /** * 附件事件的最终可访问地址(历史回读与实时流**必须共用**这一处)。 * * 顺序: * ① `com.fmode.cloud_url`(存量双写模式) * ② url 本身就是 http(s) —— `'url'` 模式下就是这个,直接用 * (2026-11-13 实测:SDK 的 `mxcUrlToHttp('https://...')` 返回**空串**, * 以前对它调一次就等于把附件整个丢掉) * ③ 最后才把 `mxc://` 转成 media 地址 */ private mediaHttpUrl(content: Record, rawUrl: string): string | null { if (!rawUrl) return null; const cloud = extractCloudUrl(content); if (cloud) return cloud; if (/^https?:\/\//i.test(rawUrl)) return rawUrl; if (!isMxcUrl(rawUrl)) return null; return this.mxcUrlToHttp(rawUrl); } mxcUrlToHttp(mxc: string): string | null { const c = this.client; if (!c) return null; try { return c.mxcUrlToHttp(mxc, undefined, undefined, undefined, false) ?? null; } catch { return null; } } /** 向上翻历史(AC-04.4) */ async paginateBack(roomId: string, limit = 30): Promise { const c = this.requireClient(); let room = c.getRoom(roomId); if (!room) { await c.joinRoom(roomId).catch(() => null); room = c.getRoom(roomId); } if (!room) return false; return c.paginateEventTimeline(room.getLiveTimeline(), { backwards: true, limit }); } /** * 是否还有更早历史(live timeline 的 pagination token 是否耗尽)。 * 用于 UI 决定要不要显示「加载更早」入口,以及分页到顶后停止请求。 */ paginationToken(roomId: string): string | null { const room = this.client?.getRoom(roomId); if (!room || !this.sdk) return null; return room.getLiveTimeline().getPaginationToken(this.sdk.Direction.Backward) ?? null; } /** 已读回执 */ async sendReceipt(roomId: string, eventId: string): Promise { const c = this.requireClient(); const ev = c .getRoom(roomId) ?.getLiveTimeline() .getEvents() .find((e) => e.getId() === eventId); if (!ev) return; await c.sendReadReceipt(ev); } /** 输入中通知(失败非致命) */ async setTyping(roomId: string, typing: boolean, timeoutMs = 8000): Promise { try { await this.requireClient().sendTyping(roomId, typing, timeoutMs); } catch { /* 非致命:typing 丢失不影响消息链路 */ } } // ---------- 事件订阅 ---------- /** * 订阅房间新消息;返回解绑函数。 * * 不过滤 sender —— 自己发的消息也要接住(local echo 依赖 Timeline 事件)。 */ onMessage(roomId: string, cb: (m: MxMessage) => void): () => void { return this.onAnyMessage((m) => { if (m.roomId === roomId) cb(m); }); } /** * 订阅**所有房间**的新消息(未读计数用);返回解绑函数。 * * 与 `onMessage` 共用同一个底层订阅(`bindGlobalMessageStream`), * 因此无论多少订阅者,SDK 侧只有**一个** `Room.timeline` 监听器。 */ onAnyMessage(cb: (m: MxMessage) => void): () => void { this.messageHandlers.add(cb); return () => { this.messageHandlers.delete(cb); }; } /** * 订阅容器的 **✅ / ❌ reaction**(2026-11-11)。 * * ## 为什么它是一个可靠的本轮结束信号 * * 实测(lj-test,`adapter.py:2331 on_processing_complete()`): * 容器**每一轮**处理结束都会把 👀 换成 ✅(成功)或 ❌(失败), * `CANCELLED` 例外不发。这是 Hermes 的内建行为,不依赖任何插件改造, * 比 `m.typing` 更不容易丢(typing 是 ephemeral,且可能被配置关掉)。 * * ## 与 typing 的分工 * * · reaction ✅ = **这一轮真的干完了**(含工具跑完、正文发完) * · typing=false = 「此刻没在打字」,中途空档也会有 * * 所以收尾判据优先用 reaction;typing 作为辅助。 * * @param cb 收到 ✅/❌ 时回调,参数为 true 表示成功、false 表示失败; * 第二个参数是发 reaction 的 mxid(群聊用来区分是哪个容器收工的) */ onTurnReaction(roomId: string, cb: (ok: boolean, sender: string, targetId: string) => void): () => void { const c = this.requireClient(); const handler = (ev: MatrixEvent) => { if (ev.getRoomId() !== roomId || ev.getType() !== 'm.reaction') return; const rel = (ev.getContent() as { 'm.relates_to'?: { key?: string; event_id?: string } })?.[ 'm.relates_to' ]; const key = rel?.key ?? ''; /* * 🔴 必须把 sender 传出去(2026-11-11 容器侧代码取证指出)。 * * 群聊里 @ 了多个容器时,**每个容器各打一个 ✅,且打在同一个 event_id 上**。 * 只按「房间里有 ✅ 就算结束」会把其他成员的 ✅ 误认成本轮的结束, * 导致某个容器还在跑、界面却已经收尾。 * * 调用方要用 sender 和「本块对应的 agent」比对,见 `chat.service`。 */ if (key === '✅') cb(true, ev.getSender() ?? '', rel?.event_id ?? ''); else if (key === '❌') cb(false, ev.getSender() ?? '', rel?.event_id ?? ''); }; c.on('Room.timeline' as never, handler as never); return () => { try { c.off('Room.timeline' as never, handler as never); } catch { /* 忽略 */ } }; } /** * 订阅**容器主动推送的回合状态事件**(2026-11-12,方案 2)。 * * ## 为什么需要 * * 容器 `post_llm_call` 时会把 `extra.currentSessions` 的 status 改成 `idle` 并 * **立即**走云函数上报(实测 turn 结束 1 秒内完成)。但 App 拉云函数是 * **15 秒轮询**,所以「容器 17 秒报 idle → App 最迟 32 秒才知道」—— * 用户看到左侧要转半分钟才停。 * * 这个订阅让容器在 turn 结束时**直接往房间推一个 Matrix 事件**, * App 收到即刷新,无需等轮询。 * * ## 协议(与容器约定,二者任选其一即可) * * **A(首选)自定义事件类型** `com.fmode.turn_status`: * ```json * { "sessionKey": "$approot_xxx | !roomId", "status": "idle|running" } * ``` * * **B(兜底)** `m.room.message` 带 `fmode.turn_status` 字段(同结构)。 * 之所以保留 B:容器若无法发送非 `m.room.message` 事件,可用消息体携带, * 但 App 侧**不得把它当普通消息渲染**(本方法只解析、不入会话列表)。 * * 🔴 **与 ✅ reaction 完全无关**:✅ 只代表「这条消息发完了」, * 本事件才代表「这一轮结束了」。两者并存,互不替代。 * * @param roomId 房间 * @param cb 收到状态事件时回调:会话 key + 新状态 + 发送者 mxid */ onTurnStatus( cb: (sessionKey: string, status: string, sender: string, roomId: string) => void, ): () => void { const c = this.requireClient(); const handler = (ev: MatrixEvent) => { /* * 🔴 **监听所有房间**,不限定 roomId(2026-11-12 修正)。 * * 一房间一会话之后,左侧列表里每条会话都活在自己的房间里。 * 原先只监听「当前打开的那个房间」→ 后台会话的 turn_status 收不到 * → 左侧只能等 15 秒轮询,用户实测「回复完了还在转很久」。 */ const type = ev.getType(); const content = ev.getContent() as Record | undefined; /* A:自定义事件类型直接带字段 */ let payload: { sessionKey?: unknown; status?: unknown } | undefined; if (type === TURN_STATUS_EVENT) { payload = content as { sessionKey?: unknown; status?: unknown }; } else if (type === 'm.room.message') { /* B:兜底 —— 消息体里带 fmode.turn_status */ const inner = content?.['fmode.turn_status']; if (inner && typeof inner === 'object') { payload = inner as { sessionKey?: unknown; status?: unknown }; } } if (!payload) return; const key = typeof payload.sessionKey === 'string' ? payload.sessionKey : ''; const st = typeof payload.status === 'string' ? payload.status : ''; if (!key || !st) return; cb(key, st, ev.getSender() ?? '', ev.getRoomId() ?? ''); }; c.on('Room.timeline' as never, handler as never); return () => { try { c.off('Room.timeline' as never, handler as never); } catch { /* 忽略 */ } }; } /** 订阅对方 typing 状态;返回解绑函数 */ onTyping(roomId: string, cb: (typing: boolean, userId: string) => void): () => void { const c = this.requireClient(); const handler = (ev: MatrixEvent, member: RoomMember) => { if (member?.roomId !== roomId || ev.getType() !== 'm.typing') return; cb(!!member.typing, member.userId ?? ''); }; c.on('RoomMember.typing' as never, handler as never); return () => { try { c.off('RoomMember.typing' as never, handler as never); } catch { /* 忽略 */ } }; } /** 绑定全局消息流(只绑一次,幂等) */ private bindGlobalMessageStream(): void { const c = this.client; if (!c || this.globalBound) return; this.globalBound = true; c.on('Room.timeline' as never, ((ev: MatrixEvent, _room?: unknown, _toStart?: boolean, data?: { liveEvent?: boolean }) => { if (ev.getType() !== 'm.room.message') return; // 跳过本地未发送回执(echo 会再触发一次) const sending = (ev as unknown as { isSending?: () => boolean }).isSending; if (typeof sending === 'function' && sending.call(ev)) return; const msgtype = String(ev.getContent()['msgtype'] ?? ''); /* * 🔴 实时流必须与 `timelineToMessages()` 用**同一套类型白名单**(2026-11-03 修复)。 * * 此前这里只认 TEXT_MSGTYPES,`m.image` / `m.file` 被直接 return 丢弃 —— * 表现为「历史消息里的图片能看到(走 timeline 路径),对方刚发来的图片却收不到」, * 移动端截图场景几乎必现(用户发图 → agent 回复,App 里两侧都缺图)。 */ if (!TEXT_MSGTYPES.includes(msgtype) && !isMediaMsgType(msgtype)) return; const media = (() => { const m = parseNativeMedia(ev.getContent()); if (!m) return undefined; const http = this.mediaHttpUrl(ev.getContent(), m.mxcUrl); return http ? { name: m.name, url: http, mimeType: m.mimeType, size: m.size ?? -1 } : undefined; })(); const msg: MxMessage = { roomId: ev.getRoomId() ?? '', eventId: ev.getId() ?? '', sender: ev.getSender() ?? '', threadId: extractThreadId(ev.getContent()) ?? undefined, // 与 timelineToMessages 同一套解析(两处必须一致,否则刷新前后归属不同) dispatch: extractDispatch(ev.getContent()), sessionId: extractSessionId(ev.getContent()), localId: extractLocalId(ev.getContent()), replyTo: extractReplyTo(ev.getContent()), fmodeKind: extractFmodeKind(ev.getContent()), body: (ev.getContent()['body'] as string) ?? '', media, ts: ev.getTs(), kind: kindOf(msgtype), // 仅真正的实时事件为 true;初次 /sync 的历史回填为 false live: !!data?.liveEvent, }; for (const h of this.messageHandlers) { try { h(msg); } catch { /* 单个订阅者异常不影响其它订阅者 */ } } }) as never); } // ---------- 工具 ---------- /** `@:fmode.cn`(v4.2 P3 约定) */ static agentMxId(objectId: string): string { return `@${objectId.toLowerCase()}:${MATRIX_SERVER_NAME}`; } /** 解析文件卡片 body;非卡片返回 null */ static parseFileCard( body: string, ): { name: string; size: number; mimeType: string; url: string } | null { if (!body || body[0] !== '{') return null; try { const j = JSON.parse(body); if ((j.kind === 'file' || j.type === 'fcard') && j.url && j.name) { return { name: String(j.name), size: Number(j.size) || -1, mimeType: String(j.mimeType ?? j.mime ?? ''), url: String(j.url), }; } return null; } catch { return null; } } private requireClient(): MatrixClient { if (!this.client) throw new Error('尚未连接,请稍后重试'); return this.client; } /** 动态导入 SDK(NFR-02:不进主包) */ private async load(): Promise { if (!this.sdk) { this.sdk = await import('matrix-js-sdk'); /* * 🔴 SDK 日志降噪(否则控制台卡顿)。 * * matrix-js-sdk 的 logger 默认输出 **warn 及以上**,且每条都带完整调用栈 * (push rule 缺失的告警会在每次 startClient 时批量刷屏, * 叠加 zone.js 的调度栈,一次上百行)。 * 这些告警不影响收发:push rule 是服务端推送规则,浏览器端本就不用。 * 故收敛为只保留 error;取不到 logger 时静默跳过,绝不影响连接。 */ const sdkAny = this.sdk as { logger?: { setLevel?: (l: string) => void } }; if (sdkAny.logger && typeof sdkAny.logger.setLevel === 'function') { sdkAny.logger.setLevel('error'); } } return this.sdk; } } /** homeserver 常量(与《API-契约》§1.2 一致;地址真源见 environment.base.ts) */ /** * 容器推送回合状态用的自定义 Matrix 事件类型(2026-11-12)。 * * 与 ✅ reaction 的区别:✅ = 「这条消息发完了」;本事件 = 「这一轮结束了」。 */ export const TURN_STATUS_EVENT = 'com.fmode.turn_status'; /** * 初次 `/sync` 每个房间带多少条历史事件(2026-11-12 实测调优)。 * * ## 为什么是 5 而不是 50 * * 一房间一会话后房间数持续增长(实测 184 个),每次 `/sync` 拉的量是 * `房间数 × limit`。实测对比(同一账号、同一时刻): * * | limit | 耗时 | 事件数 | * |-------|--------|--------| * | 50 | 1593ms | 4010 | * | 5 | **451ms** | 920 | * * 降到 5 后首屏快 3.5 倍。**不会丢历史** —— * 打开会话时 `ChatService.backfillSessionTimeline()` 会按会话回溯补齐, * 命中即停,多数情况 1~2 轮。 */ export const INITIAL_SYNC_LIMIT = 5; export const MATRIX_HS = environment.endpoints.matrixHs; export const MATRIX_SERVER_NAME = environment.endpoints.matrixServerName;