mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-15 03:01:02 +00:00
fix(matrix): mark finalized preview edits
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
EventType,
|
||||
MsgType,
|
||||
RelationType,
|
||||
type MatrixExtraContentFields,
|
||||
type MatrixOutboundContent,
|
||||
type MatrixSendOpts,
|
||||
type MatrixSendResult,
|
||||
@@ -103,6 +104,16 @@ function hasMatrixMentionsMetadata(content: Record<string, unknown> | undefined)
|
||||
return Boolean(content && Object.hasOwn(content, "m.mentions"));
|
||||
}
|
||||
|
||||
function withMatrixExtraContentFields<T extends Record<string, unknown>>(
|
||||
content: T,
|
||||
extraContent?: MatrixExtraContentFields,
|
||||
): T {
|
||||
if (!extraContent) {
|
||||
return content;
|
||||
}
|
||||
return { ...content, ...extraContent };
|
||||
}
|
||||
|
||||
async function resolvePreviousEditMentions(params: {
|
||||
client: MatrixClient;
|
||||
content: Record<string, unknown> | undefined;
|
||||
@@ -401,6 +412,7 @@ export async function sendSingleTextMessageMatrix(
|
||||
accountId?: string;
|
||||
msgtype?: MatrixTextMsgType;
|
||||
includeMentions?: boolean;
|
||||
extraContent?: MatrixExtraContentFields;
|
||||
} = {},
|
||||
): Promise<MatrixSendResult> {
|
||||
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<string> {
|
||||
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,
|
||||
|
||||
@@ -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<string, string[]>;
|
||||
|
||||
export type MatrixReplyRelation = {
|
||||
@@ -118,3 +120,5 @@ export type MatrixFormattedContent = MessageEventContent & {
|
||||
format?: string;
|
||||
formatted_body?: string;
|
||||
};
|
||||
|
||||
export type MatrixExtraContentFields = Record<string, unknown>;
|
||||
|
||||
Reference in New Issue
Block a user