fix(matrix): wire presentation metadata delivery

This commit is contained in:
Peter Steinberger
2026-05-09 09:15:08 +01:00
parent ad39262604
commit e582cebf2d
9 changed files with 211 additions and 10 deletions

View File

@@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai
- Plugins/install: add `npm-pack:<path.tgz>` installs so local npm pack artifacts run through the same managed npm-root install, lockfile verification, dependency scan, and install-record path as registry npm plugins.
- Channels/plugins: show configured official external channels as missing-plugin status rows and send errors with exact install/doctor repair commands after raw package-manager upgrades leave Feishu or WhatsApp uninstalled. Fixes #78702 and #78593. Thanks @MarkMa84 and @mkupiainen.
- Matrix: move the Matrix channel back to an official external ClawHub/npm plugin so core installs no longer need Matrix SDK runtime dependencies.
- Matrix: attach `com.openclaw.presentation` metadata to semantic presentation replies so OpenClaw-aware Matrix clients can render rich buttons, selects, context rows, and dividers while stock clients keep the plain text fallback. (#73312) Thanks @kakahu2015.
- Codex app-server: disarm the short post-tool completion watchdog after current-turn activity, expose `appServer.turnCompletionIdleTimeoutMs`, and include raw assistant item context in idle-timeout diagnostics so status-only post-tool stalls stop failing as idle. Fixes #77984. Thanks @roseware-dev and @rubencu.
- Plugin skills/Windows: publish plugin-provided skill directories as junctions on Windows so standard users without Developer Mode can register plugin skills without symlink EPERM failures. Fixes #77958. (#77971) Thanks @hclsys and @jarro.
- Shell env/Windows: hide the login-shell environment probe child window so gateway startup and shell-env refreshes do not flash a console on Windows. Fixes #78159. (#78266) Thanks @BradGroux.

View File

@@ -767,6 +767,10 @@
"source": "Matrix QA",
"target": "Matrix QA"
},
{
"source": "Matrix presentation metadata",
"target": "Matrix 呈现元数据"
},
{
"source": "QA overview",
"target": "QA overview"

View File

@@ -1066,6 +1066,7 @@
"channels/imessage-from-bluebubbles",
"channels/matrix",
"channels/matrix-migration",
"channels/matrix-presentation",
"channels/matrix-push-rules"
]
},

View File

