diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index e30606ce2f2..d3e6ba33067 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -196,7 +196,7 @@ done: - `streaming: "partial"` creates one editable preview message for the current assistant block instead of sending multiple partial messages. - `blockStreaming: true` enables separate Matrix progress messages. With `streaming: "partial"`, Matrix keeps the live draft for the current block and preserves completed blocks as separate messages. - When `streaming: "partial"` and `blockStreaming` is off, Matrix only edits the live draft and sends the completed reply once that block or turn finishes. -- Draft preview events use quiet Matrix notices, so notifications fire on completed blocks or the final completed reply instead of the first streamed token. +- Draft preview events use quiet Matrix notices. On stock Matrix push rules, notice previews and later edit events are both non-notifying. - If the preview no longer fits in one Matrix event, OpenClaw stops preview streaming and falls back to normal final delivery. - Media replies still 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 behavior. @@ -204,6 +204,60 @@ done: `blockStreaming` does not enable draft previews by itself. Use `streaming: "partial"` for preview edits; then add `blockStreaming: true` only if you also want completed assistant blocks to remain visible as separate progress messages. +If you need notifications without custom Matrix push rules, leave `streaming` off. Then: + +- `blockStreaming: true` sends each finished block as a normal notifying Matrix message. +- `blockStreaming: false` sends only the final completed reply as a normal notifying Matrix message. + +### Self-hosted push rules for finalized previews + +If you run your own Matrix infrastructure and want `streaming: "partial"` previews to notify only when a +block or final reply is done, add a per-user push rule for finalized preview edits. + +OpenClaw marks finalized text-only preview edits with: + +```json +{ + "com.openclaw.finalized_preview": true +} +``` + +Create an override push rule for each recipient account which should receive these notifications: + +```bash +curl -X PUT \ + "https://matrix.example.org/_matrix/client/v3/pushrules/global/override/openclaw-finalized-preview" \ + -H "Authorization: Bearer $USER_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + --data '{ + "conditions": [ + { "kind": "event_match", "key": "type", "pattern": "m.room.message" }, + { + "kind": "event_property_is", + "key": "content.m\\.relates_to.rel_type", + "value": "m.replace" + }, + { + "kind": "event_property_is", + "key": "content.com\\.openclaw\\.finalized_preview", + "value": true + }, + { "kind": "event_match", "key": "sender", "pattern": "@bot:example.org" } + ], + "actions": [ + "notify", + { "set_tweak": "sound", "value": "default" }, + { "set_tweak": "highlight", "value": false } + ] + }' +``` + +Notes: + +- Create the rule with the receiving user's access token, not the bot's. +- New user-defined `override` rules are inserted ahead of default suppress rules, so no extra ordering parameter is needed. +- This only affects text-only preview edits that OpenClaw can safely finalize in place. Media fallbacks and stale-preview fallbacks still use normal Matrix delivery. + ## Encryption and verification In encrypted (E2EE) rooms, outbound image events use `thumbnail_file` so image previews are encrypted alongside the full attachment. Unencrypted rooms still use plain `thumbnail_url`. No configuration is needed — the plugin detects E2EE state automatically. diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index 9ff19fec7aa..17a7b3683d5 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -8,6 +8,7 @@ import { } from "openclaw/plugin-sdk/conversation-runtime"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { installMatrixMonitorTestRuntime } from "../../test-runtime.js"; +import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "../send/types.js"; import { createMatrixRoomMessageHandler } from "./handler.js"; import { createMatrixHandlerTestHarness, @@ -2080,6 +2081,14 @@ describe("matrix monitor handler draft streaming", () => { await deliver({ text: "Single block" }, { kind: "final" }); expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1); + expect(editMessageMatrixMock).toHaveBeenCalledWith( + "!room:example.org", + "$draft1", + "Single block", + expect.objectContaining({ + extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true }, + }), + ); expect(deliverMatrixRepliesMock).not.toHaveBeenCalled(); expect(redactEventMock).not.toHaveBeenCalled(); await finish(); @@ -2097,6 +2106,14 @@ describe("matrix monitor handler draft streaming", () => { deliverMatrixRepliesMock.mockClear(); await deliver({ text: "Block one" }, { kind: "block" }); + expect(editMessageMatrixMock).toHaveBeenCalledWith( + "!room:example.org", + "$draft1", + "Block one", + expect.objectContaining({ + extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true }, + }), + ); expect(deliverMatrixRepliesMock).not.toHaveBeenCalled(); expect(redactEventMock).not.toHaveBeenCalled(); @@ -2112,6 +2129,14 @@ describe("matrix monitor handler draft streaming", () => { await deliver({ text: "Block two" }, { kind: "final" }); + expect(editMessageMatrixMock).toHaveBeenCalledWith( + "!room:example.org", + "$draft2", + "Block two", + expect.objectContaining({ + extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true }, + }), + ); expect(deliverMatrixRepliesMock).not.toHaveBeenCalled(); expect(redactEventMock).not.toHaveBeenCalled(); await finish(); @@ -2400,7 +2425,14 @@ describe("matrix monitor handler draft streaming", () => { expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1); }); expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toBe("Beta"); - expect(editMessageMatrixMock).not.toHaveBeenCalled(); + expect(editMessageMatrixMock).toHaveBeenCalledWith( + "!room:example.org", + "$draft1", + "Alpha", + expect.objectContaining({ + extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true }, + }), + ); sendSingleTextMessageMatrixMock.mockClear(); editMessageMatrixMock.mockClear(); @@ -2414,7 +2446,14 @@ describe("matrix monitor handler draft streaming", () => { expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1); }); expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toBe("Gamma"); - expect(editMessageMatrixMock).not.toHaveBeenCalled(); + expect(editMessageMatrixMock).toHaveBeenCalledWith( + "!room:example.org", + "$draft2", + "Beta", + expect.objectContaining({ + extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true }, + }), + ); await finish(); }); diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 090fdc63038..54342d2de07 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -32,6 +32,7 @@ import { sendTypingMatrix, } from "../send.js"; import { resolveMatrixStoredSessionMeta } from "../session-store-metadata.js"; +import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "../send/types.js"; import { resolveMatrixMonitorAccessState } from "./access-state.js"; import { resolveMatrixAckReactionConfig } from "./ack-config.js"; import { resolveMatrixAllowListMatch } from "./allowlist.js"; @@ -84,6 +85,10 @@ async function redactMatrixDraftEvent( await client.redactEvent(roomId, draftEventId).catch(() => {}); } +function buildMatrixFinalizedPreviewContent(): Record { + return { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true }; +} + export type MatrixMonitorHandlerParams = { client: MatrixClient; core: PluginRuntime; @@ -1421,30 +1426,29 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam !payloadReplyMismatch && !mustDeliverFinalNormally ) { - if (payload.text !== draftStream.lastSentText()) { - try { - await editMessageMatrix(roomId, draftEventId, payload.text, { - client, - cfg, - threadId: threadTarget, - accountId: _route.accountId, - }); - } catch { - await redactMatrixDraftEvent(client, roomId, draftEventId); - await deliverMatrixReplies({ - cfg, - replies: [payload], - roomId, - client, - runtime, - textLimit, - replyToMode, - threadId: threadTarget, - accountId: _route.accountId, - mediaLocalRoots, - tableMode, - }); - } + try { + await editMessageMatrix(roomId, draftEventId, payload.text, { + client, + cfg, + threadId: threadTarget, + accountId: _route.accountId, + extraContent: buildMatrixFinalizedPreviewContent(), + }); + } catch { + await redactMatrixDraftEvent(client, roomId, draftEventId); + await deliverMatrixReplies({ + cfg, + replies: [payload], + roomId, + client, + runtime, + textLimit, + replyToMode, + threadId: threadTarget, + accountId: _route.accountId, + mediaLocalRoots, + tableMode, + }); } draftConsumed = true; } else if (draftEventId && hasMedia && !payloadReplyMismatch) { diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index ea0dd4f2fb3..9ff9da0c364 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -9,6 +9,7 @@ import { sendSingleTextMessageMatrix, sendTypingMatrix, } from "./send.js"; +import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "./send/types.js"; const loadOutboundMediaFromUrlMock = vi.hoisted(() => vi.fn()); const loadWebMediaMock = vi.fn().mockResolvedValue({ @@ -621,6 +622,20 @@ describe("sendSingleTextMessageMatrix", () => { (sendMessage.mock.calls[0]?.[1] as { formatted_body?: string }).formatted_body, ).not.toContain("matrix.to"); }); + + it("merges extra content fields into single-event sends", async () => { + const { client, sendMessage } = makeClient(); + + await sendSingleTextMessageMatrix("room:!room:example", "done", { + client, + extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true }, + }); + + expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({ + body: "done", + [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true, + }); + }); }); describe("editMessageMatrix mentions", () => { @@ -731,6 +746,22 @@ describe("editMessageMatrix mentions", () => { )["m.new_content"]?.formatted_body, ).not.toContain("matrix.to"); }); + + it("merges extra content fields into edit payloads and m.new_content", async () => { + const { client, sendMessage } = makeClient(); + + await editMessageMatrix("room:!room:example", "$original", "done", { + client, + extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true }, + }); + + expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({ + [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true, + "m.new_content": { + [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true, + }, + }); + }); }); describe("sendPollMatrix mentions", () => { diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts index 5b7ee528d06..7c40e9b41cc 100644 --- a/extensions/matrix/src/matrix/send.ts +++ b/extensions/matrix/src/matrix/send.ts @@ -33,6 +33,7 @@ import { EventType, MsgType, RelationType, + type MatrixExtraContentFields, type MatrixOutboundContent, type MatrixSendOpts, type MatrixSendResult, @@ -103,6 +104,16 @@ function hasMatrixMentionsMetadata(content: Record | undefined) return Boolean(content && Object.hasOwn(content, "m.mentions")); } +function withMatrixExtraContentFields>( + content: T, + extraContent?: MatrixExtraContentFields, +): T { + if (!extraContent) { + return content; + } + return { ...content, ...extraContent }; +} + async function resolvePreviousEditMentions(params: { client: MatrixClient; content: Record | undefined; @@ -401,6 +412,7 @@ export async function sendSingleTextMessageMatrix( accountId?: string; msgtype?: MatrixTextMsgType; includeMentions?: boolean; + extraContent?: MatrixExtraContentFields; } = {}, ): Promise { const { trimmedText, convertedText, singleEventLimit, fitsInSingleEvent } = @@ -428,9 +440,12 @@ export async function sendSingleTextMessageMatrix( const relation = normalizedThreadId ? buildThreadRelation(normalizedThreadId, opts.replyToId) : buildReplyRelation(opts.replyToId); - const content = buildTextContent(convertedText, relation, { - msgtype: opts.msgtype, - }); + const content = withMatrixExtraContentFields( + buildTextContent(convertedText, relation, { + msgtype: opts.msgtype, + }), + opts.extraContent, + ); await enrichMatrixFormattedContent({ client, content, @@ -476,6 +491,7 @@ export async function editMessageMatrix( timeoutMs?: number; msgtype?: MatrixTextMsgType; includeMentions?: boolean; + extraContent?: MatrixExtraContentFields; } = {}, ): Promise { return await withResolvedMatrixSendClient( @@ -494,9 +510,12 @@ export async function editMessageMatrix( accountId: opts.accountId, }); const convertedText = getCore().channel.text.convertMarkdownTables(newText, tableMode); - const newContent = buildTextContent(convertedText, undefined, { - msgtype: opts.msgtype, - }); + const newContent = withMatrixExtraContentFields( + buildTextContent(convertedText, undefined, { + msgtype: opts.msgtype, + }), + opts.extraContent, + ); await enrichMatrixFormattedContent({ client, content: newContent, diff --git a/extensions/matrix/src/matrix/send/types.ts b/extensions/matrix/src/matrix/send/types.ts index acff0c4f8ed..9d777753874 100644 --- a/extensions/matrix/src/matrix/send/types.ts +++ b/extensions/matrix/src/matrix/send/types.ts @@ -38,6 +38,8 @@ export const EventType = { RoomMessage: "m.room.message", } as const; +export const MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY = "com.openclaw.finalized_preview" as const; + export type MatrixDirectAccountData = Record; export type MatrixReplyRelation = { @@ -118,3 +120,5 @@ export type MatrixFormattedContent = MessageEventContent & { format?: string; formatted_body?: string; }; + +export type MatrixExtraContentFields = Record;