fix(extensions): synthesize mediaLocalRoots propagation across sendMedia adapters

Restore deterministic mediaLocalRoots propagation through extension sendMedia adapters and add coverage for local/remote media handling in Google Chat.

Synthesis of #33581, #33545, #33540, #33536, #33528.

Co-authored-by: bmendonca3 <bmendonca3@users.noreply.github.com>
This commit is contained in:
Tak Hoffman 2026-03-03 21:30:41 -06:00 committed by GitHub
parent 9889c6da53
commit 87e6ce7c3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 342 additions and 9 deletions

View File

@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Runtime/tool-state stability: recover from dangling Anthropic `tool_use` after compaction, serialize long-running Discord handler runs without blocking new inbound events, and prevent stale busy snapshots from suppressing stuck-channel recovery. (from #33630, #33583) Thanks @kevinWangSheng and @theotarr.
- Extensions/media local-root propagation: consistently forward `mediaLocalRoots` through extension `sendMedia` adapters (Google Chat, Slack, iMessage, Signal, WhatsApp), preserving non-local media behavior while restoring local attachment resolution from configured roots. Synthesis of #33581, #33545, #33540, #33536, #33528. Thanks @bmendonca3.
- Gateway/security default response headers: add `Permissions-Policy: camera=(), microphone=(), geolocation=()` to baseline gateway HTTP security headers for all responses. (#30186) thanks @habakan.
- Plugins/startup loading: lazily initialize plugin runtime, split startup-critical plugin SDK imports into `openclaw/plugin-sdk/core` and `openclaw/plugin-sdk/telegram`, and preserve `api.runtime` reflection semantics for plugin compatibility. (#28620) thanks @hmemcpy.
- Build/lazy runtime boundaries: replace ineffective dynamic import sites with dedicated lazy runtime boundaries across Slack slash handling, Telegram audit, CLI send deps, memory fallback, and outbound delivery paths while preserving behavior. (#33690) thanks @gumadeiras.

View File

@ -0,0 +1,168 @@
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import { describe, expect, it, vi } from "vitest";
const uploadGoogleChatAttachmentMock = vi.hoisted(() => vi.fn());
const sendGoogleChatMessageMock = vi.hoisted(() => vi.fn());
vi.mock("./api.js", () => ({
sendGoogleChatMessage: sendGoogleChatMessageMock,
uploadGoogleChatAttachment: uploadGoogleChatAttachmentMock,
}));
import { googlechatPlugin } from "./channel.js";
import { setGoogleChatRuntime } from "./runtime.js";
describe("googlechatPlugin outbound sendMedia", () => {
it("loads local media with mediaLocalRoots via runtime media loader", async () => {
const loadWebMedia = vi.fn(async () => ({
buffer: Buffer.from("image-bytes"),
fileName: "image.png",
contentType: "image/png",
}));
const fetchRemoteMedia = vi.fn(async () => ({
buffer: Buffer.from("remote-bytes"),
fileName: "remote.png",
contentType: "image/png",
}));
setGoogleChatRuntime({
media: { loadWebMedia },
channel: {
media: { fetchRemoteMedia },
text: { chunkMarkdownText: (text: string) => [text] },
},
} as unknown as PluginRuntime);
uploadGoogleChatAttachmentMock.mockResolvedValue({
attachmentUploadToken: "token-1",
});
sendGoogleChatMessageMock.mockResolvedValue({
messageName: "spaces/AAA/messages/msg-1",
});
const cfg: OpenClawConfig = {
channels: {
googlechat: {
enabled: true,
serviceAccount: {
type: "service_account",
client_email: "bot@example.com",
private_key: "test-key",
token_uri: "https://oauth2.googleapis.com/token",
},
},
},
};
const result = await googlechatPlugin.outbound?.sendMedia?.({
cfg,
to: "spaces/AAA",
text: "caption",
mediaUrl: "/tmp/workspace/image.png",
mediaLocalRoots: ["/tmp/workspace"],
accountId: "default",
});
expect(loadWebMedia).toHaveBeenCalledWith(
"/tmp/workspace/image.png",
expect.objectContaining({
localRoots: ["/tmp/workspace"],
}),
);
expect(fetchRemoteMedia).not.toHaveBeenCalled();
expect(uploadGoogleChatAttachmentMock).toHaveBeenCalledWith(
expect.objectContaining({
space: "spaces/AAA",
filename: "image.png",
contentType: "image/png",
}),
);
expect(sendGoogleChatMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
space: "spaces/AAA",
text: "caption",
}),
);
expect(result).toEqual({
channel: "googlechat",
messageId: "spaces/AAA/messages/msg-1",
chatId: "spaces/AAA",
});
});
it("keeps remote URL media fetch on fetchRemoteMedia with maxBytes cap", async () => {
const loadWebMedia = vi.fn(async () => ({
buffer: Buffer.from("should-not-be-used"),
fileName: "unused.png",
contentType: "image/png",
}));
const fetchRemoteMedia = vi.fn(async () => ({
buffer: Buffer.from("remote-bytes"),
fileName: "remote.png",
contentType: "image/png",
}));
setGoogleChatRuntime({
media: { loadWebMedia },
channel: {
media: { fetchRemoteMedia },
text: { chunkMarkdownText: (text: string) => [text] },
},
} as unknown as PluginRuntime);
uploadGoogleChatAttachmentMock.mockResolvedValue({
attachmentUploadToken: "token-2",
});
sendGoogleChatMessageMock.mockResolvedValue({
messageName: "spaces/AAA/messages/msg-2",
});
const cfg: OpenClawConfig = {
channels: {
googlechat: {
enabled: true,
serviceAccount: {
type: "service_account",
client_email: "bot@example.com",
private_key: "test-key",
token_uri: "https://oauth2.googleapis.com/token",
},
},
},
};
const result = await googlechatPlugin.outbound?.sendMedia?.({
cfg,
to: "spaces/AAA",
text: "caption",
mediaUrl: "https://example.com/image.png",
accountId: "default",
});
expect(fetchRemoteMedia).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://example.com/image.png",
maxBytes: 20 * 1024 * 1024,
}),
);
expect(loadWebMedia).not.toHaveBeenCalled();
expect(uploadGoogleChatAttachmentMock).toHaveBeenCalledWith(
expect.objectContaining({
space: "spaces/AAA",
filename: "remote.png",
contentType: "image/png",
}),
);
expect(sendGoogleChatMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
space: "spaces/AAA",
text: "caption",
}),
);
expect(result).toEqual({
channel: "googlechat",
messageId: "spaces/AAA/messages/msg-2",
chatId: "spaces/AAA",
});
});
});

