diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 2c0e64cdcc8..908fdbc5018 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -208,6 +208,12 @@ Notes: - Media replies always send attachments normally. If a stale preview can no longer be reused safely, OpenClaw redacts it before sending the final media reply. - Preview edits cost extra Matrix API calls. Leave `streaming: "off"` if you want the most conservative rate-limit profile. +## Approval metadata + +Matrix native approval prompts are normal `m.room.message` events with OpenClaw-specific custom event content under `com.openclaw.approval`. Matrix permits custom event-content keys, so stock clients still render the text body while OpenClaw-aware clients can read the structured approval id, kind, state, available decisions, and exec/plugin details. + +When an approval prompt is too long for one Matrix event, OpenClaw chunks the visible text and attaches `com.openclaw.approval` to the first chunk only. Reactions for allow/deny decisions are bound to that first event, so long prompts keep the same approval target as single-event prompts. + ### Self-hosted push rules for quiet finalized previews `streaming: "quiet"` only notifies recipients once a block or turn is finalized — a per-user push rule has to match the finalized preview marker. See [Matrix push rules for quiet previews](/channels/matrix-push-rules) for the full recipe (recipient token, pusher check, rule install, per-homeserver notes). diff --git a/docs/tools/exec-approvals-advanced.md b/docs/tools/exec-approvals-advanced.md index 782b7ee51ae..14ea8755ec0 100644 --- a/docs/tools/exec-approvals-advanced.md +++ b/docs/tools/exec-approvals-advanced.md @@ -301,6 +301,9 @@ Shared behavior: without a second Slack-local fallback layer - Matrix native DM/channel routing and reaction shortcuts handle both exec and plugin approvals; plugin authorization still comes from `channels.matrix.dm.allowFrom` +- Matrix native prompts include `com.openclaw.approval` custom event content on the first prompt + event so OpenClaw-aware Matrix clients can read structured approval state while stock clients + keep the plain-text `/approve` fallback - the requester does not need to be an approver - the originating chat can approve directly with `/approve` when that chat already supports commands and replies - native Discord approval buttons route by approval id kind: `plugin:` ids go diff --git a/extensions/matrix/src/approval-handler.runtime.test.ts b/extensions/matrix/src/approval-handler.runtime.test.ts index bf2e38799a2..8ef5a537664 100644 --- a/extensions/matrix/src/approval-handler.runtime.test.ts +++ b/extensions/matrix/src/approval-handler.runtime.test.ts @@ -228,6 +228,7 @@ describe("matrixApprovalNativeRuntime", () => { pendingPayload.text, expect.objectContaining({ accountId: "default", + extraContent: pendingPayload.extraContent, }), ); expect(reactMessage).toHaveBeenCalledWith( diff --git a/extensions/matrix/src/approval-handler.runtime.ts b/extensions/matrix/src/approval-handler.runtime.ts index c499e03ee0f..223d44d2a0a 100644 --- a/extensions/matrix/src/approval-handler.runtime.ts +++ b/extensions/matrix/src/approval-handler.runtime.ts @@ -218,7 +218,6 @@ function buildMatrixApprovalMetadata(params: { kind: params.view.approvalKind, phase: params.view.phase, title: params.view.title, - description: params.view.description ?? undefined, expiresAtMs: params.view.expiresAtMs, metadata: params.view.metadata, allowedDecisions: Array.from(params.allowedDecisions), @@ -433,6 +432,7 @@ export const matrixApprovalNativeRuntime = createChannelApprovalNativeRuntimeAda accountId: resolved.accountId, client: resolved.context.client, threadId: preparedTarget.threadId, + extraContent: pendingPayload.extraContent, }); } const messageIds = Array.from( diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index dd206927e8e..e1be0f26dcd 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -630,6 +630,28 @@ describe("sendMessageMatrix threads", () => { messageIds: ["$m1", "$m2", "$m3"], }); }); + + it("merges extra content into only the first chunked text event", async () => { + const { client, sendMessage } = makeClient(); + convertMarkdownTablesMock.mockImplementation(() => "first|second|third"); + chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|")); + + await sendMessageMatrix("room:!room:example", "ignored", { + client, + cfg: {} as never, + extraContent: { "com.openclaw.approval": { id: "req-1" } }, + }); + + expect(sendMessage).toHaveBeenCalledTimes(3); + expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({ + body: "first", + "com.openclaw.approval": { id: "req-1" }, + }); + expect(sendMessage.mock.calls[1]?.[1]).toMatchObject({ body: "second" }); + expect(sendMessage.mock.calls[1]?.[1]).not.toHaveProperty("com.openclaw.approval"); + expect(sendMessage.mock.calls[2]?.[1]).toMatchObject({ body: "third" }); + expect(sendMessage.mock.calls[2]?.[1]).not.toHaveProperty("com.openclaw.approval"); + }); }); describe("sendSingleTextMessageMatrix", () => { diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts index ca792de330e..e8189729c62 100644 --- a/extensions/matrix/src/matrix/send.ts +++ b/extensions/matrix/src/matrix/send.ts @@ -211,8 +211,11 @@ export async function sendMessageMatrix( const relation = threadId ? buildThreadRelation(threadId, opts.replyToId) : buildReplyRelation(opts.replyToId); + let pendingExtraContent = opts.extraContent; const sendContent = async (content: MatrixOutboundContent) => { - const eventId = await client.sendMessage(roomId, content); + const contentWithExtra = withMatrixExtraContentFields(content, pendingExtraContent); + pendingExtraContent = undefined; + const eventId = await client.sendMessage(roomId, contentWithExtra); return eventId; }; diff --git a/extensions/matrix/src/matrix/send/types.ts b/extensions/matrix/src/matrix/send/types.ts index 89ba915f283..da90dd9de62 100644 --- a/extensions/matrix/src/matrix/send/types.ts +++ b/extensions/matrix/src/matrix/send/types.ts @@ -102,6 +102,8 @@ export type MatrixSendOpts = { replyToId?: string; threadId?: string | number | null; timeoutMs?: number; + /** Additional Matrix event content fields to merge into the first sent event. */ + extraContent?: MatrixExtraContentFields; /** Send audio as voice message instead of audio file. Defaults to false. */ audioAsVoice?: boolean; };