fix: preserve matrix approval metadata fallback

This commit is contained in:
Gustavo Madeira Santana
2026-04-27 14:37:01 -04:00
parent 02a1424ffa
commit 0e06533dff
7 changed files with 39 additions and 2 deletions

View File

@@ -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).

View File

@@ -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

View File

@@ -228,6 +228,7 @@ describe("matrixApprovalNativeRuntime", () => {
pendingPayload.text,
expect.objectContaining({
accountId: "default",
extraContent: pendingPayload.extraContent,
}),
);
expect(reactMessage).toHaveBeenCalledWith(

View File

@@ -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(

View File

@@ -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", () => {

View File

@@ -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;
};

View File

@@ -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;
};