View File

@ -421,7 +421,16 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
chatId: space,
};
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId, threadId }) => {
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
replyToId,
threadId,
}) => {
if (!mediaUrl) {
throw new Error("Google Chat mediaUrl is required.");
}
@ -443,10 +452,16 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
(cfg.channels?.["googlechat"] as { mediaMaxMb?: number } | undefined)?.mediaMaxMb,
accountId,
});
const loaded = await runtime.channel.media.fetchRemoteMedia({
url: mediaUrl,
maxBytes: maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
});
const effectiveMaxBytes = maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
const loaded = /^https?:\/\//i.test(mediaUrl)
? await runtime.channel.media.fetchRemoteMedia({
url: mediaUrl,
maxBytes: effectiveMaxBytes,
})
: await runtime.media.loadWebMedia(mediaUrl, {
maxBytes: effectiveMaxBytes,
localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined,
});
const upload = await uploadGoogleChatAttachment({
account,
space,

View File

@ -63,4 +63,33 @@ describe("imessagePlugin outbound", () => {
);
expect(result).toEqual({ channel: "imessage", messageId: "m-media" });
});
it("forwards mediaLocalRoots on direct sendMedia adapter path", async () => {
const sendIMessage = vi.fn().mockResolvedValue({ messageId: "m-media-local" });
const sendMedia = imessagePlugin.outbound?.sendMedia;
expect(sendMedia).toBeDefined();
const mediaLocalRoots = ["/tmp/workspace"];
const result = await sendMedia!({
cfg,
to: "chat_id:88",
text: "caption",
mediaUrl: "/tmp/workspace/pic.png",
mediaLocalRoots,
accountId: "acct-1",
deps: { sendIMessage },
});
expect(sendIMessage).toHaveBeenCalledWith(
"chat_id:88",
"caption",
expect.objectContaining({
mediaUrl: "/tmp/workspace/pic.png",
mediaLocalRoots,
accountId: "acct-1",
maxBytes: 3 * 1024 * 1024,
}),
);
expect(result).toEqual({ channel: "imessage", messageId: "m-media-local" });
});
});

View File

