mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(channels): preserve workspace media access in gateway sends (#117711)
This commit is contained in:
committed by
GitHub
parent
120fa5d11c
commit
28e681c92f
@@ -171,15 +171,21 @@ describe("matrixMessageActions account propagation", () => {
|
||||
});
|
||||
|
||||
it("forwards mediaLocalRoots for media sends", async () => {
|
||||
const mediaAccess = {
|
||||
localRoots: ["/tmp/openclaw-matrix-test"],
|
||||
readFile: async () => Buffer.from("chart"),
|
||||
workspaceDir: "/tmp/openclaw-matrix-test",
|
||||
};
|
||||
await matrixMessageActions.handleAction?.(
|
||||
createContext({
|
||||
action: "send",
|
||||
accountId: "ops",
|
||||
mediaLocalRoots: ["/tmp/openclaw-matrix-test"],
|
||||
mediaAccess,
|
||||
mediaLocalRoots: mediaAccess.localRoots,
|
||||
params: {
|
||||
to: "room:!room:example",
|
||||
message: "hello",
|
||||
media: "file:///tmp/photo.png",
|
||||
media: "chart.png",
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -187,8 +193,9 @@ describe("matrixMessageActions account propagation", () => {
|
||||
const call = matrixActionCall();
|
||||
expect(call.input.action).toBe("sendMessage");
|
||||
expect(call.input.accountId).toBe("ops");
|
||||
expect(call.input.mediaUrl).toBe("file:///tmp/photo.png");
|
||||
expect(call.input.mediaUrl).toBe("chart.png");
|
||||
expect(call.cfg).toBeTypeOf("object");
|
||||
expect(call.options.mediaAccess).toBe(mediaAccess);
|
||||
expect(call.options).toMatchObject({ mediaLocalRoots: ["/tmp/openclaw-matrix-test"] });
|
||||
});
|
||||
|
||||
|
||||
@@ -176,6 +176,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
{
|
||||
...(action === "send" && ctx.mediaAccess ? { mediaAccess: ctx.mediaAccess } : {}),
|
||||
mediaLocalRoots,
|
||||
readContext: {
|
||||
accountId,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { setMatrixRuntime } from "../../runtime.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import * as sendModule from "../send.js";
|
||||
import { editMatrixMessage, readMatrixMessages } from "./messages.js";
|
||||
import { editMatrixMessage, readMatrixMessages, sendMatrixMessage } from "./messages.js";
|
||||
|
||||
const MATRIX_ACTION_TEST_CFG = {
|
||||
channels: {
|
||||
@@ -177,6 +177,34 @@ function mockCallArg(
|
||||
}
|
||||
|
||||
describe("matrix message actions", () => {
|
||||
it("preserves workspace media access through the shared Matrix send helper", async () => {
|
||||
const mediaAccess = {
|
||||
localRoots: ["/tmp/openclaw-matrix-test"],
|
||||
readFile: async () => Buffer.from("chart"),
|
||||
workspaceDir: "/tmp/openclaw-matrix-test",
|
||||
};
|
||||
const sendSpy = vi.spyOn(sendModule, "sendMessageMatrix").mockResolvedValue({
|
||||
messageId: "$sent",
|
||||
roomId: "!room:example.org",
|
||||
} as never);
|
||||
|
||||
try {
|
||||
await sendMatrixMessage("!room:example.org", "caption", {
|
||||
cfg: MATRIX_ACTION_TEST_CFG,
|
||||
mediaUrl: "chart.png",
|
||||
mediaAccess,
|
||||
mediaLocalRoots: mediaAccess.localRoots,
|
||||
});
|
||||
|
||||
const options = sendSpy.mock.calls[0]?.[2];
|
||||
expect(options?.mediaUrl).toBe("chart.png");
|
||||
expect(options?.mediaAccess).toBe(mediaAccess);
|
||||
expect(options?.mediaLocalRoots).toBe(mediaAccess.localRoots);
|
||||
} finally {
|
||||
sendSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards timeoutMs to the shared Matrix edit helper", async () => {
|
||||
const editSpy = vi.spyOn(sendModule, "editMessageMatrix").mockResolvedValue("evt-edit");
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ export async function sendMatrixMessage(
|
||||
return await sendMessageMatrix(to, content, {
|
||||
cfg: opts.cfg,
|
||||
mediaUrl: opts.mediaUrl,
|
||||
...(opts.mediaAccess ? { mediaAccess: opts.mediaAccess } : {}),
|
||||
mediaLocalRoots: opts.mediaLocalRoots,
|
||||
replyToId: opts.replyToId,
|
||||
threadId: opts.threadId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Matrix type declarations define plugin contracts.
|
||||
import type { OutboundMediaAccess } from "openclaw/plugin-sdk/media-runtime";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { MATRIX_REACTION_EVENT_TYPE } from "../reaction-common.js";
|
||||
import type { MatrixClient, MessageEventContent } from "../sdk.js";
|
||||
@@ -30,6 +31,7 @@ export type RoomPinnedEventsEventContent = {
|
||||
export type MatrixActionClientOpts = {
|
||||
client?: MatrixClient;
|
||||
cfg?: CoreConfig;
|
||||
mediaAccess?: OutboundMediaAccess;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
timeoutMs?: number;
|
||||
accountId?: string | null;
|
||||
|
||||
@@ -510,15 +510,32 @@ describe("sendMessageMatrix media", () => {
|
||||
|
||||
it("uploads media with url payloads", async () => {
|
||||
const { client, sendMessage, uploadContent } = makeClient();
|
||||
const mediaAccess = {
|
||||
localRoots: ["/tmp/openclaw"],
|
||||
workspaceDir: "/tmp/openclaw",
|
||||
};
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "caption", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
mediaUrl: "file:///tmp/photo.png",
|
||||
mediaUrl: "chart.png",
|
||||
mediaAccess,
|
||||
mediaLocalRoots: mediaAccess.localRoots,
|
||||
});
|
||||
|
||||
expect(mockCallArg(loadOutboundMediaFromUrlMock, "loadOutboundMediaFromUrl", 0)).toBe(
|
||||
"chart.png",
|
||||
);
|
||||
const mediaOptions = requireRecord(
|
||||
mockCallArg(loadOutboundMediaFromUrlMock, "loadOutboundMediaFromUrl", 1),
|
||||
"outbound media options",
|
||||
);
|
||||
expect(mediaOptions.mediaAccess).toBe(mediaAccess);
|
||||
expect(mediaOptions.mediaLocalRoots).toBe(mediaAccess.localRoots);
|
||||
|
||||
const uploadArg = mockCallArg(uploadContent, "uploadContent", 0);
|
||||
expect(Buffer.isBuffer(uploadArg)).toBe(true);
|
||||
expect(uploadArg).toEqual(Buffer.from("media"));
|
||||
|
||||
const content = sentContent(sendMessage) as {
|
||||
url?: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Matrix type declarations define plugin contracts.
|
||||
import type { MessageReceipt } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { OutboundMediaAccess } from "openclaw/plugin-sdk/media-runtime";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { MATRIX_ANNOTATION_RELATION_TYPE, MATRIX_REACTION_EVENT_TYPE } from "../reaction-common.js";
|
||||
import type {
|
||||
@@ -90,10 +91,7 @@ export type MatrixSendOpts = {
|
||||
cfg: CoreConfig;
|
||||
client?: import("../sdk.js").MatrixClient;
|
||||
mediaUrl?: string;
|
||||
mediaAccess?: {
|
||||
localRoots?: readonly string[];
|
||||
readFile?: (filePath: string) => Promise<Buffer>;
|
||||
};
|
||||
mediaAccess?: OutboundMediaAccess;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
||||
accountId?: string;
|
||||
|
||||
@@ -99,13 +99,18 @@ describe("matrixOutbound cfg threading", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const mediaAccess = {
|
||||
localRoots: ["/tmp/openclaw"],
|
||||
workspaceDir: "/tmp/openclaw",
|
||||
};
|
||||
|
||||
await matrixOutbound.sendMedia!({
|
||||
cfg,
|
||||
to: "room:!room:example",
|
||||
text: "caption",
|
||||
mediaUrl: "file:///tmp/cat.png",
|
||||
mediaLocalRoots: ["/tmp/openclaw"],
|
||||
mediaUrl: "chart.png",
|
||||
mediaAccess,
|
||||
mediaLocalRoots: mediaAccess.localRoots,
|
||||
accountId: "default",
|
||||
audioAsVoice: true,
|
||||
});
|
||||
@@ -115,7 +120,8 @@ describe("matrixOutbound cfg threading", () => {
|
||||
expect(call[1]).toBe("caption");
|
||||
const options = mockOptions(mocks.sendMessageMatrix, "sendMessageMatrix");
|
||||
expect(options.cfg).toBe(cfg);
|
||||
expect(options.mediaUrl).toBe("file:///tmp/cat.png");
|
||||
expect(options.mediaUrl).toBe("chart.png");
|
||||
expect(options.mediaAccess).toBe(mediaAccess);
|
||||
expect(options.mediaLocalRoots).toEqual(["/tmp/openclaw"]);
|
||||
expect(options.audioAsVoice).toBe(true);
|
||||
});
|
||||
|
||||
@@ -245,6 +245,7 @@ export const matrixOutbound: ChannelOutboundAdapter = {
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
mediaAccess,
|
||||
deps,
|
||||
replyToId,
|
||||
threadId,
|
||||
@@ -265,6 +266,7 @@ export const matrixOutbound: ChannelOutboundAdapter = {
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
mediaAccess,
|
||||
replyToId: replyToId ?? undefined,
|
||||
threadId: resolvedThreadId,
|
||||
accountId: accountId ?? undefined,
|
||||
|
||||
@@ -444,25 +444,32 @@ describe("handleMatrixAction pollVote", () => {
|
||||
|
||||
it("accepts media-only message sends", async () => {
|
||||
const cfg = { channels: { matrix: { actions: { messages: true } } } } as CoreConfig;
|
||||
const mediaAccess = {
|
||||
localRoots: ["/tmp/openclaw-matrix-test"],
|
||||
readFile: async () => Buffer.from("chart"),
|
||||
workspaceDir: "/tmp/openclaw-matrix-test",
|
||||
};
|
||||
await handleMatrixAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
accountId: "ops",
|
||||
to: "room:!room:example",
|
||||
mediaUrl: "file:///tmp/photo.png",
|
||||
mediaUrl: "chart.png",
|
||||
},
|
||||
cfg,
|
||||
{ mediaLocalRoots: ["/tmp/openclaw-matrix-test"] },
|
||||
{ mediaAccess, mediaLocalRoots: mediaAccess.localRoots },
|
||||
);
|
||||
|
||||
expect(mocks.sendMatrixMessage).toHaveBeenCalledWith("room:!room:example", undefined, {
|
||||
cfg,
|
||||
accountId: "ops",
|
||||
mediaUrl: "file:///tmp/photo.png",
|
||||
mediaUrl: "chart.png",
|
||||
mediaAccess,
|
||||
mediaLocalRoots: ["/tmp/openclaw-matrix-test"],
|
||||
replyToId: undefined,
|
||||
threadId: undefined,
|
||||
});
|
||||
expect(mocks.sendMatrixMessage.mock.lastCall?.[2]?.mediaAccess).toBe(mediaAccess);
|
||||
});
|
||||
|
||||
it("accepts shared media aliases and voice-send flags", async () => {
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
voteMatrixPoll,
|
||||
verifyMatrixRecoveryKey,
|
||||
} from "./matrix/actions.js";
|
||||
import type { MatrixMessageSummary } from "./matrix/actions/types.js";
|
||||
import type { MatrixActionClientOpts, MatrixMessageSummary } from "./matrix/actions/types.js";
|
||||
import { withAuthorizedMatrixReadTarget, type MatrixReadContext } from "./matrix/read-policy.js";
|
||||
import type { MatrixClient } from "./matrix/sdk.js";
|
||||
import { reactMatrixMessage } from "./matrix/send.js";
|
||||
@@ -164,7 +164,9 @@ function readPositiveIntegerArrayParam(params: Record<string, unknown>, key: str
|
||||
export async function handleMatrixAction(
|
||||
params: Record<string, unknown>,
|
||||
cfg: CoreConfig,
|
||||
opts: { mediaLocalRoots?: readonly string[]; readContext?: MatrixReadContext } = {},
|
||||
opts: Pick<MatrixActionClientOpts, "mediaAccess" | "mediaLocalRoots"> & {
|
||||
readContext?: MatrixReadContext;
|
||||
} = {},
|
||||
): Promise<AgentToolResult<unknown>> {
|
||||
const action = readStringParam(params, "action", { required: true });
|
||||
const accountId = readStringParam(params, "accountId") ?? undefined;
|
||||
@@ -283,6 +285,7 @@ export async function handleMatrixAction(
|
||||
: undefined;
|
||||
const result = await sendMatrixMessage(to, content, {
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
...(opts.mediaAccess ? { mediaAccess: opts.mediaAccess } : {}),
|
||||
mediaLocalRoots: opts.mediaLocalRoots,
|
||||
replyToId: replyToId ?? undefined,
|
||||
threadId: threadId ?? undefined,
|
||||
|
||||
@@ -74,6 +74,7 @@ const sendDurableMessageBatch = vi.fn(
|
||||
mediaAccess?: {
|
||||
localRoots?: readonly string[];
|
||||
readFile?: (filePath: string) => Promise<Buffer>;
|
||||
workspaceDir?: string;
|
||||
};
|
||||
}) => {
|
||||
const payload = params.payloads[0] ?? {};
|
||||
@@ -111,6 +112,7 @@ const sendDurableMessageBatch = vi.fn(
|
||||
asVideoNote: payload.videoAsNote,
|
||||
silent: params.silent,
|
||||
forceDocument: params.forceDocument,
|
||||
...(params.mediaAccess ? { mediaAccess: params.mediaAccess } : {}),
|
||||
mediaLocalRoots: params.mediaAccess?.localRoots,
|
||||
mediaReadFile: params.mediaAccess?.readFile,
|
||||
};
|
||||
@@ -1392,22 +1394,64 @@ describe("handleTelegramAction", () => {
|
||||
expect(options.silent).toBe(true);
|
||||
});
|
||||
|
||||
it("forwards trusted mediaLocalRoots into sendMessageTelegram", async () => {
|
||||
it("preserves host-owned workspace media access and legacy roots", async () => {
|
||||
const mediaReadFile = vi.fn(async (_filePath: string) => Buffer.from("chart"));
|
||||
const mediaAccess = {
|
||||
localRoots: ["/tmp/agent-root"],
|
||||
readFile: mediaReadFile,
|
||||
workspaceDir: "/tmp/agent-root",
|
||||
};
|
||||
await handleTelegramAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to: "@testchannel",
|
||||
content: "Hello with local media",
|
||||
mediaUrl: "chart.png",
|
||||
mediaAccess: { localRoots: ["/tmp/model-root"], workspaceDir: "/tmp/model-root" },
|
||||
},
|
||||
telegramConfig(),
|
||||
{ mediaLocalRoots: ["/tmp/agent-root"] },
|
||||
{
|
||||
mediaAccess,
|
||||
mediaLocalRoots: ["/tmp/conflicting-root"],
|
||||
mediaReadFile: vi.fn(async (_filePath: string) => Buffer.from("untrusted")),
|
||||
},
|
||||
);
|
||||
const durableOptions = requireRecord(
|
||||
mockCall(sendDurableMessageBatch, 0, "workspace media access")[0],
|
||||
"workspace media access batch",
|
||||
);
|
||||
expect(durableOptions.mediaAccess).toBe(mediaAccess);
|
||||
expect(durableOptions.session).toBeUndefined();
|
||||
const call = mockCall(sendMessageTelegram, 0, "local media roots");
|
||||
expect(call[0]).toBe("@testchannel");
|
||||
expect(call[1]).toBe("Hello with local media");
|
||||
expect(requireRecord(call[2], "local media roots options").mediaLocalRoots).toEqual([
|
||||
"/tmp/agent-root",
|
||||
]);
|
||||
const sendOptions = requireRecord(call[2], "local media roots options");
|
||||
expect(sendOptions.mediaUrl).toBe("chart.png");
|
||||
expect(sendOptions.mediaAccess).toBe(mediaAccess);
|
||||
expect(sendOptions.mediaLocalRoots).toEqual(["/tmp/agent-root"]);
|
||||
expect(sendOptions.mediaReadFile).toBe(mediaReadFile);
|
||||
|
||||
await handleTelegramAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to: "@testchannel",
|
||||
content: "Hello with legacy media roots",
|
||||
mediaUrl: "legacy-chart.png",
|
||||
},
|
||||
telegramConfig(),
|
||||
{ mediaLocalRoots: ["/tmp/legacy-root"] },
|
||||
);
|
||||
const legacyDurableOptions = requireRecord(
|
||||
mockCall(sendDurableMessageBatch, 1, "legacy media access")[0],
|
||||
"legacy media access batch",
|
||||
);
|
||||
expect(legacyDurableOptions.mediaAccess).toEqual({ localRoots: ["/tmp/legacy-root"] });
|
||||
expect(
|
||||
requireRecord(
|
||||
mockCall(sendMessageTelegram, 1, "legacy media roots")[2],
|
||||
"legacy media options",
|
||||
).mediaLocalRoots,
|
||||
).toEqual(["/tmp/legacy-root"]);
|
||||
});
|
||||
|
||||
it("forwards gateway client scopes into Telegram send target resolution", async () => {
|
||||
|
||||
@@ -346,6 +346,7 @@ export async function handleTelegramAction(
|
||||
params: Record<string, unknown>,
|
||||
cfg: OpenClawConfig,
|
||||
options?: {
|
||||
mediaAccess?: ChannelMessageActionContext["mediaAccess"];
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
||||
sessionKey?: string | null;
|
||||
@@ -562,12 +563,13 @@ export async function handleTelegramAction(
|
||||
quoteText,
|
||||
});
|
||||
const mediaAccess =
|
||||
options?.mediaLocalRoots || options?.mediaReadFile
|
||||
options?.mediaAccess ??
|
||||
(options?.mediaLocalRoots || options?.mediaReadFile
|
||||
? {
|
||||
...(options.mediaLocalRoots ? { localRoots: options.mediaLocalRoots } : {}),
|
||||
...(options.mediaReadFile ? { readFile: options.mediaReadFile } : {}),
|
||||
}
|
||||
: undefined;
|
||||
: undefined);
|
||||
const outboundSession = buildOutboundSessionContext({
|
||||
cfg,
|
||||
sessionKey: options?.sessionKey,
|
||||
|
||||
@@ -44,6 +44,10 @@ describe("telegramMessageActions", () => {
|
||||
});
|
||||
|
||||
it("forwards only host-owned mutation context to the runtime", async () => {
|
||||
const mediaAccess = {
|
||||
localRoots: ["/tmp/agent-root"],
|
||||
workspaceDir: "/tmp/agent-root",
|
||||
};
|
||||
await telegramMessageActions.handleAction?.({
|
||||
channel: "telegram",
|
||||
action: "delete",
|
||||
@@ -51,9 +55,12 @@ describe("telegramMessageActions", () => {
|
||||
messageId: "9001",
|
||||
to: "-1001:topic:77",
|
||||
conversationReadOrigin: "direct-operator",
|
||||
mediaAccess: { localRoots: ["/tmp/forged-root"], workspaceDir: "/tmp/forged-root" },
|
||||
},
|
||||
cfg: { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig,
|
||||
accountId: "work",
|
||||
mediaAccess,
|
||||
mediaLocalRoots: ["/tmp/conflicting-root"],
|
||||
requesterAccountId: "work",
|
||||
conversationReadOrigin: "delegated",
|
||||
toolContext: {
|
||||
@@ -68,6 +75,7 @@ describe("telegramMessageActions", () => {
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
conversationReadOrigin: "delegated",
|
||||
mediaAccess,
|
||||
requesterAccountId: "work",
|
||||
toolContext: expect.objectContaining({ currentMessageId: "9001" }),
|
||||
}),
|
||||
@@ -76,6 +84,8 @@ describe("telegramMessageActions", () => {
|
||||
action: "deleteMessage",
|
||||
messageId: "9001",
|
||||
});
|
||||
expect(handleTelegramActionMock.mock.calls[0]?.[0]).not.toHaveProperty("mediaAccess");
|
||||
expect(handleTelegramActionMock.mock.calls[0]?.[2]?.mediaAccess).toBe(mediaAccess);
|
||||
});
|
||||
|
||||
it("allows interactive-only sends", async () => {
|
||||
|
||||
@@ -248,6 +248,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||
params,
|
||||
cfg,
|
||||
accountId,
|
||||
mediaAccess,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
sessionKey,
|
||||
@@ -263,6 +264,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||
}
|
||||
const {
|
||||
conversationReadOrigin: _modelConversationReadOrigin,
|
||||
mediaAccess: _modelMediaAccess,
|
||||
requesterAccountId: _modelRequesterAccountId,
|
||||
toolContext: _modelToolContext,
|
||||
...runtimeParams
|
||||
@@ -282,6 +284,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||
},
|
||||
cfg,
|
||||
{
|
||||
...(mediaAccess !== undefined ? { mediaAccess } : {}),
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
sessionKey,
|
||||
|
||||
@@ -72,15 +72,20 @@ describe("telegramOutbound", () => {
|
||||
sendLocationTelegramMock.mockReset();
|
||||
});
|
||||
|
||||
it("forwards mediaLocalRoots in direct media sends", async () => {
|
||||
it("forwards workspace-scoped media access in direct media sends", async () => {
|
||||
sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-media" });
|
||||
const mediaReadFile = vi.fn(async (_filePath: string) => Buffer.from("chart"));
|
||||
const workspaceDir = "/tmp/agent-root";
|
||||
const mediaAccess = { localRoots: [workspaceDir], readFile: mediaReadFile, workspaceDir };
|
||||
|
||||
const result = await telegramOutbound.sendMedia!({
|
||||
cfg: {} as never,
|
||||
to: "12345",
|
||||
text: "hello",
|
||||
mediaUrl: "/tmp/image.png",
|
||||
mediaLocalRoots: ["/tmp/agent-root"],
|
||||
mediaUrl: "chart.png",
|
||||
mediaAccess,
|
||||
mediaLocalRoots: mediaAccess.localRoots,
|
||||
mediaReadFile,
|
||||
accountId: "ops",
|
||||
replyToId: "900",
|
||||
threadId: "12",
|
||||
@@ -95,11 +100,15 @@ describe("telegramOutbound", () => {
|
||||
accountId: "ops",
|
||||
silent: undefined,
|
||||
gatewayClientScopes: undefined,
|
||||
mediaUrl: "/tmp/image.png",
|
||||
mediaUrl: "chart.png",
|
||||
mediaAccess,
|
||||
mediaLocalRoots: ["/tmp/agent-root"],
|
||||
mediaReadFile: undefined,
|
||||
mediaReadFile,
|
||||
forceDocument: false,
|
||||
});
|
||||
expect(lastCallOptions(sendMessageTelegramMock, "12345", "hello").mediaAccess).toBe(
|
||||
mediaAccess,
|
||||
);
|
||||
expect(result).toEqual({ channel: "telegram", messageId: "tg-media" });
|
||||
});
|
||||
|
||||
@@ -107,6 +116,7 @@ describe("telegramOutbound", () => {
|
||||
sendMessageTelegramMock
|
||||
.mockResolvedValueOnce({ messageId: "tg-1", chatId: "12345" })
|
||||
.mockResolvedValueOnce({ messageId: "tg-2", chatId: "12345" });
|
||||
const mediaAccess = { localRoots: ["/tmp/media"], workspaceDir: "/tmp/media" };
|
||||
|
||||
const result = await telegramOutbound.sendPayload!({
|
||||
cfg: {} as never,
|
||||
@@ -114,7 +124,7 @@ describe("telegramOutbound", () => {
|
||||
text: "",
|
||||
payload: {
|
||||
text: "Approval required",
|
||||
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
|
||||
mediaUrls: ["chart.png", "chart-2.png"],
|
||||
channelData: {
|
||||
telegram: {
|
||||
quoteText: "quoted",
|
||||
@@ -123,20 +133,22 @@ describe("telegramOutbound", () => {
|
||||
transcriptMessageId: "assistant-media",
|
||||
deliverySignature: resolveTelegramPromptContextDeliverySignature({
|
||||
text: "Approval required",
|
||||
mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
|
||||
mediaUrls: ["chart.png", "chart-2.png"],
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mediaLocalRoots: ["/tmp/media"],
|
||||
mediaAccess,
|
||||
mediaLocalRoots: mediaAccess.localRoots,
|
||||
accountId: "ops",
|
||||
deps: { sendTelegram: sendMessageTelegramMock },
|
||||
});
|
||||
|
||||
expect(sendMessageTelegramMock).toHaveBeenCalledTimes(2);
|
||||
const firstOptions = callOptionsAt(sendMessageTelegramMock, 0, "12345", "Approval required");
|
||||
expect(firstOptions.mediaUrl).toBe("https://example.com/1.jpg");
|
||||
expect(firstOptions.mediaUrl).toBe("chart.png");
|
||||
expect(firstOptions.mediaAccess).toBe(mediaAccess);
|
||||
expect(firstOptions.mediaLocalRoots).toEqual(["/tmp/media"]);
|
||||
expect(firstOptions.quoteText).toBe("quoted");
|
||||
expect(firstOptions.buttons).toEqual([
|
||||
@@ -153,7 +165,8 @@ describe("telegramOutbound", () => {
|
||||
finalPart: false,
|
||||
});
|
||||
const secondOptions = callOptionsAt(sendMessageTelegramMock, 1, "12345", "");
|
||||
expect(secondOptions.mediaUrl).toBe("https://example.com/2.jpg");
|
||||
expect(secondOptions.mediaUrl).toBe("chart-2.png");
|
||||
expect(secondOptions.mediaAccess).toBe(mediaAccess);
|
||||
expect(secondOptions.mediaLocalRoots).toEqual(["/tmp/media"]);
|
||||
expect(secondOptions.quoteText).toBe("quoted");
|
||||
expect(secondOptions.buttons).toBeUndefined();
|
||||
|
||||
@@ -521,6 +521,7 @@ export function createTelegramOutboundAdapter(
|
||||
return await send(outboundTo, params.text, {
|
||||
...baseOpts,
|
||||
mediaUrl: params.mediaUrl,
|
||||
...(params.mediaAccess !== undefined ? { mediaAccess: params.mediaAccess } : {}),
|
||||
mediaLocalRoots: params.mediaLocalRoots,
|
||||
mediaReadFile: params.mediaReadFile,
|
||||
forceDocument: params.forceDocument ?? false,
|
||||
@@ -541,6 +542,7 @@ export function createTelegramOutboundAdapter(
|
||||
payload: params.payload,
|
||||
baseOpts: {
|
||||
...baseOpts,
|
||||
...(params.mediaAccess !== undefined ? { mediaAccess: params.mediaAccess } : {}),
|
||||
mediaLocalRoots: params.mediaLocalRoots,
|
||||
mediaReadFile: params.mediaReadFile,
|
||||
forceDocument: params.forceDocument ?? false,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { MessageReceipt } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { MarkdownTableMode, ReplyToMode } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { OutboundMediaAccess } from "openclaw/plugin-sdk/media-runtime";
|
||||
import type { RetryConfig } from "openclaw/plugin-sdk/retry-runtime";
|
||||
import type { TelegramInlineButtons } from "./button-types.js";
|
||||
import type { createTelegramPromptContextProjectionCursor } from "./prompt-context-projection.js";
|
||||
@@ -12,6 +13,7 @@ export type TelegramSendOpts = {
|
||||
accountId?: string;
|
||||
verbose?: boolean;
|
||||
mediaUrl?: string;
|
||||
mediaAccess?: OutboundMediaAccess;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
||||
gatewayClientScopes?: readonly string[];
|
||||
|
||||
@@ -223,6 +223,7 @@ async function sendMessageTelegramWithContext(
|
||||
mediaUrl,
|
||||
buildOutboundMediaLoadOptions({
|
||||
maxBytes: mediaMaxBytes,
|
||||
mediaAccess: opts.mediaAccess,
|
||||
mediaLocalRoots: opts.mediaLocalRoots,
|
||||
mediaReadFile: opts.mediaReadFile,
|
||||
optimizeImages: opts.forceDocument ? false : undefined,
|
||||
|
||||
@@ -3803,6 +3803,10 @@ describe("sendMessageTelegram", () => {
|
||||
|
||||
it("defaults outbound media uploads to 100MB", async () => {
|
||||
const chatId = "123";
|
||||
const mediaAccess = {
|
||||
localRoots: ["/tmp/agent-root"],
|
||||
workspaceDir: "/tmp/agent-root",
|
||||
};
|
||||
const sendPhoto = vi.fn().mockResolvedValue({
|
||||
message_id: 60,
|
||||
chat: { id: chatId },
|
||||
@@ -3821,15 +3825,19 @@ describe("sendMessageTelegram", () => {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
api,
|
||||
mediaUrl: "https://example.com/photo.jpg",
|
||||
mediaUrl: "chart.png",
|
||||
mediaAccess,
|
||||
});
|
||||
|
||||
const [mediaUrl, options] = requireMockCall(
|
||||
firstMockCall(loadWebMedia, "loadWebMedia call"),
|
||||
"load web media call",
|
||||
);
|
||||
expect(mediaUrl).toBe("https://example.com/photo.jpg");
|
||||
expect(requireRecord(options, "load web media options").maxBytes).toBe(100 * 1024 * 1024);
|
||||
expect(mediaUrl).toBe("chart.png");
|
||||
const loadOptions = requireRecord(options, "load web media options");
|
||||
expect(loadOptions.maxBytes).toBe(100 * 1024 * 1024);
|
||||
expect(loadOptions.localRoots).toEqual(mediaAccess.localRoots);
|
||||
expect(loadOptions.workspaceDir).toBe(mediaAccess.workspaceDir);
|
||||
});
|
||||
|
||||
it("uses configured telegram mediaMaxMb for outbound uploads", async () => {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
title: Channel sends preserve workspace-relative media authority
|
||||
|
||||
scenario:
|
||||
id: channel-workspace-relative-media
|
||||
surface: channels
|
||||
coverage:
|
||||
primary:
|
||||
- channels.outbound-direct-text-media-sends
|
||||
secondary:
|
||||
- channels.media-roots
|
||||
objective: Deliver a workspace-relative image through direct Gateway and plugin-action sends.
|
||||
execution:
|
||||
kind: flow
|
||||
channels:
|
||||
- matrix
|
||||
- telegram
|
||||
timeoutMs: 90000
|
||||
|
||||
flow:
|
||||
steps:
|
||||
- name: delivers workspace-relative images through direct and plugin-action sends
|
||||
actions:
|
||||
- call: waitForGatewayHealthy
|
||||
args: [{ ref: env }, 60000]
|
||||
- call: waitForTransportReady
|
||||
args: [{ ref: env }, 60000]
|
||||
- set: marker
|
||||
value:
|
||||
expr: "`QA-WORKSPACE-RELATIVE-MEDIA-${randomUUID()}`"
|
||||
- set: filename
|
||||
value:
|
||||
expr: "`${marker}.png`"
|
||||
- set: fixturePath
|
||||
value:
|
||||
expr: "path.join(env.gateway.workspaceDir, filename)"
|
||||
- set: delivery
|
||||
value:
|
||||
expr: "transport.buildAgentDelivery({ target: 'group:main' })"
|
||||
- set: outboundStartIndex
|
||||
value:
|
||||
expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound').length"
|
||||
- set: matrixRecordIndex
|
||||
value:
|
||||
expr: "transport.id === 'matrix' ? scenarioContext.faultProxyObserver.records().length : 0"
|
||||
- try:
|
||||
actions:
|
||||
- call: fs.writeFile
|
||||
args:
|
||||
- ref: fixturePath
|
||||
- expr: "Buffer.from(imageUnderstandingValidPngBase64, 'base64')"
|
||||
- call: env.gateway.call
|
||||
saveAs: sent
|
||||
args:
|
||||
- send
|
||||
- channel: { expr: delivery.channel }
|
||||
to: { expr: delivery.to }
|
||||
accountId: { expr: transport.accountId }
|
||||
agentId: qa
|
||||
message: { ref: marker }
|
||||
mediaUrl: { expr: "`./${filename}`" }
|
||||
idempotencyKey: { expr: randomUUID() }
|
||||
- timeoutMs: 60000
|
||||
- assert:
|
||||
expr: "Boolean(sent?.messageId) && sent.channel === delivery.channel"
|
||||
message: Gateway did not return a successful channel delivery receipt
|
||||
- waitForOutbound:
|
||||
textIncludes: { ref: marker }
|
||||
sinceIndex: { ref: outboundStartIndex }
|
||||
timeoutMs: 60000
|
||||
saveAs: outbound
|
||||
- assert:
|
||||
expr: "state.getSnapshot().messages.filter((message) => message.accountId === transport.accountId && message.direction === 'outbound' && message.text === marker).length === 1"
|
||||
message: workspace-relative image caption was not delivered exactly once
|
||||
- call: env.gateway.call
|
||||
saveAs: actionSent
|
||||
args:
|
||||
- message.action
|
||||
- channel: { expr: delivery.channel }
|
||||
action: send
|
||||
accountId: { expr: transport.accountId }
|
||||
agentId: qa
|
||||
idempotencyKey: { expr: randomUUID() }
|
||||
params:
|
||||
to: { expr: delivery.to }
|
||||
message: { expr: "`${marker}-ACTION`" }
|
||||
media: { expr: "`./${filename}`" }
|
||||
- timeoutMs: 60000
|
||||
- assert:
|
||||
expr: "actionSent?.ok === true && Boolean(actionSent.messageId ?? actionSent.result?.messageId)"
|
||||
message: channel plugin action did not return a successful delivery receipt
|
||||
- waitForOutbound:
|
||||
textIncludes: { expr: "`${marker}-ACTION`" }
|
||||
sinceIndex: { ref: outboundStartIndex }
|
||||
timeoutMs: 60000
|
||||
saveAs: actionOutbound
|
||||
- assert:
|
||||
expr: "state.getSnapshot().messages.filter((message) => message.accountId === transport.accountId && message.direction === 'outbound' && message.text === `${marker}-ACTION`).length === 1"
|
||||
message: plugin-action image caption was not delivered exactly once
|
||||
- if:
|
||||
expr: "transport.id === 'matrix'"
|
||||
then:
|
||||
- assert:
|
||||
expr: "[marker, `${marker}-ACTION`].every((caption) => scenarioContext.observedEvents.some((event) => event.roomId === delivery.to && event.sender === scenarioContext.sutUserId && event.body === caption && event.msgtype === 'm.image')) && scenarioContext.faultProxyObserver.records().slice(matrixRecordIndex).filter((record) => record.request.method === 'POST' && record.request.route.includes('/media/') && record.request.route.endsWith('/upload') && record.response.status >= 200 && record.response.status < 300).length >= 2"
|
||||
message: Matrix did not upload and deliver both native image events
|
||||
finally:
|
||||
- call: fs.rm
|
||||
args:
|
||||
- ref: fixturePath
|
||||
- force: true
|
||||
detailsExpr: "JSON.stringify({ channel: delivery.channel, relativeMediaUrl: `./${filename}`, messageId: sent.messageId, outboundId: outbound.id, actionMessageId: actionSent.messageId ?? actionSent.result?.messageId, actionOutboundId: actionOutbound.id, nativeImagesVerified: transport.id === 'matrix' })"
|
||||
@@ -140,6 +140,7 @@ vi.mock("../../agents/agent-scope.js", () => ({
|
||||
config?: unknown;
|
||||
agentId?: string;
|
||||
}) => resolveAgentIdFromSessionKeyForTests({ sessionKey }),
|
||||
resolveAgentConfig: () => undefined,
|
||||
resolveDefaultAgentId: () => "main",
|
||||
resolveAgentWorkspaceDir: () => TEST_AGENT_WORKSPACE,
|
||||
}));
|
||||
@@ -3898,9 +3899,87 @@ describe("gateway send mirroring", () => {
|
||||
expect(firstRespondCall(respond)[0]).toBe(true);
|
||||
const actionCall = lastDispatchChannelMessageActionCall();
|
||||
expect(actionCall?.mediaLocalRoots).toContain(TEST_AGENT_WORKSPACE);
|
||||
expect(actionCall).not.toHaveProperty("mediaAccess");
|
||||
expect(actionCall).not.toHaveProperty("mediaReadFile");
|
||||
expect(actionCall?.gatewayClientScopes).toEqual(["operator.write"]);
|
||||
});
|
||||
|
||||
it("passes reader-free workspace media access only to gateway send actions", async () => {
|
||||
registerMessageActionPlugin({ registrySuffix: "message-action-workspace-media-access" });
|
||||
|
||||
const { respond } = await runMessageActionRequest(
|
||||
{
|
||||
channel: "telegram",
|
||||
action: "send",
|
||||
params: { to: "123", message: "chart", mediaUrl: "chart.png" },
|
||||
agentId: "work",
|
||||
idempotencyKey: "idem-message-action-workspace-media-access",
|
||||
},
|
||||
{ connect: { scopes: ["operator.write"] } },
|
||||
);
|
||||
|
||||
expect(firstRespondCall(respond)[0]).toBe(true);
|
||||
const actionCall = lastDispatchChannelMessageActionCall();
|
||||
expect(actionCall?.mediaAccess).toMatchObject({
|
||||
localRoots: expect.arrayContaining([TEST_AGENT_WORKSPACE]),
|
||||
workspaceDir: TEST_AGENT_WORKSPACE,
|
||||
});
|
||||
expect(actionCall?.mediaAccess.localRoots).toBe(actionCall?.mediaLocalRoots);
|
||||
expect(actionCall?.mediaAccess).not.toHaveProperty("readFile");
|
||||
expect(actionCall).not.toHaveProperty("mediaReadFile");
|
||||
});
|
||||
|
||||
it("uses signed sender group policy without granting gateway send host reads", async () => {
|
||||
const plugin = registerMessageActionPlugin({
|
||||
chatType: "group",
|
||||
registrySuffix: "message-action-signed-sender-media-policy",
|
||||
});
|
||||
const resolveToolPolicy = vi.fn(({ senderId }: { senderId?: string | null }) =>
|
||||
senderId === "blocked-sender" ? { deny: ["read"] } : undefined,
|
||||
);
|
||||
plugin.groups = { resolveToolPolicy };
|
||||
const sessionKey = "agent:work:telegram:group:ops";
|
||||
|
||||
const { respond } = await runMessageActionRequest(
|
||||
{
|
||||
channel: "telegram",
|
||||
action: "send",
|
||||
params: { to: "ops", message: "chart", mediaUrl: "chart.png" },
|
||||
requesterSenderId: "forged-allowed-sender",
|
||||
sessionKey,
|
||||
agentId: "work",
|
||||
idempotencyKey: "idem-message-action-signed-sender-media-policy",
|
||||
},
|
||||
{
|
||||
internal: {
|
||||
agentRuntimeIdentity: {
|
||||
kind: "agentRuntime",
|
||||
agentId: "work",
|
||||
sessionKey,
|
||||
messageActionContext: {
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
requesterSenderId: "blocked-sender",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...makeContext(),
|
||||
getRuntimeConfig: () => ({ tools: { allow: ["read"] } }),
|
||||
} as GatewayRequestContext,
|
||||
);
|
||||
|
||||
expect(firstRespondCall(respond)[0]).toBe(true);
|
||||
expect(resolveToolPolicy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ groupId: "ops", senderId: "blocked-sender" }),
|
||||
);
|
||||
const actionCall = lastDispatchChannelMessageActionCall();
|
||||
expect(actionCall?.requesterSenderId).toBe("blocked-sender");
|
||||
expect(actionCall?.mediaAccess.workspaceDir).toBe(TEST_AGENT_WORKSPACE);
|
||||
expect(actionCall?.mediaAccess).not.toHaveProperty("readFile");
|
||||
expect(actionCall).not.toHaveProperty("mediaReadFile");
|
||||
});
|
||||
|
||||
it("materializes buffer-only message.action sends on the gateway before plugin dispatch", async () => {
|
||||
registerMessageActionPlugin({ registrySuffix: "message-action-buffer-materialize" });
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-resolver.js";
|
||||
import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
|
||||
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
|
||||
import { resolveAgentScopedOutboundMediaAccess } from "../../media/read-capability.js";
|
||||
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
|
||||
import { extractToolPayload } from "../../plugin-sdk/tool-payload.js";
|
||||
import { normalizePollInput } from "../../polls.js";
|
||||
@@ -956,6 +957,29 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||
if (accountId) {
|
||||
request.params.accountId = accountId;
|
||||
}
|
||||
const resolvedMediaAccess =
|
||||
request.action === "send"
|
||||
? resolveAgentScopedOutboundMediaAccess({
|
||||
cfg,
|
||||
agentId,
|
||||
sessionKey,
|
||||
messageProvider: sessionKey ? undefined : channel,
|
||||
accountId: sessionKey
|
||||
? (trustedContext.requesterAccountId ?? accountId)
|
||||
: accountId,
|
||||
requesterSenderId: trustedContext.requesterSenderId,
|
||||
})
|
||||
: undefined;
|
||||
// Gateway identities omit trusted sender aliases; expose roots/workspace
|
||||
// only so a host reader cannot bypass alias-based group read policy.
|
||||
const mediaAccess = resolvedMediaAccess
|
||||
? {
|
||||
localRoots: resolvedMediaAccess.localRoots,
|
||||
...(resolvedMediaAccess.workspaceDir
|
||||
? { workspaceDir: resolvedMediaAccess.workspaceDir }
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
if (request.action === "send") {
|
||||
await hydrateAttachmentParamsForAction({
|
||||
cfg,
|
||||
@@ -1006,7 +1030,9 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||
sessionId: normalizeOptionalString(request.sessionId) ?? undefined,
|
||||
inboundEventKind: request.inboundTurnKind,
|
||||
agentId,
|
||||
mediaLocalRoots: getAgentScopedMediaLocalRoots(cfg, agentId),
|
||||
...(mediaAccess
|
||||
? { mediaAccess, mediaLocalRoots: mediaAccess.localRoots }
|
||||
: { mediaLocalRoots: getAgentScopedMediaLocalRoots(cfg, agentId) }),
|
||||
toolContext: trustedContext.toolContext,
|
||||
dryRun: false,
|
||||
gatewayClientScopes,
|
||||
|
||||
Reference in New Issue
Block a user