mirror of https://github.com/openclaw/openclaw.git
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:
parent
9889c6da53
commit
87e6ce7c3a
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue