fix(cli): route gateway media sends through sendMedia (openclaw#64492)

Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm test -- src/cli/send-runtime/channel-outbound-send.test.ts src/gateway/server-methods/send.test.ts

Representative verification note:
- pnpm check reached tsgo in this worktree and then failed locally without actionable diagnostics; treated as an unhealthy local tooling signal rather than a PR-specific regression.

Co-authored-by: ShionEria <267903315+ShionEria@users.noreply.github.com>
This commit is contained in:
Shion Eria
2026-04-11 05:33:46 +08:00
committed by GitHub
parent e1a350d08e
commit 552667271e
4 changed files with 190 additions and 0 deletions

View File

@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- CLI/WhatsApp media sends: route gateway-mode outbound sends with `--media` through the channel `sendMedia` path and preserve media access context, so WhatsApp document and attachment sends stop silently dropping the file while still delivering the caption. (#64478) Thanks @ShionEria.
- fix(nostr): require operator.admin scope for profile mutation routes [AI]. (#63553) Thanks @pgondhi987.
- Gateway/startup: keep WebSocket RPC available while channels and plugin sidecars start, hold `chat.history` unavailable until startup sidecars finish so synchronous history reads cannot stall startup (reported in #63450), refresh advertised gateway methods after deferred plugin reloads, and enforce the pre-auth WebSocket upgrade budget before the no-handler 503 path so upgrade floods cannot bypass connection limits during that window. (#63480) Thanks @neeravmakwana.
- Gateway/tailscale: start Tailscale exposure and the gateway update check before awaiting channel and plugin sidecar startup so remote operators are not locked out when startup sidecars stall.

View File

@@ -0,0 +1,132 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
loadChannelOutboundAdapter: vi.fn(),
}));
vi.mock("../../channels/plugins/outbound/load.js", () => ({
loadChannelOutboundAdapter: mocks.loadChannelOutboundAdapter,
}));
describe("createChannelOutboundRuntimeSend", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("routes media sends through sendMedia and preserves media access", async () => {
const sendMedia = vi.fn(async () => ({ channel: "whatsapp", messageId: "wa-1" }));
mocks.loadChannelOutboundAdapter.mockResolvedValue({
sendText: vi.fn(),
sendMedia,
});
const { createChannelOutboundRuntimeSend } = await import("./channel-outbound-send.js");
const mediaReadFile = vi.fn(async () => Buffer.from("image"));
const runtimeSend = createChannelOutboundRuntimeSend({
channelId: "whatsapp" as never,
unavailableMessage: "unavailable",
});
await runtimeSend.sendMessage("+15551234567", "caption", {
cfg: {},
mediaUrl: "file:///tmp/photo.png",
mediaAccess: {
localRoots: ["/tmp/workspace"],
readFile: mediaReadFile,
},
mediaLocalRoots: ["/tmp/fallback-root"],
mediaReadFile,
accountId: "default",
gifPlayback: true,
});
expect(sendMedia).toHaveBeenCalledWith(
expect.objectContaining({
cfg: {},
to: "+15551234567",
text: "caption",
mediaUrl: "file:///tmp/photo.png",
mediaAccess: {
localRoots: ["/tmp/workspace"],
readFile: mediaReadFile,
},
mediaLocalRoots: ["/tmp/fallback-root"],
mediaReadFile,
accountId: "default",
gifPlayback: true,
}),
);
});
it("falls back to sendText for text-only sends", async () => {
const sendText = vi.fn(async () => ({ channel: "whatsapp", messageId: "wa-2" }));
mocks.loadChannelOutboundAdapter.mockResolvedValue({
sendText,
sendMedia: vi.fn(),
});
const { createChannelOutboundRuntimeSend } = await import("./channel-outbound-send.js");
const runtimeSend = createChannelOutboundRuntimeSend({
channelId: "whatsapp" as never,
unavailableMessage: "unavailable",
});
await runtimeSend.sendMessage("+15551234567", "hello", {
cfg: {},
accountId: "default",
});
expect(sendText).toHaveBeenCalledWith(
expect.objectContaining({
cfg: {},
to: "+15551234567",
text: "hello",
accountId: "default",
}),
);
});
it("falls back to sendText when media is present but sendMedia is unavailable", async () => {
const sendText = vi.fn(async () => ({ channel: "whatsapp", messageId: "wa-3" }));
mocks.loadChannelOutboundAdapter.mockResolvedValue({
sendText,
});
const { createChannelOutboundRuntimeSend } = await import("./channel-outbound-send.js");
const mediaReadFile = vi.fn(async () => Buffer.from("pdf"));
const runtimeSend = createChannelOutboundRuntimeSend({
channelId: "whatsapp" as never,
unavailableMessage: "unavailable",
});
await runtimeSend.sendMessage("+15551234567", "caption", {
cfg: {},
mediaUrl: "file:///tmp/test.pdf",
mediaAccess: {
localRoots: ["/tmp/workspace"],
readFile: mediaReadFile,
},
mediaLocalRoots: ["/tmp/fallback-root"],
mediaReadFile,
accountId: "default",
forceDocument: true,
});
expect(sendText).toHaveBeenCalledWith(
expect.objectContaining({
cfg: {},
to: "+15551234567",
text: "caption",
mediaUrl: "file:///tmp/test.pdf",
mediaAccess: {
localRoots: ["/tmp/workspace"],
readFile: mediaReadFile,
},
mediaLocalRoots: ["/tmp/fallback-root"],
mediaReadFile,
accountId: "default",
forceDocument: true,
}),
);
});
});

