matrix-client.service.ts 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903
  1. import { effect, inject, Injectable, OnDestroy, signal } from '@angular/core';
  2. import type { MatrixClient, MatrixEvent, Room, RoomMember } from 'matrix-js-sdk';
  3. import { environment } from '../../environments/environment';
  4. import { SessionService } from './session.service';
  5. /**
  6. * Matrix 客户端 —— `matrix-js-sdk` 的**唯一收敛点**
  7. *
  8. * 依据《数据访问层设计.md》§五(每域唯一 owner)与 SPEC FR-04。
  9. *
  10. * ## NFR-02:SDK 不得进主包
  11. *
  12. * `matrix-js-sdk` **只在首次真正需要时动态导入**(`await import()`),
  13. * 顶层仅 `import type`(编译期擦除,零运行时依赖)。
  14. * 验证:主包 chunk 中不得出现 `matrix-js-sdk` 字样。
  15. *
  16. * ## 身份规则(fmode-hub v4.2 P3 约定)
  17. *
  18. * - 用户 `mxid` = `@<用户 objectId 小写>:fmode.cn`
  19. * - Agent `mxid` = `@<FmodeAgent.objectId 小写>:fmode.cn`
  20. * - 凭据一律来自 provision 云函数(`RP5Ht55NeX`)**服务端签发**;
  21. * 本服务**不派生任何密码**(黄金规则 9:前端零密钥)。
  22. *
  23. * ## 不包含的能力
  24. *
  25. * 语音通话(`m.call.*` / WebRTC)按 SPEC FR-11 裁决**暂不实现**,
  26. * 故不引入 TURN 配置,也不依赖 `navigator.mediaDevices`。
  27. */
  28. /** 一条归一化后的文本消息(视图层只见此结构) */
  29. export interface MxMessage {
  30. /** 所属房间(未读计数需要知道消息来自哪个 Agent) */
  31. roomId: string;
  32. eventId: string;
  33. sender: string;
  34. /** 发送者显示名(群聊需要区分多个 agent;取不到时回落为 Matrix ID 的本地部分) */
  35. senderName?: string;
  36. /** 这条消息 @ 了谁(`m.mentions.user_ids`)。UI 据此高亮「这条是在叫我」 */
  37. mentions?: string[];
  38. /**
  39. * 所属线程(Matrix `m.thread`)。
  40. *
  41. * 🔴 **这是 App 侧「多会话」的唯一依据**。容器 `build_session_key` 按
  42. * `room + thread` 划分会话(`/opt/hermes/gateway/session.py:654`),
  43. * 同一个线程根下的消息共享同一段上下文历史。
  44. *
  45. * - 线程根消息:`threadId === eventId`(它自己就是根)
  46. * - 线程内回复:`threadId === 根的 eventId`
  47. * - 无 thread:`undefined`(容器视为新会话 —— `MATRIX_AUTO_THREAD` 默认 true)
  48. */
  49. threadId?: string;
  50. /**
  51. * 原生附件(`m.file` / `m.image` 等)。
  52. *
  53. * `url` 已经由 `mxcUrlToHttp` 转成可访问的 HTTP 地址 —— UI 直接可用。
  54. */
  55. media?: { name: string; url: string; mimeType: string; size: number };
  56. body: string;
  57. ts: number;
  58. kind: 'text' | 'notice' | 'emote';
  59. /**
  60. * 是否**实时到达**的消息(`data.liveEvent === true`)。
  61. *
  62. * 🔴 `/sync` 初次同步会把**历史消息**逐条塞进 timeline,这些也会触发
  63. * `Room.timeline` 事件。若不加区分,未读计数会把整个历史都算成未读
  64. * (实测曾出现「刚连接就有 39 条未读」)。
  65. *
  66. * 未读计数**只计 `live === true`** 的消息;timeline 渲染两者都要。
  67. */
  68. live: boolean;
  69. }
  70. /** 同步连接状态(驱动 ChatService 的 ConnectionState) */
  71. export type MxSyncState = 'connecting' | 'connected' | 'disconnected';
  72. function kindOf(msgtype: string | undefined): MxMessage['kind'] {
  73. if (msgtype === 'm.notice') return 'notice';
  74. if (msgtype === 'm.emote') return 'emote';
  75. return 'text';
  76. }
  77. /**
  78. * 发送者的展示名。
  79. *
  80. * 优先用房间成员里的 displayName(群聊里每个 agent 都设了名字,如 `lj-test`);
  81. * 取不到时回落到 Matrix ID 的本地部分(`@zgdcmgnqcd:fmode.cn` → `zgdcmgnqcd`),
  82. * **不要**直接显示完整 ID —— 太长且对用户没有意义。
  83. */
  84. function senderDisplayName(room: { getMember(id: string): { name?: string } | null } | undefined, sender: string): string {
  85. try {
  86. const m = room?.getMember(sender);
  87. if (m?.name) return m.name;
  88. } catch {
  89. /* 房间还没就绪时 getMember 可能抛错 */
  90. }
  91. const local = sender.replace(/^@/, '').split(':')[0];
  92. return local || sender;
  93. }
  94. /**
  95. * 从消息内容里取出被 @ 的成员。
  96. *
  97. * Matrix 的 mention 是结构化的(`m.mentions.user_ids`),不是从文本里正则匹配 ——
  98. * 文本里的 `@名字` 只是给人看的,真正被 @ 的人在这个数组里。
  99. */
  100. function extractMentions(content: Record<string, unknown>): string[] | undefined {
  101. const m = content['m.mentions'] as { user_ids?: unknown } | undefined;
  102. if (!m || !Array.isArray(m.user_ids)) return undefined;
  103. const ids = m.user_ids.filter((x): x is string => typeof x === 'string');
  104. return ids.length ? ids : undefined;
  105. }
  106. /** HTML 转义(formatted_body 里插入用户内容必须先转义) */
  107. function escapeHtml(s: string): string {
  108. return s
  109. .replace(/&/g, '&amp;')
  110. .replace(/</g, '&lt;')
  111. .replace(/>/g, '&gt;')
  112. .replace(/"/g, '&quot;');
  113. }
  114. /**
  115. * 从消息内容里取出所属线程根 event_id。
  116. *
  117. * Matrix 规范的 thread 关系:
  118. * ```json
  119. * { "m.relates_to": { "rel_type": "m.thread", "event_id": "$线程根" } }
  120. * ```
  121. * 注意 `rel_type` 必须是 `m.thread` —— 其它关系类型(`m.replace` 编辑、
  122. * `m.reference` 引用)都不是线程,不能混为一谈。
  123. */
  124. export function extractThreadId(content: Record<string, unknown> | undefined): string | undefined {
  125. const rel = content?.['m.relates_to'] as
  126. | { rel_type?: unknown; event_id?: unknown }
  127. | undefined;
  128. if (!rel || rel.rel_type !== 'm.thread') return undefined;
  129. return typeof rel.event_id === 'string' && rel.event_id ? rel.event_id : undefined;
  130. }
  131. /**
  132. * 构造 `m.relates_to`(thread 关系)。
  133. *
  134. * 依据容器实测回复(lj-test,2026-10-28):`event_id` 必须是**线程根**的
  135. * event_id,后续每条消息都填同一个根,容器才不会把它切成新会话。
  136. *
  137. * `is_falling_back` + `m.in_reply_to` 是给不支持 thread 的客户端做降级展示的
  138. * (容器侧不依赖它们,但发出去能让其它 Matrix 客户端正常显示)。
  139. *
  140. * @param threadRootId 线程根 event_id
  141. * @param lastEventId 该线程内最近一条消息的 event_id(降级用,可省略)
  142. */
  143. export function buildThreadRelation(
  144. threadRootId: string,
  145. lastEventId?: string,
  146. ): Record<string, unknown> {
  147. return {
  148. rel_type: 'm.thread',
  149. event_id: threadRootId,
  150. is_falling_back: true,
  151. ...(lastEventId ? { 'm.in_reply_to': { event_id: lastEventId } } : {}),
  152. };
  153. }
  154. import { isMediaMsgType, parseNativeMedia } from './matrix-media';
  155. const TEXT_MSGTYPES = ['m.text', 'm.notice', 'm.emote'];
  156. @Injectable({ providedIn: 'root' })
  157. export class MatrixClientService implements OnDestroy {
  158. /** 懒加载的 SDK 模块(首次 `load()` 时填充) */
  159. private sdk: typeof import('matrix-js-sdk') | null = null;
  160. private client: MatrixClient | null = null;
  161. // ---------- 连接状态(供 chat 页徽标消费) ----------
  162. private readonly _syncState = signal<MxSyncState>('disconnected');
  163. readonly syncState = this._syncState.asReadonly();
  164. /**
  165. * 本地 store 版本号(每次 sync 完成 +1)。
  166. * 聊天页 `effect` 订阅它 → 重读 timeline,解决「同步晚于首读 → 历史空白」。
  167. */
  168. private readonly _storeVersion = signal(0);
  169. readonly storeVersion = this._storeVersion.asReadonly();
  170. private readonly _userId = signal<string>('');
  171. /** 当前登录的 Matrix 用户 id(`@xxx:fmode.cn`) */
  172. readonly userId = this._userId.asReadonly();
  173. private token = '';
  174. private deviceId = '';
  175. /** 显式停止标志:避免 disconnect() 后 sync 回调再改状态 */
  176. private stopped = false;
  177. /**
  178. * 初次同步是否已完成。
  179. *
  180. * 🔴 未读计数的**分界线**:初次 `/sync` 会把房间历史逐条回填(且按时间
  181. * 升序),若直接计数会把整个历史都算成未读。故此标志为 false 期间,
  182. * 未读只推进基线、不计数。
  183. */
  184. private initialSyncDone = false;
  185. /** 初次同步是否完成(未读计数用于区分历史与实时) */
  186. get hasInitialSync(): boolean {
  187. return this.initialSyncDone;
  188. }
  189. /**
  190. * 全局消息流 —— **全项目唯一一处** `Room.timeline` 订阅。
  191. *
  192. * 多路复用给:① 聊天页(当前房间)② 未读计数(所有房间)。
  193. * 这样 sync 常驻时也只有一个监听器,不会重复触发。
  194. */
  195. private readonly messageHandlers = new Set<(m: MxMessage) => void>();
  196. private globalBound = false;
  197. private readonly session = inject(SessionService);
  198. constructor() {
  199. // 🔴 登出即断连:不依赖页面记得调用 disconnect()。
  200. // 任一页面的 doLogout() 清掉 token 后,这里自动收敛。
  201. effect(() => {
  202. if (!this.session.token() && this.client) this.disconnect();
  203. });
  204. }
  205. ngOnDestroy(): void {
  206. this.disconnect();
  207. }
  208. // ---------- 生命周期(会话级常驻) ----------
  209. /**
  210. * 建立**会话级**连接(整个登录会话只调用一次,由 SessionService 持有者驱动)。
  211. *
  212. * 🔴 与旧实现的关键差异:这是**常驻**的 —— 不在聊天页 `close()` 时断开,
  213. * 否则离开会话后收不到消息,未读计数永远无法累加。
  214. * 断开只发生在**退出登录**时(`disconnect()`)。
  215. *
  216. * @param accessToken 云函数返回的 token(**不落 localStorage**,仅存内存)
  217. * @param userId 形如 `@sr2wipsdyq:fmode.cn`
  218. * @param deviceId 云函数返回的 deviceId(可空)
  219. */
  220. async connectSession(accessToken: string, userId: string, deviceId?: string): Promise<void> {
  221. if (!accessToken || !userId) {
  222. throw new Error('登录凭据不完整,请重新进入');
  223. }
  224. // 同身份重复调用:幂等,不打断现有 sync
  225. if (this.client && this.token === accessToken && this._userId() === userId) {
  226. return;
  227. }
  228. this.disconnect();
  229. const sdk = await this.load();
  230. this.token = accessToken;
  231. this.deviceId = deviceId ?? '';
  232. this._userId.set(userId);
  233. this.stopped = false;
  234. this.initialSyncDone = false;
  235. this._syncState.set('connecting');
  236. this.client = sdk.createClient({
  237. baseUrl: MATRIX_HS,
  238. accessToken,
  239. userId,
  240. ...(deviceId ? { deviceId } : {}),
  241. /*
  242. * 关闭 VoIP(2026-10-31)—— 本项目不做语音通话(FR-11「暂不实现」)。
  243. *
  244. * 🔴 必须用 SDK 真实的选项名 `disableVoip`(源码 client.js:521):
  245. * 它会让 `supportsVoip()` 返回 false,从而跳过 turnServer 轮询。
  246. * 之前误用 `fallbackICEServerAllowed`(不是 createClient 的选项)→ 完全无效,
  247. * 控制台仍每 10s 报一次 `GET /voip/turnServer 404 M_NOT_FOUND`。
  248. */
  249. disableVoip: true,
  250. });
  251. /*
  252. * 压掉 client 自身的 info/warn 日志(2026-11-01)。
  253. *
  254. * 🔴 控制台被大量「Missing/Adding default global push rule …」刷屏:
  255. * 这是 SDK 拿服务端 push rules 与自身默认表对比时的提示(`client.logger`),
  256. * 属于**服务端规则版本与 SDK 版本不一致**的既有状况,不影响收发消息。
  257. * 之前只设了模块级 `logger`,而 client 有独立的 logger 实例,所以没生效。
  258. * 这里把 client 的 logger 提到 error 级,只保留真正需要处理的错误。
  259. */
  260. try {
  261. const cl = this.client as unknown as { logger?: { setLevel?: (l: string) => void } };
  262. cl.logger?.setLevel?.('error');
  263. } catch {
  264. /* 结构不符时忽略:不影响主流程 */
  265. }
  266. // sync 状态 → 连接状态(FR-04 AC-04.9:断线可自动重连)
  267. this.client.on(sdk.ClientEvent.Sync, (state: string) => {
  268. if (this.stopped) return;
  269. if (state === 'SYNCING' || state === 'PREPARED') this._syncState.set('connected');
  270. else if (state === 'ERROR') this._syncState.set('disconnected');
  271. else if (state === 'RECONNECTING') this._syncState.set('connecting');
  272. // 🔴 首个 SYNCING = 初次同步完成 → 之后的消息才算「实时」
  273. if (state === 'SYNCING') {
  274. this.initialSyncDone = true;
  275. /*
  276. * 🔴 通知「timeline 已就绪」。
  277. *
  278. * `startClient()` 是异步的:`openReal()` 里紧接着读 `timelineToMessages()`
  279. * 时,房间的历史事件**往往还没落到本地 store** → 拿到空数组;
  280. * 而后续只依赖 realtime 事件补消息,历史内容永远补不上
  281. * (表现为:刷新后会话空白、重开看不到之前的内容)。
  282. *
  283. * 每次 SYNCING 递增版本号,聊天页据此重读一次 timeline。
  284. */
  285. this._storeVersion.update((n) => n + 1);
  286. }
  287. });
  288. // 收到邀请自动接受(Agent 侧建房的场景)
  289. this.client.on(sdk.RoomMemberEvent.Membership, (_ev: MatrixEvent, member: RoomMember) => {
  290. if (this.stopped) return;
  291. if (member.membership === 'invite' && member.userId === userId) {
  292. void this.client?.joinRoom(member.roomId).catch(() => undefined);
  293. }
  294. });
  295. // 全局消息流(未读计数 + 聊天页复用)
  296. this.bindGlobalMessageStream();
  297. this.client.startClient({ initialSyncLimit: 50 });
  298. }
  299. /** 断开并清理(**仅退出登录时**调用) */
  300. disconnect(): void {
  301. this.stopped = true;
  302. this.initialSyncDone = false;
  303. this.messageHandlers.clear();
  304. this.globalBound = false;
  305. if (this.client) {
  306. try {
  307. this.client.stopClient();
  308. } catch {
  309. /* 忽略:销毁期异常不阻塞清理 */
  310. }
  311. try {
  312. this.client.removeAllListeners();
  313. } catch {
  314. /* 忽略 */
  315. }
  316. }
  317. this.client = null;
  318. this.token = '';
  319. this.deviceId = '';
  320. this._userId.set('');
  321. this._syncState.set('disconnected');
  322. }
  323. /** 是否已建立会话级连接 */
  324. get connected(): boolean {
  325. return !!this.client;
  326. }
  327. // ---------- 房间 ----------
  328. /**
  329. * 确保与目标 Agent 的 DM 房间可用。
  330. *
  331. * 优先用 provision 返回的 `roomId`(绑定表真相);未提供时按
  332. * `m.direct` accountData 查找,仍无则创建。
  333. */
  334. async ensureRoom(
  335. agentMxId: string,
  336. opts?: { roomId?: string | null; agentName?: string },
  337. ): Promise<string> {
  338. const c = this.requireClient();
  339. // ① 绑定表给的 roomId 优先(与容器侧同一房间)
  340. if (opts?.roomId) {
  341. const room = c.getRoom(opts.roomId);
  342. if (room && room.getMyMembership() === 'join') return opts.roomId;
  343. // 本地尚未 join → 尝试加入(可能是别的设备建的房)
  344. const joined = await c.joinRoom(opts.roomId).catch(() => null);
  345. if (joined) return opts.roomId;
  346. }
  347. // ② m.direct 里已有记录
  348. const directEv = c.getAccountData('m.direct' as never);
  349. const direct = directEv?.getContent() as Record<string, string[]> | undefined;
  350. for (const rid of direct?.[agentMxId] ?? []) {
  351. const room = c.getRoom(rid);
  352. if (room?.getMyMembership() === 'join') return rid;
  353. }
  354. // ③ 兜底:遍历已 join 房间找共同成员
  355. for (const room of c.getRooms()) {
  356. if (room.getMyMembership() !== 'join') continue;
  357. if (room.getMember(agentMxId)) return room.roomId;
  358. }
  359. // ④ 都没有 → 新建 DM
  360. const sdk = await this.load();
  361. const created = await c.createRoom({
  362. invite: [agentMxId],
  363. is_direct: true,
  364. preset: sdk.Preset.TrustedPrivateChat,
  365. name: opts?.agentName,
  366. });
  367. return created.room_id;
  368. }
  369. /** 该房间是否已是「已加入」状态 */
  370. isJoined(roomId: string): boolean {
  371. return !!this.client?.getRoom(roomId)?.getMyMembership?.();
  372. }
  373. // ---------- 消息收发 ----------
  374. /**
  375. * 发文本(AC-04.2)
  376. *
  377. * @param threadRootId 线程根 event_id。**不传则容器视为新会话**
  378. * (`MATRIX_AUTO_THREAD=true` 会把每条无 thread 消息切成独立会话)。
  379. * 多会话场景必须传同一个根。
  380. * @param lastEventId 线程内最近一条消息,仅用于 `m.in_reply_to` 降级展示
  381. */
  382. async sendText(
  383. roomId: string,
  384. text: string,
  385. threadRootId?: string,
  386. lastEventId?: string,
  387. ): Promise<{ event_id?: string }> {
  388. const c = this.requireClient();
  389. const sdk = await this.load();
  390. return c.sendEvent(roomId, sdk.EventType.RoomMessage, {
  391. msgtype: sdk.MsgType.Text,
  392. body: text,
  393. ...(threadRootId ? { 'm.relates_to': buildThreadRelation(threadRootId, lastEventId) } : {}),
  394. });
  395. }
  396. /**
  397. * 发一条 **@ 指定成员** 的消息。
  398. *
  399. * ## 🔴 为什么不能只在文本里写「@名字」
  400. *
  401. * Matrix 的 mention 是**结构化**的:消息内容里要有
  402. * `m.mentions: { user_ids: [...] }`,客户端/机器人据此判断「这条是不是在叫我」。
  403. * 只写文本 `@lj-test 你好` 对方**收不到任何 mention 信号**。
  404. *
  405. * P0 实测:容器靠 mention 判断是否需要回应(`MATRIX_REQUIRE_MENTION=true`),
  406. * 所以这里必须同时做三件事:
  407. * ① 正文里插入可读的 `@名字`
  408. * ② `format: org.matrix.custom.html` + `formatted_body` 里放真正的链接
  409. * ③ `m.mentions.user_ids` 列出被 @ 的人
  410. */
  411. async sendMention(
  412. roomId: string,
  413. userId: string,
  414. text: string,
  415. displayName?: string,
  416. ): Promise<{ event_id?: string }> {
  417. return this.sendMentionMany(roomId, [userId], text, displayName ? [displayName] : undefined);
  418. }
  419. /** 同时 @ 多个成员(群内协作) */
  420. async sendMentionMany(
  421. roomId: string,
  422. userIds: string[],
  423. text: string,
  424. displayNames?: string[],
  425. threadRootId?: string,
  426. lastEventId?: string,
  427. ): Promise<{ event_id?: string }> {
  428. const c = this.requireClient();
  429. const sdk = await this.load();
  430. const names = userIds.map((id, i) => displayNames?.[i] || id.replace(/^@/, '').split(':')[0]);
  431. const prefix = names.map((n) => `@${n}`).join(' ');
  432. const body = `${prefix} ${text}`;
  433. const htmlPrefix = userIds
  434. .map(
  435. (id, i) =>
  436. `<a href="https://matrix.to/#/${encodeURIComponent(id)}">${escapeHtml(names[i])}</a>`,
  437. )
  438. .join(' ');
  439. return c.sendEvent(roomId, sdk.EventType.RoomMessage, {
  440. msgtype: sdk.MsgType.Text,
  441. body,
  442. format: 'org.matrix.custom.html',
  443. formatted_body: `${htmlPrefix} ${escapeHtml(text)}`,
  444. 'm.mentions': { user_ids: userIds },
  445. ...(threadRootId ? { 'm.relates_to': buildThreadRelation(threadRootId, lastEventId) } : {}),
  446. } as never);
  447. }
  448. /** 发文件卡片(`m.text` + JSON body,与容器侧解析约定一致) */
  449. /**
  450. * 发送文件(**Matrix 原生附件**)。
  451. *
  452. * 🔴 为什么不用 `m.text + JSON body`:
  453. * 容器解析附件只认 Matrix 原生事件 ——
  454. * `{ msgtype: 'm.file', body: '文件名', url: 'mxc://...' }`
  455. * (见 `matrix-media.ts` 的实测记录:容器交付成果用的就是这种形态)。
  456. *
  457. * 早期实现把文件信息塞进 `m.text` 的 JSON body 里发出去 ——
  458. * **容器完全不认**,用户发了图,容器回「我这边只看到文字,没收到附件」。
  459. *
  460. * 流程:先把 `blob` 上传到 Matrix 媒体库拿 `mxc://` 地址,再发 `m.file` 事件。
  461. *
  462. * @param blob 文件内容
  463. * @param card 展示用的元信息(名称/大小/类型)
  464. */
  465. async sendFile(
  466. roomId: string,
  467. blob: Blob,
  468. card: { name: string; size: number; mimeType: string },
  469. threadRootId?: string,
  470. lastEventId?: string,
  471. ): Promise<{ event_id?: string }> {
  472. const c = this.requireClient();
  473. const sdk = await this.load();
  474. // ① 上传到媒体库 → mxc://
  475. const upload = await c.uploadContent(blob, { name: card.name, type: card.mimeType });
  476. const mxc = upload.content_uri;
  477. if (!mxc) throw new Error('附件上传失败(没有返回地址)');
  478. // ② 发原生附件事件。图片用 m.image(客户端可直接内联显示),其余用 m.file
  479. const isImage = card.mimeType.startsWith('image/');
  480. const content: Record<string, unknown> = {
  481. msgtype: isImage ? 'm.image' : 'm.file',
  482. body: card.name,
  483. url: mxc,
  484. info: {
  485. mimetype: card.mimeType,
  486. size: card.size,
  487. },
  488. ...(threadRootId ? { 'm.relates_to': buildThreadRelation(threadRootId, lastEventId) } : {}),
  489. };
  490. return c.sendEvent(roomId, sdk.EventType.RoomMessage, content as never);
  491. }
  492. /**
  493. * 发文件卡片(`m.text` + JSON body)。
  494. *
  495. * @deprecated 容器不解析这种形态(实测)。**发文件请用 `sendFile()`**。
  496. * 保留仅为兼容尚未迁移的调用点,新代码不要用。
  497. */
  498. async sendFileCard(
  499. roomId: string,
  500. card: { name: string; size: number; mimeType: string; url: string },
  501. threadRootId?: string,
  502. lastEventId?: string,
  503. ): Promise<{ event_id?: string }> {
  504. const c = this.requireClient();
  505. const sdk = await this.load();
  506. return c.sendEvent(roomId, sdk.EventType.RoomMessage, {
  507. msgtype: sdk.MsgType.Text,
  508. body: JSON.stringify({ kind: 'file', ...card }),
  509. ...(threadRootId ? { 'm.relates_to': buildThreadRelation(threadRootId, lastEventId) } : {}),
  510. });
  511. }
  512. /** 当前 timeline → 归一消息(时间正序) */
  513. timelineToMessages(roomId: string): MxMessage[] {
  514. const room = this.client?.getRoom(roomId);
  515. if (!room) return [];
  516. return room
  517. .getLiveTimeline()
  518. .getEvents()
  519. .filter((e) => e.getType() === 'm.room.message')
  520. /*
  521. * 🔴 也要接受原生附件(m.file / m.image …)。
  522. * 实测:容器交付成果用的是 m.file,此前只认 m.text,
  523. * 导致交付物在 App 里完全看不到。
  524. */
  525. .filter((e) => {
  526. const mt = String(e.getContent()['msgtype'] ?? '');
  527. return TEXT_MSGTYPES.includes(mt) || isMediaMsgType(mt);
  528. })
  529. .map((e) => ({
  530. roomId,
  531. eventId: e.getId() ?? '',
  532. sender: e.getSender() ?? '',
  533. senderName: senderDisplayName(room, e.getSender() ?? ''),
  534. mentions: extractMentions(e.getContent()),
  535. // 线程根 = 自身 event_id,线程内回复 = 根 event_id,无 thread = undefined
  536. threadId: extractThreadId(e.getContent()) ?? undefined,
  537. body: (e.getContent()['body'] as string) ?? '',
  538. // 原生附件 → 转成 FileCard(mxc:// 在这里转成可访问的 HTTP 地址)
  539. media: (() => {
  540. const m = parseNativeMedia(e.getContent());
  541. if (!m) return undefined;
  542. const http = this.mxcUrlToHttp(m.mxcUrl);
  543. return http
  544. ? { name: m.name, url: http, mimeType: m.mimeType, size: m.size ?? 0 }
  545. : undefined;
  546. })(),
  547. ts: e.getTs(),
  548. kind: kindOf(String(e.getContent()['msgtype'] ?? '')),
  549. // 从 timeline 读出的都视为历史(未读不依赖此值)
  550. live: false,
  551. }));
  552. }
  553. /**
  554. * 列出**群房间**(房间里有多个成员,且不是 1:1 DM)。
  555. *
  556. * 群聊功能需要用这个发现「我参加了哪些工作组」。
  557. * 判定:成员数 > 2 或房间未标记 is_direct。
  558. */
  559. /**
  560. * 实时拉房间成员(绕过 SDK 缓存)。
  561. *
  562. * 🔴 实测:`room.getJoinedMembers()` 返回的是 SDK 本地缓存,
  563. * 可能是旧的 —— 组长明明已不在群里,App 仍显示它在。
  564. * 查「组长在不在群」这种关键判断必须用实时数据。
  565. */
  566. async fetchRoomMembers(roomId: string): Promise<string[]> {
  567. const c = this.client;
  568. if (!c) return [];
  569. try {
  570. const res = (await c.getJoinedRoomMembers(roomId)) as unknown;
  571. /*
  572. * 🔴 SDK 返回的是 `{ joined: { "@a:x": {...}, "@b:x": {...} } }`,
  573. * 不是直接的成员 map。
  574. * 实测踩坑:直接 `Object.keys(res)` 拿到的是 `["joined"]` ——
  575. * 于是「组长在不在群」永远判成「不在」,误报。
  576. */
  577. const obj = res as Record<string, unknown>;
  578. const inner = (obj && typeof obj['joined'] === 'object' ? obj['joined'] : obj) as
  579. | Record<string, unknown>
  580. | undefined;
  581. if (!inner || typeof inner !== 'object') return [];
  582. return Object.keys(inner);
  583. } catch {
  584. return [];
  585. }
  586. }
  587. listGroupRooms(): { roomId: string; name: string; memberIds: string[] }[] {
  588. const c = this.client;
  589. if (!c) return [];
  590. const out: { roomId: string; name: string; memberIds: string[] }[] = [];
  591. for (const room of c.getRooms()) {
  592. if (room.getMyMembership() !== 'join') continue;
  593. /*
  594. * 🔴 群的判定是「成员 ≥ 3」,不是「不是 DM」。
  595. *
  596. * 实测:`getDMInviter()` 对我们由 owner 自己建的那些 1:1 房间返回 null
  597. * (它们不是「别人发起的 DM」),于是 2 人房间全被当成了群
  598. * (实测列出 4 个,其中 3 个是 DM)。
  599. *
  600. * 群必然 ≥ 3 人(我 + 至少两个 agent);DM 恒为 2 人。
  601. * 用人数判断最稳,不依赖 is_direct 标记。
  602. */
  603. const members = room.getJoinedMemberCount();
  604. if (members < 3) continue;
  605. out.push({
  606. roomId: room.roomId,
  607. name: room.name || '(未命名群)',
  608. memberIds: room.getJoinedMembers().map((m) => m.userId),
  609. });
  610. }
  611. return out;
  612. }
  613. /**
  614. * 读房间状态事件(如自定义的 `fmode.workgroup.roles`)。
  615. * 状态不存在时返回 null,**不抛错** —— 新群本来就没有这个状态。
  616. */
  617. async getRoomState(roomId: string, eventType: string): Promise<unknown | null> {
  618. const c = this.client;
  619. if (!c) return null;
  620. try {
  621. const ev = await c.getStateEvent(roomId, eventType, '');
  622. return ev ?? null;
  623. } catch {
  624. // M_NOT_FOUND:状态还没设置过,属正常
  625. return null;
  626. }
  627. }
  628. /** 写房间状态事件(需要足够 power level,通常由群创建者设置) */
  629. async setRoomState(roomId: string, eventType: string, content: unknown): Promise<void> {
  630. const c = this.requireClient();
  631. await c.sendStateEvent(roomId, eventType as never, content as never, '');
  632. }
  633. /**
  634. * `mxc://` → 可访问的 HTTP 地址。
  635. *
  636. * 🔴 Matrix 的附件地址是内部协议(`mxc://server/mediaId`),
  637. * 浏览器直接打开不了,必须经 SDK 转成 `/_matrix/media/...` 的 HTTP 地址。
  638. *
  639. * 🔴 参数含义(第 5 个 = useAuthenticatedApi/直接下载):
  640. * 之前传 true 生成的是 `/_matrix/media/v3/download/...` —— 该接口带
  641. * `Content-Disposition: attachment`,浏览器**只会下载、不会显示**,
  642. * 于是「在新标签页打开」变成了「下载文件」(用户实测)。
  643. * 传 false 生成 `/_matrix/media/v3/preview_url/...`(内联展示),
  644. * 图片/PDF/HTML 才能直接在标签页里打开。
  645. */
  646. /**
  647. * 给 Matrix 媒体地址补上访问令牌,供「在新标签页打开」使用。
  648. *
  649. * 🔴 背景(2026-11-01):媒体接口要鉴权,直接 window.open 会 401;
  650. * 而页面内 fetch 该地址又被 CORS 拦截(实测 Failed to fetch),
  651. * 所以没法先抓成 blob。带上 access_token 查询参数的直链是唯一能
  652. * "浏览器新标签页里直接显示"的方式(homeserver 支持该参数)。
  653. * 仅用于用户主动点击打开,不做缓存、不落库。
  654. */
  655. withAccessToken(url: string): string {
  656. if (!this.token || !url.includes('/_matrix/')) return url;
  657. if (/[?&]access_token=/.test(url)) return url;
  658. const sep = url.includes('?') ? '&' : '?';
  659. return `${url}${sep}access_token=${encodeURIComponent(this.token)}`;
  660. }
  661. mxcUrlToHttp(mxc: string): string | null {
  662. const c = this.client;
  663. if (!c) return null;
  664. try {
  665. return c.mxcUrlToHttp(mxc, undefined, undefined, undefined, false) ?? null;
  666. } catch {
  667. return null;
  668. }
  669. }
  670. /** 向上翻历史(AC-04.4) */
  671. async paginateBack(roomId: string, limit = 30): Promise<boolean> {
  672. const c = this.requireClient();
  673. let room = c.getRoom(roomId);
  674. if (!room) {
  675. await c.joinRoom(roomId).catch(() => null);
  676. room = c.getRoom(roomId);
  677. }
  678. if (!room) return false;
  679. return c.paginateEventTimeline(room.getLiveTimeline(), { backwards: true, limit });
  680. }
  681. /**
  682. * 是否还有更早历史(live timeline 的 pagination token 是否耗尽)。
  683. * 用于 UI 决定要不要显示「加载更早」入口,以及分页到顶后停止请求。
  684. */
  685. paginationToken(roomId: string): string | null {
  686. const room = this.client?.getRoom(roomId);
  687. if (!room || !this.sdk) return null;
  688. return room.getLiveTimeline().getPaginationToken(this.sdk.Direction.Backward) ?? null;
  689. }
  690. /** 已读回执 */
  691. async sendReceipt(roomId: string, eventId: string): Promise<void> {
  692. const c = this.requireClient();
  693. const ev = c
  694. .getRoom(roomId)
  695. ?.getLiveTimeline()
  696. .getEvents()
  697. .find((e) => e.getId() === eventId);
  698. if (!ev) return;
  699. await c.sendReadReceipt(ev);
  700. }
  701. /** 输入中通知(失败非致命) */
  702. async setTyping(roomId: string, typing: boolean, timeoutMs = 8000): Promise<void> {
  703. try {
  704. await this.requireClient().sendTyping(roomId, typing, timeoutMs);
  705. } catch {
  706. /* 非致命:typing 丢失不影响消息链路 */
  707. }
  708. }
  709. // ---------- 事件订阅 ----------
  710. /**
  711. * 订阅房间新消息;返回解绑函数。
  712. *
  713. * 不过滤 sender —— 自己发的消息也要接住(local echo 依赖 Timeline 事件)。
  714. */
  715. onMessage(roomId: string, cb: (m: MxMessage) => void): () => void {
  716. return this.onAnyMessage((m) => {
  717. if (m.roomId === roomId) cb(m);
  718. });
  719. }
  720. /**
  721. * 订阅**所有房间**的新消息(未读计数用);返回解绑函数。
  722. *
  723. * 与 `onMessage` 共用同一个底层订阅(`bindGlobalMessageStream`),
  724. * 因此无论多少订阅者,SDK 侧只有**一个** `Room.timeline` 监听器。
  725. */
  726. onAnyMessage(cb: (m: MxMessage) => void): () => void {
  727. this.messageHandlers.add(cb);
  728. return () => {
  729. this.messageHandlers.delete(cb);
  730. };
  731. }
  732. /** 订阅对方 typing 状态;返回解绑函数 */
  733. onTyping(roomId: string, cb: (typing: boolean, userId: string) => void): () => void {
  734. const c = this.requireClient();
  735. const handler = (ev: MatrixEvent, member: RoomMember) => {
  736. if (member?.roomId !== roomId || ev.getType() !== 'm.typing') return;
  737. cb(!!member.typing, member.userId ?? '');
  738. };
  739. c.on('RoomMember.typing' as never, handler as never);
  740. return () => {
  741. try {
  742. c.off('RoomMember.typing' as never, handler as never);
  743. } catch {
  744. /* 忽略 */
  745. }
  746. };
  747. }
  748. /** 绑定全局消息流(只绑一次,幂等) */
  749. private bindGlobalMessageStream(): void {
  750. const c = this.client;
  751. if (!c || this.globalBound) return;
  752. this.globalBound = true;
  753. c.on('Room.timeline' as never, ((ev: MatrixEvent, _room?: unknown, _toStart?: boolean, data?: { liveEvent?: boolean }) => {
  754. if (ev.getType() !== 'm.room.message') return;
  755. // 跳过本地未发送回执(echo 会再触发一次)
  756. const sending = (ev as unknown as { isSending?: () => boolean }).isSending;
  757. if (typeof sending === 'function' && sending.call(ev)) return;
  758. const msgtype = String(ev.getContent()['msgtype'] ?? '');
  759. if (!TEXT_MSGTYPES.includes(msgtype)) return;
  760. const msg: MxMessage = {
  761. roomId: ev.getRoomId() ?? '',
  762. eventId: ev.getId() ?? '',
  763. sender: ev.getSender() ?? '',
  764. threadId: extractThreadId(ev.getContent()) ?? undefined,
  765. body: (ev.getContent()['body'] as string) ?? '',
  766. ts: ev.getTs(),
  767. kind: kindOf(msgtype),
  768. // 仅真正的实时事件为 true;初次 /sync 的历史回填为 false
  769. live: !!data?.liveEvent,
  770. };
  771. for (const h of this.messageHandlers) {
  772. try {
  773. h(msg);
  774. } catch {
  775. /* 单个订阅者异常不影响其它订阅者 */
  776. }
  777. }
  778. }) as never);
  779. }
  780. // ---------- 工具 ----------
  781. /** `@<objectId 小写>:fmode.cn`(v4.2 P3 约定) */
  782. static agentMxId(objectId: string): string {
  783. return `@${objectId.toLowerCase()}:${MATRIX_SERVER_NAME}`;
  784. }
  785. /** 解析文件卡片 body;非卡片返回 null */
  786. static parseFileCard(
  787. body: string,
  788. ): { name: string; size: number; mimeType: string; url: string } | null {
  789. if (!body || body[0] !== '{') return null;
  790. try {
  791. const j = JSON.parse(body);
  792. if ((j.kind === 'file' || j.type === 'fcard') && j.url && j.name) {
  793. return {
  794. name: String(j.name),
  795. size: Number(j.size) || 0,
  796. mimeType: String(j.mimeType ?? j.mime ?? ''),
  797. url: String(j.url),
  798. };
  799. }
  800. return null;
  801. } catch {
  802. return null;
  803. }
  804. }
  805. private requireClient(): MatrixClient {
  806. if (!this.client) throw new Error('尚未连接,请稍后重试');
  807. return this.client;
  808. }
  809. /** 动态导入 SDK(NFR-02:不进主包) */
  810. private async load(): Promise<typeof import('matrix-js-sdk')> {
  811. if (!this.sdk) {
  812. this.sdk = await import('matrix-js-sdk');
  813. /*
  814. * 🔴 SDK 日志降噪(否则控制台卡顿)。
  815. *
  816. * matrix-js-sdk 的 logger 默认输出 **warn 及以上**,且每条都带完整调用栈
  817. * (push rule 缺失的告警会在每次 startClient 时批量刷屏,
  818. * 叠加 zone.js 的调度栈,一次上百行)。
  819. * 这些告警不影响收发:push rule 是服务端推送规则,浏览器端本就不用。
  820. * 故收敛为只保留 error;取不到 logger 时静默跳过,绝不影响连接。
  821. */
  822. const sdkAny = this.sdk as { logger?: { setLevel?: (l: string) => void } };
  823. if (sdkAny.logger && typeof sdkAny.logger.setLevel === 'function') {
  824. sdkAny.logger.setLevel('error');
  825. }
  826. }
  827. return this.sdk;
  828. }
  829. }
  830. /** homeserver 常量(与《API-契约》§1.2 一致;地址真源见 environment.base.ts) */
  831. export const MATRIX_HS = environment.endpoints.matrixHs;
  832. export const MATRIX_SERVER_NAME = environment.endpoints.matrixServerName;