From 87e6ce7c3a2f166eb1abec967ae20682e0d37ced Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:30:41 -0600 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + .../googlechat/src/channel.outbound.test.ts | 168 ++++++++++++++++++ extensions/googlechat/src/channel.ts | 25 ++- .../imessage/src/channel.outbound.test.ts | 29 +++ extensions/imessage/src/channel.ts | 5 +- extensions/signal/src/channel.test.ts | 34 ++++ extensions/signal/src/channel.ts | 5 +- extensions/slack/src/channel.test.ts | 27 +++ extensions/slack/src/channel.ts | 13 +- extensions/whatsapp/src/channel.test.ts | 41 +++++ extensions/whatsapp/src/channel.ts | 3 +- 11 files changed, 342 insertions(+), 9 deletions(-) create mode 100644 extensions/googlechat/src/channel.outbound.test.ts create mode 100644 extensions/signal/src/channel.test.ts create mode 100644 extensions/whatsapp/src/channel.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a993b3d510..b9f9f2ec1ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/googlechat/src/channel.outbound.test.ts b/extensions/googlechat/src/channel.outbound.test.ts new file mode 100644 index 00000000000..b50dbc7c6ae --- /dev/null +++ b/extensions/googlechat/src/channel.outbound.test.ts @@ -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", + }); + }); +}); diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 0233cac7017..f79d2212ec7 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -421,7 +421,16 @@ export const googlechatPlugin: ChannelPlugin = { 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 = { (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, diff --git a/extensions/imessage/src/channel.outbound.test.ts b/extensions/imessage/src/channel.outbound.test.ts index a2b5a3a4354..e850c1a1501 100644 --- a/extensions/imessage/src/channel.outbound.test.ts +++ b/extensions/imessage/src/channel.outbound.test.ts @@ -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" }); + }); }); diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 994df82c73f..1a3eee85102 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -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 = { }); 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, diff --git a/extensions/signal/src/channel.test.ts b/extensions/signal/src/channel.test.ts new file mode 100644 index 00000000000..ee15deb0ec8 --- /dev/null +++ b/extensions/signal/src/channel.test.ts @@ -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", + }), + ); + }); +}); diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 44f0bd43294..ff0623705b7 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -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 = { }); 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, }); diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index 006054f0930..204c016a6dc 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -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", () => { diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index f5b073dc045..5a1364fe8f2 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -371,7 +371,17 @@ export const slackPlugin: ChannelPlugin = { }); 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 = { }); const result = await send(to, text, { mediaUrl, + mediaLocalRoots, threadTs: threadTsValue != null ? String(threadTsValue) : undefined, accountId: accountId ?? undefined, ...(tokenOverride ? { token: tokenOverride } : {}), diff --git a/extensions/whatsapp/src/channel.test.ts b/extensions/whatsapp/src/channel.test.ts new file mode 100644 index 00000000000..b1e13f87833 --- /dev/null +++ b/extensions/whatsapp/src/channel.test.ts @@ -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" }); + }); +}); diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index ef36857d899..d45cbe113f2 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -295,11 +295,12 @@ export const whatsappPlugin: ChannelPlugin = { }); 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, });