View File

@@ -1,12 +1,15 @@
import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import { loadConfig } from "../../config/config.js";
import type { OutboundMediaAccess } from "../../media/load-options.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
type RuntimeSendOpts = {
cfg?: ReturnType<typeof loadConfig>;
mediaUrl?: string;
mediaAccess?: OutboundMediaAccess;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
accountId?: string;
messageThreadId?: string | number;
replyToMessageId?: string | number;
@@ -23,6 +26,28 @@ export function createChannelOutboundRuntimeSend(params: {
return {
sendMessage: async (to: string, text: string, opts: RuntimeSendOpts = {}) => {
const outbound = await loadChannelOutboundAdapter(params.channelId);
const hasMedia = Boolean(opts.mediaUrl);
if (hasMedia && outbound?.sendMedia) {
return await outbound.sendMedia({
cfg: opts.cfg ?? loadConfig(),
to,
text,
mediaUrl: opts.mediaUrl,
mediaAccess: opts.mediaAccess,
mediaLocalRoots: opts.mediaLocalRoots,
mediaReadFile: opts.mediaReadFile,
accountId: opts.accountId,
threadId: opts.messageThreadId,
replyToId:
opts.replyToMessageId == null
? undefined
: normalizeOptionalString(String(opts.replyToMessageId)),
silent: opts.silent,
forceDocument: opts.forceDocument,
gifPlayback: opts.gifPlayback,
gatewayClientScopes: opts.gatewayClientScopes,
});
}
if (!outbound?.sendText) {
throw new Error(params.unavailableMessage);
}
@@ -31,7 +56,9 @@ export function createChannelOutboundRuntimeSend(params: {
to,
text,
mediaUrl: opts.mediaUrl,
mediaAccess: opts.mediaAccess,
mediaLocalRoots: opts.mediaLocalRoots,
mediaReadFile: opts.mediaReadFile,
accountId: opts.accountId,
threadId: opts.messageThreadId,
replyToId:

View File

@@ -225,6 +225,36 @@ describe("gateway send mirroring", () => {
);
});
it("passes outbound session context for gateway media sends", async () => {
mockDeliverySuccess("m-whatsapp-media");
await runSend({
to: "+15551234567",
message: "caption",
mediaUrl: "file:///tmp/workspace/photo.png",
channel: "whatsapp",
agentId: "work",
idempotencyKey: "idem-whatsapp-media",
});
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
channel: "whatsapp",
payloads: [
{
text: "caption",
mediaUrl: "file:///tmp/workspace/photo.png",
mediaUrls: undefined,
},
],
session: expect.objectContaining({
agentId: "work",
key: "agent:work:whatsapp:resolved",
}),
}),
);
});
it("forwards gateway client scopes into outbound delivery", async () => {
mockDeliverySuccess("m-scope");