matrix-client.service.ts 65 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652
  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 { FmodeApi } from './api/fmode-api';
  5. import { SessionService } from './session.service';
  6. import { setServerUploadLimit } from './upload-limits';
  7. import { isAllowedMediaUrl } from './s3-link';
  8. /**
  9. * Matrix 客户端 —— `matrix-js-sdk` 的**唯一收敛点**
  10. *
  11. * 依据《数据访问层设计.md》§五(每域唯一 owner)与 SPEC FR-04。
  12. *
  13. * ## NFR-02:SDK 不得进主包
  14. *
  15. * `matrix-js-sdk` **只在首次真正需要时动态导入**(`await import()`),
  16. * 顶层仅 `import type`(编译期擦除,零运行时依赖)。
  17. * 验证:主包 chunk 中不得出现 `matrix-js-sdk` 字样。
  18. *
  19. * ## 身份规则(fmode-hub v4.2 P3 约定)
  20. *
  21. * - 用户 `mxid` = `@<用户 objectId 小写>:fmode.cn`
  22. * - Agent `mxid` = `@<FmodeAgent.objectId 小写>:fmode.cn`
  23. * - 凭据一律来自 provision 云函数(`RP5Ht55NeX`)**服务端签发**;
  24. * 本服务**不派生任何密码**(黄金规则 9:前端零密钥)。
  25. *
  26. * ## 不包含的能力
  27. *
  28. * 语音通话(`m.call.*` / WebRTC)按 SPEC FR-11 裁决**暂不实现**,
  29. * 故不引入 TURN 配置,也不依赖 `navigator.mediaDevices`。
  30. */
  31. /** 一条归一化后的文本消息(视图层只见此结构) */
  32. export interface MxMessage {
  33. /** 所属房间(未读计数需要知道消息来自哪个 Agent) */
  34. roomId: string;
  35. eventId: string;
  36. sender: string;
  37. /** 发送者显示名(群聊需要区分多个 agent;取不到时回落为 Matrix ID 的本地部分) */
  38. senderName?: string;
  39. /** 这条消息 @ 了谁(`m.mentions.user_ids`)。UI 据此高亮「这条是在叫我」 */
  40. mentions?: string[];
  41. /**
  42. * 所属线程(Matrix `m.thread`)。
  43. *
  44. * 🔴 **这是 App 侧「多会话」的唯一依据**。容器 `build_session_key` 按
  45. * `room + thread` 划分会话(`/opt/hermes/gateway/session.py:654`),
  46. * 同一个线程根下的消息共享同一段上下文历史。
  47. *
  48. * - 线程根消息:`threadId === eventId`(它自己就是根)
  49. * - 线程内回复:`threadId === 根的 eventId`
  50. * - 无 thread:`undefined`(容器视为新会话 —— `MATRIX_AUTO_THREAD` 默认 true)
  51. */
  52. threadId?: string;
  53. /**
  54. * 原生附件(`m.file` / `m.image` 等)。
  55. *
  56. * `url` 已经由 `mxcUrlToHttp` 转成可访问的 HTTP 地址 —— UI 直接可用。
  57. */
  58. media?: { name: string; url: string; mimeType: string; size: number };
  59. body: string;
  60. ts: number;
  61. kind: 'text' | 'notice' | 'emote';
  62. /**
  63. * 是否**实时到达**的消息(`data.liveEvent === true`)。
  64. *
  65. * 🔴 `/sync` 初次同步会把**历史消息**逐条塞进 timeline,这些也会触发
  66. * `Room.timeline` 事件。若不加区分,未读计数会把整个历史都算成未读
  67. * (实测曾出现「刚连接就有 39 条未读」)。
  68. *
  69. * 未读计数**只计 `live === true`** 的消息;timeline 渲染两者都要。
  70. */
  71. live: boolean;
  72. /**
  73. * 群聊派活标记(2026-11-06)。
  74. *
  75. * 用户在群聊里 @ 容器派活时,App 把归属信息写进消息 content 的
  76. * `com.fmode.dispatch` 字段。这样**归属随事件本身持久化** ——
  77. * 刷新、换设备、重新同步都能读到,不依赖本地存储、也不依赖时钟。
  78. *
  79. * 🔴 用途:`ChatService.belongsToSession` 见到它就**不把这个房间级消息
  80. * 归入任何单聊会话**。群聊与单聊共用同一个 DM 房间,物理上分不开,
  81. * 只能在归属判定上隔离(用户裁决 2026-11-06:单聊永远看不到群聊派活)。
  82. */
  83. dispatch?: MxDispatchMeta;
  84. /**
  85. * 单聊会话归属标记(2026-11-06)。
  86. *
  87. * 与 `dispatch` 对称:单聊里发的房间级消息(图片/文件不带 thread)
  88. * 打上会话 id,`belongsToSession` 据此**优先归属**,
  89. * 避免被「派活之后的房间级消息」规则误挡。
  90. */
  91. sessionId?: string;
  92. /**
  93. * 发送时写入的本地消息 id(`com.fmode.local`)。
  94. *
  95. * 🔴 用于把「乐观消息」和它的服务端 echo 对上(2026-11-09):
  96. * 乐观消息 id 是 `local-*`,echo 的 id 是 `$eventId`,两者天然不同。
  97. * 没有这个键,同一条消息会在「本地一条 + 回读一条」地重复出现
  98. * (实测:发一句「你好」,`messages()` 里出现 3 条)。
  99. */
  100. localId?: string;
  101. /**
  102. * 这条消息**回复的是哪一条**(`m.relates_to.m.in_reply_to.event_id`)。
  103. *
  104. * 🔴 2026-11-10:容器对每条用户消息分别回话(图文被拆成两条时各回一轮),
  105. * 靠它把回复归到**正确的那条提问**。此前没有解析,回合聚合只能按顺序猜,
  106. * 结果图片那条的回复被算到了文字那条下面。
  107. */
  108. replyTo?: string;
  109. /**
  110. * 容器声明的消息类别(`content["fmode.kind"]`)。
  111. *
  112. * 2026-11-11:容器侧确认 `_interim_send` 标记没有进 Matrix 内容,
  113. * App 只能猜。已请容器把它落成这个自定义键(Matrix 允许 `m.` 以外的键)。
  114. * 读不到就是 `undefined` → 调用方回退启发式。
  115. */
  116. fmodeKind?: 'process' | 'final';
  117. }
  118. /** 群聊派活元信息(写入 Matrix content 的 `com.fmode.dispatch`) */
  119. export interface MxDispatchMeta {
  120. /** 群会话 id */
  121. groupId: string;
  122. /** 本轮提问 id(= GroupStore 的 turnId) */
  123. turnId: string;
  124. /** 派给哪个容器 */
  125. targetAgentId: string;
  126. }
  127. /** Matrix content 里存放派活标记的字段名(反向域名,避开规范与容器扩展) */
  128. export const DISPATCH_KEY = 'com.fmode.dispatch';
  129. /** Matrix content 里存放单聊归属的字段名 */
  130. export const SESSION_KEY = 'com.fmode.session';
  131. /** Matrix content 里存放本地消息 id 的字段名(把乐观消息和自己的 echo 对上) */
  132. export const LOCAL_ID_KEY = 'com.fmode.local';
  133. /**
  134. * Matrix content 里存放**云盘公网链接**的字段名(2026-11-13)。
  135. *
  136. * ## 为什么要有它
  137. *
  138. * 用户发的文件是**两份**:一份进 Matrix 媒体库(`mxc://`,给容器读),
  139. * 一份进云盘(`s3.fmode.cn`,给 App 长期展示与预览)。
  140. *
  141. * 但回读历史时只能拿到 `mxc://`,App 需要的却是云盘链接:
  142. * · 预览/新标签打开要走云盘(Matrix 地址需要 blob 中转,Safari 兼容差)
  143. * · 云盘链接换设备、刷新后都还能用
  144. *
  145. * 原先靠内存 Map 记住云盘地址,**刷新就丢**。现在写进事件本身,永久可回读。
  146. */
  147. export const CLOUD_URL_KEY = 'com.fmode.cloud_url';
  148. /**
  149. * 从消息 content 取云盘公网链接。
  150. *
  151. * 只接受 http(s) 且域名在白名单内 —— 防止历史/异常数据里塞进 `javascript:` 之类。
  152. */
  153. export function extractCloudUrl(content: Record<string, unknown> | undefined): string | undefined {
  154. const raw = content?.[CLOUD_URL_KEY];
  155. if (typeof raw !== 'string' || !raw) return undefined;
  156. try {
  157. const u = new URL(raw);
  158. if (u.protocol !== 'https:' && u.protocol !== 'http:') return undefined;
  159. return u.toString();
  160. } catch {
  161. return undefined;
  162. }
  163. }
  164. /**
  165. * 从消息 content 解析群聊派活标记。
  166. *
  167. * 字段缺失 / 形状不对一律返回 undefined —— 宁可当成普通消息,也不能误判归属。
  168. */
  169. export function extractDispatch(content: Record<string, unknown>): MxDispatchMeta | undefined {
  170. const raw = content[DISPATCH_KEY];
  171. if (!raw || typeof raw !== 'object') return undefined;
  172. const d = raw as Record<string, unknown>;
  173. if (typeof d['groupId'] !== 'string' || !d['groupId']) return undefined;
  174. return {
  175. groupId: d['groupId'],
  176. turnId: typeof d['turnId'] === 'string' ? d['turnId'] : '',
  177. targetAgentId: typeof d['targetAgentId'] === 'string' ? d['targetAgentId'] : '',
  178. };
  179. }
  180. /** 从消息 content 解析单聊会话归属 */
  181. export function extractSessionId(content: Record<string, unknown>): string | undefined {
  182. const raw = content[SESSION_KEY];
  183. if (typeof raw === 'string' && raw) return raw;
  184. if (raw && typeof raw === 'object') {
  185. const id = (raw as Record<string, unknown>)['sessionId'];
  186. if (typeof id === 'string' && id) return id;
  187. }
  188. return undefined;
  189. }
  190. /**
  191. * 从消息 content 解析发送时写入的本地消息 id。
  192. *
  193. * 形状不对一律 undefined —— 宁可多显示一条,也不能认错人。
  194. */
  195. export function extractLocalId(content: Record<string, unknown>): string | undefined {
  196. const raw = content[LOCAL_ID_KEY];
  197. return typeof raw === 'string' && raw ? raw : undefined;
  198. }
  199. /** 同步连接状态(驱动 ChatService 的 ConnectionState) */
  200. export type MxSyncState = 'connecting' | 'connected' | 'disconnected';
  201. function kindOf(msgtype: string | undefined): MxMessage['kind'] {
  202. if (msgtype === 'm.notice') return 'notice';
  203. if (msgtype === 'm.emote') return 'emote';
  204. return 'text';
  205. }
  206. /**
  207. * 发送者的展示名。
  208. *
  209. * 优先用房间成员里的 displayName(群聊里每个 agent 都设了名字,如 `lj-test`);
  210. * 取不到时回落到 Matrix ID 的本地部分(`@zgdcmgnqcd:fmode.cn` → `zgdcmgnqcd`),
  211. * **不要**直接显示完整 ID —— 太长且对用户没有意义。
  212. */
  213. function senderDisplayName(room: { getMember(id: string): { name?: string } | null } | undefined, sender: string): string {
  214. try {
  215. const m = room?.getMember(sender);
  216. if (m?.name) return m.name;
  217. } catch {
  218. /* 房间还没就绪时 getMember 可能抛错 */
  219. }
  220. const local = sender.replace(/^@/, '').split(':')[0];
  221. return local || sender;
  222. }
  223. /**
  224. * 从消息内容里取出被 @ 的成员。
  225. *
  226. * Matrix 的 mention 是结构化的(`m.mentions.user_ids`),不是从文本里正则匹配 ——
  227. * 文本里的 `@名字` 只是给人看的,真正被 @ 的人在这个数组里。
  228. */
  229. function extractMentions(content: Record<string, unknown>): string[] | undefined {
  230. const m = content['m.mentions'] as { user_ids?: unknown } | undefined;
  231. if (!m || !Array.isArray(m.user_ids)) return undefined;
  232. const ids = m.user_ids.filter((x): x is string => typeof x === 'string');
  233. return ids.length ? ids : undefined;
  234. }
  235. /** HTML 转义(formatted_body 里插入用户内容必须先转义) */
  236. function escapeHtml(s: string): string {
  237. return s
  238. .replace(/&/g, '&amp;')
  239. .replace(/</g, '&lt;')
  240. .replace(/>/g, '&gt;')
  241. .replace(/"/g, '&quot;');
  242. }
  243. /**
  244. * 从消息内容里取出所属线程根 event_id。
  245. *
  246. * Matrix 规范的 thread 关系:
  247. * ```json
  248. * { "m.relates_to": { "rel_type": "m.thread", "event_id": "$线程根" } }
  249. * ```
  250. * 注意 `rel_type` 必须是 `m.thread` —— 其它关系类型(`m.replace` 编辑、
  251. * `m.reference` 引用)都不是线程,不能混为一谈。
  252. */
  253. /**
  254. * 判断 sync 的错误是不是「token 失效」(2026-11-12)。
  255. *
  256. * SDK 对 `M_UNKNOWN_TOKEN` 会**永久停止** sync(`sync.ts:586`),
  257. * 只有这一类错误值得自动重签凭据;网络类错误 SDK 自己会退避重连。
  258. */
  259. export function isUnknownToken(err: unknown): boolean {
  260. if (!err || typeof err !== 'object') return false;
  261. const e = err as { errcode?: unknown; data?: { errcode?: unknown } };
  262. const code = e.errcode ?? e.data?.errcode;
  263. return code === 'M_UNKNOWN_TOKEN';
  264. }
  265. export function extractThreadId(content: Record<string, unknown> | undefined): string | undefined {
  266. const rel = content?.['m.relates_to'] as
  267. | { rel_type?: unknown; event_id?: unknown }
  268. | undefined;
  269. if (!rel || rel.rel_type !== 'm.thread') return undefined;
  270. return typeof rel.event_id === 'string' && rel.event_id ? rel.event_id : undefined;
  271. }
  272. /**
  273. * 提取「这条消息在回复哪一条」。
  274. *
  275. * Matrix 里 thread 内回复带 `m.relates_to.m.in_reply_to.event_id`。
  276. * 容器每收到一条用户消息都会回一轮(不合并),靠这个字段才能把回复
  277. * 归到**它真正回应提问的那一条**上(2026-11-10)。
  278. */
  279. export function extractReplyTo(content: Record<string, unknown> | undefined): string | undefined {
  280. const rel = content?.['m.relates_to'] as { 'm.in_reply_to'?: { event_id?: unknown } } | undefined;
  281. const id = rel?.['m.in_reply_to']?.event_id;
  282. return typeof id === 'string' && id ? id : undefined;
  283. }
  284. /**
  285. * 读取容器声明的消息类别(`content["fmode.kind"]`,2026-11-11)。
  286. *
  287. * 容器改了 Matrix adapter 之后,过程行会带 `"process"`、正文带 `"final"`。
  288. * 只认这两个值;其它(含缺失)返回 `undefined` → 调用方回退文本启发式,
  289. * 保证**未改造的容器照常工作**。
  290. */
  291. export function extractFmodeKind(
  292. content: Record<string, unknown> | undefined,
  293. ): 'process' | 'final' | undefined {
  294. const v = content?.['fmode.kind'];
  295. return v === 'process' || v === 'final' ? v : undefined;
  296. }
  297. /**
  298. * 构造 `m.relates_to`(thread 关系)。
  299. *
  300. * 依据容器实测回复(lj-test,2026-10-28):`event_id` 必须是**线程根**的
  301. * event_id,后续每条消息都填同一个根,容器才不会把它切成新会话。
  302. *
  303. * `is_falling_back` + `m.in_reply_to` 是给不支持 thread 的客户端做降级展示的
  304. * (容器侧不依赖它们,但发出去能让其它 Matrix 客户端正常显示)。
  305. *
  306. * @param threadRootId 线程根 event_id
  307. * @param lastEventId 该线程内最近一条消息的 event_id(降级用,可省略)
  308. */
  309. export function buildThreadRelation(
  310. threadRootId: string,
  311. lastEventId?: string,
  312. ): Record<string, unknown> {
  313. return {
  314. rel_type: 'm.thread',
  315. event_id: threadRootId,
  316. is_falling_back: true,
  317. ...(lastEventId ? { 'm.in_reply_to': { event_id: lastEventId } } : {}),
  318. };
  319. }
  320. import { isMediaMsgType, isMxcUrl, parseNativeMedia } from './matrix-media';
  321. const TEXT_MSGTYPES = ['m.text', 'm.notice', 'm.emote'];
  322. @Injectable({ providedIn: 'root' })
  323. export class MatrixClientService implements OnDestroy {
  324. /** 懒加载的 SDK 模块(首次 `load()` 时填充) */
  325. private sdk: typeof import('matrix-js-sdk') | null = null;
  326. /** startClient 是否已调用过(保证全局消息流只启一次) */
  327. private started = false;
  328. private client: MatrixClient | null = null;
  329. // ---------- 连接状态(供 chat 页徽标消费) ----------
  330. private readonly _syncState = signal<MxSyncState>('disconnected');
  331. readonly syncState = this._syncState.asReadonly();
  332. /**
  333. * 本地 store 版本号(每次 sync 完成 +1)。
  334. * 聊天页 `effect` 订阅它 → 重读 timeline,解决「同步晚于首读 → 历史空白」。
  335. */
  336. private readonly _storeVersion = signal(0);
  337. readonly storeVersion = this._storeVersion.asReadonly();
  338. private readonly _userId = signal<string>('');
  339. /** 当前登录的 Matrix 用户 id(`@xxx:fmode.cn`) */
  340. readonly userId = this._userId.asReadonly();
  341. private token = '';
  342. private deviceId = '';
  343. /** 显式停止标志:避免 disconnect() 后 sync 回调再改状态 */
  344. private stopped = false;
  345. /**
  346. * 初次同步是否已完成。
  347. *
  348. * 🔴 未读计数的**分界线**:初次 `/sync` 会把房间历史逐条回填(且按时间
  349. * 升序),若直接计数会把整个历史都算成未读。故此标志为 false 期间,
  350. * 未读只推进基线、不计数。
  351. */
  352. private initialSyncDone = false;
  353. /** 初次同步是否完成(未读计数用于区分历史与实时) */
  354. get hasInitialSync(): boolean {
  355. return this.initialSyncDone;
  356. }
  357. /** 等待初次同步完成的挂起者(sync 完成时统一放行) */
  358. private syncWaiters: (() => void)[] = [];
  359. /**
  360. * 等到初次 `/sync` 完成(2026-11-07)。
  361. *
  362. * 🔴 群聊重建必须先等它:首次同步前各房间 timeline 是空的,
  363. * 此时读出来的群历史会被判成「这个群没有任何消息」,覆盖掉本地缓存。
  364. * 已同步则立即返回;断线且永不重连时由调用方的超时兜底。
  365. */
  366. async synced(timeoutMs = 15_000): Promise<boolean> {
  367. if (this.initialSyncDone) return true;
  368. return new Promise<boolean>((resolve) => {
  369. let done = false;
  370. const finish = (ok: boolean): void => {
  371. if (done) return;
  372. done = true;
  373. const i = this.syncWaiters.indexOf(wake);
  374. if (i >= 0) this.syncWaiters.splice(i, 1);
  375. resolve(ok);
  376. };
  377. const wake = (): void => finish(true);
  378. this.syncWaiters.push(wake);
  379. setTimeout(() => finish(this.initialSyncDone), timeoutMs);
  380. });
  381. }
  382. /** 当前登录的 Matrix 用户 id(实时消息判定「是不是我发的」用) */
  383. currentUserId(): string {
  384. return this._userId() || this.client?.getUserId() || '';
  385. }
  386. /**
  387. * 全局消息流 —— **全项目唯一一处** `Room.timeline` 订阅。
  388. *
  389. * 多路复用给:① 聊天页(当前房间)② 未读计数(所有房间)。
  390. * 这样 sync 常驻时也只有一个监听器,不会重复触发。
  391. */
  392. private readonly messageHandlers = new Set<(m: MxMessage) => void>();
  393. private globalBound = false;
  394. private readonly session = inject(SessionService);
  395. private readonly api = inject(FmodeApi);
  396. /** token 失效自愈的尝试次数(成功后归零,见 Sync 事件处理) */
  397. private recoveryAttempts = 0;
  398. constructor() {
  399. // 🔴 登出即断连:不依赖页面记得调用 disconnect()。
  400. // 任一页面的 doLogout() 清掉 token 后,这里自动收敛。
  401. effect(() => {
  402. if (!this.session.token() && this.client) this.disconnect();
  403. });
  404. }
  405. ngOnDestroy(): void {
  406. this.disconnect();
  407. }
  408. // ---------- 生命周期(会话级常驻) ----------
  409. /**
  410. * 建立**会话级**连接(整个登录会话只调用一次,由 SessionService 持有者驱动)。
  411. *
  412. * 🔴 与旧实现的关键差异:这是**常驻**的 —— 不在聊天页 `close()` 时断开,
  413. * 否则离开会话后收不到消息,未读计数永远无法累加。
  414. * 断开只发生在**退出登录**时(`disconnect()`)。
  415. *
  416. * @param accessToken 云函数返回的 token(**不落 localStorage**,仅存内存)
  417. * @param userId 形如 `@sr2wipsdyq:fmode.cn`
  418. * @param deviceId 云函数返回的 deviceId(可空)
  419. */
  420. async connectSession(accessToken: string, userId: string, deviceId?: string): Promise<void> {
  421. if (!accessToken || !userId) {
  422. throw new Error('登录凭据不完整,请重新进入');
  423. }
  424. // 同身份重复调用:幂等,不打断现有 sync
  425. if (this.client && this.token === accessToken && this._userId() === userId) {
  426. /*
  427. * 🔴 幂等返回前必须确认 **sync 真的在跑**(2026-11-05 修复)。
  428. *
  429. * 场景:群聊页只调 `provisionMatrix` + `ensureRoom`,从不打开单聊。
  430. * 若此时客户端已存在(别的页面建的)但没 startClient,
  431. * 这里直接 return → 全局消息流从未启动 → **一个回复都收不到**
  432. * (实测:单聊 12 秒收到,群聊 60 秒为空)。
  433. */
  434. this.ensureSyncing();
  435. return;
  436. }
  437. this.disconnect();
  438. const sdk = await this.load();
  439. this.token = accessToken;
  440. this.deviceId = deviceId ?? '';
  441. this._userId.set(userId);
  442. this.stopped = false;
  443. this.initialSyncDone = false;
  444. this._syncState.set('connecting');
  445. this.client = sdk.createClient({
  446. baseUrl: MATRIX_HS,
  447. accessToken,
  448. userId,
  449. ...(deviceId ? { deviceId } : {}),
  450. /*
  451. * 关闭 VoIP(2026-10-31)—— 本项目不做语音通话(FR-11「暂不实现」)。
  452. *
  453. * 🔴 必须用 SDK 真实的选项名 `disableVoip`(源码 client.js:521):
  454. * 它会让 `supportsVoip()` 返回 false,从而跳过 turnServer 轮询。
  455. * 之前误用 `fallbackICEServerAllowed`(不是 createClient 的选项)→ 完全无效,
  456. * 控制台仍每 10s 报一次 `GET /voip/turnServer 404 M_NOT_FOUND`。
  457. */
  458. disableVoip: true,
  459. });
  460. /*
  461. * 压掉 client 自身的 info/warn 日志(2026-11-01)。
  462. *
  463. * 🔴 控制台被大量「Missing/Adding default global push rule …」刷屏:
  464. * 这是 SDK 拿服务端 push rules 与自身默认表对比时的提示(`client.logger`),
  465. * 属于**服务端规则版本与 SDK 版本不一致**的既有状况,不影响收发消息。
  466. * 之前只设了模块级 `logger`,而 client 有独立的 logger 实例,所以没生效。
  467. * 这里把 client 的 logger 提到 error 级,只保留真正需要处理的错误。
  468. */
  469. try {
  470. const cl = this.client as unknown as { logger?: { setLevel?: (l: string) => void } };
  471. cl.logger?.setLevel?.('error');
  472. } catch {
  473. /* 结构不符时忽略:不影响主流程 */
  474. }
  475. // sync 状态 → 连接状态(FR-04 AC-04.9:断线可自动重连)
  476. this.client.on(sdk.ClientEvent.Sync, (state: string, _prev: string | null, data?: { error?: unknown }) => {
  477. if (this.stopped) return;
  478. if (state === 'SYNCING' || state === 'PREPARED') {
  479. this._syncState.set('connected');
  480. this.recoveryAttempts = 0; // 恢复正常 → 重置退避
  481. } else if (state === 'ERROR') {
  482. this._syncState.set('disconnected');
  483. /*
  484. * 🔴 token 失效会让 SDK **永久**停掉 sync(2026-11-12 实测)。
  485. *
  486. * `matrix-js-sdk` 的 `SyncApi.shouldAbortSync()` 遇到 `M_UNKNOWN_TOKEN`
  487. * 会直接 `this.stop()`(`sync.ts:586-595`)—— 之后网络恢复、页面一直在,
  488. * sync 也都不会再起来,界面永远停在「连接已断开」,
  489. * 只能靠用户手点「重新连接」(用户截图实证)。
  490. *
  491. * 这里做自愈:用 provision 的 `validate:1` 探活并重新签发凭据。
  492. */
  493. if (isUnknownToken(data?.error)) void this.recoverFromExpiredToken();
  494. } else if (state === 'RECONNECTING') this._syncState.set('connecting');
  495. // 🔴 首个 SYNCING = 初次同步完成 → 之后的消息才算「实时」
  496. if (state === 'SYNCING') {
  497. this.initialSyncDone = true;
  498. // 放行等待「初次同步完成」的调用方(群聊重建,2026-11-07)
  499. for (const w of this.syncWaiters.splice(0)) w();
  500. /*
  501. * 探测服务端上传上限(2026-11-13)。
  502. *
  503. * 用户裁决「去掉 20MB 的写死限制,看 Matrix 最大支持多少」。
  504. * 连上后问一次 `/media/v3/config`,把 `m.upload.size` 写进
  505. * `upload-limits`,后续选文件时按真实上限判断。
  506. * 失败不影响任何功能 —— 拿不到就视为「不限制」。
  507. */
  508. void this.probeUploadLimit();
  509. /*
  510. * 🔴 通知「timeline 已就绪」。
  511. *
  512. * `startClient()` 是异步的:`openReal()` 里紧接着读 `timelineToMessages()`
  513. * 时,房间的历史事件**往往还没落到本地 store** → 拿到空数组;
  514. * 而后续只依赖 realtime 事件补消息,历史内容永远补不上
  515. * (表现为:刷新后会话空白、重开看不到之前的内容)。
  516. *
  517. * 每次 SYNCING 递增版本号,聊天页据此重读一次 timeline。
  518. */
  519. this._storeVersion.update((n) => n + 1);
  520. }
  521. });
  522. // 收到邀请自动接受(Agent 侧建房的场景)
  523. this.client.on(sdk.RoomMemberEvent.Membership, (_ev: MatrixEvent, member: RoomMember) => {
  524. if (this.stopped) return;
  525. if (member.membership === 'invite' && member.userId === userId) {
  526. void this.client?.joinRoom(member.roomId).catch(() => undefined);
  527. }
  528. });
  529. // 全局消息流(未读计数 + 聊天页复用)
  530. this.bindGlobalMessageStream();
  531. this.started = true;
  532. this.client.startClient({ initialSyncLimit: INITIAL_SYNC_LIMIT });
  533. }
  534. /** 断开并清理(**仅退出登录时**调用) */
  535. disconnect(): void {
  536. this.stopped = true;
  537. this.initialSyncDone = false;
  538. this.messageHandlers.clear();
  539. this.globalBound = false;
  540. if (this.client) {
  541. try {
  542. this.client.stopClient();
  543. } catch {
  544. /* 忽略:销毁期异常不阻塞清理 */
  545. }
  546. try {
  547. this.client.removeAllListeners();
  548. } catch {
  549. /* 忽略 */
  550. }
  551. }
  552. this.client = null;
  553. this.started = false;
  554. this.token = '';
  555. this.deviceId = '';
  556. this._userId.set('');
  557. this._syncState.set('disconnected');
  558. }
  559. /** 是否已建立会话级连接 */
  560. get connected(): boolean {
  561. return !!this.client;
  562. }
  563. /**
  564. * token 失效后自愈:重新签发凭据并重建连接(2026-11-12)。
  565. *
  566. * ## 为什么需要
  567. *
  568. * SDK 在 `M_UNKNOWN_TOKEN` 时会 `SyncApi.stop()` 永久停掉 sync,
  569. * 网络恢复也不会自己回来 —— 用户看到的就是一直挂着的「连接已断开」。
  570. *
  571. * ## 做法
  572. *
  573. * 用 `validateMatrix()`(云函数侧 `validate:1`)探活并在失效时重签凭据,
  574. * 拿到新 token 后 `connectSession()` 重建客户端。
  575. *
  576. * 🔴 必须**用现有 userId 重连**,而不是直接重连旧 token;
  577. * 同时保留指数退避与次数上限,避免云函数故障时打爆服务端。
  578. */
  579. private async recoverFromExpiredToken(): Promise<void> {
  580. if (this.stopped) return;
  581. const userId = this._userId();
  582. if (!userId) return;
  583. const attempt = this.recoveryAttempts++;
  584. if (attempt >= 3) return; // 上限:连续 3 次失败就交给用户手动「重新连接」
  585. /* 指数退避:0s / 3s / 9s */
  586. if (attempt > 0) await new Promise((r) => setTimeout(r, 3000 * attempt));
  587. if (this.stopped) return;
  588. this._syncState.set('connecting');
  589. try {
  590. const agentId = this.recoveryAgentId ?? '';
  591. const prov = await this.api.validateMatrix(agentId);
  592. if (!prov?.accessToken || prov.accessToken === this.token) return; // 没换到新凭据,别再重试
  593. this.connectSession(prov.accessToken, prov.userMxId || userId, prov.deviceId);
  594. } catch {
  595. /* 网络/云函数故障:保持 disconnected,UI 仍有「重新连接」兜底 */
  596. }
  597. }
  598. /**
  599. * 记录「当前连的是哪个容器的房间」,供 token 失效自愈时调 `validateMatrix`。
  600. *
  601. * 取不到时传空串 —— `validateMatrix('')` 只校验用户账号级凭据,够用。
  602. */
  603. setRecoveryAgentId(agentId: string): void {
  604. this.recoveryAgentId = agentId;
  605. }
  606. private recoveryAgentId = '';
  607. // ---------- 房间 ----------
  608. /**
  609. * 确保与目标 Agent 的 DM 房间可用。
  610. *
  611. * 优先用 provision 返回的 `roomId`(绑定表真相);未提供时按
  612. * `m.direct` accountData 查找,仍无则创建。
  613. */
  614. async ensureRoom(
  615. agentMxId: string,
  616. opts?: { roomId?: string | null; agentName?: string },
  617. ): Promise<string> {
  618. const c = this.requireClient();
  619. // ① 绑定表给的 roomId 优先(与容器侧同一房间)
  620. if (opts?.roomId) {
  621. const room = c.getRoom(opts.roomId);
  622. if (room && room.getMyMembership() === 'join') return opts.roomId;
  623. // 本地尚未 join → 尝试加入(可能是别的设备建的房)
  624. const joined = await c.joinRoom(opts.roomId).catch(() => null);
  625. if (joined) return opts.roomId;
  626. }
  627. // ② m.direct 里已有记录
  628. const directEv = c.getAccountData('m.direct' as never);
  629. const direct = directEv?.getContent() as Record<string, string[]> | undefined;
  630. for (const rid of direct?.[agentMxId] ?? []) {
  631. const room = c.getRoom(rid);
  632. if (room?.getMyMembership() === 'join') return rid;
  633. }
  634. // ③ 兜底:遍历已 join 房间找共同成员
  635. for (const room of c.getRooms()) {
  636. if (room.getMyMembership() !== 'join') continue;
  637. if (room.getMember(agentMxId)) return room.roomId;
  638. }
  639. // ④ 都没有 → 新建 DM
  640. const sdk = await this.load();
  641. const created = await c.createRoom({
  642. invite: [agentMxId],
  643. is_direct: true,
  644. preset: sdk.Preset.TrustedPrivateChat,
  645. name: opts?.agentName,
  646. });
  647. return created.room_id;
  648. }
  649. /** 该房间是否已是「已加入」状态 */
  650. isJoined(roomId: string): boolean {
  651. return !!this.client?.getRoom(roomId)?.getMyMembership?.();
  652. }
  653. /**
  654. * 为**一条新会话**创建独立房间(一房间一会话,2026-11-12)。
  655. *
  656. * ## 为什么要这样
  657. *
  658. * 老架构里 51 条会话共用一个 DM 房间,容器只能上报房间号 `!roomId`,
  659. * 而 App 无法据此区分「是 51 条里的哪一条」——
  660. * 于是「一条会话在跑」会被误判成「同房间所有会话都在跑」(用户实测的乱转)。
  661. *
  662. * 一房间一会话后,**房间号本身就是会话的唯一标识**,容器上报无歧义。
  663. *
  664. * ## 实测依据(2026-11-12)
  665. *
  666. * · 客户端 `createRoom({invite:[agentMxId]})` → 容器 **3 秒内自动进房**;
  667. * · 同时往两个房间发消息 → 容器 `currentSessions` 出现 **2 条独立的 running**,
  668. * 各自回复正确内容,互不串台。
  669. *
  670. * 也就是说:**容器侧零改造**,这是它原生支持的模式。
  671. *
  672. * ⚠️ 代价:Matrix 房间数会增加(每个会话一个)。需要配套清理长期不活跃的房间,
  673. * 否则 `/sync` 首包会越来越慢(实测 173 房间 / 5013 事件已约 1 秒)。
  674. */
  675. async createSessionRoom(agentMxId: string, agentName?: string): Promise<string> {
  676. const c = this.requireClient();
  677. const sdk = await this.load();
  678. const created = await c.createRoom({
  679. invite: [agentMxId],
  680. is_direct: true,
  681. preset: sdk.Preset.TrustedPrivateChat,
  682. name: agentName,
  683. /*
  684. * 标记「这是一条会话房间」:便于日后按来源批量清理,
  685. * 不会与新老房间混淆(老房间没有这个标记)。
  686. */
  687. creation_content: { 'fmode.session_room': true },
  688. });
  689. return created.room_id;
  690. }
  691. /**
  692. * 等容器进入某个房间(最多 `timeoutMs`)。
  693. *
  694. * 新建房间后必须等容器进房才能发消息,否则容器收不到
  695. * (它只监听自己已加入的房间)。实测进房约 3 秒。
  696. *
  697. * 🔴 2026-11-13 用户实测「新建对话很慢」后调整:
  698. * ① 轮询间隔 1000ms → **200ms**:原先最坏情况要等满 1 秒才发现已进房;
  699. * ② 默认超时 30s → **8s**:实测进房约 3 秒,8 秒没进基本就是不会进了。
  700. * 原先 30 秒意味着容器异常时用户要干等半分钟才看到界面。
  701. * 进房判断本身是**本地内存查表**(`getRoom().getMember()`),
  702. * 提高轮询频率没有网络开销。
  703. */
  704. async waitAgentJoined(roomId: string, agentMxId: string, timeoutMs = 8_000): Promise<boolean> {
  705. const deadline = Date.now() + timeoutMs;
  706. while (Date.now() < deadline) {
  707. const room = this.client?.getRoom(roomId);
  708. if (room?.getMember(agentMxId)?.membership === 'join') return true;
  709. await new Promise((r) => setTimeout(r, 200));
  710. }
  711. return false;
  712. }
  713. // ---------- 消息收发 ----------
  714. /**
  715. * 发文本(AC-04.2)
  716. *
  717. * @param threadRootId 线程根 event_id。**不传则容器视为新会话**
  718. * (`MATRIX_AUTO_THREAD=true` 会把每条无 thread 消息切成独立会话)。
  719. * 多会话场景必须传同一个根。
  720. * @param lastEventId 线程内最近一条消息,仅用于 `m.in_reply_to` 降级展示
  721. */
  722. async sendText(
  723. roomId: string,
  724. text: string,
  725. threadRootId?: string,
  726. lastEventId?: string,
  727. extra?: Record<string, unknown>,
  728. ): Promise<{ event_id?: string }> {
  729. const c = this.requireClient();
  730. const sdk = await this.load();
  731. return c.sendEvent(roomId, sdk.EventType.RoomMessage, {
  732. msgtype: sdk.MsgType.Text,
  733. body: text,
  734. ...(threadRootId ? { 'm.relates_to': buildThreadRelation(threadRootId, lastEventId) } : {}),
  735. /*
  736. * App 私有字段(2026-11-06):群聊派活 / 单聊归属。
  737. * Matrix 规范允许 `m.room.message` 携带任意额外字段,容器会忽略未知字段。
  738. * 放在 `m.relates_to` 之后,不影响任何标准字段的解析。
  739. */
  740. ...(extra ?? {}),
  741. });
  742. }
  743. /**
  744. * 房间当前最后一条消息事件的 eventId(无则 null)。
  745. *
  746. * 🔴 群聊补读的**锚点**(2026-11-06):发送前记下它,之后凡是排在它后面的
  747. * 事件才算「本轮的新回复」。用事件顺序判定,**完全不依赖两端时钟** ——
  748. * 此前用 `sentAt - 2000` 对比事件时间,容器与浏览器时钟一有偏差就漏收。
  749. */
  750. lastMessageEventId(roomId: string): string | null {
  751. const list = this.timelineToMessages(roomId);
  752. for (let i = list.length - 1; i >= 0; i--) {
  753. if (list[i].eventId) return list[i].eventId;
  754. }
  755. return null;
  756. }
  757. /**
  758. * 房间 timeline 的 `eventId → 下标` 索引。
  759. *
  760. * 供「某个锚点之后有哪些事件」的判定使用。每次同步后重建,
  761. * 避免逐条 `indexOf` 的 O(n²)。
  762. */
  763. timelineIndex(roomId: string): Map<string, number> {
  764. const map = new Map<string, number>();
  765. const list = this.timelineToMessages(roomId);
  766. for (let i = 0; i < list.length; i++) {
  767. if (list[i].eventId) map.set(list[i].eventId, i);
  768. }
  769. return map;
  770. }
  771. /**
  772. * 发一条 **@ 指定成员** 的消息。
  773. *
  774. * ## 🔴 为什么不能只在文本里写「@名字」
  775. *
  776. * Matrix 的 mention 是**结构化**的:消息内容里要有
  777. * `m.mentions: { user_ids: [...] }`,客户端/机器人据此判断「这条是不是在叫我」。
  778. * 只写文本 `@lj-test 你好` 对方**收不到任何 mention 信号**。
  779. *
  780. * P0 实测:容器靠 mention 判断是否需要回应(`MATRIX_REQUIRE_MENTION=true`),
  781. * 所以这里必须同时做三件事:
  782. * ① 正文里插入可读的 `@名字`
  783. * ② `format: org.matrix.custom.html` + `formatted_body` 里放真正的链接
  784. * ③ `m.mentions.user_ids` 列出被 @ 的人
  785. */
  786. async sendMention(
  787. roomId: string,
  788. userId: string,
  789. text: string,
  790. displayName?: string,
  791. ): Promise<{ event_id?: string }> {
  792. return this.sendMentionMany(roomId, [userId], text, displayName ? [displayName] : undefined);
  793. }
  794. /** 同时 @ 多个成员(群内协作) */
  795. async sendMentionMany(
  796. roomId: string,
  797. userIds: string[],
  798. text: string,
  799. displayNames?: string[],
  800. threadRootId?: string,
  801. lastEventId?: string,
  802. ): Promise<{ event_id?: string }> {
  803. const c = this.requireClient();
  804. const sdk = await this.load();
  805. const names = userIds.map((id, i) => displayNames?.[i] || id.replace(/^@/, '').split(':')[0]);
  806. const prefix = names.map((n) => `@${n}`).join(' ');
  807. const body = `${prefix} ${text}`;
  808. const htmlPrefix = userIds
  809. .map(
  810. (id, i) =>
  811. `<a href="https://matrix.to/#/${encodeURIComponent(id)}">${escapeHtml(names[i])}</a>`,
  812. )
  813. .join(' ');
  814. return c.sendEvent(roomId, sdk.EventType.RoomMessage, {
  815. msgtype: sdk.MsgType.Text,
  816. body,
  817. format: 'org.matrix.custom.html',
  818. formatted_body: `${htmlPrefix} ${escapeHtml(text)}`,
  819. 'm.mentions': { user_ids: userIds },
  820. ...(threadRootId ? { 'm.relates_to': buildThreadRelation(threadRootId, lastEventId) } : {}),
  821. } as never);
  822. }
  823. /** 发文件卡片(`m.text` + JSON body,与容器侧解析约定一致) */
  824. /**
  825. * 发送文件(**Matrix 原生附件**)。
  826. *
  827. * 🔴 为什么不用 `m.text + JSON body`:
  828. * 容器解析附件只认 Matrix 原生事件 ——
  829. * `{ msgtype: 'm.file', body: '文件名', url: 'mxc://...' }`
  830. * (见 `matrix-media.ts` 的实测记录:容器交付成果用的就是这种形态)。
  831. *
  832. * 早期实现把文件信息塞进 `m.text` 的 JSON body 里发出去 ——
  833. * **容器完全不认**,用户发了图,容器回「我这边只看到文字,没收到附件」。
  834. *
  835. * 流程:先把 `blob` 上传到 Matrix 媒体库拿 `mxc://` 地址,再发 `m.file` 事件。
  836. *
  837. * @param blob 文件内容
  838. * @param card 展示用的元信息(名称/大小/类型)
  839. */
  840. async sendFile(
  841. roomId: string,
  842. blob: Blob,
  843. card: { name: string; size: number; mimeType: string },
  844. threadRootId?: string,
  845. lastEventId?: string,
  846. /**
  847. * App 私有字段(2026-11-07):群聊发附件时写入 `com.fmode.dispatch`,
  848. * 让这条文件事件也能被「派活切段」识别为**用户在本 turn 发的内容**。
  849. * 容器会忽略未知字段。
  850. */
  851. extra?: Record<string, unknown>,
  852. ): Promise<{ event_id?: string }> {
  853. const c = this.requireClient();
  854. const sdk = await this.load();
  855. // ① 上传到媒体库 → mxc://
  856. const upload = await c.uploadContent(blob, { name: card.name, type: card.mimeType });
  857. const mxc = upload.content_uri;
  858. if (!mxc) throw new Error('附件上传失败(没有返回地址)');
  859. // ② 发原生附件事件。图片用 m.image(客户端可直接内联显示),其余用 m.file
  860. const isImage = card.mimeType.startsWith('image/');
  861. const content: Record<string, unknown> = {
  862. msgtype: isImage ? 'm.image' : 'm.file',
  863. body: card.name,
  864. url: mxc,
  865. info: {
  866. mimetype: card.mimeType,
  867. size: card.size,
  868. },
  869. ...(threadRootId ? { 'm.relates_to': buildThreadRelation(threadRootId, lastEventId) } : {}),
  870. ...(extra ?? {}),
  871. };
  872. return c.sendEvent(roomId, sdk.EventType.RoomMessage, content as never);
  873. }
  874. /**
  875. * 发「已上传到个人存储」的附件事件(2026-11-13,附件 `'url'` 模式)。
  876. *
  877. * ## 与 `sendFile()` 的区别
  878. *
  879. * `sendFile()` 会把**文件本体 `uploadContent` 进 Matrix 媒体库**拿 `mxc://` ——
  880. * 文件因此进出一趟 homeserver。用户裁决:**只允许收发 cdn/obs 地址**,
  881. * 所以本方法**不做任何上传**,把外部拿到的云盘地址直接写进 `content.url`。
  882. *
  883. * ## 事件形态
  884. *
  885. * 仍是标准的 `m.image`/`m.file`(容器侧按 msgtype 分发,不能改成 `m.text`),
  886. * 唯一差别是 `url` 为 `https://s3.fmode.cn/...`。
  887. * 实测(2026-11-13)`matrix.fmode.cn` **接受并原样读回**非 mxc 的 url。
  888. *
  889. * @param url 云盘公网地址(必须是 https)
  890. */
  891. async sendFileByUrl(
  892. roomId: string,
  893. url: string,
  894. card: { name: string; size: number; mimeType: string },
  895. threadRootId?: string,
  896. lastEventId?: string,
  897. extra?: Record<string, unknown>,
  898. ): Promise<{ event_id?: string }> {
  899. /*
  900. * 🔴 只放行本平台云盘域名(2026-11-13 评审 G1):
  901. * 只判 https 太宽 —— `https://evil.example.com/x` 也会发进事件,
  902. * 而容器侧白名单只认 s3/file-cloud,等于两边口径不一致。
  903. */
  904. if (!isAllowedMediaUrl(url)) throw new Error('附件地址无效(必须为云盘 https 地址)');
  905. const c = this.requireClient();
  906. const sdk = await this.load();
  907. const isImage = card.mimeType.startsWith('image/');
  908. const content: Record<string, unknown> = {
  909. msgtype: isImage ? 'm.image' : 'm.file',
  910. body: card.name,
  911. url,
  912. info: {
  913. mimetype: card.mimeType,
  914. size: card.size,
  915. },
  916. // 与 url 同值:兼容只认 `com.fmode.cloud_url` 的存量回读逻辑
  917. [CLOUD_URL_KEY]: url,
  918. ...(threadRootId ? { 'm.relates_to': buildThreadRelation(threadRootId, lastEventId) } : {}),
  919. ...(extra ?? {}),
  920. };
  921. return c.sendEvent(roomId, sdk.EventType.RoomMessage, content as never);
  922. }
  923. /** 当前 timeline → 归一消息(时间正序) */
  924. timelineToMessages(roomId: string): MxMessage[] {
  925. const room = this.client?.getRoom(roomId);
  926. if (!room) return [];
  927. return this.collectRoomEvents(room)
  928. .filter((e) => e.getType() === 'm.room.message')
  929. /*
  930. * 🔴 也要接受原生附件(m.file / m.image …)。
  931. * 实测:容器交付成果用的是 m.file,此前只认 m.text,
  932. * 导致交付物在 App 里完全看不到。
  933. */
  934. .filter((e) => {
  935. const mt = String(e.getContent()['msgtype'] ?? '');
  936. return TEXT_MSGTYPES.includes(mt) || isMediaMsgType(mt);
  937. })
  938. .map((e) => ({
  939. roomId,
  940. eventId: e.getId() ?? '',
  941. sender: e.getSender() ?? '',
  942. senderName: senderDisplayName(room, e.getSender() ?? ''),
  943. mentions: extractMentions(e.getContent()),
  944. // 线程根 = 自身 event_id,线程内回复 = 根 event_id,无 thread = undefined
  945. threadId: extractThreadId(e.getContent()) ?? undefined,
  946. // 🔴 派活 / 单聊归属标记必须在这里解析(2026-11-06):
  947. // 与实时流那条路径**两处都要加**,否则刷新后归属信息丢失。
  948. dispatch: extractDispatch(e.getContent()),
  949. sessionId: extractSessionId(e.getContent()),
  950. localId: extractLocalId(e.getContent()),
  951. replyTo: extractReplyTo(e.getContent()),
  952. fmodeKind: extractFmodeKind(e.getContent()),
  953. body: (e.getContent()['body'] as string) ?? '',
  954. /*
  955. * 原生附件 → 转成 FileCard。
  956. *
  957. * 🔴 **优先用云盘链接**(2026-11-13 用户实测反馈):
  958. * 原先一律用 `mxc://` 转出来的 `/_matrix/media/...`,
  959. * 但那个地址 ① 预览/新标签要用 blob 中转(Safari 兼容差)
  960. * ② 平台清理媒体后就失效。用户要的是**云盘公网链接**。
  961. * 事件里带了 `com.fmode.cloud_url` 就直接用它;
  962. * 老消息没这个字段才退回 Matrix 地址。
  963. */
  964. media: (() => {
  965. const m = parseNativeMedia(e.getContent());
  966. if (!m) return undefined;
  967. const http = this.mediaHttpUrl(e.getContent(), m.mxcUrl);
  968. return http
  969. ? { name: m.name, url: http, mimeType: m.mimeType, size: m.size ?? -1 }
  970. : undefined;
  971. })(),
  972. ts: e.getTs(),
  973. kind: kindOf(String(e.getContent()['msgtype'] ?? '')),
  974. // 从 timeline 读出的都视为历史(未读不依赖此值)
  975. live: false,
  976. }));
  977. }
  978. /**
  979. * 收集房间的**全部**消息事件:顶层 timeline + 各 thread 内的回复。
  980. *
  981. * ## 为什么需要(2026-11-03 修复)
  982. *
  983. * `getLiveTimeline().getEvents()` **只返回房间顶层事件**。而用户在 App 里
  984. * 发图/发文件时,消息带 `m.relates_to{rel_type:'m.thread', event_id:<会话根>}`,
  985. * 属于**线程内回复** —— 只读顶层 timeline 就完全看不到这些附件。
  986. *
  987. * 实测:某会话 sync 里有 33 条媒体消息(含 jpg),界面 0 张。
  988. *
  989. * 这里把每个顶层事件的 thread 一并展开,再按 eventId 去重、按时间排序。
  990. */
  991. private collectRoomEvents(room: Room): MatrixEvent[] {
  992. const top = room.getLiveTimeline().getEvents();
  993. const seen = new Set<string>();
  994. const out: MatrixEvent[] = [];
  995. const push = (e: MatrixEvent): void => {
  996. const id = e.getId() ?? '';
  997. if (id && seen.has(id)) return;
  998. if (id) seen.add(id);
  999. out.push(e);
  1000. };
  1001. for (const e of top) {
  1002. push(e);
  1003. /*
  1004. * `Thread` 只在**该事件确实是线程根**且 SDK 已建索引时才存在,
  1005. * 且 `thread.events` 可能为空(`/sync` 携带的 thread 回复不一定挂到根上)。
  1006. * 所以这里做双保险:能拿到就用,拿不到再从房间事件池里按 relates_to 过滤。
  1007. */
  1008. try {
  1009. const thread = e.getThread?.();
  1010. for (const te of thread?.events ?? []) push(te);
  1011. } catch {
  1012. /* 个别事件结构异常不影响整体 */
  1013. }
  1014. const rootId = e.getId();
  1015. if (!rootId) continue;
  1016. try {
  1017. /*
  1018. * `room.getThread(rootId)` 返回已建的线程;没有则退回全量事件里筛。
  1019. * 后者是唯一保证不漏的手段 —— 线程回复就在房间事件池里。
  1020. */
  1021. const t = room.getThread?.(rootId);
  1022. for (const te of t?.events ?? []) push(te);
  1023. } catch {
  1024. /* 同上 */
  1025. }
  1026. }
  1027. /*
  1028. * 兜底:把房间里**所有**事件(含未挂到根的线程回复)扫一遍。
  1029. * `getLiveTimeline` 只给顶层,故改从 `room.getUnfilteredTimelineSet()`
  1030. * 拿全部 set;仍取不到时退回顶层,保证不抛错。
  1031. */
  1032. try {
  1033. const sets = (room as unknown as { getUnfilteredTimelineSet?: () => { getLiveTimeline?: () => { getEvents?: () => MatrixEvent[] } } })
  1034. .getUnfilteredTimelineSet?.();
  1035. for (const te of sets?.getLiveTimeline?.()?.getEvents?.() ?? []) push(te);
  1036. } catch {
  1037. /* 结构不符时忽略 */
  1038. }
  1039. return out.sort((a, b) => a.getTs() - b.getTs());
  1040. }
  1041. /**
  1042. * 确保 sync 已启动(幂等)。
  1043. *
  1044. * `startClient()` 是异步的且只需一次;重复调用 SDK 会忽略,
  1045. * 但这里用标志位避免重复触发,也方便排查「到底有没有起来」。
  1046. */
  1047. /**
  1048. * 探测 homeserver 的单文件上传上限(2026-11-13)。
  1049. *
  1050. * `GET /_matrix/media/v3/config` → `{ "m.upload.size": 33554432 }`
  1051. * (实测 `matrix.fmode.cn` 为 **32MB**)。
  1052. *
  1053. * 拿到后写进 `upload-limits`,选文件时按**真实上限**判断;
  1054. * 拿不到就保持「不限制」——宁可让服务端报错,也不误伤用户。
  1055. *
  1056. * 失败静默:这是体验优化,不是功能依赖。
  1057. */
  1058. private async probeUploadLimit(): Promise<void> {
  1059. try {
  1060. const token = this.token;
  1061. if (!token) return;
  1062. const res = await fetch(`${MATRIX_HS}/_matrix/media/v3/config`, {
  1063. headers: { Authorization: `Bearer ${token}` },
  1064. });
  1065. if (!res.ok) return;
  1066. const body = (await res.json()) as { 'm.upload.size'?: unknown };
  1067. const size = body['m.upload.size'];
  1068. if (typeof size === 'number') setServerUploadLimit(size);
  1069. } catch {
  1070. /* 探测失败:保持不限制 */
  1071. }
  1072. }
  1073. ensureSyncing(): void {
  1074. if (!this.client || this.stopped) return;
  1075. /*
  1076. * 🔴 先补绑全局消息流(2026-11-05)。
  1077. * `bindGlobalMessageStream` 是幂等的,但**只在首次建客户端时调用过**;
  1078. * 若客户端由别的路径创建、绑定被跳过,`onMessage` 永远收不到东西。
  1079. */
  1080. this.bindGlobalMessageStream();
  1081. if (this.started) return;
  1082. this.started = true;
  1083. try {
  1084. this.client.startClient({ initialSyncLimit: INITIAL_SYNC_LIMIT });
  1085. } catch {
  1086. /* 已在运行等情况:忽略 */
  1087. }
  1088. }
  1089. /**
  1090. * 列出**群房间**(房间里有多个成员,且不是 1:1 DM)。
  1091. *
  1092. * 群聊功能需要用这个发现「我参加了哪些工作组」。
  1093. * 判定:成员数 > 2 或房间未标记 is_direct。
  1094. */
  1095. /**
  1096. * 实时拉房间成员(绕过 SDK 缓存)。
  1097. *
  1098. * 🔴 实测:`room.getJoinedMembers()` 返回的是 SDK 本地缓存,
  1099. * 可能是旧的 —— 组长明明已不在群里,App 仍显示它在。
  1100. * 查「组长在不在群」这种关键判断必须用实时数据。
  1101. */
  1102. async fetchRoomMembers(roomId: string): Promise<string[]> {
  1103. const c = this.client;
  1104. if (!c) return [];
  1105. try {
  1106. const res = (await c.getJoinedRoomMembers(roomId)) as unknown;
  1107. /*
  1108. * 🔴 SDK 返回的是 `{ joined: { "@a:x": {...}, "@b:x": {...} } }`,
  1109. * 不是直接的成员 map。
  1110. * 实测踩坑:直接 `Object.keys(res)` 拿到的是 `["joined"]` ——
  1111. * 于是「组长在不在群」永远判成「不在」,误报。
  1112. */
  1113. const obj = res as Record<string, unknown>;
  1114. const inner = (obj && typeof obj['joined'] === 'object' ? obj['joined'] : obj) as
  1115. | Record<string, unknown>
  1116. | undefined;
  1117. if (!inner || typeof inner !== 'object') return [];
  1118. return Object.keys(inner);
  1119. } catch {
  1120. return [];
  1121. }
  1122. }
  1123. listGroupRooms(): { roomId: string; name: string; memberIds: string[] }[] {
  1124. const c = this.client;
  1125. if (!c) return [];
  1126. const out: { roomId: string; name: string; memberIds: string[] }[] = [];
  1127. for (const room of c.getRooms()) {
  1128. if (room.getMyMembership() !== 'join') continue;
  1129. /*
  1130. * 🔴 群的判定是「成员 ≥ 3」,不是「不是 DM」。
  1131. *
  1132. * 实测:`getDMInviter()` 对我们由 owner 自己建的那些 1:1 房间返回 null
  1133. * (它们不是「别人发起的 DM」),于是 2 人房间全被当成了群
  1134. * (实测列出 4 个,其中 3 个是 DM)。
  1135. *
  1136. * 群必然 ≥ 3 人(我 + 至少两个 agent);DM 恒为 2 人。
  1137. * 用人数判断最稳,不依赖 is_direct 标记。
  1138. */
  1139. const members = room.getJoinedMemberCount();
  1140. if (members < 3) continue;
  1141. out.push({
  1142. roomId: room.roomId,
  1143. name: room.name || '(未命名群)',
  1144. memberIds: room.getJoinedMembers().map((m) => m.userId),
  1145. });
  1146. }
  1147. return out;
  1148. }
  1149. /**
  1150. * 读房间状态事件(如自定义的 `fmode.workgroup.roles`)。
  1151. * 状态不存在时返回 null,**不抛错** —— 新群本来就没有这个状态。
  1152. */
  1153. async getRoomState(roomId: string, eventType: string): Promise<unknown | null> {
  1154. const c = this.client;
  1155. if (!c) return null;
  1156. try {
  1157. const ev = await c.getStateEvent(roomId, eventType, '');
  1158. return ev ?? null;
  1159. } catch {
  1160. // M_NOT_FOUND:状态还没设置过,属正常
  1161. return null;
  1162. }
  1163. }
  1164. /** 写房间状态事件(需要足够 power level,通常由群创建者设置) */
  1165. async setRoomState(roomId: string, eventType: string, content: unknown): Promise<void> {
  1166. const c = this.requireClient();
  1167. await c.sendStateEvent(roomId, eventType as never, content as never, '');
  1168. }
  1169. /**
  1170. * `mxc://` → 可访问的 HTTP 地址。
  1171. *
  1172. * 🔴 Matrix 的附件地址是内部协议(`mxc://server/mediaId`),
  1173. * 浏览器直接打开不了,必须经 SDK 转成 `/_matrix/media/...` 的 HTTP 地址。
  1174. *
  1175. * 🔴 参数含义(第 5 个 = useAuthenticatedApi/直接下载):
  1176. * 之前传 true 生成的是 `/_matrix/media/v3/download/...` —— 该接口带
  1177. * `Content-Disposition: attachment`,浏览器**只会下载、不会显示**,
  1178. * 于是「在新标签页打开」变成了「下载文件」(用户实测)。
  1179. * 传 false 生成 `/_matrix/media/v3/preview_url/...`(内联展示),
  1180. * 图片/PDF/HTML 才能直接在标签页里打开。
  1181. */
  1182. /**
  1183. * 给 Matrix 媒体地址补上访问令牌,供「在新标签页打开」使用。
  1184. *
  1185. * 🔴 背景(2026-11-01):媒体接口要鉴权,直接 window.open 会 401;
  1186. * 而页面内 fetch 该地址又被 CORS 拦截(实测 Failed to fetch),
  1187. * 所以没法先抓成 blob。带上 access_token 查询参数的直链是唯一能
  1188. * "浏览器新标签页里直接显示"的方式(homeserver 支持该参数)。
  1189. * 仅用于用户主动点击打开,不做缓存、不落库。
  1190. */
  1191. withAccessToken(url: string): string {
  1192. if (!this.token || !url.includes('/_matrix/')) return url;
  1193. if (/[?&]access_token=/.test(url)) return url;
  1194. const sep = url.includes('?') ? '&' : '?';
  1195. return `${url}${sep}access_token=${encodeURIComponent(this.token)}`;
  1196. }
  1197. /**
  1198. * 附件事件的最终可访问地址(历史回读与实时流**必须共用**这一处)。
  1199. *
  1200. * 顺序:
  1201. * ① `com.fmode.cloud_url`(存量双写模式)
  1202. * ② url 本身就是 http(s) —— `'url'` 模式下就是这个,直接用
  1203. * (2026-11-13 实测:SDK 的 `mxcUrlToHttp('https://...')` 返回**空串**,
  1204. * 以前对它调一次就等于把附件整个丢掉)
  1205. * ③ 最后才把 `mxc://` 转成 media 地址
  1206. */
  1207. private mediaHttpUrl(content: Record<string, unknown>, rawUrl: string): string | null {
  1208. if (!rawUrl) return null;
  1209. const cloud = extractCloudUrl(content);
  1210. if (cloud) return cloud;
  1211. if (/^https?:\/\//i.test(rawUrl)) return rawUrl;
  1212. if (!isMxcUrl(rawUrl)) return null;
  1213. return this.mxcUrlToHttp(rawUrl);
  1214. }
  1215. mxcUrlToHttp(mxc: string): string | null {
  1216. const c = this.client;
  1217. if (!c) return null;
  1218. try {
  1219. return c.mxcUrlToHttp(mxc, undefined, undefined, undefined, false) ?? null;
  1220. } catch {
  1221. return null;
  1222. }
  1223. }
  1224. /** 向上翻历史(AC-04.4) */
  1225. async paginateBack(roomId: string, limit = 30): Promise<boolean> {
  1226. const c = this.requireClient();
  1227. let room = c.getRoom(roomId);
  1228. if (!room) {
  1229. await c.joinRoom(roomId).catch(() => null);
  1230. room = c.getRoom(roomId);
  1231. }
  1232. if (!room) return false;
  1233. return c.paginateEventTimeline(room.getLiveTimeline(), { backwards: true, limit });
  1234. }
  1235. /**
  1236. * 是否还有更早历史(live timeline 的 pagination token 是否耗尽)。
  1237. * 用于 UI 决定要不要显示「加载更早」入口,以及分页到顶后停止请求。
  1238. */
  1239. paginationToken(roomId: string): string | null {
  1240. const room = this.client?.getRoom(roomId);
  1241. if (!room || !this.sdk) return null;
  1242. return room.getLiveTimeline().getPaginationToken(this.sdk.Direction.Backward) ?? null;
  1243. }
  1244. /** 已读回执 */
  1245. async sendReceipt(roomId: string, eventId: string): Promise<void> {
  1246. const c = this.requireClient();
  1247. const ev = c
  1248. .getRoom(roomId)
  1249. ?.getLiveTimeline()
  1250. .getEvents()
  1251. .find((e) => e.getId() === eventId);
  1252. if (!ev) return;
  1253. await c.sendReadReceipt(ev);
  1254. }
  1255. /** 输入中通知(失败非致命) */
  1256. async setTyping(roomId: string, typing: boolean, timeoutMs = 8000): Promise<void> {
  1257. try {
  1258. await this.requireClient().sendTyping(roomId, typing, timeoutMs);
  1259. } catch {
  1260. /* 非致命:typing 丢失不影响消息链路 */
  1261. }
  1262. }
  1263. // ---------- 事件订阅 ----------
  1264. /**
  1265. * 订阅房间新消息;返回解绑函数。
  1266. *
  1267. * 不过滤 sender —— 自己发的消息也要接住(local echo 依赖 Timeline 事件)。
  1268. */
  1269. onMessage(roomId: string, cb: (m: MxMessage) => void): () => void {
  1270. return this.onAnyMessage((m) => {
  1271. if (m.roomId === roomId) cb(m);
  1272. });
  1273. }
  1274. /**
  1275. * 订阅**所有房间**的新消息(未读计数用);返回解绑函数。
  1276. *
  1277. * 与 `onMessage` 共用同一个底层订阅(`bindGlobalMessageStream`),
  1278. * 因此无论多少订阅者,SDK 侧只有**一个** `Room.timeline` 监听器。
  1279. */
  1280. onAnyMessage(cb: (m: MxMessage) => void): () => void {
  1281. this.messageHandlers.add(cb);
  1282. return () => {
  1283. this.messageHandlers.delete(cb);
  1284. };
  1285. }
  1286. /**
  1287. * 订阅容器的 **✅ / ❌ reaction**(2026-11-11)。
  1288. *
  1289. * ## 为什么它是一个可靠的本轮结束信号
  1290. *
  1291. * 实测(lj-test,`adapter.py:2331 on_processing_complete()`):
  1292. * 容器**每一轮**处理结束都会把 👀 换成 ✅(成功)或 ❌(失败),
  1293. * `CANCELLED` 例外不发。这是 Hermes 的内建行为,不依赖任何插件改造,
  1294. * 比 `m.typing` 更不容易丢(typing 是 ephemeral,且可能被配置关掉)。
  1295. *
  1296. * ## 与 typing 的分工
  1297. *
  1298. * · reaction ✅ = **这一轮真的干完了**(含工具跑完、正文发完)
  1299. * · typing=false = 「此刻没在打字」,中途空档也会有
  1300. *
  1301. * 所以收尾判据优先用 reaction;typing 作为辅助。
  1302. *
  1303. * @param cb 收到 ✅/❌ 时回调,参数为 true 表示成功、false 表示失败;
  1304. * 第二个参数是发 reaction 的 mxid(群聊用来区分是哪个容器收工的)
  1305. */
  1306. onTurnReaction(roomId: string, cb: (ok: boolean, sender: string, targetId: string) => void): () => void {
  1307. const c = this.requireClient();
  1308. const handler = (ev: MatrixEvent) => {
  1309. if (ev.getRoomId() !== roomId || ev.getType() !== 'm.reaction') return;
  1310. const rel = (ev.getContent() as { 'm.relates_to'?: { key?: string; event_id?: string } })?.[
  1311. 'm.relates_to'
  1312. ];
  1313. const key = rel?.key ?? '';
  1314. /*
  1315. * 🔴 必须把 sender 传出去(2026-11-11 容器侧代码取证指出)。
  1316. *
  1317. * 群聊里 @ 了多个容器时,**每个容器各打一个 ✅,且打在同一个 event_id 上**。
  1318. * 只按「房间里有 ✅ 就算结束」会把其他成员的 ✅ 误认成本轮的结束,
  1319. * 导致某个容器还在跑、界面却已经收尾。
  1320. *
  1321. * 调用方要用 sender 和「本块对应的 agent」比对,见 `chat.service`。
  1322. */
  1323. if (key === '✅') cb(true, ev.getSender() ?? '', rel?.event_id ?? '');
  1324. else if (key === '❌') cb(false, ev.getSender() ?? '', rel?.event_id ?? '');
  1325. };
  1326. c.on('Room.timeline' as never, handler as never);
  1327. return () => {
  1328. try {
  1329. c.off('Room.timeline' as never, handler as never);
  1330. } catch {
  1331. /* 忽略 */
  1332. }
  1333. };
  1334. }
  1335. /**
  1336. * 订阅**容器主动推送的回合状态事件**(2026-11-12,方案 2)。
  1337. *
  1338. * ## 为什么需要
  1339. *
  1340. * 容器 `post_llm_call` 时会把 `extra.currentSessions` 的 status 改成 `idle` 并
  1341. * **立即**走云函数上报(实测 turn 结束 1 秒内完成)。但 App 拉云函数是
  1342. * **15 秒轮询**,所以「容器 17 秒报 idle → App 最迟 32 秒才知道」——
  1343. * 用户看到左侧要转半分钟才停。
  1344. *
  1345. * 这个订阅让容器在 turn 结束时**直接往房间推一个 Matrix 事件**,
  1346. * App 收到即刷新,无需等轮询。
  1347. *
  1348. * ## 协议(与容器约定,二者任选其一即可)
  1349. *
  1350. * **A(首选)自定义事件类型** `com.fmode.turn_status`:
  1351. * ```json
  1352. * { "sessionKey": "$approot_xxx | !roomId", "status": "idle|running" }
  1353. * ```
  1354. *
  1355. * **B(兜底)** `m.room.message` 带 `fmode.turn_status` 字段(同结构)。
  1356. * 之所以保留 B:容器若无法发送非 `m.room.message` 事件,可用消息体携带,
  1357. * 但 App 侧**不得把它当普通消息渲染**(本方法只解析、不入会话列表)。
  1358. *
  1359. * 🔴 **与 ✅ reaction 完全无关**:✅ 只代表「这条消息发完了」,
  1360. * 本事件才代表「这一轮结束了」。两者并存,互不替代。
  1361. *
  1362. * @param roomId 房间
  1363. * @param cb 收到状态事件时回调:会话 key + 新状态 + 发送者 mxid
  1364. */
  1365. onTurnStatus(
  1366. cb: (sessionKey: string, status: string, sender: string, roomId: string) => void,
  1367. ): () => void {
  1368. const c = this.requireClient();
  1369. const handler = (ev: MatrixEvent) => {
  1370. /*
  1371. * 🔴 **监听所有房间**,不限定 roomId(2026-11-12 修正)。
  1372. *
  1373. * 一房间一会话之后,左侧列表里每条会话都活在自己的房间里。
  1374. * 原先只监听「当前打开的那个房间」→ 后台会话的 turn_status 收不到
  1375. * → 左侧只能等 15 秒轮询,用户实测「回复完了还在转很久」。
  1376. */
  1377. const type = ev.getType();
  1378. const content = ev.getContent() as Record<string, unknown> | undefined;
  1379. /* A:自定义事件类型直接带字段 */
  1380. let payload: { sessionKey?: unknown; status?: unknown } | undefined;
  1381. if (type === TURN_STATUS_EVENT) {
  1382. payload = content as { sessionKey?: unknown; status?: unknown };
  1383. } else if (type === 'm.room.message') {
  1384. /* B:兜底 —— 消息体里带 fmode.turn_status */
  1385. const inner = content?.['fmode.turn_status'];
  1386. if (inner && typeof inner === 'object') {
  1387. payload = inner as { sessionKey?: unknown; status?: unknown };
  1388. }
  1389. }
  1390. if (!payload) return;
  1391. const key = typeof payload.sessionKey === 'string' ? payload.sessionKey : '';
  1392. const st = typeof payload.status === 'string' ? payload.status : '';
  1393. if (!key || !st) return;
  1394. cb(key, st, ev.getSender() ?? '', ev.getRoomId() ?? '');
  1395. };
  1396. c.on('Room.timeline' as never, handler as never);
  1397. return () => {
  1398. try {
  1399. c.off('Room.timeline' as never, handler as never);
  1400. } catch {
  1401. /* 忽略 */
  1402. }
  1403. };
  1404. }
  1405. /** 订阅对方 typing 状态;返回解绑函数 */
  1406. onTyping(roomId: string, cb: (typing: boolean, userId: string) => void): () => void {
  1407. const c = this.requireClient();
  1408. const handler = (ev: MatrixEvent, member: RoomMember) => {
  1409. if (member?.roomId !== roomId || ev.getType() !== 'm.typing') return;
  1410. cb(!!member.typing, member.userId ?? '');
  1411. };
  1412. c.on('RoomMember.typing' as never, handler as never);
  1413. return () => {
  1414. try {
  1415. c.off('RoomMember.typing' as never, handler as never);
  1416. } catch {
  1417. /* 忽略 */
  1418. }
  1419. };
  1420. }
  1421. /** 绑定全局消息流(只绑一次,幂等) */
  1422. private bindGlobalMessageStream(): void {
  1423. const c = this.client;
  1424. if (!c || this.globalBound) return;
  1425. this.globalBound = true;
  1426. c.on('Room.timeline' as never, ((ev: MatrixEvent, _room?: unknown, _toStart?: boolean, data?: { liveEvent?: boolean }) => {
  1427. if (ev.getType() !== 'm.room.message') return;
  1428. // 跳过本地未发送回执(echo 会再触发一次)
  1429. const sending = (ev as unknown as { isSending?: () => boolean }).isSending;
  1430. if (typeof sending === 'function' && sending.call(ev)) return;
  1431. const msgtype = String(ev.getContent()['msgtype'] ?? '');
  1432. /*
  1433. * 🔴 实时流必须与 `timelineToMessages()` 用**同一套类型白名单**(2026-11-03 修复)。
  1434. *
  1435. * 此前这里只认 TEXT_MSGTYPES,`m.image` / `m.file` 被直接 return 丢弃 ——
  1436. * 表现为「历史消息里的图片能看到(走 timeline 路径),对方刚发来的图片却收不到」,
  1437. * 移动端截图场景几乎必现(用户发图 → agent 回复,App 里两侧都缺图)。
  1438. */
  1439. if (!TEXT_MSGTYPES.includes(msgtype) && !isMediaMsgType(msgtype)) return;
  1440. const media = (() => {
  1441. const m = parseNativeMedia(ev.getContent());
  1442. if (!m) return undefined;
  1443. const http = this.mediaHttpUrl(ev.getContent(), m.mxcUrl);
  1444. return http ? { name: m.name, url: http, mimeType: m.mimeType, size: m.size ?? -1 } : undefined;
  1445. })();
  1446. const msg: MxMessage = {
  1447. roomId: ev.getRoomId() ?? '',
  1448. eventId: ev.getId() ?? '',
  1449. sender: ev.getSender() ?? '',
  1450. threadId: extractThreadId(ev.getContent()) ?? undefined,
  1451. // 与 timelineToMessages 同一套解析(两处必须一致,否则刷新前后归属不同)
  1452. dispatch: extractDispatch(ev.getContent()),
  1453. sessionId: extractSessionId(ev.getContent()),
  1454. localId: extractLocalId(ev.getContent()),
  1455. replyTo: extractReplyTo(ev.getContent()),
  1456. fmodeKind: extractFmodeKind(ev.getContent()),
  1457. body: (ev.getContent()['body'] as string) ?? '',
  1458. media,
  1459. ts: ev.getTs(),
  1460. kind: kindOf(msgtype),
  1461. // 仅真正的实时事件为 true;初次 /sync 的历史回填为 false
  1462. live: !!data?.liveEvent,
  1463. };
  1464. for (const h of this.messageHandlers) {
  1465. try {
  1466. h(msg);
  1467. } catch {
  1468. /* 单个订阅者异常不影响其它订阅者 */
  1469. }
  1470. }
  1471. }) as never);
  1472. }
  1473. // ---------- 工具 ----------
  1474. /** `@<objectId 小写>:fmode.cn`(v4.2 P3 约定) */
  1475. static agentMxId(objectId: string): string {
  1476. return `@${objectId.toLowerCase()}:${MATRIX_SERVER_NAME}`;
  1477. }
  1478. /** 解析文件卡片 body;非卡片返回 null */
  1479. static parseFileCard(
  1480. body: string,
  1481. ): { name: string; size: number; mimeType: string; url: string } | null {
  1482. if (!body || body[0] !== '{') return null;
  1483. try {
  1484. const j = JSON.parse(body);
  1485. if ((j.kind === 'file' || j.type === 'fcard') && j.url && j.name) {
  1486. return {
  1487. name: String(j.name),
  1488. size: Number(j.size) || -1,
  1489. mimeType: String(j.mimeType ?? j.mime ?? ''),
  1490. url: String(j.url),
  1491. };
  1492. }
  1493. return null;
  1494. } catch {
  1495. return null;
  1496. }
  1497. }
  1498. private requireClient(): MatrixClient {
  1499. if (!this.client) throw new Error('尚未连接,请稍后重试');
  1500. return this.client;
  1501. }
  1502. /** 动态导入 SDK(NFR-02:不进主包) */
  1503. private async load(): Promise<typeof import('matrix-js-sdk')> {
  1504. if (!this.sdk) {
  1505. this.sdk = await import('matrix-js-sdk');
  1506. /*
  1507. * 🔴 SDK 日志降噪(否则控制台卡顿)。
  1508. *
  1509. * matrix-js-sdk 的 logger 默认输出 **warn 及以上**,且每条都带完整调用栈
  1510. * (push rule 缺失的告警会在每次 startClient 时批量刷屏,
  1511. * 叠加 zone.js 的调度栈,一次上百行)。
  1512. * 这些告警不影响收发:push rule 是服务端推送规则,浏览器端本就不用。
  1513. * 故收敛为只保留 error;取不到 logger 时静默跳过,绝不影响连接。
  1514. */
  1515. const sdkAny = this.sdk as { logger?: { setLevel?: (l: string) => void } };
  1516. if (sdkAny.logger && typeof sdkAny.logger.setLevel === 'function') {
  1517. sdkAny.logger.setLevel('error');
  1518. }
  1519. }
  1520. return this.sdk;
  1521. }
  1522. }
  1523. /** homeserver 常量(与《API-契约》§1.2 一致;地址真源见 environment.base.ts) */
  1524. /**
  1525. * 容器推送回合状态用的自定义 Matrix 事件类型(2026-11-12)。
  1526. *
  1527. * 与 ✅ reaction 的区别:✅ = 「这条消息发完了」;本事件 = 「这一轮结束了」。
  1528. */
  1529. export const TURN_STATUS_EVENT = 'com.fmode.turn_status';
  1530. /**
  1531. * 初次 `/sync` 每个房间带多少条历史事件(2026-11-12 实测调优)。
  1532. *
  1533. * ## 为什么是 5 而不是 50
  1534. *
  1535. * 一房间一会话后房间数持续增长(实测 184 个),每次 `/sync` 拉的量是
  1536. * `房间数 × limit`。实测对比(同一账号、同一时刻):
  1537. *
  1538. * | limit | 耗时 | 事件数 |
  1539. * |-------|--------|--------|
  1540. * | 50 | 1593ms | 4010 |
  1541. * | 5 | **451ms** | 920 |
  1542. *
  1543. * 降到 5 后首屏快 3.5 倍。**不会丢历史** ——
  1544. * 打开会话时 `ChatService.backfillSessionTimeline()` 会按会话回溯补齐,
  1545. * 命中即停,多数情况 1~2 轮。
  1546. */
  1547. export const INITIAL_SYNC_LIMIT = 5;
  1548. export const MATRIX_HS = environment.endpoints.matrixHs;
  1549. export const MATRIX_SERVER_NAME = environment.endpoints.matrixServerName;