@@ -130,6 +130,72 @@ describe("matrix channel message adapter", () => {
});
});
it("forwards presentation payload hooks through the registered outbound adapter", async () => {
const outbound = matrixPlugin.outbound;
expect(outbound?.presentationCapabilities).toMatchObject({
supported: true,
buttons: true,
selects: true,
context: true,
divider: true,
});
if (!outbound?.renderPresentation || !outbound.sendPayload) {
throw new Error("Expected Matrix outbound presentation payload hooks.");
}
const presentation = {
title: "Select thinking level",
tone: "info" as const,
blocks: [
{
type: "buttons" as const,
buttons: [{ label: "Low", value: "/think low" }],
},
],
};
const rendered = await outbound.renderPresentation({
payload: { text: "fallback", presentation },
presentation,
ctx: {} as never,
});
expect(rendered?.channelData?.matrix).toMatchObject({
extraContent: {
"com.openclaw.presentation": {
...presentation,
version: 1,
type: "message.presentation",
},
},
});
await outbound.sendPayload({
cfg,
to: "room:!room:example",
text: rendered?.text ?? "",
payload: rendered!,
accountId: "default",
threadId: "$thread",
});
expect(mocks.sendMessageMatrix).toHaveBeenLastCalledWith(
"room:!room:example",
rendered?.text,
expect.objectContaining({
cfg,
accountId: "default",
threadId: "$thread",
extraContent: {
"com.openclaw.presentation": {
...presentation,
version: 1,
type: "message.presentation",
},
},
}),
);
});
it("backs declared live preview finalizer capabilities with adapter proofs", async () => {
const adapter = matrixPlugin.message;

View File

@@ -335,6 +335,13 @@ const matrixChannelOutbound: ChannelOutboundAdapter = {
messageSendingHooks: true,
},
},
presentationCapabilities: {
supported: true,
buttons: true,
selects: true,
context: true,
divider: true,
},
shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload }) =>
shouldSuppressLocalMatrixExecApprovalPrompt({
cfg,
@@ -343,6 +350,14 @@ const matrixChannelOutbound: ChannelOutboundAdapter = {
}),
...createRuntimeOutboundDelegates({
getRuntime: loadMatrixChannelRuntime,
renderPresentation: {
resolve: (runtime) => runtime.matrixOutbound.renderPresentation,
unavailableMessage: "Matrix outbound presentation rendering is unavailable",
},
sendPayload: {
resolve: (runtime) => runtime.matrixOutbound.sendPayload,
unavailableMessage: "Matrix outbound payload delivery is unavailable",
},
sendText: {
resolve: (runtime) => runtime.matrixOutbound.sendText,
unavailableMessage: "Matrix outbound text delivery is unavailable",

View File

@@ -259,6 +259,53 @@ describe("matrixOutbound cfg threading", () => {
);
});
it("only forwards presentation metadata from Matrix extraContent", async () => {
const cfg = {
channels: {
matrix: {
accessToken: "resolved-token",
},
},
} as OpenClawConfig;
const presentationContent = {
version: 1,
type: "message.presentation",
title: "Select model",
blocks: [{ type: "divider" }],
};
await matrixOutbound.sendPayload!({
cfg,
to: "room:!room:example",
text: "Select model",
payload: {
text: "Select model",
channelData: {
matrix: {
extraContent: {
body: "spoofed",
msgtype: "m.notice",
"m.relates_to": { "m.in_reply_to": { event_id: "$spoof" } },
"com.openclaw.presentation": presentationContent,
},
},
},
},
accountId: "default",
});
expect(mocks.sendMessageMatrix).toHaveBeenCalledWith(
"room:!room:example",
"Select model",
expect.objectContaining({
extraContent: {
"com.openclaw.presentation": presentationContent,
},
}),
);
});
it("sends all media URLs via sendPayload", async () => {
const cfg = {
channels: {
@@ -281,7 +328,6 @@ describe("matrixOutbound cfg threading", () => {
});
expect(mocks.sendMessageMatrix).toHaveBeenCalledTimes(2);
// First call: caption + media
expect(mocks.sendMessageMatrix).toHaveBeenNthCalledWith(
1,
"room:!room:example",
@@ -291,7 +337,6 @@ describe("matrixOutbound cfg threading", () => {
threadId: "$thread",
}),
);
// Second call: no text, just media
expect(mocks.sendMessageMatrix).toHaveBeenNthCalledWith(
2,
"room:!room:example",
@@ -335,7 +380,6 @@ describe("matrixOutbound cfg threading", () => {
});
expect(mocks.sendMessageMatrix).toHaveBeenCalledTimes(2);
// First call gets extraContent
expect(mocks.sendMessageMatrix).toHaveBeenNthCalledWith(
1,
"room:!room:example",
@@ -349,7 +393,6 @@ describe("matrixOutbound cfg threading", () => {
},
}),
);
// Second call does NOT get extraContent
expect(mocks.sendMessageMatrix).toHaveBeenNthCalledWith(
2,
"room:!room:example",
@@ -380,7 +423,6 @@ describe("matrixOutbound cfg threading", () => {
accountId: "default",
});
// Every URL must be sent — none may be silently dropped
expect(mocks.sendMessageMatrix).toHaveBeenCalledTimes(3);
expect(mocks.sendMessageMatrix).toHaveBeenNthCalledWith(
1,

View File

@@ -39,6 +39,21 @@ function buildMatrixPresentationContent(presentation: MessagePresentation) {
};
}
function resolveMatrixPresentationContent(
payload: ReplyPayload,
): Record<string, unknown> | undefined {
const extraContent = toRecord(resolveMatrixChannelData(payload).extraContent);
const presentation = toRecord(extraContent?.[MATRIX_OPENCLAW_PRESENTATION_KEY]);
if (
!presentation ||
presentation.version !== 1 ||
presentation.type !== MATRIX_OPENCLAW_PRESENTATION_TYPE
) {
return undefined;
}
return presentation;
}
function renderMatrixPresentationPayload(params: {
payload: ReplyPayload;
presentation: MessagePresentation;
@@ -55,7 +70,6 @@ function renderMatrixPresentationPayload(params: {
matrix: {
...matrixData,
extraContent: {
...matrixData.extraContent,
[MATRIX_OPENCLAW_PRESENTATION_KEY]: buildMatrixPresentationContent(params.presentation),
},
},
@@ -64,8 +78,8 @@ function renderMatrixPresentationPayload(params: {
}
function resolveMatrixExtraContent(payload: ReplyPayload): MatrixExtraContentFields | undefined {
const extraContent = resolveMatrixChannelData(payload).extraContent;
return extraContent && Object.keys(extraContent).length > 0 ? extraContent : undefined;
const presentation = resolveMatrixPresentationContent(payload);
return presentation ? { [MATRIX_OPENCLAW_PRESENTATION_KEY]: presentation } : undefined;
}
export const matrixOutbound: ChannelOutboundAdapter = {

View File

@@ -3,6 +3,11 @@ import {
createRuntimeDirectoryLiveAdapter,
createRuntimeOutboundDelegates,
} from "./runtime-forwarders.js";
import type { ChannelOutboundAdapter } from "./types.adapters.js";
type RenderPresentationParams = Parameters<
NonNullable<ChannelOutboundAdapter["renderPresentation"]>
>[0];
describe("createRuntimeDirectoryLiveAdapter", () => {
it("forwards live directory calls through the runtime getter", async () => {
@@ -28,16 +33,37 @@ describe("createRuntimeDirectoryLiveAdapter", () => {
describe("createRuntimeOutboundDelegates", () => {
it("forwards outbound methods through the runtime getter", async () => {
const renderPresentation = vi.fn(async (ctx: RenderPresentationParams) => ({
...ctx.payload,
text: "rendered",
}));
const sendPayload = vi.fn(async () => ({ channel: "x", messageId: "payload-1" }));
const sendText = vi.fn(async () => ({ channel: "x", messageId: "1" }));
const outbound = createRuntimeOutboundDelegates({
getRuntime: async () => ({ outbound: { sendText } }),
getRuntime: async () => ({ outbound: { renderPresentation, sendPayload, sendText } }),
renderPresentation: { resolve: (runtime) => runtime.outbound.renderPresentation },
sendPayload: { resolve: (runtime) => runtime.outbound.sendPayload },
sendText: { resolve: (runtime) => runtime.outbound.sendText },
});
await expect(
outbound.renderPresentation?.({
payload: { text: "raw" },
presentation: { blocks: [{ type: "text", text: "shown" }] },
ctx: {} as never,
}),
).resolves.toEqual({
text: "rendered",
});
await expect(
outbound.sendPayload?.({ cfg: {} as never, to: "a", text: "hi", payload: { text: "hi" } }),
).resolves.toEqual({ channel: "x", messageId: "payload-1" });
await expect(outbound.sendText?.({ cfg: {} as never, to: "a", text: "hi" })).resolves.toEqual({
channel: "x",
messageId: "1",
});
expect(renderPresentation).toHaveBeenCalled();
expect(sendPayload).toHaveBeenCalled();
expect(sendText).toHaveBeenCalled();
});

View File

@@ -3,7 +3,7 @@ import type { ChannelDirectoryAdapter, ChannelOutboundAdapter } from "./types.ad
type MaybePromise<T> = T | Promise<T>;
type DirectoryMethod = "self" | "listPeersLive" | "listGroupsLive" | "listGroupMembers";
type OutboundMethod = "sendText" | "sendMedia" | "sendPoll";
type OutboundMethod = "renderPresentation" | "sendPayload" | "sendText" | "sendMedia" | "sendPoll";
type DirectorySelfParams = Parameters<NonNullable<ChannelDirectoryAdapter["self"]>>[0];
type DirectoryListParams = Parameters<NonNullable<ChannelDirectoryAdapter["listPeersLive"]>>[0];
@@ -13,6 +13,10 @@ type DirectoryGroupMembersParams = Parameters<
type SendTextParams = Parameters<NonNullable<ChannelOutboundAdapter["sendText"]>>[0];
type SendMediaParams = Parameters<NonNullable<ChannelOutboundAdapter["sendMedia"]>>[0];
type SendPollParams = Parameters<NonNullable<ChannelOutboundAdapter["sendPoll"]>>[0];
type RenderPresentationParams = Parameters<
NonNullable<ChannelOutboundAdapter["renderPresentation"]>
>[0];
type SendPayloadParams = Parameters<NonNullable<ChannelOutboundAdapter["sendPayload"]>>[0];
async function resolveForwardedMethod<Runtime, Fn>(params: {
getRuntime: () => MaybePromise<Runtime>;
@@ -80,6 +84,14 @@ export function createRuntimeDirectoryLiveAdapter<Runtime>(params: {
export function createRuntimeOutboundDelegates<Runtime>(params: {
getRuntime: () => MaybePromise<Runtime>;
renderPresentation?: {
resolve: (runtime: Runtime) => ChannelOutboundAdapter["renderPresentation"] | null | undefined;
unavailableMessage?: string;
};
sendPayload?: {
resolve: (runtime: Runtime) => ChannelOutboundAdapter["sendPayload"] | null | undefined;
unavailableMessage?: string;
};
sendText?: {
resolve: (runtime: Runtime) => ChannelOutboundAdapter["sendText"] | null | undefined;
unavailableMessage?: string;
@@ -94,6 +106,26 @@ export function createRuntimeOutboundDelegates<Runtime>(params: {
};
}): Pick<ChannelOutboundAdapter, OutboundMethod> {
return {
renderPresentation: params.renderPresentation
? async (ctx: RenderPresentationParams) =>
await (
await resolveForwardedMethod({
getRuntime: params.getRuntime,
resolve: params.renderPresentation!.resolve,
unavailableMessage: params.renderPresentation!.unavailableMessage,
})
)(ctx)
: undefined,
sendPayload: params.sendPayload
? async (ctx: SendPayloadParams) =>
await (
await resolveForwardedMethod({
getRuntime: params.getRuntime,
resolve: params.sendPayload!.resolve,
unavailableMessage: params.sendPayload!.unavailableMessage,
})
)(ctx)
: undefined,
sendText: params.sendText
? async (ctx: SendTextParams) =>
await (