| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903 |
- import { effect, inject, Injectable, OnDestroy, signal } from '@angular/core';
- import type { MatrixClient, MatrixEvent, Room, RoomMember } from 'matrix-js-sdk';
- import { environment } from '../../environments/environment';
- import { SessionService } from './session.service';
- /**
- * Matrix 客户端 —— `matrix-js-sdk` 的**唯一收敛点**
- *
- * 依据《数据访问层设计.md》§五(每域唯一 owner)与 SPEC FR-04。
- *
- * ## NFR-02:SDK 不得进主包
- *
- * `matrix-js-sdk` **只在首次真正需要时动态导入**(`await import()`),
- * 顶层仅 `import type`(编译期擦除,零运行时依赖)。
- * 验证:主包 chunk 中不得出现 `matrix-js-sdk` 字样。
- *
- * ## 身份规则(fmode-hub v4.2 P3 约定)
- *
- * - 用户 `mxid` = `@<用户 objectId 小写>:fmode.cn`
- * - Agent `mxid` = `@<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;
- }
- /** 同步连接状态(驱动 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` 引用)都不是线程,不能混为一谈。
- */
- 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;
- }
- /**
- * 构造 `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, parseNativeMedia } from './matrix-media';
- const TEXT_MSGTYPES = ['m.text', 'm.notice', 'm.emote'];
- @Injectable({ providedIn: 'root' })
- export class MatrixClientService implements OnDestroy {
- /** 懒加载的 SDK 模块(首次 `load()` 时填充) */
- private sdk: typeof import('matrix-js-sdk') | null = null;
- private client: MatrixClient | null = null;
- // ---------- 连接状态(供 chat 页徽标消费) ----------
- private readonly _syncState = signal<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;
- }
- /**
- * 全局消息流 —— **全项目唯一一处** `Room.timeline` 订阅。
- *
- * 多路复用给:① 聊天页(当前房间)② 未读计数(所有房间)。
- * 这样 sync 常驻时也只有一个监听器,不会重复触发。
- */
- private readonly messageHandlers = new Set<(m: MxMessage) => void>();
- private globalBound = false;
- private readonly session = inject(SessionService);
- constructor() {
- // 🔴 登出即断连:不依赖页面记得调用 disconnect()。
- // 任一页面的 doLogout() 清掉 token 后,这里自动收敛。
- effect(() => {
- if (!this.session.token() && this.client) this.disconnect();
- });
- }
- ngOnDestroy(): void {
- this.disconnect();
- }
- // ---------- 生命周期(会话级常驻) ----------
- /**
- * 建立**会话级**连接(整个登录会话只调用一次,由 SessionService 持有者驱动)。
- *
- * 🔴 与旧实现的关键差异:这是**常驻**的 —— 不在聊天页 `close()` 时断开,
- * 否则离开会话后收不到消息,未读计数永远无法累加。
- * 断开只发生在**退出登录**时(`disconnect()`)。
- *
- * @param accessToken 云函数返回的 token(**不落 localStorage**,仅存内存)
- * @param userId 形如 `@sr2wipsdyq:fmode.cn`
- * @param deviceId 云函数返回的 deviceId(可空)
- */
- async connectSession(accessToken: string, userId: string, deviceId?: string): Promise<void> {
- if (!accessToken || !userId) {
- throw new Error('登录凭据不完整,请重新进入');
- }
- // 同身份重复调用:幂等,不打断现有 sync
- if (this.client && this.token === accessToken && this._userId() === userId) {
- return;
- }
- this.disconnect();
- const sdk = await this.load();
- this.token = accessToken;
- this.deviceId = deviceId ?? '';
- this._userId.set(userId);
- this.stopped = false;
- this.initialSyncDone = false;
- this._syncState.set('connecting');
- this.client = sdk.createClient({
- baseUrl: MATRIX_HS,
- accessToken,
- userId,
- ...(deviceId ? { deviceId } : {}),
- /*
- * 关闭 VoIP(2026-10-31)—— 本项目不做语音通话(FR-11「暂不实现」)。
- *
- * 🔴 必须用 SDK 真实的选项名 `disableVoip`(源码 client.js:521):
- * 它会让 `supportsVoip()` 返回 false,从而跳过 turnServer 轮询。
- * 之前误用 `fallbackICEServerAllowed`(不是 createClient 的选项)→ 完全无效,
- * 控制台仍每 10s 报一次 `GET /voip/turnServer 404 M_NOT_FOUND`。
- */
- disableVoip: true,
- });
- /*
- * 压掉 client 自身的 info/warn 日志(2026-11-01)。
- *
- * 🔴 控制台被大量「Missing/Adding default global push rule …」刷屏:
- * 这是 SDK 拿服务端 push rules 与自身默认表对比时的提示(`client.logger`),
- * 属于**服务端规则版本与 SDK 版本不一致**的既有状况,不影响收发消息。
- * 之前只设了模块级 `logger`,而 client 有独立的 logger 实例,所以没生效。
- * 这里把 client 的 logger 提到 error 级,只保留真正需要处理的错误。
- */
- try {
- const cl = this.client as unknown as { logger?: { setLevel?: (l: string) => void } };
- cl.logger?.setLevel?.('error');
- } catch {
- /* 结构不符时忽略:不影响主流程 */
- }
- // sync 状态 → 连接状态(FR-04 AC-04.9:断线可自动重连)
- this.client.on(sdk.ClientEvent.Sync, (state: string) => {
- if (this.stopped) return;
- if (state === 'SYNCING' || state === 'PREPARED') this._syncState.set('connected');
- else if (state === 'ERROR') this._syncState.set('disconnected');
- else if (state === 'RECONNECTING') this._syncState.set('connecting');
- // 🔴 首个 SYNCING = 初次同步完成 → 之后的消息才算「实时」
- if (state === 'SYNCING') {
- this.initialSyncDone = true;
- /*
- * 🔴 通知「timeline 已就绪」。
- *
- * `startClient()` 是异步的:`openReal()` 里紧接着读 `timelineToMessages()`
- * 时,房间的历史事件**往往还没落到本地 store** → 拿到空数组;
- * 而后续只依赖 realtime 事件补消息,历史内容永远补不上
- * (表现为:刷新后会话空白、重开看不到之前的内容)。
- *
- * 每次 SYNCING 递增版本号,聊天页据此重读一次 timeline。
- */
- this._storeVersion.update((n) => n + 1);
- }
- });
- // 收到邀请自动接受(Agent 侧建房的场景)
- this.client.on(sdk.RoomMemberEvent.Membership, (_ev: MatrixEvent, member: RoomMember) => {
- if (this.stopped) return;
- if (member.membership === 'invite' && member.userId === userId) {
- void this.client?.joinRoom(member.roomId).catch(() => undefined);
- }
- });
- // 全局消息流(未读计数 + 聊天页复用)
- this.bindGlobalMessageStream();
- this.client.startClient({ initialSyncLimit: 50 });
- }
- /** 断开并清理(**仅退出登录时**调用) */
- disconnect(): void {
- this.stopped = true;
- this.initialSyncDone = false;
- this.messageHandlers.clear();
- this.globalBound = false;
- if (this.client) {
- try {
- this.client.stopClient();
- } catch {
- /* 忽略:销毁期异常不阻塞清理 */
- }
- try {
- this.client.removeAllListeners();
- } catch {
- /* 忽略 */
- }
- }
- this.client = null;
- this.token = '';
- this.deviceId = '';
- this._userId.set('');
- this._syncState.set('disconnected');
- }
- /** 是否已建立会话级连接 */
- get connected(): boolean {
- return !!this.client;
- }
- // ---------- 房间 ----------
- /**
- * 确保与目标 Agent 的 DM 房间可用。
- *
- * 优先用 provision 返回的 `roomId`(绑定表真相);未提供时按
- * `m.direct` accountData 查找,仍无则创建。
- */
- async ensureRoom(
- agentMxId: string,
- opts?: { roomId?: string | null; agentName?: string },
- ): Promise<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?.();
- }
- // ---------- 消息收发 ----------
- /**
- * 发文本(AC-04.2)
- *
- * @param threadRootId 线程根 event_id。**不传则容器视为新会话**
- * (`MATRIX_AUTO_THREAD=true` 会把每条无 thread 消息切成独立会话)。
- * 多会话场景必须传同一个根。
- * @param lastEventId 线程内最近一条消息,仅用于 `m.in_reply_to` 降级展示
- */
- async sendText(
- roomId: string,
- text: string,
- threadRootId?: string,
- lastEventId?: string,
- ): Promise<{ event_id?: string }> {
- const c = this.requireClient();
- const sdk = await this.load();
- return c.sendEvent(roomId, sdk.EventType.RoomMessage, {
- msgtype: sdk.MsgType.Text,
- body: text,
- ...(threadRootId ? { 'm.relates_to': buildThreadRelation(threadRootId, lastEventId) } : {}),
- });
- }
- /**
- * 发一条 **@ 指定成员** 的消息。
- *
- * ## 🔴 为什么不能只在文本里写「@名字」
- *
- * Matrix 的 mention 是**结构化**的:消息内容里要有
- * `m.mentions: { user_ids: [...] }`,客户端/机器人据此判断「这条是不是在叫我」。
- * 只写文本 `@lj-test 你好` 对方**收不到任何 mention 信号**。
- *
- * P0 实测:容器靠 mention 判断是否需要回应(`MATRIX_REQUIRE_MENTION=true`),
- * 所以这里必须同时做三件事:
- * ① 正文里插入可读的 `@名字`
- * ② `format: org.matrix.custom.html` + `formatted_body` 里放真正的链接
- * ③ `m.mentions.user_ids` 列出被 @ 的人
- */
- async sendMention(
- roomId: string,
- userId: string,
- text: string,
- displayName?: string,
- ): Promise<{ event_id?: string }> {
- return this.sendMentionMany(roomId, [userId], text, displayName ? [displayName] : undefined);
- }
- /** 同时 @ 多个成员(群内协作) */
- async sendMentionMany(
- roomId: string,
- userIds: string[],
- text: string,
- displayNames?: string[],
- threadRootId?: string,
- lastEventId?: string,
- ): Promise<{ event_id?: string }> {
- const c = this.requireClient();
- const sdk = await this.load();
- const names = userIds.map((id, i) => displayNames?.[i] || id.replace(/^@/, '').split(':')[0]);
- const prefix = names.map((n) => `@${n}`).join(' ');
- const body = `${prefix} ${text}`;
- const htmlPrefix = userIds
- .map(
- (id, i) =>
- `<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,
- ): 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) } : {}),
- };
- return c.sendEvent(roomId, sdk.EventType.RoomMessage, content as never);
- }
- /**
- * 发文件卡片(`m.text` + JSON body)。
- *
- * @deprecated 容器不解析这种形态(实测)。**发文件请用 `sendFile()`**。
- * 保留仅为兼容尚未迁移的调用点,新代码不要用。
- */
- async sendFileCard(
- roomId: string,
- card: { name: string; size: number; mimeType: string; url: string },
- threadRootId?: string,
- lastEventId?: string,
- ): Promise<{ event_id?: string }> {
- const c = this.requireClient();
- const sdk = await this.load();
- return c.sendEvent(roomId, sdk.EventType.RoomMessage, {
- msgtype: sdk.MsgType.Text,
- body: JSON.stringify({ kind: 'file', ...card }),
- ...(threadRootId ? { 'm.relates_to': buildThreadRelation(threadRootId, lastEventId) } : {}),
- });
- }
- /** 当前 timeline → 归一消息(时间正序) */
- timelineToMessages(roomId: string): MxMessage[] {
- const room = this.client?.getRoom(roomId);
- if (!room) return [];
- return room
- .getLiveTimeline()
- .getEvents()
- .filter((e) => e.getType() === 'm.room.message')
- /*
- * 🔴 也要接受原生附件(m.file / m.image …)。
- * 实测:容器交付成果用的是 m.file,此前只认 m.text,
- * 导致交付物在 App 里完全看不到。
- */
- .filter((e) => {
- const mt = String(e.getContent()['msgtype'] ?? '');
- return TEXT_MSGTYPES.includes(mt) || isMediaMsgType(mt);
- })
- .map((e) => ({
- roomId,
- eventId: e.getId() ?? '',
- sender: e.getSender() ?? '',
- senderName: senderDisplayName(room, e.getSender() ?? ''),
- mentions: extractMentions(e.getContent()),
- // 线程根 = 自身 event_id,线程内回复 = 根 event_id,无 thread = undefined
- threadId: extractThreadId(e.getContent()) ?? undefined,
- body: (e.getContent()['body'] as string) ?? '',
- // 原生附件 → 转成 FileCard(mxc:// 在这里转成可访问的 HTTP 地址)
- media: (() => {
- const m = parseNativeMedia(e.getContent());
- if (!m) return undefined;
- const http = this.mxcUrlToHttp(m.mxcUrl);
- return http
- ? { name: m.name, url: http, mimeType: m.mimeType, size: m.size ?? 0 }
- : undefined;
- })(),
- ts: e.getTs(),
- kind: kindOf(String(e.getContent()['msgtype'] ?? '')),
- // 从 timeline 读出的都视为历史(未读不依赖此值)
- live: false,
- }));
- }
- /**
- * 列出**群房间**(房间里有多个成员,且不是 1:1 DM)。
- *
- * 群聊功能需要用这个发现「我参加了哪些工作组」。
- * 判定:成员数 > 2 或房间未标记 is_direct。
- */
- /**
- * 实时拉房间成员(绕过 SDK 缓存)。
- *
- * 🔴 实测:`room.getJoinedMembers()` 返回的是 SDK 本地缓存,
- * 可能是旧的 —— 组长明明已不在群里,App 仍显示它在。
- * 查「组长在不在群」这种关键判断必须用实时数据。
- */
- async fetchRoomMembers(roomId: string): Promise<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)}`;
- }
- 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);
- };
- }
- /** 订阅对方 typing 状态;返回解绑函数 */
- onTyping(roomId: string, cb: (typing: boolean, userId: string) => void): () => void {
- const c = this.requireClient();
- const handler = (ev: MatrixEvent, member: RoomMember) => {
- if (member?.roomId !== roomId || ev.getType() !== 'm.typing') return;
- cb(!!member.typing, member.userId ?? '');
- };
- c.on('RoomMember.typing' as never, handler as never);
- return () => {
- try {
- c.off('RoomMember.typing' as never, handler as never);
- } catch {
- /* 忽略 */
- }
- };
- }
- /** 绑定全局消息流(只绑一次,幂等) */
- private bindGlobalMessageStream(): void {
- const c = this.client;
- if (!c || this.globalBound) return;
- this.globalBound = true;
- c.on('Room.timeline' as never, ((ev: MatrixEvent, _room?: unknown, _toStart?: boolean, data?: { liveEvent?: boolean }) => {
- if (ev.getType() !== 'm.room.message') return;
- // 跳过本地未发送回执(echo 会再触发一次)
- const sending = (ev as unknown as { isSending?: () => boolean }).isSending;
- if (typeof sending === 'function' && sending.call(ev)) return;
- const msgtype = String(ev.getContent()['msgtype'] ?? '');
- if (!TEXT_MSGTYPES.includes(msgtype)) return;
- const msg: MxMessage = {
- roomId: ev.getRoomId() ?? '',
- eventId: ev.getId() ?? '',
- sender: ev.getSender() ?? '',
- threadId: extractThreadId(ev.getContent()) ?? undefined,
- body: (ev.getContent()['body'] as string) ?? '',
- ts: ev.getTs(),
- kind: kindOf(msgtype),
- // 仅真正的实时事件为 true;初次 /sync 的历史回填为 false
- live: !!data?.liveEvent,
- };
- for (const h of this.messageHandlers) {
- try {
- h(msg);
- } catch {
- /* 单个订阅者异常不影响其它订阅者 */
- }
- }
- }) as never);
- }
- // ---------- 工具 ----------
- /** `@<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) || 0,
- mimeType: String(j.mimeType ?? j.mime ?? ''),
- url: String(j.url),
- };
- }
- return null;
- } catch {
- return null;
- }
- }
- private requireClient(): MatrixClient {
- if (!this.client) throw new Error('尚未连接,请稍后重试');
- return this.client;
- }
- /** 动态导入 SDK(NFR-02:不进主包) */
- private async load(): Promise<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) */
- export const MATRIX_HS = environment.endpoints.matrixHs;
- export const MATRIX_SERVER_NAME = environment.endpoints.matrixServerName;
|