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