@ -54,6 +54,7 @@ async function sendIMessageOutbound(params: {
to: string;
text: string;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
accountId?: string;
deps?: { sendIMessage?: IMessageSendFn };
replyToId?: string;
@ -69,6 +70,7 @@ async function sendIMessageOutbound(params: {
});
return await send(params.to, params.text, {
...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}),
...(params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}),
maxBytes,
accountId: params.accountId ?? undefined,
replyToId: params.replyToId ?? undefined,
@ -239,12 +241,13 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
});
return { channel: "imessage", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps, replyToId }) => {
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, replyToId }) => {
const result = await sendIMessageOutbound({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId: accountId ?? undefined,
deps,
replyToId: replyToId ?? undefined,

View File

@ -0,0 +1,34 @@
import { describe, expect, it, vi } from "vitest";
import { signalPlugin } from "./channel.js";
describe("signalPlugin outbound sendMedia", () => {
it("forwards mediaLocalRoots to sendMessageSignal", async () => {
const sendSignal = vi.fn(async () => ({ messageId: "m1" }));
const mediaLocalRoots = ["/tmp/workspace"];
const sendMedia = signalPlugin.outbound?.sendMedia;
if (!sendMedia) {
throw new Error("signal outbound sendMedia is unavailable");
}
await sendMedia({
cfg: {} as never,
to: "signal:+15551234567",
text: "photo",
mediaUrl: "/tmp/workspace/photo.png",
mediaLocalRoots,
accountId: "default",
deps: { sendSignal },
});
expect(sendSignal).toHaveBeenCalledWith(
"signal:+15551234567",
"photo",
expect.objectContaining({
mediaUrl: "/tmp/workspace/photo.png",
mediaLocalRoots,
accountId: "default",
}),
);
});
});

View File

@ -68,6 +68,7 @@ async function sendSignalOutbound(params: {
to: string;
text: string;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
accountId?: string;
deps?: { sendSignal?: SignalSendFn };
}) {
@ -80,6 +81,7 @@ async function sendSignalOutbound(params: {
});
return await send(params.to, params.text, {
...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}),
...(params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}),
maxBytes,
accountId: params.accountId ?? undefined,
});
@ -270,12 +272,13 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
});
return { channel: "signal", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => {
const result = await sendSignalOutbound({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId: accountId ?? undefined,
deps,
});

View File

@ -108,6 +108,33 @@ describe("slackPlugin outbound", () => {
);
expect(result).toEqual({ channel: "slack", messageId: "m-media" });
});
it("forwards mediaLocalRoots for sendMedia", async () => {
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-media-local" });
const sendMedia = slackPlugin.outbound?.sendMedia;
expect(sendMedia).toBeDefined();
const mediaLocalRoots = ["/tmp/workspace"];
const result = await sendMedia!({
cfg,
to: "C999",
text: "caption",
mediaUrl: "/tmp/workspace/image.png",
mediaLocalRoots,
accountId: "default",
deps: { sendSlack },
});
expect(sendSlack).toHaveBeenCalledWith(
"C999",
"caption",
expect.objectContaining({
mediaUrl: "/tmp/workspace/image.png",
mediaLocalRoots,
}),
);
expect(result).toEqual({ channel: "slack", messageId: "m-media-local" });
});
});
describe("slackPlugin config", () => {

View File

@ -371,7 +371,17 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
});
return { channel: "slack", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, threadId, cfg }) => {
sendMedia: async ({
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
deps,
replyToId,
threadId,
cfg,
}) => {
const { send, threadTsValue, tokenOverride } = resolveSlackSendContext({
cfg,
accountId: accountId ?? undefined,
@ -381,6 +391,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
});
const result = await send(to, text, {
mediaUrl,
mediaLocalRoots,
threadTs: threadTsValue != null ? String(threadTsValue) : undefined,
accountId: accountId ?? undefined,
...(tokenOverride ? { token: tokenOverride } : {}),

View File

@ -0,0 +1,41 @@
import { describe, expect, it, vi } from "vitest";
import { whatsappPlugin } from "./channel.js";
describe("whatsappPlugin outbound sendMedia", () => {
it("forwards mediaLocalRoots to sendMessageWhatsApp", async () => {
const sendWhatsApp = vi.fn(async () => ({
messageId: "msg-1",
toJid: "15551234567@s.whatsapp.net",
}));
const mediaLocalRoots = ["/tmp/workspace"];
const outbound = whatsappPlugin.outbound;
if (!outbound?.sendMedia) {
throw new Error("whatsapp outbound sendMedia is unavailable");
}
const result = await outbound.sendMedia({
cfg: {} as never,
to: "whatsapp:+15551234567",
text: "photo",
mediaUrl: "/tmp/workspace/photo.png",
mediaLocalRoots,
accountId: "default",
deps: { sendWhatsApp },
gifPlayback: false,
});
expect(sendWhatsApp).toHaveBeenCalledWith(
"whatsapp:+15551234567",
"photo",
expect.objectContaining({
verbose: false,
mediaUrl: "/tmp/workspace/photo.png",
mediaLocalRoots,
accountId: "default",
gifPlayback: false,
}),
);
expect(result).toMatchObject({ channel: "whatsapp", messageId: "msg-1" });
});
});

View File

@ -295,11 +295,12 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
});
return { channel: "whatsapp", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, deps, gifPlayback }) => {
sendMedia: async ({ to, text, mediaUrl, mediaLocalRoots, accountId, deps, gifPlayback }) => {
const send = deps?.sendWhatsApp ?? getWhatsAppRuntime().channel.whatsapp.sendMessageWhatsApp;
const result = await send(to, text, {
verbose: false,
mediaUrl,
mediaLocalRoots,
accountId: accountId ?? undefined,
gifPlayback,
});