diff --git a/docs/cli/pairing.md b/docs/cli/pairing.md index 13ad8a59948..e7c3e4d7ff3 100644 --- a/docs/cli/pairing.md +++ b/docs/cli/pairing.md @@ -29,4 +29,5 @@ openclaw pairing approve --channel telegram --account work --notify - Channel input: pass it positionally (`pairing list telegram`) or with `--channel `. - `pairing list` supports `--account ` for multi-account channels. - `pairing approve` supports `--account ` and `--notify`. +- When you combine `pairing approve --account --notify`, OpenClaw sends the approval notice from that same account. - If only one pairing-capable channel is configured, `pairing approve ` is allowed. diff --git a/extensions/matrix/scripts/live-basic-send.ts b/extensions/matrix/scripts/live-basic-send.ts index c4a792e7d72..f0469748c50 100644 --- a/extensions/matrix/scripts/live-basic-send.ts +++ b/extensions/matrix/scripts/live-basic-send.ts @@ -14,6 +14,7 @@ async function main() { password: auth.password, deviceId: auth.deviceId, encryption: false, + accountId: auth.accountId, }); const targetUserId = process.argv[2]?.trim() || "@user:example.org"; diff --git a/extensions/matrix/scripts/live-cross-signing-probe.ts b/extensions/matrix/scripts/live-cross-signing-probe.ts index 6b5051a112a..3fbddaf0714 100644 --- a/extensions/matrix/scripts/live-cross-signing-probe.ts +++ b/extensions/matrix/scripts/live-cross-signing-probe.ts @@ -25,6 +25,7 @@ async function main() { password: auth.password, deviceId: auth.deviceId, encryption: true, + accountId: auth.accountId, }); const initCrypto = (client as unknown as { initializeCryptoIfNeeded?: () => Promise }) .initializeCryptoIfNeeded; diff --git a/extensions/matrix/scripts/live-e2ee-room-state.ts b/extensions/matrix/scripts/live-e2ee-room-state.ts index b8af65f3812..015febd7cf6 100644 --- a/extensions/matrix/scripts/live-e2ee-room-state.ts +++ b/extensions/matrix/scripts/live-e2ee-room-state.ts @@ -23,6 +23,7 @@ async function main() { password: auth.password, deviceId: auth.deviceId, encryption: false, + accountId: auth.accountId, }); try { diff --git a/extensions/matrix/scripts/live-e2ee-send-room.ts b/extensions/matrix/scripts/live-e2ee-send-room.ts index 02b253ce385..85e87e7a0d3 100644 --- a/extensions/matrix/scripts/live-e2ee-send-room.ts +++ b/extensions/matrix/scripts/live-e2ee-send-room.ts @@ -31,6 +31,7 @@ async function main() { password: auth.password, deviceId: auth.deviceId, encryption: true, + accountId: auth.accountId, }); const stamp = new Date().toISOString(); diff --git a/extensions/matrix/scripts/live-e2ee-send.ts b/extensions/matrix/scripts/live-e2ee-send.ts index aec6b65bdbb..6d6977622c9 100644 --- a/extensions/matrix/scripts/live-e2ee-send.ts +++ b/extensions/matrix/scripts/live-e2ee-send.ts @@ -26,6 +26,7 @@ async function main() { password: auth.password, deviceId: auth.deviceId, encryption: true, + accountId: auth.accountId, }); const stamp = new Date().toISOString(); diff --git a/extensions/matrix/scripts/live-e2ee-wait-reply.ts b/extensions/matrix/scripts/live-e2ee-wait-reply.ts index 9eb88feb197..cf415bd1ede 100644 --- a/extensions/matrix/scripts/live-e2ee-wait-reply.ts +++ b/extensions/matrix/scripts/live-e2ee-wait-reply.ts @@ -40,6 +40,7 @@ async function main() { password: auth.password, deviceId: auth.deviceId, encryption: true, + accountId: auth.accountId, }); try { diff --git a/extensions/matrix/scripts/live-read-room.ts b/extensions/matrix/scripts/live-read-room.ts index 0ff9c473efb..2bf3df85d09 100644 --- a/extensions/matrix/scripts/live-read-room.ts +++ b/extensions/matrix/scripts/live-read-room.ts @@ -21,6 +21,7 @@ async function main() { password: auth.password, deviceId: auth.deviceId, encryption: false, + accountId: auth.accountId, }); try { diff --git a/extensions/matrix/src/channel.account-paths.test.ts b/extensions/matrix/src/channel.account-paths.test.ts new file mode 100644 index 00000000000..bd9d13651ca --- /dev/null +++ b/extensions/matrix/src/channel.account-paths.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const sendMessageMatrixMock = vi.hoisted(() => vi.fn()); +const probeMatrixMock = vi.hoisted(() => vi.fn()); +const resolveMatrixAuthMock = vi.hoisted(() => vi.fn()); + +vi.mock("./matrix/send.js", async () => { + const actual = await vi.importActual("./matrix/send.js"); + return { + ...actual, + sendMessageMatrix: (...args: unknown[]) => sendMessageMatrixMock(...args), + }; +}); + +vi.mock("./matrix/probe.js", async () => { + const actual = await vi.importActual("./matrix/probe.js"); + return { + ...actual, + probeMatrix: (...args: unknown[]) => probeMatrixMock(...args), + }; +}); + +vi.mock("./matrix/client.js", async () => { + const actual = await vi.importActual("./matrix/client.js"); + return { + ...actual, + resolveMatrixAuth: (...args: unknown[]) => resolveMatrixAuthMock(...args), + }; +}); + +const { matrixPlugin } = await import("./channel.js"); + +describe("matrix account path propagation", () => { + beforeEach(() => { + vi.clearAllMocks(); + sendMessageMatrixMock.mockResolvedValue({ + messageId: "$sent", + roomId: "!room:example.org", + }); + probeMatrixMock.mockResolvedValue({ + ok: true, + error: null, + status: null, + elapsedMs: 5, + userId: "@poe:example.org", + }); + resolveMatrixAuthMock.mockResolvedValue({ + accountId: "poe", + homeserver: "https://matrix.example.org", + userId: "@poe:example.org", + accessToken: "poe-token", + }); + }); + + it("forwards accountId when notifying pairing approval", async () => { + await matrixPlugin.pairing!.notifyApproval?.({ + cfg: {}, + id: "@user:example.org", + accountId: "poe", + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledWith( + "user:@user:example.org", + expect.any(String), + { accountId: "poe" }, + ); + }); + + it("forwards accountId to matrix probes", async () => { + await matrixPlugin.status!.probeAccount?.({ + cfg: {} as never, + timeoutMs: 500, + account: { + accountId: "poe", + } as never, + }); + + expect(resolveMatrixAuthMock).toHaveBeenCalledWith({ + cfg: {}, + accountId: "poe", + }); + expect(probeMatrixMock).toHaveBeenCalledWith({ + homeserver: "https://matrix.example.org", + accessToken: "poe-token", + userId: "@poe:example.org", + timeoutMs: 500, + accountId: "poe", + }); + }); +}); diff --git a/extensions/matrix/src/directory-live.test.ts b/extensions/matrix/src/directory-live.test.ts index d499574bc8d..8d856eb6fd9 100644 --- a/extensions/matrix/src/directory-live.test.ts +++ b/extensions/matrix/src/directory-live.test.ts @@ -12,6 +12,7 @@ describe("matrix directory live", () => { beforeEach(() => { vi.mocked(resolveMatrixAuth).mockReset(); vi.mocked(resolveMatrixAuth).mockResolvedValue({ + accountId: "assistant", homeserver: "https://matrix.example.org", userId: "@bot:example.org", accessToken: "test-token", diff --git a/extensions/matrix/src/matrix/actions/client.test.ts b/extensions/matrix/src/matrix/actions/client.test.ts index d05c193bdeb..10768836a95 100644 --- a/extensions/matrix/src/matrix/actions/client.test.ts +++ b/extensions/matrix/src/matrix/actions/client.test.ts @@ -42,6 +42,7 @@ describe("resolveActionClient", () => { getActiveMatrixClientMock.mockReturnValue(null); isBunRuntimeMock.mockReturnValue(false); resolveMatrixAuthMock.mockResolvedValue({ + accountId: "default", homeserver: "https://matrix.example.org", userId: "@bot:example.org", accessToken: "token", @@ -53,7 +54,7 @@ describe("resolveActionClient", () => { ({ cfg, accountId }: { cfg: unknown; accountId?: string | null }) => ({ cfg, env: process.env, - accountId: accountId ?? undefined, + accountId: accountId ?? "default", resolved: { homeserver: "https://matrix.example.org", userId: "@bot:example.org", @@ -129,6 +130,7 @@ describe("resolveActionClient", () => { }, }); resolveMatrixAuthMock.mockResolvedValue({ + accountId: "ops", homeserver: "https://ops.example.org", userId: "@ops:example.org", accessToken: "ops-token", diff --git a/extensions/matrix/src/matrix/actions/client.ts b/extensions/matrix/src/matrix/actions/client.ts index 088c7870045..5bd72347fac 100644 --- a/extensions/matrix/src/matrix/actions/client.ts +++ b/extensions/matrix/src/matrix/actions/client.ts @@ -43,7 +43,7 @@ export async function resolveActionClient( deviceId: auth.deviceId, encryption: auth.encryption, localTimeoutMs: opts.timeoutMs, - accountId: authContext.accountId, + accountId: auth.accountId, autoBootstrapCrypto: false, }); await client.prepareForOneOff(); diff --git a/extensions/matrix/src/matrix/actions/messages.test.ts b/extensions/matrix/src/matrix/actions/messages.test.ts new file mode 100644 index 00000000000..1ed2291d916 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/messages.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MatrixClient } from "../sdk.js"; +import { readMatrixMessages } from "./messages.js"; + +function createMessagesClient(params: { + chunk: Array>; + hydratedChunk?: Array>; + pollRoot?: Record; + pollRelations?: Array>; +}) { + const doRequest = vi.fn(async () => ({ + chunk: params.chunk, + start: "start-token", + end: "end-token", + })); + const hydrateEvents = vi.fn( + async (_roomId: string, _events: Array>) => + (params.hydratedChunk ?? params.chunk) as any, + ); + const getEvent = vi.fn(async () => params.pollRoot ?? null); + const getRelations = vi.fn(async () => ({ + events: params.pollRelations ?? [], + nextBatch: null, + prevBatch: null, + })); + + return { + client: { + doRequest, + hydrateEvents, + getEvent, + getRelations, + stop: vi.fn(), + } as unknown as MatrixClient, + doRequest, + hydrateEvents, + getEvent, + getRelations, + }; +} + +describe("matrix message actions", () => { + it("includes poll snapshots when reading message history", async () => { + const { client, doRequest, getEvent, getRelations } = createMessagesClient({ + chunk: [ + { + event_id: "$vote", + sender: "@bob:example.org", + type: "m.poll.response", + origin_server_ts: 20, + content: { + "m.poll.response": { answers: ["a1"] }, + "m.relates_to": { rel_type: "m.reference", event_id: "$poll" }, + }, + }, + { + event_id: "$msg", + sender: "@alice:example.org", + type: "m.room.message", + origin_server_ts: 10, + content: { + msgtype: "m.text", + body: "hello", + }, + }, + ], + pollRoot: { + event_id: "$poll", + sender: "@alice:example.org", + type: "m.poll.start", + origin_server_ts: 1, + content: { + "m.poll.start": { + question: { "m.text": "Favorite fruit?" }, + kind: "m.poll.disclosed", + max_selections: 1, + answers: [ + { id: "a1", "m.text": "Apple" }, + { id: "a2", "m.text": "Strawberry" }, + ], + }, + }, + }, + pollRelations: [ + { + event_id: "$vote", + sender: "@bob:example.org", + type: "m.poll.response", + origin_server_ts: 20, + content: { + "m.poll.response": { answers: ["a1"] }, + "m.relates_to": { rel_type: "m.reference", event_id: "$poll" }, + }, + }, + ], + }); + + const result = await readMatrixMessages("room:!room:example.org", { client, limit: 2.9 }); + + expect(doRequest).toHaveBeenCalledWith( + "GET", + expect.stringContaining("/rooms/!room%3Aexample.org/messages"), + expect.objectContaining({ limit: 2 }), + ); + expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$poll"); + expect(getRelations).toHaveBeenCalledWith( + "!room:example.org", + "$poll", + "m.reference", + undefined, + { + from: undefined, + }, + ); + expect(result.messages).toEqual([ + expect.objectContaining({ + eventId: "$poll", + body: expect.stringContaining("1. Apple (1 vote)"), + msgtype: "m.text", + }), + expect.objectContaining({ + eventId: "$msg", + body: "hello", + }), + ]); + }); + + it("dedupes multiple poll events for the same poll within one read page", async () => { + const { client, getEvent } = createMessagesClient({ + chunk: [ + { + event_id: "$vote", + sender: "@bob:example.org", + type: "m.poll.response", + origin_server_ts: 20, + content: { + "m.poll.response": { answers: ["a1"] }, + "m.relates_to": { rel_type: "m.reference", event_id: "$poll" }, + }, + }, + { + event_id: "$poll", + sender: "@alice:example.org", + type: "m.poll.start", + origin_server_ts: 1, + content: { + "m.poll.start": { + question: { "m.text": "Favorite fruit?" }, + answers: [{ id: "a1", "m.text": "Apple" }], + }, + }, + }, + ], + pollRoot: { + event_id: "$poll", + sender: "@alice:example.org", + type: "m.poll.start", + origin_server_ts: 1, + content: { + "m.poll.start": { + question: { "m.text": "Favorite fruit?" }, + answers: [{ id: "a1", "m.text": "Apple" }], + }, + }, + }, + pollRelations: [], + }); + + const result = await readMatrixMessages("room:!room:example.org", { client }); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0]).toEqual( + expect.objectContaining({ + eventId: "$poll", + body: expect.stringContaining("[Poll]"), + }), + ); + expect(getEvent).toHaveBeenCalledTimes(1); + }); + + it("uses hydrated history events so encrypted poll entries can be read", async () => { + const { client, hydrateEvents } = createMessagesClient({ + chunk: [ + { + event_id: "$enc", + sender: "@bob:example.org", + type: "m.room.encrypted", + origin_server_ts: 20, + content: {}, + }, + ], + hydratedChunk: [ + { + event_id: "$vote", + sender: "@bob:example.org", + type: "m.poll.response", + origin_server_ts: 20, + content: { + "m.poll.response": { answers: ["a1"] }, + "m.relates_to": { rel_type: "m.reference", event_id: "$poll" }, + }, + }, + ], + pollRoot: { + event_id: "$poll", + sender: "@alice:example.org", + type: "m.poll.start", + origin_server_ts: 1, + content: { + "m.poll.start": { + question: { "m.text": "Favorite fruit?" }, + answers: [{ id: "a1", "m.text": "Apple" }], + }, + }, + }, + pollRelations: [], + }); + + const result = await readMatrixMessages("room:!room:example.org", { client }); + + expect(hydrateEvents).toHaveBeenCalledWith( + "!room:example.org", + expect.arrayContaining([expect.objectContaining({ event_id: "$enc" })]), + ); + expect(result.messages).toHaveLength(1); + expect(result.messages[0]?.eventId).toBe("$poll"); + }); +}); diff --git a/extensions/matrix/src/matrix/actions/messages.ts b/extensions/matrix/src/matrix/actions/messages.ts index 4cbe913c0d3..3c8716e2532 100644 --- a/extensions/matrix/src/matrix/actions/messages.ts +++ b/extensions/matrix/src/matrix/actions/messages.ts @@ -1,3 +1,5 @@ +import { fetchMatrixPollMessageSummary, resolveMatrixPollRootEventId } from "../poll-summary.js"; +import { isPollEventType } from "../poll-types.js"; import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js"; import { withResolvedActionClient } from "./client.js"; import { resolveMatrixActionLimit } from "./limits.js"; @@ -99,10 +101,30 @@ export async function readMatrixMessages( from: token, }, )) as { chunk: MatrixRawEvent[]; start?: string; end?: string }; - const messages = res.chunk - .filter((event) => event.type === EventType.RoomMessage) - .filter((event) => !event.unsigned?.redacted_because) - .map(summarizeMatrixRawEvent); + const hydratedChunk = await client.hydrateEvents(resolvedRoom, res.chunk); + const seenPollRoots = new Set(); + const messages: MatrixMessageSummary[] = []; + for (const event of hydratedChunk) { + if (event.unsigned?.redacted_because) { + continue; + } + if (event.type === EventType.RoomMessage) { + messages.push(summarizeMatrixRawEvent(event)); + continue; + } + if (!isPollEventType(event.type)) { + continue; + } + const pollRootId = resolveMatrixPollRootEventId(event); + if (!pollRootId || seenPollRoots.has(pollRootId)) { + continue; + } + seenPollRoots.add(pollRootId); + const pollSummary = await fetchMatrixPollMessageSummary(client, resolvedRoom, event); + if (pollSummary) { + messages.push(pollSummary); + } + } return { messages, nextBatch: res.end ?? null, diff --git a/extensions/matrix/src/matrix/actions/summary.ts b/extensions/matrix/src/matrix/actions/summary.ts index 5fd81401183..b0168d4f2c6 100644 --- a/extensions/matrix/src/matrix/actions/summary.ts +++ b/extensions/matrix/src/matrix/actions/summary.ts @@ -1,3 +1,4 @@ +import { fetchMatrixPollMessageSummary } from "../poll-summary.js"; import type { MatrixClient } from "../sdk.js"; import { EventType, @@ -67,6 +68,10 @@ export async function fetchEventSummary( if (raw.unsigned?.redacted_because) { return null; } + const pollSummary = await fetchMatrixPollMessageSummary(client, roomId, raw); + if (pollSummary) { + return pollSummary; + } return summarizeMatrixRawEvent(raw); } catch { // Event not found, redacted, or inaccessible - return null diff --git a/extensions/matrix/src/matrix/client-bootstrap.ts b/extensions/matrix/src/matrix/client-bootstrap.ts index 66512291945..d444440b121 100644 --- a/extensions/matrix/src/matrix/client-bootstrap.ts +++ b/extensions/matrix/src/matrix/client-bootstrap.ts @@ -1,11 +1,5 @@ import { createMatrixClient } from "./client.js"; - -type MatrixClientBootstrapAuth = { - homeserver: string; - userId: string; - accessToken: string; - encryption?: boolean; -}; +import type { MatrixAuth } from "./client/types.js"; type MatrixCryptoPrepare = { prepare: (rooms?: string[]) => Promise; @@ -14,9 +8,8 @@ type MatrixCryptoPrepare = { type MatrixBootstrapClient = Awaited>; export async function createPreparedMatrixClient(opts: { - auth: MatrixClientBootstrapAuth; + auth: Pick; timeoutMs?: number; - accountId?: string; }): Promise { const client = await createMatrixClient({ homeserver: opts.auth.homeserver, @@ -24,7 +17,7 @@ export async function createPreparedMatrixClient(opts: { accessToken: opts.auth.accessToken, encryption: opts.auth.encryption, localTimeoutMs: opts.timeoutMs, - accountId: opts.accountId, + accountId: opts.auth.accountId, }); if (opts.auth.encryption && client.crypto) { try { diff --git a/extensions/matrix/src/matrix/client.test.ts b/extensions/matrix/src/matrix/client.test.ts index 3b090eea78b..84bfc9f7e88 100644 --- a/extensions/matrix/src/matrix/client.test.ts +++ b/extensions/matrix/src/matrix/client.test.ts @@ -130,6 +130,7 @@ describe("resolveMatrixAuth", () => { }), ); expect(auth).toMatchObject({ + accountId: "default", homeserver: "https://matrix.example.org", userId: "@bot:example.org", accessToken: "tok-123", @@ -144,7 +145,7 @@ describe("resolveMatrixAuth", () => { deviceId: "DEVICE123", }), expect.any(Object), - undefined, + "default", ); }); @@ -206,6 +207,7 @@ describe("resolveMatrixAuth", () => { }); expect(auth).toMatchObject({ + accountId: "default", homeserver: "https://matrix.example.org", userId: "@bot:example.org", accessToken: "cached-token", @@ -238,6 +240,7 @@ describe("resolveMatrixAuth", () => { const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv }); expect(auth.deviceId).toBe("DEVICE123"); + expect(auth.accountId).toBe("default"); expect(saveMatrixCredentialsMock).toHaveBeenCalledWith( expect.objectContaining({ homeserver: "https://matrix.example.org", @@ -246,7 +249,7 @@ describe("resolveMatrixAuth", () => { deviceId: "DEVICE123", }), expect.any(Object), - undefined, + "default", ); }); @@ -273,6 +276,7 @@ describe("resolveMatrixAuth", () => { expect(doRequestSpy).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami"); expect(auth).toMatchObject({ + accountId: "default", homeserver: "https://matrix.example.org", userId: "@bot:example.org", accessToken: "tok-123", @@ -304,6 +308,7 @@ describe("resolveMatrixAuth", () => { const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv }); expect(auth).toMatchObject({ + accountId: "default", homeserver: "https://matrix.example.org", userId: "@bot:example.org", accessToken: "tok-123", @@ -332,6 +337,7 @@ describe("resolveMatrixAuth", () => { const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv }); expect(auth).toMatchObject({ + accountId: "ops", homeserver: "https://ops.example.org", userId: "@ops:example.org", accessToken: "ops-token", diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts index 1f7c2f491c4..c23f8bc5e0d 100644 --- a/extensions/matrix/src/matrix/client/config.ts +++ b/extensions/matrix/src/matrix/client/config.ts @@ -291,7 +291,7 @@ export function resolveMatrixAuthContext(params?: { }): { cfg: CoreConfig; env: NodeJS.ProcessEnv; - accountId?: string; + accountId: string; resolved: MatrixResolvedConfig; } { const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig); @@ -301,11 +301,12 @@ export function resolveMatrixAuthContext(params?: { const effectiveAccountId = explicitAccountId ?? (defaultResolved.homeserver - ? undefined - : (resolveImplicitMatrixAccountId(cfg, env) ?? undefined)); - const resolved = effectiveAccountId - ? resolveMatrixConfigForAccount(cfg, effectiveAccountId, env) - : defaultResolved; + ? DEFAULT_ACCOUNT_ID + : (resolveImplicitMatrixAccountId(cfg, env) ?? DEFAULT_ACCOUNT_ID)); + const resolved = + effectiveAccountId === DEFAULT_ACCOUNT_ID && defaultResolved.homeserver + ? defaultResolved + : resolveMatrixConfigForAccount(cfg, effectiveAccountId, env); return { cfg, @@ -390,6 +391,7 @@ export async function resolveMatrixAuth(params?: { await touchMatrixCredentials(env, accountId); } return { + accountId, homeserver: resolved.homeserver, userId, accessToken: resolved.accessToken, @@ -404,6 +406,7 @@ export async function resolveMatrixAuth(params?: { if (cachedCredentials) { await touchMatrixCredentials(env, accountId); return { + accountId, homeserver: cachedCredentials.homeserver, userId: cachedCredentials.userId, accessToken: cachedCredentials.accessToken, @@ -446,6 +449,7 @@ export async function resolveMatrixAuth(params?: { } const auth: MatrixAuth = { + accountId, homeserver: resolved.homeserver, userId: login.user_id ?? resolved.userId, accessToken, diff --git a/extensions/matrix/src/matrix/client/shared.test.ts b/extensions/matrix/src/matrix/client/shared.test.ts index 72b708f3c93..bebed5f0cf7 100644 --- a/extensions/matrix/src/matrix/client/shared.test.ts +++ b/extensions/matrix/src/matrix/client/shared.test.ts @@ -2,10 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { MatrixAuth } from "./types.js"; const resolveMatrixAuthMock = vi.hoisted(() => vi.fn()); +const resolveMatrixAuthContextMock = vi.hoisted(() => vi.fn()); const createMatrixClientMock = vi.hoisted(() => vi.fn()); vi.mock("./config.js", () => ({ resolveMatrixAuth: resolveMatrixAuthMock, + resolveMatrixAuthContext: resolveMatrixAuthContextMock, })); vi.mock("./create-client.js", () => ({ @@ -20,6 +22,7 @@ import { function authFor(accountId: string): MatrixAuth { return { + accountId, homeserver: "https://matrix.example.org", userId: `@${accountId}:example.org`, accessToken: `token-${accountId}`, @@ -45,7 +48,16 @@ function createMockClient(name: string) { describe("resolveSharedMatrixClient", () => { beforeEach(() => { resolveMatrixAuthMock.mockReset(); + resolveMatrixAuthContextMock.mockReset(); createMatrixClientMock.mockReset(); + resolveMatrixAuthContextMock.mockImplementation( + ({ accountId }: { accountId?: string | null } = {}) => ({ + cfg: undefined, + env: undefined, + accountId: accountId ?? "default", + resolved: {}, + }), + ); }); afterEach(() => { @@ -100,7 +112,7 @@ describe("resolveSharedMatrixClient", () => { await resolveSharedMatrixClient({ accountId: "main", startClient: false }); await resolveSharedMatrixClient({ accountId: "ops", startClient: false }); - stopSharedClientForAccount(mainAuth, "main"); + stopSharedClientForAccount(mainAuth); expect(mainClient.stop).toHaveBeenCalledTimes(1); expect(poeClient.stop).toHaveBeenCalledTimes(0); @@ -109,4 +121,45 @@ describe("resolveSharedMatrixClient", () => { expect(poeClient.stop).toHaveBeenCalledTimes(1); }); + + it("reuses the effective implicit account instead of keying it as default", async () => { + const poeAuth = authFor("ops"); + const poeClient = createMockClient("ops"); + + resolveMatrixAuthContextMock.mockReturnValue({ + cfg: undefined, + env: undefined, + accountId: "ops", + resolved: {}, + }); + resolveMatrixAuthMock.mockResolvedValue(poeAuth); + createMatrixClientMock.mockResolvedValue(poeClient); + + const first = await resolveSharedMatrixClient({ startClient: false }); + const second = await resolveSharedMatrixClient({ startClient: false }); + + expect(first).toBe(poeClient); + expect(second).toBe(poeClient); + expect(resolveMatrixAuthMock).toHaveBeenCalledWith({ + cfg: undefined, + env: undefined, + accountId: "ops", + }); + expect(createMatrixClientMock).toHaveBeenCalledTimes(1); + expect(createMatrixClientMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "ops", + }), + ); + }); + + it("rejects mismatched explicit account ids when auth is already resolved", async () => { + await expect( + resolveSharedMatrixClient({ + auth: authFor("ops"), + accountId: "main", + startClient: false, + }), + ).rejects.toThrow("Matrix shared client account mismatch"); + }); }); diff --git a/extensions/matrix/src/matrix/client/shared.ts b/extensions/matrix/src/matrix/client/shared.ts index fd7c76995f1..1bfc86da272 100644 --- a/extensions/matrix/src/matrix/client/shared.ts +++ b/extensions/matrix/src/matrix/client/shared.ts @@ -1,9 +1,9 @@ +import { normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id"; import type { CoreConfig } from "../../types.js"; import type { MatrixClient } from "../sdk.js"; import { LogService } from "../sdk/logger.js"; -import { resolveMatrixAuth } from "./config.js"; +import { resolveMatrixAuth, resolveMatrixAuthContext } from "./config.js"; import { createMatrixClient } from "./create-client.js"; -import { DEFAULT_ACCOUNT_KEY } from "./storage.js"; import type { MatrixAuth } from "./types.js"; type SharedMatrixClientState = { @@ -17,20 +17,19 @@ type SharedMatrixClientState = { const sharedClientStates = new Map(); const sharedClientPromises = new Map>(); -function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string { +function buildSharedClientKey(auth: MatrixAuth): string { return [ auth.homeserver, auth.userId, auth.accessToken, auth.encryption ? "e2ee" : "plain", - accountId ?? DEFAULT_ACCOUNT_KEY, + auth.accountId, ].join("|"); } async function createSharedMatrixClient(params: { auth: MatrixAuth; timeoutMs?: number; - accountId?: string | null; }): Promise { const client = await createMatrixClient({ homeserver: params.auth.homeserver, @@ -41,11 +40,11 @@ async function createSharedMatrixClient(params: { encryption: params.auth.encryption, localTimeoutMs: params.timeoutMs, initialSyncLimit: params.auth.initialSyncLimit, - accountId: params.accountId, + accountId: params.auth.accountId, }); return { client, - key: buildSharedClientKey(params.auth, params.accountId), + key: buildSharedClientKey(params.auth), started: false, cryptoReady: false, startPromise: null, @@ -103,14 +102,27 @@ export async function resolveSharedMatrixClient( accountId?: string | null; } = {}, ): Promise { + const requestedAccountId = normalizeOptionalAccountId(params.accountId); + if (params.auth && requestedAccountId && requestedAccountId !== params.auth.accountId) { + throw new Error( + `Matrix shared client account mismatch: requested ${requestedAccountId}, auth resolved ${params.auth.accountId}`, + ); + } + const authContext = params.auth + ? null + : resolveMatrixAuthContext({ + cfg: params.cfg, + env: params.env, + accountId: params.accountId, + }); const auth = params.auth ?? (await resolveMatrixAuth({ - cfg: params.cfg, - env: params.env, - accountId: params.accountId, + cfg: authContext?.cfg ?? params.cfg, + env: authContext?.env ?? params.env, + accountId: authContext?.accountId, })); - const key = buildSharedClientKey(auth, params.accountId); + const key = buildSharedClientKey(auth); const shouldStart = params.startClient !== false; const existingState = sharedClientStates.get(key); @@ -143,7 +155,6 @@ export async function resolveSharedMatrixClient( const creationPromise = createSharedMatrixClient({ auth, timeoutMs: params.timeoutMs, - accountId: params.accountId, }); sharedClientPromises.set(key, creationPromise); @@ -181,8 +192,8 @@ export function stopSharedClient(): void { sharedClientPromises.clear(); } -export function stopSharedClientForAccount(auth: MatrixAuth, accountId?: string | null): void { - const key = buildSharedClientKey(auth, accountId); +export function stopSharedClientForAccount(auth: MatrixAuth): void { + const key = buildSharedClientKey(auth); const state = sharedClientStates.get(key); if (!state) { return; diff --git a/extensions/matrix/src/matrix/client/types.ts b/extensions/matrix/src/matrix/client/types.ts index 4a6bac48a40..8830062e942 100644 --- a/extensions/matrix/src/matrix/client/types.ts +++ b/extensions/matrix/src/matrix/client/types.ts @@ -17,6 +17,7 @@ export type MatrixResolvedConfig = { * both will need to be recreated together. */ export type MatrixAuth = { + accountId: string; homeserver: string; userId: string; accessToken: string; diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts index bda9748a32c..9c22d1aa09b 100644 --- a/extensions/matrix/src/matrix/monitor/events.test.ts +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -45,7 +45,7 @@ function createHarness(params?: { registerMatrixMonitorEvents({ client, - auth: { encryption: true } as MatrixAuth, + auth: { accountId: "default", encryption: true } as MatrixAuth, logVerboseMessage: vi.fn(), warnedEncryptedRooms: new Set(), warnedCryptoMissingRooms: new Set(), diff --git a/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts b/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts index ddb648de890..6c278d0bce4 100644 --- a/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts @@ -99,6 +99,7 @@ describe("createMatrixRoomMessageHandler inbound body formatting", () => { logger: { info: () => {}, warn: () => {}, + error: () => {}, } as RuntimeLogger, logVerboseMessage: () => {}, allowFrom: [], @@ -247,6 +248,7 @@ describe("createMatrixRoomMessageHandler inbound body formatting", () => { logger: { info: () => {}, warn: () => {}, + error: () => {}, } as RuntimeLogger, logVerboseMessage: () => {}, allowFrom: [], diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 2e8ba7221c2..e294c253dd2 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -15,16 +15,8 @@ import { type RuntimeLogger, } from "openclaw/plugin-sdk/matrix"; import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js"; -import { - formatPollAsText, - formatPollResultsAsText, - isPollEventType, - isPollStartType, - parsePollStartContent, - resolvePollReferenceEventId, - buildPollResultsSummary, - type PollStartContent, -} from "../poll-types.js"; +import { fetchMatrixPollSnapshot } from "../poll-summary.js"; +import { isPollEventType } from "../poll-types.js"; import type { LocationMessageEventContent, MatrixClient } from "../sdk.js"; import { reactMatrixMessage, @@ -217,60 +209,18 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam let content = event.content as RoomMessageEventContent; if (isPollEvent) { - const pollEventId = isPollStartType(eventType) - ? (event.event_id ?? "") - : resolvePollReferenceEventId(event.content); - if (!pollEventId) { - return; - } - const pollEvent = isPollStartType(eventType) - ? event - : await client.getEvent(roomId, pollEventId).catch((err) => { - logVerboseMessage( - `matrix: failed resolving poll root room=${roomId} id=${pollEventId}: ${String(err)}`, - ); - return null; - }); - if ( - !pollEvent || - !isPollStartType(typeof pollEvent.type === "string" ? pollEvent.type : "") - ) { - return; - } - const pollStartContent = pollEvent.content as PollStartContent; - const pollSummary = parsePollStartContent(pollStartContent); - if (!pollSummary) { - return; - } - pollSummary.eventId = pollEventId; - pollSummary.roomId = roomId; - pollSummary.sender = typeof pollEvent.sender === "string" ? pollEvent.sender : senderId; - pollSummary.senderName = await getMemberDisplayName(roomId, pollSummary.sender); - - const relationEvents: MatrixRawEvent[] = []; - let nextBatch: string | undefined; - do { - const page = await client.getRelations(roomId, pollEventId, "m.reference", undefined, { - from: nextBatch, - }); - relationEvents.push(...page.events); - nextBatch = page.nextBatch ?? undefined; - } while (nextBatch); - - const pollResults = buildPollResultsSummary({ - pollEventId, - roomId, - sender: pollSummary.sender, - senderName: pollSummary.senderName, - content: pollStartContent, - relationEvents, + const pollSnapshot = await fetchMatrixPollSnapshot(client, roomId, event).catch((err) => { + logVerboseMessage( + `matrix: failed resolving poll snapshot room=${roomId} id=${event.event_id ?? "unknown"}: ${String(err)}`, + ); + return null; }); - const pollText = pollResults - ? formatPollResultsAsText(pollResults) - : formatPollAsText(pollSummary); + if (!pollSnapshot) { + return; + } content = { msgtype: "m.text", - body: pollText, + body: pollSnapshot.text, } as unknown as RoomMessageEventContent; } diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 89730b69c0c..17f30a775eb 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -18,6 +18,7 @@ import { setActiveMatrixClient } from "../active-client.js"; import { isBunRuntime, resolveMatrixAuth, + resolveMatrixAuthContext, resolveSharedMatrixClient, stopSharedClientForAccount, } from "../client.js"; @@ -137,8 +138,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi return allowList.map(String); }; + const authContext = resolveMatrixAuthContext({ + cfg, + accountId: opts.accountId, + }); + const effectiveAccountId = authContext.accountId; + // Resolve account-specific config for multi-account support - const account = resolveMatrixAccount({ cfg, accountId: opts.accountId }); + const account = resolveMatrixAccount({ cfg, accountId: effectiveAccountId }); const accountConfig = account.config; const allowlistOnly = accountConfig.allowlistOnly === true; @@ -239,7 +246,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi }, }; - const auth = await resolveMatrixAuth({ cfg, accountId: opts.accountId }); + const auth = await resolveMatrixAuth({ cfg, accountId: effectiveAccountId }); const resolvedInitialSyncLimit = typeof opts.initialSyncLimit === "number" ? Math.max(0, Math.floor(opts.initialSyncLimit)) @@ -252,9 +259,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi cfg, auth: authWithLimit, startClient: false, - accountId: opts.accountId ?? undefined, + accountId: auth.accountId, }); - setActiveMatrixClient(client, opts.accountId); + setActiveMatrixClient(client, auth.accountId); const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg); const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); @@ -339,7 +346,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi await resolveSharedMatrixClient({ cfg, auth: authWithLimit, - accountId: opts.accountId, + accountId: auth.accountId, }); logVerboseMessage("matrix: client started"); const threadBindingManager = await createMatrixThreadBindingManager({ @@ -398,7 +405,6 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi client, auth, accountConfig, - accountId: account.accountId, env: process.env, }); if (startupVerification.kind === "verified") { @@ -440,7 +446,6 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi const legacyCryptoRestore = await maybeRestoreLegacyMatrixBackup({ client, auth, - accountId: account.accountId, env: process.env, }); if (legacyCryptoRestore.kind === "restored") { @@ -474,9 +479,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi try { threadBindingManager.stop(); logVerboseMessage("matrix: stopping client"); - stopSharedClientForAccount(auth, opts.accountId); + stopSharedClientForAccount(auth); } finally { - setActiveMatrixClient(null, opts.accountId); + setActiveMatrixClient(null, auth.accountId); resolve(); } }; diff --git a/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.test.ts b/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.test.ts index 6b74361dff6..6b2040af606 100644 --- a/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.test.ts +++ b/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.test.ts @@ -27,6 +27,7 @@ describe("maybeRestoreLegacyMatrixBackup", () => { await withTempHome(async (home) => { const stateDir = path.join(home, ".openclaw"); const auth = { + accountId: "default", homeserver: "https://matrix.example.org", userId: "@bot:example.org", accessToken: "tok-123", @@ -89,6 +90,7 @@ describe("maybeRestoreLegacyMatrixBackup", () => { await withTempHome(async (home) => { const stateDir = path.join(home, ".openclaw"); const auth = { + accountId: "default", homeserver: "https://matrix.example.org", userId: "@bot:example.org", accessToken: "tok-123", diff --git a/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.ts b/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.ts index f991cd4377c..648d904c4ff 100644 --- a/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.ts +++ b/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.ts @@ -45,8 +45,7 @@ function isMigrationState(value: unknown): value is MatrixLegacyCryptoMigrationS export async function maybeRestoreLegacyMatrixBackup(params: { client: Pick; - auth: Pick; - accountId?: string | null; + auth: Pick; env?: NodeJS.ProcessEnv; stateDir?: string; }): Promise { @@ -57,7 +56,7 @@ export async function maybeRestoreLegacyMatrixBackup(params: { homeserver: params.auth.homeserver, userId: params.auth.userId, accessToken: params.auth.accessToken, - accountId: params.accountId, + accountId: params.auth.accountId, }); const statePath = path.join(rootDir, "legacy-crypto-migration.json"); const { value } = await readJsonFileWithFallback( diff --git a/extensions/matrix/src/matrix/monitor/startup-verification.test.ts b/extensions/matrix/src/matrix/monitor/startup-verification.test.ts index b35dfbaeffc..e3dd06e5829 100644 --- a/extensions/matrix/src/matrix/monitor/startup-verification.test.ts +++ b/extensions/matrix/src/matrix/monitor/startup-verification.test.ts @@ -12,6 +12,16 @@ function createStateFilePath(rootDir: string): string { return path.join(rootDir, "startup-verification.json"); } +function createAuth(accountId = "default") { + return { + accountId, + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "token", + encryption: true, + }; +} + type VerificationSummaryLike = { id: string; transactionId?: string; @@ -80,12 +90,7 @@ describe("ensureMatrixStartupVerification", () => { const result = await ensureMatrixStartupVerification({ client: harness.client as never, - auth: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "token", - encryption: true, - }, + auth: createAuth(), accountConfig: {}, stateFilePath: createStateFilePath(tempHome), }); @@ -105,12 +110,7 @@ describe("ensureMatrixStartupVerification", () => { const result = await ensureMatrixStartupVerification({ client: harness.client as never, - auth: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "token", - encryption: true, - }, + auth: createAuth(), accountConfig: {}, stateFilePath: createStateFilePath(tempHome), }); @@ -135,12 +135,7 @@ describe("ensureMatrixStartupVerification", () => { const result = await ensureMatrixStartupVerification({ client: harness.client as never, - auth: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "token", - encryption: true, - }, + auth: createAuth(), accountConfig: {}, stateFilePath: createStateFilePath(tempHome), }); @@ -156,12 +151,7 @@ describe("ensureMatrixStartupVerification", () => { const harness = createHarness(); await ensureMatrixStartupVerification({ client: harness.client as never, - auth: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "token", - encryption: true, - }, + auth: createAuth(), accountConfig: {}, stateFilePath: createStateFilePath(tempHome), nowMs: Date.now(), @@ -170,12 +160,7 @@ describe("ensureMatrixStartupVerification", () => { const second = await ensureMatrixStartupVerification({ client: harness.client as never, - auth: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "token", - encryption: true, - }, + auth: createAuth(), accountConfig: {}, stateFilePath: createStateFilePath(tempHome), nowMs: Date.now() + 60_000, @@ -193,12 +178,7 @@ describe("ensureMatrixStartupVerification", () => { const result = await ensureMatrixStartupVerification({ client: harness.client as never, - auth: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "token", - encryption: true, - }, + auth: createAuth(), accountConfig: { startupVerification: "off", }, @@ -216,12 +196,7 @@ describe("ensureMatrixStartupVerification", () => { const result = await ensureMatrixStartupVerification({ client: harness.client as never, - auth: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "token", - encryption: true, - }, + auth: createAuth(), accountConfig: {}, stateFilePath: createStateFilePath(tempHome), nowMs: Date.parse("2026-03-08T12:00:00.000Z"), @@ -243,12 +218,7 @@ describe("ensureMatrixStartupVerification", () => { const result = await ensureMatrixStartupVerification({ client: harness.client as never, - auth: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "token", - encryption: true, - }, + auth: createAuth(), accountConfig: {}, stateFilePath: createStateFilePath(tempHome), }); @@ -261,12 +231,7 @@ describe("ensureMatrixStartupVerification", () => { const cooledDown = await ensureMatrixStartupVerification({ client: harness.client as never, - auth: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "token", - encryption: true, - }, + auth: createAuth(), accountConfig: {}, stateFilePath: createStateFilePath(tempHome), nowMs: Date.now() + 60_000, @@ -286,12 +251,7 @@ describe("ensureMatrixStartupVerification", () => { await ensureMatrixStartupVerification({ client: failingHarness.client as never, - auth: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "token", - encryption: true, - }, + auth: createAuth(), accountConfig: {}, stateFilePath, nowMs: Date.parse("2026-03-08T12:00:00.000Z"), @@ -300,12 +260,7 @@ describe("ensureMatrixStartupVerification", () => { const retryingHarness = createHarness(); const result = await ensureMatrixStartupVerification({ client: retryingHarness.client as never, - auth: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "token", - encryption: true, - }, + auth: createAuth(), accountConfig: {}, stateFilePath, nowMs: Date.parse("2026-03-08T13:30:00.000Z"), @@ -322,12 +277,7 @@ describe("ensureMatrixStartupVerification", () => { await ensureMatrixStartupVerification({ client: unverified.client as never, - auth: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "token", - encryption: true, - }, + auth: createAuth(), accountConfig: {}, stateFilePath, nowMs: Date.parse("2026-03-08T12:00:00.000Z"), @@ -338,12 +288,7 @@ describe("ensureMatrixStartupVerification", () => { const verified = createHarness({ verified: true }); const result = await ensureMatrixStartupVerification({ client: verified.client as never, - auth: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "token", - encryption: true, - }, + auth: createAuth(), accountConfig: {}, stateFilePath, }); diff --git a/extensions/matrix/src/matrix/monitor/startup-verification.ts b/extensions/matrix/src/matrix/monitor/startup-verification.ts index 5cb258c8236..e91fcdbf6eb 100644 --- a/extensions/matrix/src/matrix/monitor/startup-verification.ts +++ b/extensions/matrix/src/matrix/monitor/startup-verification.ts @@ -44,14 +44,13 @@ function normalizeCooldownHours(value: number | undefined): number { function resolveStartupVerificationStatePath(params: { auth: MatrixAuth; - accountId?: string | null; env?: NodeJS.ProcessEnv; }): string { const storagePaths = resolveMatrixStoragePaths({ homeserver: params.auth.homeserver, userId: params.auth.userId, accessToken: params.auth.accessToken, - accountId: params.accountId, + accountId: params.auth.accountId, env: params.env, }); return path.join(storagePaths.rootDir, STARTUP_VERIFICATION_STATE_FILENAME); @@ -143,7 +142,6 @@ export async function ensureMatrixStartupVerification(params: { client: Pick; auth: MatrixAuth; accountConfig: Pick; - accountId?: string | null; env?: NodeJS.ProcessEnv; nowMs?: number; stateFilePath?: string; @@ -157,7 +155,6 @@ export async function ensureMatrixStartupVerification(params: { params.stateFilePath ?? resolveStartupVerificationStatePath({ auth: params.auth, - accountId: params.accountId, env: params.env, }); diff --git a/extensions/matrix/src/matrix/poll-summary.ts b/extensions/matrix/src/matrix/poll-summary.ts new file mode 100644 index 00000000000..f98723826ce --- /dev/null +++ b/extensions/matrix/src/matrix/poll-summary.ts @@ -0,0 +1,110 @@ +import type { MatrixMessageSummary } from "./actions/types.js"; +import { + buildPollResultsSummary, + formatPollAsText, + formatPollResultsAsText, + isPollEventType, + isPollStartType, + parsePollStartContent, + resolvePollReferenceEventId, + type PollStartContent, +} from "./poll-types.js"; +import type { MatrixClient, MatrixRawEvent } from "./sdk.js"; + +export type MatrixPollSnapshot = { + pollEventId: string; + triggerEvent: MatrixRawEvent; + rootEvent: MatrixRawEvent; + text: string; +}; + +export function resolveMatrixPollRootEventId( + event: Pick, +): string | null { + if (isPollStartType(event.type)) { + const eventId = event.event_id?.trim(); + return eventId ? eventId : null; + } + return resolvePollReferenceEventId(event.content); +} + +async function readAllPollRelations( + client: MatrixClient, + roomId: string, + pollEventId: string, +): Promise { + const relationEvents: MatrixRawEvent[] = []; + let nextBatch: string | undefined; + do { + const page = await client.getRelations(roomId, pollEventId, "m.reference", undefined, { + from: nextBatch, + }); + relationEvents.push(...page.events); + nextBatch = page.nextBatch ?? undefined; + } while (nextBatch); + return relationEvents; +} + +export async function fetchMatrixPollSnapshot( + client: MatrixClient, + roomId: string, + event: MatrixRawEvent, +): Promise { + if (!isPollEventType(event.type)) { + return null; + } + + const pollEventId = resolveMatrixPollRootEventId(event); + if (!pollEventId) { + return null; + } + + const rootEvent = isPollStartType(event.type) + ? event + : ((await client.getEvent(roomId, pollEventId)) as MatrixRawEvent); + if (!isPollStartType(rootEvent.type)) { + return null; + } + + const pollStartContent = rootEvent.content as PollStartContent; + const pollSummary = parsePollStartContent(pollStartContent); + if (!pollSummary) { + return null; + } + + const relationEvents = await readAllPollRelations(client, roomId, pollEventId); + const pollResults = buildPollResultsSummary({ + pollEventId, + roomId, + sender: rootEvent.sender, + senderName: rootEvent.sender, + content: pollStartContent, + relationEvents, + }); + + return { + pollEventId, + triggerEvent: event, + rootEvent, + text: pollResults ? formatPollResultsAsText(pollResults) : formatPollAsText(pollSummary), + }; +} + +export async function fetchMatrixPollMessageSummary( + client: MatrixClient, + roomId: string, + event: MatrixRawEvent, +): Promise { + const snapshot = await fetchMatrixPollSnapshot(client, roomId, event); + if (!snapshot) { + return null; + } + + return { + eventId: snapshot.pollEventId, + sender: snapshot.rootEvent.sender, + body: snapshot.text, + msgtype: "m.text", + timestamp: snapshot.triggerEvent.origin_server_ts || snapshot.rootEvent.origin_server_ts, + }; +} diff --git a/extensions/matrix/src/matrix/poll-types.ts b/extensions/matrix/src/matrix/poll-types.ts index ea5938290f5..23743df64ee 100644 --- a/extensions/matrix/src/matrix/poll-types.ts +++ b/extensions/matrix/src/matrix/poll-types.ts @@ -322,7 +322,9 @@ export function buildPollResultsSummary(params: { }); } - const voteCounts = new Map(parsed.answers.map((answer) => [answer.id, 0] as const)); + const voteCounts = new Map( + parsed.answers.map((answer): [string, number] => [answer.id, 0]), + ); let totalVotes = 0; for (const latestVote of latestVoteBySender.values()) { if (latestVote.answerIds.length === 0) { diff --git a/extensions/matrix/src/matrix/probe.test.ts b/extensions/matrix/src/matrix/probe.test.ts index a15c433185c..2bbf2534565 100644 --- a/extensions/matrix/src/matrix/probe.test.ts +++ b/extensions/matrix/src/matrix/probe.test.ts @@ -50,4 +50,22 @@ describe("probeMatrix", () => { localTimeoutMs: 500, }); }); + + it("passes accountId through to client creation", async () => { + await probeMatrix({ + homeserver: "https://matrix.example.org", + accessToken: "tok", + userId: "@bot:example.org", + timeoutMs: 500, + accountId: "ops", + }); + + expect(createMatrixClientMock).toHaveBeenCalledWith({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok", + localTimeoutMs: 500, + accountId: "ops", + }); + }); }); diff --git a/extensions/matrix/src/matrix/probe.ts b/extensions/matrix/src/matrix/probe.ts index 0f888e355f4..6b0b9d9aec1 100644 --- a/extensions/matrix/src/matrix/probe.ts +++ b/extensions/matrix/src/matrix/probe.ts @@ -12,6 +12,7 @@ export async function probeMatrix(params: { accessToken: string; userId?: string; timeoutMs: number; + accountId?: string | null; }): Promise { const started = Date.now(); const result: MatrixProbe = { @@ -48,6 +49,7 @@ export async function probeMatrix(params: { userId: inputUserId, accessToken: params.accessToken, localTimeoutMs: params.timeoutMs, + accountId: params.accountId, }); // The client wrapper resolves user ID via whoami when needed. const userId = await client.getUserId(); diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index 6b50ece5f83..fa58392c686 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -579,6 +579,25 @@ export class MatrixClient { }; } + async hydrateEvents( + roomId: string, + events: Array>, + ): Promise { + if (events.length === 0) { + return []; + } + + const mapper = this.client.getEventMapper(); + const mappedEvents = events.map((event) => + mapper({ + room_id: roomId, + ...event, + }), + ); + await Promise.all(mappedEvents.map((event) => this.client.decryptEventIfNeeded(event))); + return mappedEvents.map((event) => matrixEventToRaw(event)); + } + async setTyping(roomId: string, typing: boolean, timeoutMs: number): Promise { await this.client.sendTyping(roomId, typing, timeoutMs); } diff --git a/extensions/matrix/src/matrix/send/client.test.ts b/extensions/matrix/src/matrix/send/client.test.ts index 2f839a7f1ae..828fd6dd8de 100644 --- a/extensions/matrix/src/matrix/send/client.test.ts +++ b/extensions/matrix/src/matrix/send/client.test.ts @@ -5,6 +5,8 @@ const getActiveMatrixClientMock = vi.fn(); const createMatrixClientMock = vi.fn(); const isBunRuntimeMock = vi.fn(() => false); const resolveMatrixAuthMock = vi.fn(); +const resolveMatrixAuthContextMock = vi.fn(); +const getMatrixRuntimeMock = vi.fn(); vi.mock("../active-client.js", () => ({ getActiveMatrixClient: (...args: unknown[]) => getActiveMatrixClientMock(...args), @@ -14,6 +16,11 @@ vi.mock("../client.js", () => ({ createMatrixClient: (...args: unknown[]) => createMatrixClientMock(...args), isBunRuntime: () => isBunRuntimeMock(), resolveMatrixAuth: (...args: unknown[]) => resolveMatrixAuthMock(...args), + resolveMatrixAuthContext: (...args: unknown[]) => resolveMatrixAuthContextMock(...args), +})); + +vi.mock("../../runtime.js", () => ({ + getMatrixRuntime: () => getMatrixRuntimeMock(), })); let resolveMatrixClient: typeof import("./client.js").resolveMatrixClient; @@ -30,7 +37,19 @@ describe("resolveMatrixClient", () => { vi.clearAllMocks(); getActiveMatrixClientMock.mockReturnValue(null); isBunRuntimeMock.mockReturnValue(false); + getMatrixRuntimeMock.mockReturnValue({ + config: { + loadConfig: () => ({}), + }, + }); + resolveMatrixAuthContextMock.mockReturnValue({ + cfg: {}, + env: process.env, + accountId: "default", + resolved: {}, + }); resolveMatrixAuthMock.mockResolvedValue({ + accountId: "default", homeserver: "https://matrix.example.org", userId: "@bot:example.org", accessToken: "token", @@ -75,4 +94,35 @@ describe("resolveMatrixClient", () => { expect(resolveMatrixAuthMock).not.toHaveBeenCalled(); expect(createMatrixClientMock).not.toHaveBeenCalled(); }); + + it("uses the effective account id when auth resolution is implicit", async () => { + resolveMatrixAuthContextMock.mockReturnValue({ + cfg: {}, + env: process.env, + accountId: "ops", + resolved: {}, + }); + resolveMatrixAuthMock.mockResolvedValue({ + accountId: "ops", + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "token", + password: undefined, + deviceId: "DEVICE123", + encryption: false, + }); + + await resolveMatrixClient({}); + + expect(getActiveMatrixClientMock).toHaveBeenCalledWith("ops"); + expect(resolveMatrixAuthMock).toHaveBeenCalledWith({ + cfg: {}, + accountId: "ops", + }); + expect(createMatrixClientMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "ops", + }), + ); + }); }); diff --git a/extensions/matrix/src/matrix/send/client.ts b/extensions/matrix/src/matrix/send/client.ts index 75ff3204846..fa64f6c6004 100644 --- a/extensions/matrix/src/matrix/send/client.ts +++ b/extensions/matrix/src/matrix/send/client.ts @@ -2,7 +2,12 @@ import { getMatrixRuntime } from "../../runtime.js"; import type { CoreConfig } from "../../types.js"; import { resolveMatrixAccountConfig } from "../accounts.js"; import { getActiveMatrixClient } from "../active-client.js"; -import { createMatrixClient, isBunRuntime, resolveMatrixAuth } from "../client.js"; +import { + createMatrixClient, + isBunRuntime, + resolveMatrixAuth, + resolveMatrixAuthContext, +} from "../client.js"; import type { MatrixClient } from "../sdk.js"; const getCore = () => getMatrixRuntime(); @@ -32,11 +37,19 @@ export async function resolveMatrixClient(opts: { if (opts.client) { return { client: opts.client, stopOnDone: false }; } - const active = getActiveMatrixClient(opts.accountId); + const cfg = getCore().config.loadConfig() as CoreConfig; + const authContext = resolveMatrixAuthContext({ + cfg, + accountId: opts.accountId, + }); + const active = getActiveMatrixClient(authContext.accountId); if (active) { return { client: active, stopOnDone: false }; } - const auth = await resolveMatrixAuth({ accountId: opts.accountId }); + const auth = await resolveMatrixAuth({ + cfg, + accountId: authContext.accountId, + }); const client = await createMatrixClient({ homeserver: auth.homeserver, userId: auth.userId, @@ -45,7 +58,7 @@ export async function resolveMatrixClient(opts: { deviceId: auth.deviceId, encryption: auth.encryption, localTimeoutMs: opts.timeoutMs, - accountId: opts.accountId, + accountId: auth.accountId, autoBootstrapCrypto: false, }); await client.prepareForOneOff(); diff --git a/extensions/matrix/src/matrix/thread-bindings.test.ts b/extensions/matrix/src/matrix/thread-bindings.test.ts index 23f4fa5fd17..5d4eb70d983 100644 --- a/extensions/matrix/src/matrix/thread-bindings.test.ts +++ b/extensions/matrix/src/matrix/thread-bindings.test.ts @@ -47,6 +47,7 @@ describe("matrix thread bindings", () => { await createMatrixThreadBindingManager({ accountId: "ops", auth: { + accountId: "ops", homeserver: "https://matrix.example.org", userId: "@bot:example.org", accessToken: "token", @@ -87,6 +88,7 @@ describe("matrix thread bindings", () => { await createMatrixThreadBindingManager({ accountId: "ops", auth: { + accountId: "ops", homeserver: "https://matrix.example.org", userId: "@bot:example.org", accessToken: "token", @@ -137,6 +139,7 @@ describe("matrix thread bindings", () => { await createMatrixThreadBindingManager({ accountId: "ops", auth: { + accountId: "ops", homeserver: "https://matrix.example.org", userId: "@bot:example.org", accessToken: "token", @@ -182,6 +185,7 @@ describe("matrix thread bindings", () => { await createMatrixThreadBindingManager({ accountId: "ops", auth: { + accountId: "ops", homeserver: "https://matrix.example.org", userId: "@bot:example.org", accessToken: "token", diff --git a/extensions/matrix/src/matrix/thread-bindings.ts b/extensions/matrix/src/matrix/thread-bindings.ts index 897b1c2d629..ca617efdb5d 100644 --- a/extensions/matrix/src/matrix/thread-bindings.ts +++ b/extensions/matrix/src/matrix/thread-bindings.ts @@ -339,6 +339,11 @@ export async function createMatrixThreadBindingManager(params: { enableSweeper?: boolean; logVerboseMessage?: (message: string) => void; }): Promise { + if (params.auth.accountId !== params.accountId) { + throw new Error( + `Matrix thread binding account mismatch: requested ${params.accountId}, auth resolved ${params.auth.accountId}`, + ); + } const existing = MANAGERS_BY_ACCOUNT_ID.get(params.accountId); if (existing) { return existing; diff --git a/src/channels/plugins/pairing.ts b/src/channels/plugins/pairing.ts index f179ae6983e..50e9456f65f 100644 --- a/src/channels/plugins/pairing.ts +++ b/src/channels/plugins/pairing.ts @@ -52,6 +52,7 @@ export async function notifyPairingApproved(params: { channelId: ChannelId; id: string; cfg: OpenClawConfig; + accountId?: string; runtime?: RuntimeEnv; /** Extension channels can pass their adapter directly to bypass registry lookup. */ pairingAdapter?: ChannelPairingAdapter; @@ -64,6 +65,7 @@ export async function notifyPairingApproved(params: { await adapter.notifyApproval({ cfg: params.cfg, id: params.id, + accountId: params.accountId, runtime: params.runtime, }); } diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index df84ee4d3d2..5870d8402cd 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -268,6 +268,7 @@ export type ChannelPairingAdapter = { notifyApproval?: (params: { cfg: OpenClawConfig; id: string; + accountId?: string; runtime?: RuntimeEnv; }) => Promise; }; diff --git a/src/cli/pairing-cli.test.ts b/src/cli/pairing-cli.test.ts index 97d9c9c7751..f0c8a69c195 100644 --- a/src/cli/pairing-cli.test.ts +++ b/src/cli/pairing-cli.test.ts @@ -220,6 +220,28 @@ describe("pairing cli", () => { }); }); + it("forwards --account to approval notifications", async () => { + mockApprovedPairing(); + + await runPairing([ + "pairing", + "approve", + "--channel", + "telegram", + "--account", + "yy", + "--notify", + "ABCDEFGH", + ]); + + expect(notifyPairingApproved).toHaveBeenCalledWith({ + channelId: "telegram", + id: "123", + cfg: {}, + accountId: "yy", + }); + }); + it("defaults approve to the sole available channel when only code is provided", async () => { listPairingChannels.mockReturnValueOnce(["slack"]); mockApprovedPairing(); diff --git a/src/cli/pairing-cli.ts b/src/cli/pairing-cli.ts index 7c8cbc750ea..e426ed3263e 100644 --- a/src/cli/pairing-cli.ts +++ b/src/cli/pairing-cli.ts @@ -44,9 +44,9 @@ function parseChannel(raw: unknown, channels: PairingChannel[]): PairingChannel throw new Error(`Invalid channel: ${value}`); } -async function notifyApproved(channel: PairingChannel, id: string) { +async function notifyApproved(channel: PairingChannel, id: string, accountId?: string) { const cfg = loadConfig(); - await notifyPairingApproved({ channelId: channel, id, cfg }); + await notifyPairingApproved({ channelId: channel, id, cfg, accountId }); } export function registerPairingCli(program: Command) { @@ -166,7 +166,7 @@ export function registerPairingCli(program: Command) { if (!opts.notify) { return; } - await notifyApproved(channel, approved.id).catch((err) => { + await notifyApproved(channel, approved.id, accountId || undefined).catch((err) => { defaultRuntime.log(theme.warn(`Failed to notify requester: ${String(err)}`)); }); });