From 3e6dd9a2ffde99dd02b2aa705c51168c2bb12c13 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 9 Mar 2026 00:29:10 -0400 Subject: [PATCH] poll and profile fixes --- .../src/actions.account-propagation.test.ts | 21 ++ extensions/matrix/src/actions.ts | 5 + .../matrix/src/matrix/actions/client.test.ts | 68 ++++ .../matrix/src/matrix/actions/client.ts | 20 +- .../matrix/src/matrix/actions/profile.ts | 5 + extensions/matrix/src/matrix/client.test.ts | 38 ++ extensions/matrix/src/matrix/client.ts | 2 + extensions/matrix/src/matrix/client/config.ts | 96 ++++- .../monitor/handler.body-for-agent.test.ts | 351 +++++++++++++----- .../matrix/src/matrix/monitor/handler.ts | 73 +++- .../matrix/src/matrix/poll-types.test.ts | 110 ++++++ extensions/matrix/src/matrix/poll-types.ts | 198 ++++++++++ extensions/matrix/src/matrix/profile.test.ts | 31 ++ extensions/matrix/src/matrix/profile.ts | 63 +++- extensions/matrix/src/matrix/sdk.test.ts | 117 ++++++ extensions/matrix/src/matrix/sdk.ts | 39 +- extensions/matrix/src/matrix/sdk/types.ts | 7 + extensions/matrix/src/matrix/send.test.ts | 32 ++ extensions/matrix/src/profile-update.ts | 13 +- extensions/matrix/src/tool-actions.test.ts | 19 + extensions/matrix/src/tool-actions.ts | 5 + src/agents/tools/message-tool.test.ts | 1 + src/agents/tools/message-tool.ts | 12 + 23 files changed, 1183 insertions(+), 143 deletions(-) diff --git a/extensions/matrix/src/actions.account-propagation.test.ts b/extensions/matrix/src/actions.account-propagation.test.ts index 2b6595e5ec8..5a3299330a8 100644 --- a/extensions/matrix/src/actions.account-propagation.test.ts +++ b/extensions/matrix/src/actions.account-propagation.test.ts @@ -105,4 +105,25 @@ describe("matrixMessageActions account propagation", () => { expect.any(Object), ); }); + + it("forwards local avatar paths for self-profile updates", async () => { + await matrixMessageActions.handleAction?.( + createContext({ + action: "set-profile", + accountId: "ops", + params: { + path: "/tmp/avatar.jpg", + }, + }), + ); + + expect(mocks.handleMatrixAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "setProfile", + accountId: "ops", + avatarPath: "/tmp/avatar.jpg", + }), + expect.any(Object), + ); + }); }); diff --git a/extensions/matrix/src/actions.ts b/extensions/matrix/src/actions.ts index b504fcaba0b..cb666a32c91 100644 --- a/extensions/matrix/src/actions.ts +++ b/extensions/matrix/src/actions.ts @@ -189,10 +189,15 @@ export const matrixMessageActions: ChannelMessageActionAdapter = { } if (action === "set-profile") { + const avatarPath = + readStringParam(params, "avatarPath") ?? + readStringParam(params, "path") ?? + readStringParam(params, "filePath"); return await dispatch({ action: "setProfile", displayName: readStringParam(params, "displayName") ?? readStringParam(params, "name"), avatarUrl: readStringParam(params, "avatarUrl"), + avatarPath, }); } diff --git a/extensions/matrix/src/matrix/actions/client.test.ts b/extensions/matrix/src/matrix/actions/client.test.ts index 8e25cad8fbc..d05c193bdeb 100644 --- a/extensions/matrix/src/matrix/actions/client.test.ts +++ b/extensions/matrix/src/matrix/actions/client.test.ts @@ -6,6 +6,7 @@ const getActiveMatrixClientMock = vi.fn(); const createMatrixClientMock = vi.fn(); const isBunRuntimeMock = vi.fn(() => false); const resolveMatrixAuthMock = vi.fn(); +const resolveMatrixAuthContextMock = vi.fn(); vi.mock("../../runtime.js", () => ({ getMatrixRuntime: () => ({ @@ -23,6 +24,7 @@ vi.mock("../client.js", () => ({ createMatrixClient: createMatrixClientMock, isBunRuntime: () => isBunRuntimeMock(), resolveMatrixAuth: resolveMatrixAuthMock, + resolveMatrixAuthContext: resolveMatrixAuthContextMock, })); let resolveActionClient: typeof import("./client.js").resolveActionClient; @@ -47,6 +49,21 @@ describe("resolveActionClient", () => { deviceId: "DEVICE123", encryption: false, }); + resolveMatrixAuthContextMock.mockImplementation( + ({ cfg, accountId }: { cfg: unknown; accountId?: string | null }) => ({ + cfg, + env: process.env, + accountId: accountId ?? undefined, + resolved: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "token", + password: undefined, + deviceId: "DEVICE123", + encryption: false, + }, + }), + ); createMatrixClientMock.mockResolvedValue(createMockMatrixClient()); ({ resolveActionClient } = await import("./client.js")); @@ -84,4 +101,55 @@ describe("resolveActionClient", () => { expect(resolveMatrixAuthMock).not.toHaveBeenCalled(); expect(createMatrixClientMock).not.toHaveBeenCalled(); }); + + it("uses the implicit resolved account id for active client lookup and storage", async () => { + loadConfigMock.mockReturnValue({ + channels: { + matrix: { + accounts: { + ops: { + homeserver: "https://ops.example.org", + userId: "@ops:example.org", + accessToken: "ops-token", + }, + }, + }, + }, + }); + resolveMatrixAuthContextMock.mockReturnValue({ + cfg: loadConfigMock(), + env: process.env, + accountId: "ops", + resolved: { + homeserver: "https://ops.example.org", + userId: "@ops:example.org", + accessToken: "ops-token", + deviceId: "OPSDEVICE", + encryption: true, + }, + }); + resolveMatrixAuthMock.mockResolvedValue({ + homeserver: "https://ops.example.org", + userId: "@ops:example.org", + accessToken: "ops-token", + password: undefined, + deviceId: "OPSDEVICE", + encryption: true, + }); + + await resolveActionClient({}); + + expect(getActiveMatrixClientMock).toHaveBeenCalledWith("ops"); + expect(resolveMatrixAuthMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "ops", + }), + ); + expect(createMatrixClientMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "ops", + homeserver: "https://ops.example.org", + }), + ); + }); }); diff --git a/extensions/matrix/src/matrix/actions/client.ts b/extensions/matrix/src/matrix/actions/client.ts index a3981be0520..088c7870045 100644 --- a/extensions/matrix/src/matrix/actions/client.ts +++ b/extensions/matrix/src/matrix/actions/client.ts @@ -1,7 +1,12 @@ import { getMatrixRuntime } from "../../runtime.js"; import type { CoreConfig } from "../../types.js"; import { getActiveMatrixClient } from "../active-client.js"; -import { createMatrixClient, isBunRuntime, resolveMatrixAuth } from "../client.js"; +import { + createMatrixClient, + isBunRuntime, + resolveMatrixAuth, + resolveMatrixAuthContext, +} from "../client.js"; import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js"; export function ensureNodeRuntime() { @@ -17,13 +22,18 @@ export async function resolveActionClient( if (opts.client) { return { client: opts.client, stopOnDone: false }; } - const active = getActiveMatrixClient(opts.accountId); + const cfg = getMatrixRuntime().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({ - cfg: getMatrixRuntime().config.loadConfig() as CoreConfig, - accountId: opts.accountId, + cfg, + accountId: authContext.accountId, }); const client = await createMatrixClient({ homeserver: auth.homeserver, @@ -33,7 +43,7 @@ export async function resolveActionClient( deviceId: auth.deviceId, encryption: auth.encryption, localTimeoutMs: opts.timeoutMs, - accountId: opts.accountId, + accountId: authContext.accountId, autoBootstrapCrypto: false, }); await client.prepareForOneOff(); diff --git a/extensions/matrix/src/matrix/actions/profile.ts b/extensions/matrix/src/matrix/actions/profile.ts index 1d3f8c924db..c31f69478a5 100644 --- a/extensions/matrix/src/matrix/actions/profile.ts +++ b/extensions/matrix/src/matrix/actions/profile.ts @@ -7,10 +7,12 @@ export async function updateMatrixOwnProfile( opts: MatrixActionClientOpts & { displayName?: string; avatarUrl?: string; + avatarPath?: string; } = {}, ): Promise { const displayName = opts.displayName?.trim(); const avatarUrl = opts.avatarUrl?.trim(); + const avatarPath = opts.avatarPath?.trim(); const runtime = getMatrixRuntime(); return await withResolvedActionClient( opts, @@ -21,7 +23,10 @@ export async function updateMatrixOwnProfile( userId, displayName: displayName || undefined, avatarUrl: avatarUrl || undefined, + avatarPath: avatarPath || undefined, loadAvatarFromUrl: async (url, maxBytes) => await runtime.media.loadWebMedia(url, maxBytes), + loadAvatarFromPath: async (path, maxBytes) => + await runtime.media.loadWebMedia(path, maxBytes), }); }, "persist", diff --git a/extensions/matrix/src/matrix/client.test.ts b/extensions/matrix/src/matrix/client.test.ts index 2ced0631084..3b090eea78b 100644 --- a/extensions/matrix/src/matrix/client.test.ts +++ b/extensions/matrix/src/matrix/client.test.ts @@ -311,4 +311,42 @@ describe("resolveMatrixAuth", () => { encryption: true, }); }); + + it("falls back to the sole configured account when no global homeserver is set", async () => { + const cfg = { + channels: { + matrix: { + accounts: { + ops: { + homeserver: "https://ops.example.org", + userId: "@ops:example.org", + accessToken: "ops-token", + deviceId: "OPSDEVICE", + encryption: true, + }, + }, + }, + }, + } as CoreConfig; + + const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv }); + + expect(auth).toMatchObject({ + homeserver: "https://ops.example.org", + userId: "@ops:example.org", + accessToken: "ops-token", + deviceId: "OPSDEVICE", + encryption: true, + }); + expect(saveMatrixCredentialsMock).toHaveBeenCalledWith( + expect.objectContaining({ + homeserver: "https://ops.example.org", + userId: "@ops:example.org", + accessToken: "ops-token", + deviceId: "OPSDEVICE", + }), + expect.any(Object), + "ops", + ); + }); }); diff --git a/extensions/matrix/src/matrix/client.ts b/extensions/matrix/src/matrix/client.ts index 82fe95d0fed..91a91cb3cae 100644 --- a/extensions/matrix/src/matrix/client.ts +++ b/extensions/matrix/src/matrix/client.ts @@ -6,7 +6,9 @@ export { resolveMatrixConfig, resolveMatrixConfigForAccount, resolveScopedMatrixEnvConfig, + resolveImplicitMatrixAccountId, resolveMatrixAuth, + resolveMatrixAuthContext, } from "./client/config.js"; export { createMatrixClient } from "./client/create-client.js"; export { diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts index 197e182e0aa..1f7c2f491c4 100644 --- a/extensions/matrix/src/matrix/client/config.ts +++ b/extensions/matrix/src/matrix/client/config.ts @@ -1,8 +1,16 @@ -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + normalizeOptionalAccountId, +} from "openclaw/plugin-sdk/account-id"; import { getMatrixRuntime } from "../../runtime.js"; import { normalizeResolvedSecretInputString } from "../../secret-input.js"; import type { CoreConfig } from "../../types.js"; -import { findMatrixAccountConfig, resolveMatrixBaseConfig } from "../account-config.js"; +import { + findMatrixAccountConfig, + resolveMatrixAccountsMap, + resolveMatrixBaseConfig, +} from "../account-config.js"; import { MatrixClient } from "../sdk.js"; import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; import type { MatrixAuth, MatrixResolvedConfig } from "./types.js"; @@ -230,17 +238,89 @@ export function resolveMatrixConfigForAccount( }; } +function listNormalizedMatrixAccountIds(cfg: CoreConfig): string[] { + const accounts = resolveMatrixAccountsMap(cfg); + return [ + ...new Set( + Object.keys(accounts) + .filter(Boolean) + .map((accountId) => normalizeAccountId(accountId)), + ), + ]; +} + +function hasMatrixAuthInputs(config: MatrixResolvedConfig): boolean { + return Boolean(config.homeserver && (config.accessToken || (config.userId && config.password))); +} + +export function resolveImplicitMatrixAccountId( + cfg: CoreConfig, + env: NodeJS.ProcessEnv = process.env, +): string | null { + const configuredDefault = normalizeOptionalAccountId(cfg.channels?.matrix?.defaultAccount); + if (configuredDefault) { + const resolved = resolveMatrixConfigForAccount(cfg, configuredDefault, env); + if (hasMatrixAuthInputs(resolved)) { + return configuredDefault; + } + } + + const accountIds = listNormalizedMatrixAccountIds(cfg); + if (accountIds.length === 0) { + return null; + } + + const readyIds = accountIds.filter((accountId) => + hasMatrixAuthInputs(resolveMatrixConfigForAccount(cfg, accountId, env)), + ); + if (readyIds.length === 1) { + return readyIds[0] ?? null; + } + + if (readyIds.includes(DEFAULT_ACCOUNT_ID)) { + return DEFAULT_ACCOUNT_ID; + } + + return null; +} + +export function resolveMatrixAuthContext(params?: { + cfg?: CoreConfig; + env?: NodeJS.ProcessEnv; + accountId?: string | null; +}): { + cfg: CoreConfig; + env: NodeJS.ProcessEnv; + accountId?: string; + resolved: MatrixResolvedConfig; +} { + const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig); + const env = params?.env ?? process.env; + const explicitAccountId = normalizeOptionalAccountId(params?.accountId); + const defaultResolved = resolveMatrixConfig(cfg, env); + const effectiveAccountId = + explicitAccountId ?? + (defaultResolved.homeserver + ? undefined + : (resolveImplicitMatrixAccountId(cfg, env) ?? undefined)); + const resolved = effectiveAccountId + ? resolveMatrixConfigForAccount(cfg, effectiveAccountId, env) + : defaultResolved; + + return { + cfg, + env, + accountId: effectiveAccountId, + resolved, + }; +} + export async function resolveMatrixAuth(params?: { cfg?: CoreConfig; env?: NodeJS.ProcessEnv; accountId?: string | null; }): Promise { - const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig); - const env = params?.env ?? process.env; - const accountId = params?.accountId; - const resolved = accountId - ? resolveMatrixConfigForAccount(cfg, accountId, env) - : resolveMatrixConfig(cfg, env); + const { cfg, env, accountId, resolved } = resolveMatrixAuthContext(params); if (!resolved.homeserver) { throw new Error("Matrix homeserver is required (matrix.homeserver)"); } 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 d54e57d0af2..ddb648de890 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 @@ -1,141 +1,292 @@ -import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk/matrix"; -import { describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk/matrix"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { setMatrixRuntime } from "../../runtime.js"; import type { MatrixClient } from "../sdk.js"; import { createMatrixRoomMessageHandler } from "./handler.js"; import { EventType, type MatrixRawEvent } from "./types.js"; -describe("createMatrixRoomMessageHandler BodyForAgent sender label", () => { - it("stores sender-labeled BodyForAgent for group thread messages", async () => { - const recordInboundSession = vi.fn().mockResolvedValue(undefined); - const formatInboundEnvelope = vi - .fn() - .mockImplementation((params: { senderLabel?: string; body: string }) => params.body); - const finalizeInboundContext = vi - .fn() - .mockImplementation((ctx: Record) => ctx); - - const core = { +describe("createMatrixRoomMessageHandler inbound body formatting", () => { + beforeEach(() => { + setMatrixRuntime({ channel: { - pairing: { - readAllowFromStore: vi.fn().mockResolvedValue([]), + mentions: { + matchesMentionPatterns: () => false, }, - routing: { - resolveAgentRoute: vi.fn().mockReturnValue({ - agentId: "main", - accountId: undefined, - sessionKey: "agent:main:matrix:channel:!room:example.org", - mainSessionKey: "agent:main:main", - }), - }, - session: { - resolveStorePath: vi.fn().mockReturnValue("/tmp/openclaw-test-session.json"), - readSessionUpdatedAt: vi.fn().mockReturnValue(123), - recordInboundSession, - }, - reply: { - resolveEnvelopeFormatOptions: vi.fn().mockReturnValue({}), - formatInboundEnvelope, - formatAgentEnvelope: vi - .fn() - .mockImplementation((params: { body: string }) => params.body), - finalizeInboundContext, - resolveHumanDelayConfig: vi.fn().mockReturnValue(undefined), - createReplyDispatcherWithTyping: vi.fn().mockReturnValue({ - dispatcher: {}, - replyOptions: {}, - markDispatchIdle: vi.fn(), - }), - withReplyDispatcher: vi - .fn() - .mockResolvedValue({ queuedFinal: false, counts: { final: 0, partial: 0, tool: 0 } }), - }, - commands: { - shouldHandleTextCommands: vi.fn().mockReturnValue(true), - }, - text: { - hasControlCommand: vi.fn().mockReturnValue(false), - resolveMarkdownTableMode: vi.fn().mockReturnValue("code"), + media: { + saveMediaBuffer: vi.fn(), }, }, - system: { - enqueueSystemEvent: vi.fn(), + config: { + loadConfig: () => ({}), }, - } as unknown as PluginRuntime; + state: { + resolveStateDir: () => "/tmp", + }, + } as never); + }); - const runtime = { - error: vi.fn(), - } as unknown as RuntimeEnv; - const logger = { - info: vi.fn(), - warn: vi.fn(), - } as unknown as RuntimeLogger; - const logVerboseMessage = vi.fn(); - - const client = { - getUserId: vi.fn().mockResolvedValue("@bot:matrix.example.org"), - } as unknown as MatrixClient; + it("records thread metadata for group thread messages", async () => { + const recordInboundSession = vi.fn(async () => {}); + const finalizeInboundContext = vi.fn((ctx) => ctx); const handler = createMatrixRoomMessageHandler({ - client, - core, - cfg: {}, - runtime, - logger, - logVerboseMessage, + client: { + getUserId: async () => "@bot:example.org", + getEvent: async () => ({ + event_id: "$thread-root", + sender: "@alice:example.org", + type: EventType.RoomMessage, + origin_server_ts: Date.now(), + content: { + msgtype: "m.text", + body: "Root topic", + }, + }), + } as never, + core: { + channel: { + pairing: { + readAllowFromStore: async () => [] as string[], + upsertPairingRequest: async () => ({ code: "ABCDEFGH", created: false }), + }, + commands: { + shouldHandleTextCommands: () => false, + }, + text: { + hasControlCommand: () => false, + resolveMarkdownTableMode: () => "preserve", + }, + routing: { + resolveAgentRoute: () => ({ + agentId: "ops", + channel: "matrix", + accountId: "ops", + sessionKey: "agent:ops:main", + mainSessionKey: "agent:ops:main", + matchedBy: "binding.account", + }), + }, + session: { + resolveStorePath: () => "/tmp/session-store", + readSessionUpdatedAt: () => undefined, + recordInboundSession, + }, + reply: { + resolveEnvelopeFormatOptions: () => ({}), + formatAgentEnvelope: ({ body }: { body: string }) => body, + finalizeInboundContext, + createReplyDispatcherWithTyping: () => ({ + dispatcher: {}, + replyOptions: {}, + markDispatchIdle: () => {}, + }), + resolveHumanDelayConfig: () => undefined, + dispatchReplyFromConfig: async () => ({ + queuedFinal: false, + counts: { final: 0, block: 0, tool: 0 }, + }), + }, + reactions: { + shouldAckReaction: () => false, + }, + }, + } as never, + cfg: {} as never, + accountId: "ops", + runtime: { + error: () => {}, + } as RuntimeEnv, + logger: { + info: () => {}, + warn: () => {}, + } as RuntimeLogger, + logVerboseMessage: () => {}, allowFrom: [], - roomsConfig: undefined, mentionRegexes: [], groupPolicy: "open", - replyToMode: "first", + replyToMode: "off", threadReplies: "inbound", dmEnabled: true, dmPolicy: "open", - textLimit: 4000, - mediaMaxBytes: 5 * 1024 * 1024, - startupMs: Date.now(), - startupGraceMs: 60_000, + textLimit: 8_000, + mediaMaxBytes: 10_000_000, + startupMs: 0, + startupGraceMs: 0, directTracker: { - isDirectMessage: vi.fn().mockResolvedValue(false), + isDirectMessage: async () => false, }, - getRoomInfo: vi.fn().mockResolvedValue({ - name: "Dev Room", - canonicalAlias: "#dev:matrix.example.org", - altAliases: [], - }), - getMemberDisplayName: vi.fn().mockResolvedValue("Bu"), - accountId: "default", + getRoomInfo: async () => ({ altAliases: [] }), + getMemberDisplayName: async (_roomId, userId) => + userId === "@alice:example.org" ? "Alice" : "sender", }); - const event = { + await handler("!room:example.org", { type: EventType.RoomMessage, - event_id: "$event1", - sender: "@bu:matrix.example.org", + sender: "@user:example.org", + event_id: "$reply1", origin_server_ts: Date.now(), content: { msgtype: "m.text", - body: "show me my commits", - "m.mentions": { user_ids: ["@bot:matrix.example.org"] }, + body: "follow up", "m.relates_to": { rel_type: "m.thread", event_id: "$thread-root", + "m.in_reply_to": { event_id: "$thread-root" }, }, + "m.mentions": { room: true }, }, - } as unknown as MatrixRawEvent; + } as MatrixRawEvent); - await handler("!room:example.org", event); - - expect(formatInboundEnvelope).toHaveBeenCalledWith( + expect(finalizeInboundContext).toHaveBeenCalledWith( expect.objectContaining({ - chatType: "channel", - senderLabel: "Bu (bu)", + MessageThreadId: "$thread-root", + ThreadStarterBody: "Matrix thread root $thread-root from Alice:\nRoot topic", }), ); expect(recordInboundSession).toHaveBeenCalledWith( expect.objectContaining({ - ctx: expect.objectContaining({ - ChatType: "thread", - BodyForAgent: "Bu (bu): show me my commits", + sessionKey: "agent:ops:main", + }), + ); + }); + + it("records formatted poll results for inbound poll response events", async () => { + const recordInboundSession = vi.fn(async () => {}); + const finalizeInboundContext = vi.fn((ctx) => ctx); + + const handler = createMatrixRoomMessageHandler({ + client: { + getUserId: async () => "@bot:example.org", + getEvent: async () => ({ + event_id: "$poll", + sender: "@bot:example.org", + type: "m.poll.start", + origin_server_ts: 1, + content: { + "m.poll.start": { + question: { "m.text": "Lunch?" }, + kind: "m.poll.disclosed", + max_selections: 1, + answers: [ + { id: "a1", "m.text": "Pizza" }, + { id: "a2", "m.text": "Sushi" }, + ], + }, + }, }), + getRelations: async () => ({ + events: [ + { + type: "m.poll.response", + event_id: "$vote1", + sender: "@user:example.org", + origin_server_ts: 2, + content: { + "m.poll.response": { answers: ["a1"] }, + "m.relates_to": { rel_type: "m.reference", event_id: "$poll" }, + }, + }, + ], + nextBatch: null, + prevBatch: null, + }), + } as unknown as MatrixClient, + core: { + channel: { + pairing: { + readAllowFromStore: async () => [] as string[], + upsertPairingRequest: async () => ({ code: "ABCDEFGH", created: false }), + }, + commands: { + shouldHandleTextCommands: () => false, + }, + text: { + hasControlCommand: () => false, + resolveMarkdownTableMode: () => "preserve", + }, + routing: { + resolveAgentRoute: () => ({ + agentId: "ops", + channel: "matrix", + accountId: "ops", + sessionKey: "agent:ops:main", + mainSessionKey: "agent:ops:main", + matchedBy: "binding.account", + }), + }, + session: { + resolveStorePath: () => "/tmp/session-store", + readSessionUpdatedAt: () => undefined, + recordInboundSession, + }, + reply: { + resolveEnvelopeFormatOptions: () => ({}), + formatAgentEnvelope: ({ body }: { body: string }) => body, + finalizeInboundContext, + createReplyDispatcherWithTyping: () => ({ + dispatcher: {}, + replyOptions: {}, + markDispatchIdle: () => {}, + }), + resolveHumanDelayConfig: () => undefined, + dispatchReplyFromConfig: async () => ({ + queuedFinal: false, + counts: { final: 0, block: 0, tool: 0 }, + }), + }, + reactions: { + shouldAckReaction: () => false, + }, + }, + } as never, + cfg: {} as never, + accountId: "ops", + runtime: { + error: () => {}, + } as RuntimeEnv, + logger: { + info: () => {}, + warn: () => {}, + } as RuntimeLogger, + logVerboseMessage: () => {}, + allowFrom: [], + mentionRegexes: [], + groupPolicy: "open", + replyToMode: "off", + threadReplies: "inbound", + dmEnabled: true, + dmPolicy: "open", + textLimit: 8_000, + mediaMaxBytes: 10_000_000, + startupMs: 0, + startupGraceMs: 0, + directTracker: { + isDirectMessage: async () => true, + }, + getRoomInfo: async () => ({ altAliases: [] }), + getMemberDisplayName: async (_roomId, userId) => + userId === "@bot:example.org" ? "Bot" : "sender", + }); + + await handler("!room:example.org", { + type: "m.poll.response", + sender: "@user:example.org", + event_id: "$vote1", + origin_server_ts: 2, + content: { + "m.poll.response": { answers: ["a1"] }, + "m.relates_to": { rel_type: "m.reference", event_id: "$poll" }, + }, + } as MatrixRawEvent); + + expect(finalizeInboundContext).toHaveBeenCalledWith( + expect.objectContaining({ + RawBody: expect.stringMatching(/1\. Pizza \(1 vote\)[\s\S]*Total voters: 1/), + }), + ); + expect(recordInboundSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:ops:main", }), ); }); diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 58d5dd6e704..2e8ba7221c2 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -17,8 +17,12 @@ import { import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js"; import { formatPollAsText, + formatPollResultsAsText, + isPollEventType, isPollStartType, parsePollStartContent, + resolvePollReferenceEventId, + buildPollResultsSummary, type PollStartContent, } from "../poll-types.js"; import type { LocationMessageEventContent, MatrixClient } from "../sdk.js"; @@ -166,7 +170,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam return; } - const isPollEvent = isPollStartType(eventType); + const isPollEvent = isPollEventType(eventType); const isReactionEvent = eventType === EventType.Reaction; const locationContent = event.content as LocationMessageEventContent; const isLocationEvent = @@ -213,22 +217,61 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam let content = event.content as RoomMessageEventContent; if (isPollEvent) { - const pollStartContent = event.content as PollStartContent; - const pollSummary = parsePollStartContent(pollStartContent); - if (pollSummary) { - pollSummary.eventId = event.event_id ?? ""; - pollSummary.roomId = roomId; - pollSummary.sender = senderId; - const senderDisplayName = await getMemberDisplayName(roomId, senderId); - pollSummary.senderName = senderDisplayName; - const pollText = formatPollAsText(pollSummary); - content = { - msgtype: "m.text", - body: pollText, - } as unknown as RoomMessageEventContent; - } else { + 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 pollText = pollResults + ? formatPollResultsAsText(pollResults) + : formatPollAsText(pollSummary); + content = { + msgtype: "m.text", + body: pollText, + } as unknown as RoomMessageEventContent; } if ( diff --git a/extensions/matrix/src/matrix/poll-types.test.ts b/extensions/matrix/src/matrix/poll-types.test.ts index 3c78ab1b07c..9e129a45664 100644 --- a/extensions/matrix/src/matrix/poll-types.test.ts +++ b/extensions/matrix/src/matrix/poll-types.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from "vitest"; import { + buildPollResultsSummary, buildPollResponseContent, buildPollStartContent, + formatPollResultsAsText, parsePollStart, + parsePollResponseAnswerIds, parsePollStartContent, + resolvePollReferenceEventId, } from "./poll-types.js"; describe("parsePollStartContent", () => { @@ -93,3 +97,109 @@ describe("buildPollResponseContent", () => { }); }); }); + +describe("poll relation parsing", () => { + it("parses stable and unstable poll response answer ids", () => { + expect( + parsePollResponseAnswerIds({ + "m.poll.response": { answers: ["a1"] }, + "m.relates_to": { rel_type: "m.reference", event_id: "$poll" }, + }), + ).toEqual(["a1"]); + expect( + parsePollResponseAnswerIds({ + "org.matrix.msc3381.poll.response": { answers: ["a2"] }, + }), + ).toEqual(["a2"]); + }); + + it("extracts poll relation targets", () => { + expect( + resolvePollReferenceEventId({ + "m.relates_to": { rel_type: "m.reference", event_id: "$poll" }, + }), + ).toBe("$poll"); + }); +}); + +describe("buildPollResultsSummary", () => { + it("counts only the latest valid response from each sender", () => { + const summary = buildPollResultsSummary({ + pollEventId: "$poll", + roomId: "!room:example.org", + sender: "@alice:example.org", + senderName: "Alice", + content: { + "m.poll.start": { + question: { "m.text": "Lunch?" }, + kind: "m.poll.disclosed", + max_selections: 1, + answers: [ + { id: "a1", "m.text": "Pizza" }, + { id: "a2", "m.text": "Sushi" }, + ], + }, + }, + relationEvents: [ + { + event_id: "$vote1", + sender: "@bob:example.org", + type: "m.poll.response", + origin_server_ts: 1, + content: { + "m.poll.response": { answers: ["a1"] }, + "m.relates_to": { rel_type: "m.reference", event_id: "$poll" }, + }, + }, + { + event_id: "$vote2", + sender: "@bob:example.org", + type: "m.poll.response", + origin_server_ts: 2, + content: { + "m.poll.response": { answers: ["a2"] }, + "m.relates_to": { rel_type: "m.reference", event_id: "$poll" }, + }, + }, + { + event_id: "$vote3", + sender: "@carol:example.org", + type: "m.poll.response", + origin_server_ts: 3, + content: { + "m.poll.response": { answers: [] }, + "m.relates_to": { rel_type: "m.reference", event_id: "$poll" }, + }, + }, + ], + }); + + expect(summary?.entries).toEqual([ + { id: "a1", text: "Pizza", votes: 0 }, + { id: "a2", text: "Sushi", votes: 1 }, + ]); + expect(summary?.totalVotes).toBe(1); + }); + + it("formats disclosed poll results with vote totals", () => { + const text = formatPollResultsAsText({ + eventId: "$poll", + roomId: "!room:example.org", + sender: "@alice:example.org", + senderName: "Alice", + question: "Lunch?", + answers: ["Pizza", "Sushi"], + kind: "m.poll.disclosed", + maxSelections: 1, + entries: [ + { id: "a1", text: "Pizza", votes: 1 }, + { id: "a2", text: "Sushi", votes: 0 }, + ], + totalVotes: 1, + closed: false, + }); + + expect(text).toContain("1. Pizza (1 vote)"); + expect(text).toContain("Total voters: 1"); + }); +}); diff --git a/extensions/matrix/src/matrix/poll-types.ts b/extensions/matrix/src/matrix/poll-types.ts index 19b5cc12944..ea5938290f5 100644 --- a/extensions/matrix/src/matrix/poll-types.ts +++ b/extensions/matrix/src/matrix/poll-types.ts @@ -77,6 +77,16 @@ export type PollSummary = { maxSelections: number; }; +export type PollResultsSummary = PollSummary & { + entries: Array<{ + id: string; + text: string; + votes: number; + }>; + totalVotes: number; + closed: boolean; +}; + export type ParsedPollStart = { question: string; answers: PollParsedAnswer[]; @@ -101,6 +111,18 @@ export function isPollStartType(eventType: string): boolean { return (POLL_START_TYPES as readonly string[]).includes(eventType); } +export function isPollResponseType(eventType: string): boolean { + return (POLL_RESPONSE_TYPES as readonly string[]).includes(eventType); +} + +export function isPollEndType(eventType: string): boolean { + return (POLL_END_TYPES as readonly string[]).includes(eventType); +} + +export function isPollEventType(eventType: string): boolean { + return (POLL_EVENT_TYPES as readonly string[]).includes(eventType); +} + export function getTextContent(text?: TextContent): string { if (!text) { return ""; @@ -174,6 +196,182 @@ export function formatPollAsText(summary: PollSummary): string { return lines.join("\n"); } +export function resolvePollReferenceEventId(content: unknown): string | null { + if (!content || typeof content !== "object") { + return null; + } + const relates = (content as { "m.relates_to"?: { event_id?: unknown } })["m.relates_to"]; + if (!relates || typeof relates.event_id !== "string") { + return null; + } + const eventId = relates.event_id.trim(); + return eventId.length > 0 ? eventId : null; +} + +export function parsePollResponseAnswerIds(content: unknown): string[] | null { + if (!content || typeof content !== "object") { + return null; + } + const response = + (content as Record)[M_POLL_RESPONSE] ?? + (content as Record)[ORG_POLL_RESPONSE]; + if (!response || !Array.isArray(response.answers)) { + return null; + } + return response.answers.filter((answer): answer is string => typeof answer === "string"); +} + +export function buildPollResultsSummary(params: { + pollEventId: string; + roomId: string; + sender: string; + senderName: string; + content: PollStartContent; + relationEvents: Array<{ + event_id?: string; + sender?: string; + type?: string; + origin_server_ts?: number; + content?: Record; + unsigned?: { + redacted_because?: unknown; + }; + }>; +}): PollResultsSummary | null { + const parsed = parsePollStart(params.content); + if (!parsed) { + return null; + } + + let pollClosedAt = Number.POSITIVE_INFINITY; + for (const event of params.relationEvents) { + if (event.unsigned?.redacted_because) { + continue; + } + if (!isPollEndType(typeof event.type === "string" ? event.type : "")) { + continue; + } + if (event.sender !== params.sender) { + continue; + } + const ts = + typeof event.origin_server_ts === "number" && Number.isFinite(event.origin_server_ts) + ? event.origin_server_ts + : Number.POSITIVE_INFINITY; + if (ts < pollClosedAt) { + pollClosedAt = ts; + } + } + + const answerIds = new Set(parsed.answers.map((answer) => answer.id)); + const latestVoteBySender = new Map< + string, + { + ts: number; + eventId: string; + answerIds: string[]; + } + >(); + + const orderedRelationEvents = [...params.relationEvents].sort((left, right) => { + const leftTs = + typeof left.origin_server_ts === "number" && Number.isFinite(left.origin_server_ts) + ? left.origin_server_ts + : Number.POSITIVE_INFINITY; + const rightTs = + typeof right.origin_server_ts === "number" && Number.isFinite(right.origin_server_ts) + ? right.origin_server_ts + : Number.POSITIVE_INFINITY; + if (leftTs !== rightTs) { + return leftTs - rightTs; + } + return (left.event_id ?? "").localeCompare(right.event_id ?? ""); + }); + + for (const event of orderedRelationEvents) { + if (event.unsigned?.redacted_because) { + continue; + } + if (!isPollResponseType(typeof event.type === "string" ? event.type : "")) { + continue; + } + const senderId = typeof event.sender === "string" ? event.sender.trim() : ""; + if (!senderId) { + continue; + } + const eventTs = + typeof event.origin_server_ts === "number" && Number.isFinite(event.origin_server_ts) + ? event.origin_server_ts + : Number.POSITIVE_INFINITY; + if (eventTs > pollClosedAt) { + continue; + } + const rawAnswers = parsePollResponseAnswerIds(event.content) ?? []; + const normalizedAnswers = Array.from( + new Set( + rawAnswers + .map((answerId) => answerId.trim()) + .filter((answerId) => answerIds.has(answerId)) + .slice(0, parsed.maxSelections), + ), + ); + latestVoteBySender.set(senderId, { + ts: eventTs, + eventId: typeof event.event_id === "string" ? event.event_id : "", + answerIds: normalizedAnswers, + }); + } + + const voteCounts = new Map(parsed.answers.map((answer) => [answer.id, 0] as const)); + let totalVotes = 0; + for (const latestVote of latestVoteBySender.values()) { + if (latestVote.answerIds.length === 0) { + continue; + } + totalVotes += 1; + for (const answerId of latestVote.answerIds) { + voteCounts.set(answerId, (voteCounts.get(answerId) ?? 0) + 1); + } + } + + return { + eventId: params.pollEventId, + roomId: params.roomId, + sender: params.sender, + senderName: params.senderName, + question: parsed.question, + answers: parsed.answers.map((answer) => answer.text), + kind: parsed.kind, + maxSelections: parsed.maxSelections, + entries: parsed.answers.map((answer) => ({ + id: answer.id, + text: answer.text, + votes: voteCounts.get(answer.id) ?? 0, + })), + totalVotes, + closed: Number.isFinite(pollClosedAt), + }; +} + +export function formatPollResultsAsText(summary: PollResultsSummary): string { + const lines = [summary.closed ? "[Poll closed]" : "[Poll]", summary.question, ""]; + const revealResults = summary.kind === "m.poll.disclosed" || summary.closed; + for (const [index, entry] of summary.entries.entries()) { + if (!revealResults) { + lines.push(`${index + 1}. ${entry.text}`); + continue; + } + lines.push(`${index + 1}. ${entry.text} (${entry.votes} vote${entry.votes === 1 ? "" : "s"})`); + } + lines.push(""); + if (!revealResults) { + lines.push("Responses are hidden until the poll closes."); + } else { + lines.push(`Total voters: ${summary.totalVotes}`); + } + return lines.join("\n"); +} + function buildTextContent(body: string): TextContent { return { "m.text": body, diff --git a/extensions/matrix/src/matrix/profile.test.ts b/extensions/matrix/src/matrix/profile.test.ts index a85a96f4e5f..0f5035e89ee 100644 --- a/extensions/matrix/src/matrix/profile.test.ts +++ b/extensions/matrix/src/matrix/profile.test.ts @@ -29,6 +29,7 @@ describe("matrix profile sync", () => { expect(result.skipped).toBe(true); expectNoUpdates(result); + expect(result.uploadedAvatarSource).toBeNull(); expect(client.setDisplayName).not.toHaveBeenCalled(); expect(client.setAvatarUrl).not.toHaveBeenCalled(); }); @@ -49,6 +50,7 @@ describe("matrix profile sync", () => { expect(result.skipped).toBe(false); expect(result.displayNameUpdated).toBe(true); expect(result.avatarUpdated).toBe(false); + expect(result.uploadedAvatarSource).toBeNull(); expect(client.setDisplayName).toHaveBeenCalledWith("New Name"); }); @@ -93,6 +95,7 @@ describe("matrix profile sync", () => { }); expect(result.convertedAvatarFromHttp).toBe(true); + expect(result.uploadedAvatarSource).toBe("http"); expect(result.resolvedAvatarUrl).toBe("mxc://example/new-avatar"); expect(result.avatarUpdated).toBe(true); expect(loadAvatarFromUrl).toHaveBeenCalledWith( @@ -102,6 +105,34 @@ describe("matrix profile sync", () => { expect(client.setAvatarUrl).toHaveBeenCalledWith("mxc://example/new-avatar"); }); + it("uploads avatar media from a local path and then updates profile avatar", async () => { + const client = createClientStub(); + client.getUserProfile.mockResolvedValue({ + displayname: "Bot", + avatar_url: "mxc://example/old", + }); + client.uploadContent.mockResolvedValue("mxc://example/path-avatar"); + const loadAvatarFromPath = vi.fn(async () => ({ + buffer: Buffer.from("avatar-bytes"), + contentType: "image/jpeg", + fileName: "avatar.jpg", + })); + + const result = await syncMatrixOwnProfile({ + client, + userId: "@bot:example.org", + avatarPath: "/tmp/avatar.jpg", + loadAvatarFromPath, + }); + + expect(result.convertedAvatarFromHttp).toBe(false); + expect(result.uploadedAvatarSource).toBe("path"); + expect(result.resolvedAvatarUrl).toBe("mxc://example/path-avatar"); + expect(result.avatarUpdated).toBe(true); + expect(loadAvatarFromPath).toHaveBeenCalledWith("/tmp/avatar.jpg", 10 * 1024 * 1024); + expect(client.setAvatarUrl).toHaveBeenCalledWith("mxc://example/path-avatar"); + }); + it("rejects unsupported avatar URL schemes", async () => { const client = createClientStub(); diff --git a/extensions/matrix/src/matrix/profile.ts b/extensions/matrix/src/matrix/profile.ts index 2cee6aa5e2a..ea21ede89e6 100644 --- a/extensions/matrix/src/matrix/profile.ts +++ b/extensions/matrix/src/matrix/profile.ts @@ -18,6 +18,7 @@ export type MatrixProfileSyncResult = { displayNameUpdated: boolean; avatarUpdated: boolean; resolvedAvatarUrl: string | null; + uploadedAvatarSource: "http" | "path" | null; convertedAvatarFromHttp: boolean; }; @@ -42,16 +43,54 @@ export function isSupportedMatrixAvatarSource(value: string): boolean { return isMatrixMxcUri(value) || isMatrixHttpAvatarUri(value); } +async function uploadAvatarMedia(params: { + client: MatrixProfileClient; + avatarSource: string; + avatarMaxBytes: number; + loadAvatar: (source: string, maxBytes: number) => Promise; +}): Promise { + const media = await params.loadAvatar(params.avatarSource, params.avatarMaxBytes); + return await params.client.uploadContent( + media.buffer, + media.contentType, + media.fileName || "avatar", + ); +} + async function resolveAvatarUrl(params: { client: MatrixProfileClient; avatarUrl: string | null; + avatarPath?: string | null; avatarMaxBytes: number; loadAvatarFromUrl?: (url: string, maxBytes: number) => Promise; -}): Promise<{ resolvedAvatarUrl: string | null; convertedAvatarFromHttp: boolean }> { + loadAvatarFromPath?: (path: string, maxBytes: number) => Promise; +}): Promise<{ + resolvedAvatarUrl: string | null; + uploadedAvatarSource: "http" | "path" | null; + convertedAvatarFromHttp: boolean; +}> { + const avatarPath = normalizeOptionalText(params.avatarPath); + if (avatarPath) { + if (!params.loadAvatarFromPath) { + throw new Error("Matrix avatar path upload requires a media loader."); + } + return { + resolvedAvatarUrl: await uploadAvatarMedia({ + client: params.client, + avatarSource: avatarPath, + avatarMaxBytes: params.avatarMaxBytes, + loadAvatar: params.loadAvatarFromPath, + }), + uploadedAvatarSource: "path", + convertedAvatarFromHttp: false, + }; + } + const avatarUrl = normalizeOptionalText(params.avatarUrl); if (!avatarUrl) { return { resolvedAvatarUrl: null, + uploadedAvatarSource: null, convertedAvatarFromHttp: false, }; } @@ -59,6 +98,7 @@ async function resolveAvatarUrl(params: { if (isMatrixMxcUri(avatarUrl)) { return { resolvedAvatarUrl: avatarUrl, + uploadedAvatarSource: null, convertedAvatarFromHttp: false, }; } @@ -71,15 +111,14 @@ async function resolveAvatarUrl(params: { throw new Error("Matrix avatar URL conversion requires a media loader."); } - const media = await params.loadAvatarFromUrl(avatarUrl, params.avatarMaxBytes); - const uploadedMxc = await params.client.uploadContent( - media.buffer, - media.contentType, - media.fileName || "avatar", - ); - return { - resolvedAvatarUrl: uploadedMxc, + resolvedAvatarUrl: await uploadAvatarMedia({ + client: params.client, + avatarSource: avatarUrl, + avatarMaxBytes: params.avatarMaxBytes, + loadAvatar: params.loadAvatarFromUrl, + }), + uploadedAvatarSource: "http", convertedAvatarFromHttp: true, }; } @@ -89,15 +128,19 @@ export async function syncMatrixOwnProfile(params: { userId: string; displayName?: string | null; avatarUrl?: string | null; + avatarPath?: string | null; avatarMaxBytes?: number; loadAvatarFromUrl?: (url: string, maxBytes: number) => Promise; + loadAvatarFromPath?: (path: string, maxBytes: number) => Promise; }): Promise { const desiredDisplayName = normalizeOptionalText(params.displayName); const avatar = await resolveAvatarUrl({ client: params.client, avatarUrl: params.avatarUrl ?? null, + avatarPath: params.avatarPath ?? null, avatarMaxBytes: params.avatarMaxBytes ?? MATRIX_PROFILE_AVATAR_MAX_BYTES, loadAvatarFromUrl: params.loadAvatarFromUrl, + loadAvatarFromPath: params.loadAvatarFromPath, }); const desiredAvatarUrl = avatar.resolvedAvatarUrl; @@ -107,6 +150,7 @@ export async function syncMatrixOwnProfile(params: { displayNameUpdated: false, avatarUpdated: false, resolvedAvatarUrl: null, + uploadedAvatarSource: avatar.uploadedAvatarSource, convertedAvatarFromHttp: avatar.convertedAvatarFromHttp, }; } @@ -138,6 +182,7 @@ export async function syncMatrixOwnProfile(params: { displayNameUpdated, avatarUpdated, resolvedAvatarUrl: desiredAvatarUrl, + uploadedAvatarSource: avatar.uploadedAvatarSource, convertedAvatarFromHttp: avatar.convertedAvatarFromHttp, }; } diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index 23353676a85..ed3a58f9e55 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -103,11 +103,13 @@ type MatrixJsClientStub = EventEmitter & { mxcUrlToHttp: ReturnType; uploadContent: ReturnType; fetchRoomEvent: ReturnType; + getEventMapper: ReturnType; sendTyping: ReturnType; getRoom: ReturnType; getRooms: ReturnType; getCrypto: ReturnType; decryptEventIfNeeded: ReturnType; + relations: ReturnType; }; function createMatrixJsClientStub(): MatrixJsClientStub { @@ -132,11 +134,42 @@ function createMatrixJsClientStub(): MatrixJsClientStub { client.mxcUrlToHttp = vi.fn(() => null); client.uploadContent = vi.fn(async () => ({ content_uri: "mxc://example/file" })); client.fetchRoomEvent = vi.fn(async () => ({})); + client.getEventMapper = vi.fn( + () => + ( + raw: Partial<{ + room_id: string; + event_id: string; + sender: string; + type: string; + origin_server_ts: number; + content: Record; + state_key?: string; + unsigned?: { age?: number; redacted_because?: unknown }; + }>, + ) => + new FakeMatrixEvent({ + roomId: raw.room_id ?? "!mapped:example.org", + eventId: raw.event_id ?? "$mapped", + sender: raw.sender ?? "@mapped:example.org", + type: raw.type ?? "m.room.message", + ts: raw.origin_server_ts ?? Date.now(), + content: raw.content ?? {}, + stateKey: raw.state_key, + unsigned: raw.unsigned, + }), + ); client.sendTyping = vi.fn(async () => {}); client.getRoom = vi.fn(() => ({ hasEncryptionStateEvent: () => false })); client.getRooms = vi.fn(() => []); client.getCrypto = vi.fn(() => undefined); client.decryptEventIfNeeded = vi.fn(async () => {}); + client.relations = vi.fn(async () => ({ + originalEvent: null, + events: [], + nextBatch: null, + prevBatch: null, + })); return client; } @@ -183,6 +216,90 @@ describe("MatrixClient request hardening", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("decrypts encrypted room events returned by getEvent", async () => { + const client = new MatrixClient("https://matrix.example.org", "token"); + matrixJsClient.fetchRoomEvent = vi.fn(async () => ({ + room_id: "!room:example.org", + event_id: "$poll", + sender: "@alice:example.org", + type: "m.room.encrypted", + origin_server_ts: 1, + content: {}, + })); + matrixJsClient.decryptEventIfNeeded = vi.fn(async (event: FakeMatrixEvent) => { + event.emit( + "decrypted", + new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$poll", + sender: "@alice:example.org", + type: "m.poll.start", + ts: 1, + content: { + "m.poll.start": { + question: { "m.text": "Lunch?" }, + answers: [{ id: "a1", "m.text": "Pizza" }], + }, + }, + }), + ); + }); + + const event = await client.getEvent("!room:example.org", "$poll"); + + expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1); + expect(event).toMatchObject({ + event_id: "$poll", + type: "m.poll.start", + sender: "@alice:example.org", + }); + }); + + it("maps relations pages back to raw events", async () => { + const client = new MatrixClient("https://matrix.example.org", "token"); + matrixJsClient.relations = vi.fn(async () => ({ + originalEvent: new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$poll", + sender: "@alice:example.org", + type: "m.poll.start", + ts: 1, + content: { + "m.poll.start": { + question: { "m.text": "Lunch?" }, + answers: [{ id: "a1", "m.text": "Pizza" }], + }, + }, + }), + events: [ + new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$vote", + sender: "@bob:example.org", + type: "m.poll.response", + ts: 2, + content: { + "m.poll.response": { answers: ["a1"] }, + "m.relates_to": { rel_type: "m.reference", event_id: "$poll" }, + }, + }), + ], + nextBatch: null, + prevBatch: null, + })); + + const page = await client.getRelations("!room:example.org", "$poll", "m.reference"); + + expect(page.originalEvent).toMatchObject({ event_id: "$poll", type: "m.poll.start" }); + expect(page.events).toEqual([ + expect.objectContaining({ + event_id: "$vote", + type: "m.poll.response", + sender: "@bob:example.org", + }), + ]); + }); + it("blocks cross-protocol redirects when absolute endpoints are allowed", async () => { const fetchMock = vi.fn(async () => { return new Response("", { diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index 9b18b0e60ce..6b50ece5f83 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -3,6 +3,7 @@ import "fake-indexeddb/auto"; import { EventEmitter } from "node:events"; import { ClientEvent, + MatrixEventEvent, createClient as createMatrixJsClient, type MatrixClient as MatrixJsClient, type MatrixEvent, @@ -23,6 +24,7 @@ import type { MatrixClientEventMap, MatrixCryptoBootstrapApi, MatrixDeviceVerificationStatusLike, + MatrixRelationsPage, MatrixRawEvent, MessageEventContent, } from "./sdk/types.js"; @@ -539,7 +541,42 @@ export class MatrixClient { } async getEvent(roomId: string, eventId: string): Promise> { - return (await this.client.fetchRoomEvent(roomId, eventId)) as Record; + const rawEvent = (await this.client.fetchRoomEvent(roomId, eventId)) as Record; + if (rawEvent.type !== "m.room.encrypted") { + return rawEvent; + } + + const mapper = this.client.getEventMapper(); + const event = mapper(rawEvent); + let decryptedEvent: MatrixEvent | undefined; + const onDecrypted = (candidate: MatrixEvent) => { + decryptedEvent = candidate; + }; + event.once(MatrixEventEvent.Decrypted, onDecrypted); + try { + await this.client.decryptEventIfNeeded(event); + } finally { + event.off(MatrixEventEvent.Decrypted, onDecrypted); + } + return matrixEventToRaw(decryptedEvent ?? event); + } + + async getRelations( + roomId: string, + eventId: string, + relationType: string | null, + eventType?: string | null, + opts: { + from?: string; + } = {}, + ): Promise { + const result = await this.client.relations(roomId, eventId, relationType, eventType, opts); + return { + originalEvent: result.originalEvent ? matrixEventToRaw(result.originalEvent) : null, + events: result.events.map((event) => matrixEventToRaw(event)), + nextBatch: result.nextBatch ?? null, + prevBatch: result.prevBatch ?? null, + }; } async setTyping(roomId: string, typing: boolean, timeoutMs: number): Promise { diff --git a/extensions/matrix/src/matrix/sdk/types.ts b/extensions/matrix/src/matrix/sdk/types.ts index 7a7330bfa48..b625308b356 100644 --- a/extensions/matrix/src/matrix/sdk/types.ts +++ b/extensions/matrix/src/matrix/sdk/types.ts @@ -13,6 +13,13 @@ export type MatrixRawEvent = { state_key?: string; }; +export type MatrixRelationsPage = { + originalEvent?: MatrixRawEvent | null; + events: MatrixRawEvent[]; + nextBatch?: string | null; + prevBatch?: string | null; +}; + export type MatrixClientEventMap = { "room.event": [roomId: string, event: MatrixRawEvent]; "room.message": [roomId: string, event: MatrixRawEvent]; diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index a303126dd76..6d314f19286 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -343,4 +343,36 @@ describe("voteMatrixPoll", () => { ).rejects.toThrow("is not a Matrix poll start event"); expect(sendEvent).not.toHaveBeenCalled(); }); + + it("accepts decrypted poll start events returned from encrypted rooms", async () => { + const { client, getEvent, sendEvent } = makeClient(); + getEvent.mockResolvedValue({ + type: "m.poll.start", + content: { + "m.poll.start": { + question: { "m.text": "Lunch?" }, + max_selections: 1, + answers: [{ id: "a1", "m.text": "Pizza" }], + }, + }, + }); + + await expect( + voteMatrixPoll("room:!room:example", "$poll", { + client, + optionIndex: 1, + }), + ).resolves.toMatchObject({ + pollId: "$poll", + answerIds: ["a1"], + }); + expect(sendEvent).toHaveBeenCalledWith("!room:example", "m.poll.response", { + "m.poll.response": { answers: ["a1"] }, + "org.matrix.msc3381.poll.response": { answers: ["a1"] }, + "m.relates_to": { + rel_type: "m.reference", + event_id: "$poll", + }, + }); + }); }); diff --git a/extensions/matrix/src/profile-update.ts b/extensions/matrix/src/profile-update.ts index a0d67147f8d..0425d15f9bb 100644 --- a/extensions/matrix/src/profile-update.ts +++ b/extensions/matrix/src/profile-update.ts @@ -12,6 +12,7 @@ export type MatrixProfileUpdateResult = { displayNameUpdated: boolean; avatarUpdated: boolean; resolvedAvatarUrl: string | null; + uploadedAvatarSource: "http" | "path" | null; convertedAvatarFromHttp: boolean; }; configPath: string; @@ -21,25 +22,26 @@ export async function applyMatrixProfileUpdate(params: { account?: string; displayName?: string; avatarUrl?: string; + avatarPath?: string; }): Promise { const runtime = getMatrixRuntime(); const cfg = runtime.config.loadConfig() as CoreConfig; const accountId = normalizeAccountId(params.account); const displayName = params.displayName?.trim() || null; const avatarUrl = params.avatarUrl?.trim() || null; - if (!displayName && !avatarUrl) { - throw new Error("Provide name/displayName and/or avatarUrl."); + const avatarPath = params.avatarPath?.trim() || null; + if (!displayName && !avatarUrl && !avatarPath) { + throw new Error("Provide name/displayName and/or avatarUrl/avatarPath."); } const synced = await updateMatrixOwnProfile({ accountId, displayName: displayName ?? undefined, avatarUrl: avatarUrl ?? undefined, + avatarPath: avatarPath ?? undefined, }); const persistedAvatarUrl = - synced.convertedAvatarFromHttp && synced.resolvedAvatarUrl - ? synced.resolvedAvatarUrl - : avatarUrl; + synced.uploadedAvatarSource && synced.resolvedAvatarUrl ? synced.resolvedAvatarUrl : avatarUrl; const updated = updateMatrixAccountConfig(cfg, accountId, { name: displayName ?? undefined, avatarUrl: persistedAvatarUrl ?? undefined, @@ -54,6 +56,7 @@ export async function applyMatrixProfileUpdate(params: { displayNameUpdated: synced.displayNameUpdated, avatarUpdated: synced.avatarUpdated, resolvedAvatarUrl: synced.resolvedAvatarUrl, + uploadedAvatarSource: synced.uploadedAvatarSource, convertedAvatarFromHttp: synced.convertedAvatarFromHttp, }, configPath: resolveMatrixConfigPath(updated, accountId), diff --git a/extensions/matrix/src/tool-actions.test.ts b/extensions/matrix/src/tool-actions.test.ts index f9fdfaca9c5..10052c5ed79 100644 --- a/extensions/matrix/src/tool-actions.test.ts +++ b/extensions/matrix/src/tool-actions.test.ts @@ -68,6 +68,7 @@ describe("handleMatrixAction pollVote", () => { displayNameUpdated: true, avatarUpdated: true, resolvedAvatarUrl: "mxc://example/avatar", + uploadedAvatarSource: null, convertedAvatarFromHttp: false, }, configPath: "channels.matrix.accounts.ops", @@ -262,4 +263,22 @@ describe("handleMatrixAction pollVote", () => { }, }); }); + + it("accepts local avatar paths for self-profile updates", async () => { + await handleMatrixAction( + { + action: "setProfile", + accountId: "ops", + path: "/tmp/avatar.jpg", + }, + { channels: { matrix: { actions: { profile: true } } } } as CoreConfig, + ); + + expect(mocks.applyMatrixProfileUpdate).toHaveBeenCalledWith({ + account: "ops", + displayName: undefined, + avatarUrl: undefined, + avatarPath: "/tmp/avatar.jpg", + }); + }); }); diff --git a/extensions/matrix/src/tool-actions.ts b/extensions/matrix/src/tool-actions.ts index add018d1a58..22478f75236 100644 --- a/extensions/matrix/src/tool-actions.ts +++ b/extensions/matrix/src/tool-actions.ts @@ -264,10 +264,15 @@ export async function handleMatrixAction( if (!isActionEnabled("profile")) { throw new Error("Matrix profile updates are disabled."); } + const avatarPath = + readStringParam(params, "avatarPath") ?? + readStringParam(params, "path") ?? + readStringParam(params, "filePath"); const result = await applyMatrixProfileUpdate({ account: accountId, displayName: readStringParam(params, "displayName") ?? readStringParam(params, "name"), avatarUrl: readStringParam(params, "avatarUrl"), + avatarPath, }); return jsonResult({ ok: true, ...result }); } diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index c43a4ac749e..fef6f9f28da 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -245,6 +245,7 @@ describe("message tool schema scoping", () => { expect(properties.pollOptionIndex).toBeDefined(); expect(properties.pollOptionId).toBeDefined(); expect(properties.avatarUrl).toBeDefined(); + expect(properties.avatarPath).toBeDefined(); expect(properties.displayName).toBeDefined(); }, ); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 0ac8162e001..1d05c525dad 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -445,6 +445,18 @@ function buildProfileSchema() { "snake_case alias of avatarUrl for self-profile update actions. Matrix accepts mxc:// and http(s) URLs.", }), ), + avatarPath: Type.Optional( + Type.String({ + description: + "Local avatar file path for self-profile update actions. Matrix uploads this file and sets the resulting MXC URI.", + }), + ), + avatar_path: Type.Optional( + Type.String({ + description: + "snake_case alias of avatarPath for self-profile update actions. Matrix uploads this file and sets the resulting MXC URI.", + }), + ), }; }