mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 04:31:10 +00:00
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:
@@ -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.
|
||||
|
||||
132
src/cli/send-runtime/channel-outbound-send.test.ts
Normal file
132
src/cli/send-runtime/channel-outbound-send.test.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user