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 { SessionService } from './session.service'; /** * 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; } /** 同步连接状态(驱动 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` 引用)都不是线程,不能混为一谈。 */ 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; } /** * 构造 `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, 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; 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; } /** * 全局消息流 —— **全项目唯一一处** `Room.timeline` 订阅。 * * 多路复用给:① 聊天页(当前房间)② 未读计数(所有房间)。 * 这样 sync 常驻时也只有一个监听器,不会重复触发。 */ private readonly messageHandlers = new Set<(m: MxMessage) => void>(); private globalBound = false; private readonly session = inject(SessionService); 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) { 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) => { if (this.stopped) return; if (state === 'SYNCING' || state === 'PREPARED') this._syncState.set('connected'); else if (state === 'ERROR') this._syncState.set('disconnected'); else if (state === 'RECONNECTING') this._syncState.set('connecting'); // 🔴 首个 SYNCING = 初次同步完成 → 之后的消息才算「实时」 if (state === 'SYNCING') { this.initialSyncDone = true; /* * 🔴 通知「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.client.startClient({ initialSyncLimit: 50 }); } /** 断开并清理(**仅退出登录时**调用) */ 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.token = ''; this.deviceId = ''; this._userId.set(''); this._syncState.set('disconnected'); } /** 是否已建立会话级连接 */ get connected(): boolean { return !!this.client; } // ---------- 房间 ---------- /** * 确保与目标 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?.(); } // ---------- 消息收发 ---------- /** * 发文本(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, ): 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) } : {}), }); } /** * 发一条 **@ 指定成员** 的消息。 * * ## 🔴 为什么不能只在文本里写「@名字」 * * 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, ): 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) } : {}), }; return c.sendEvent(roomId, sdk.EventType.RoomMessage, content as never); } /** * 发文件卡片(`m.text` + JSON body)。 * * @deprecated 容器不解析这种形态(实测)。**发文件请用 `sendFile()`**。 * 保留仅为兼容尚未迁移的调用点,新代码不要用。 */ async sendFileCard( roomId: string, card: { name: string; size: number; mimeType: string; url: string }, threadRootId?: string, lastEventId?: string, ): 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: JSON.stringify({ kind: 'file', ...card }), ...(threadRootId ? { 'm.relates_to': buildThreadRelation(threadRootId, lastEventId) } : {}), }); } /** 当前 timeline → 归一消息(时间正序) */ timelineToMessages(roomId: string): MxMessage[] { const room = this.client?.getRoom(roomId); if (!room) return []; return room .getLiveTimeline() .getEvents() .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, body: (e.getContent()['body'] as string) ?? '', // 原生附件 → 转成 FileCard(mxc:// 在这里转成可访问的 HTTP 地址) media: (() => { const m = parseNativeMedia(e.getContent()); if (!m) return undefined; const http = this.mxcUrlToHttp(m.mxcUrl); return http ? { name: m.name, url: http, mimeType: m.mimeType, size: m.size ?? 0 } : undefined; })(), ts: e.getTs(), kind: kindOf(String(e.getContent()['msgtype'] ?? '')), // 从 timeline 读出的都视为历史(未读不依赖此值) live: false, })); } /** * 列出**群房间**(房间里有多个成员,且不是 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)}`; } 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); }; } /** 订阅对方 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'] ?? ''); if (!TEXT_MSGTYPES.includes(msgtype)) return; const msg: MxMessage = { roomId: ev.getRoomId() ?? '', eventId: ev.getId() ?? '', sender: ev.getSender() ?? '', threadId: extractThreadId(ev.getContent()) ?? undefined, body: (ev.getContent()['body'] as string) ?? '', 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) || 0, 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) */ export const MATRIX_HS = environment.endpoints.matrixHs; export const MATRIX_SERVER_NAME = environment.endpoints.matrixServerName;