fix(outbound): unify resolved cfg threading across send paths (#33987)

This commit is contained in:
Josh Avant
2026-03-04 00:20:44 -06:00
committed by GitHub
parent 4d183af0cf
commit 646817dd80
62 changed files with 1780 additions and 117 deletions

View File

@@ -847,7 +847,10 @@ describe("signalMessageActions", () => {
cfg: createSignalAccountOverrideCfg(),
accountId: "work",
params: { to: "+15550001111", messageId: "123", emoji: "👍" },
expectedArgs: ["+15550001111", 123, "👍", { accountId: "work" }],
expectedRecipient: "+15550001111",
expectedTimestamp: 123,
expectedEmoji: "👍",
expectedOptions: { accountId: "work" },
},
{
name: "normalizes uuid recipients",
@@ -858,7 +861,10 @@ describe("signalMessageActions", () => {
messageId: "123",
emoji: "🔥",
},
expectedArgs: ["123e4567-e89b-12d3-a456-426614174000", 123, "🔥", { accountId: undefined }],
expectedRecipient: "123e4567-e89b-12d3-a456-426614174000",
expectedTimestamp: 123,
expectedEmoji: "🔥",
expectedOptions: {},
},
{
name: "passes groupId and targetAuthor for group reactions",
@@ -870,17 +876,13 @@ describe("signalMessageActions", () => {
messageId: "123",
emoji: "✅",
},
expectedArgs: [
"",
123,
"✅",
{
accountId: undefined,
groupId: "group-id",
targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000",
targetAuthorUuid: undefined,
},
],
expectedRecipient: "",
expectedTimestamp: 123,
expectedEmoji: "✅",
expectedOptions: {
groupId: "group-id",
targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000",
},
},
] as const;
@@ -890,7 +892,15 @@ describe("signalMessageActions", () => {
cfg: testCase.cfg,
accountId: testCase.accountId,
});
expect(sendReactionSignal, testCase.name).toHaveBeenCalledWith(...testCase.expectedArgs);
expect(sendReactionSignal, testCase.name).toHaveBeenCalledWith(
testCase.expectedRecipient,
testCase.expectedTimestamp,
testCase.expectedEmoji,
expect.objectContaining({
cfg: testCase.cfg,
...testCase.expectedOptions,
}),
);
}
});

View File

@@ -40,6 +40,7 @@ function resolveSignalReactionTarget(raw: string): { recipient?: string; groupId
}
async function mutateSignalReaction(params: {
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
accountId?: string;
target: { recipient?: string; groupId?: string };
timestamp: number;
@@ -49,6 +50,7 @@ async function mutateSignalReaction(params: {
targetAuthorUuid?: string;
}) {
const options = {
cfg: params.cfg,
accountId: params.accountId,
groupId: params.target.groupId,
targetAuthor: params.targetAuthor,
@@ -153,6 +155,7 @@ export const signalMessageActions: ChannelMessageActionAdapter = {
throw new Error("Emoji required to remove reaction.");
}
return await mutateSignalReaction({
cfg,
accountId: accountId ?? undefined,
target,
timestamp,
@@ -167,6 +170,7 @@ export const signalMessageActions: ChannelMessageActionAdapter = {
throw new Error("Emoji required to add reaction.");
}
return await mutateSignalReaction({
cfg,
accountId: accountId ?? undefined,
target,
timestamp,

View File

@@ -5,6 +5,7 @@ import { resolveChannelMediaMaxBytes } from "../media-limits.js";
import type { ChannelOutboundAdapter } from "../types.js";
type DirectSendOptions = {
cfg: OpenClawConfig;
accountId?: string | null;
replyToId?: string | null;
mediaUrl?: string;
@@ -121,6 +122,7 @@ export function createDirectTextMediaOutbound<
sendParams.to,
sendParams.text,
sendParams.buildOptions({
cfg: sendParams.cfg,
mediaUrl: sendParams.mediaUrl,
mediaLocalRoots: sendParams.mediaLocalRoots,
accountId: sendParams.accountId,

View File

@@ -143,9 +143,16 @@ describe("discordOutbound", () => {
it("uses webhook persona delivery for bound thread text replies", async () => {
mockBoundThreadManager();
const cfg = {
channels: {
discord: {
token: "resolved-token",
},
},
};
const result = await discordOutbound.sendText?.({
cfg: {},
cfg,
to: "channel:parent-1",
text: "hello from persona",
accountId: "default",
@@ -169,6 +176,10 @@ describe("discordOutbound", () => {
avatarUrl: "https://example.com/avatar.png",
}),
);
expect(
(hoisted.sendWebhookMessageDiscordMock.mock.calls[0]?.[1] as { cfg?: unknown } | undefined)
?.cfg,
).toBe(cfg);
expect(hoisted.sendMessageDiscordMock).not.toHaveBeenCalled();
expect(result).toEqual({
channel: "discord",

View File

@@ -1,3 +1,4 @@
import type { OpenClawConfig } from "../../../config/config.js";
import {
getThreadBindingManager,
type ThreadBindingRecord,
@@ -38,6 +39,7 @@ function resolveDiscordWebhookIdentity(params: {
}
async function maybeSendDiscordWebhookText(params: {
cfg?: OpenClawConfig;
text: string;
threadId?: string | number | null;
accountId?: string | null;
@@ -68,6 +70,7 @@ async function maybeSendDiscordWebhookText(params: {
webhookToken: binding.webhookToken,
accountId: binding.accountId,
threadId: binding.threadId,
cfg: params.cfg,
replyTo: params.replyToId ?? undefined,
username: persona.username,
avatarUrl: persona.avatarUrl,
@@ -83,9 +86,10 @@ export const discordOutbound: ChannelOutboundAdapter = {
resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to),
sendPayload: async (ctx) =>
await sendTextMediaPayload({ channel: "discord", ctx, adapter: discordOutbound }),
sendText: async ({ to, text, accountId, deps, replyToId, threadId, identity, silent }) => {
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity, silent }) => {
if (!silent) {
const webhookResult = await maybeSendDiscordWebhookText({
cfg,
text,
threadId,
accountId,
@@ -103,10 +107,12 @@ export const discordOutbound: ChannelOutboundAdapter = {
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
silent: silent ?? undefined,
cfg,
});
return { channel: "discord", ...result };
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
@@ -126,14 +132,16 @@ export const discordOutbound: ChannelOutboundAdapter = {
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
silent: silent ?? undefined,
cfg,
});
return { channel: "discord", ...result };
},
sendPoll: async ({ to, poll, accountId, threadId, silent }) => {
sendPoll: async ({ cfg, to, poll, accountId, threadId, silent }) => {
const target = resolveDiscordOutboundTarget({ to, threadId });
return await sendPollDiscord(target, poll, {
accountId: accountId ?? undefined,
silent: silent ?? undefined,
cfg,
});
},
};

View File

@@ -13,12 +13,14 @@ export const imessageOutbound = createDirectTextMediaOutbound({
channel: "imessage",
resolveSender: resolveIMessageSender,
resolveMaxBytes: createScopedChannelMediaMaxBytesResolver("imessage"),
buildTextOptions: ({ maxBytes, accountId, replyToId }) => ({
buildTextOptions: ({ cfg, maxBytes, accountId, replyToId }) => ({
config: cfg,
maxBytes,
accountId: accountId ?? undefined,
replyToId: replyToId ?? undefined,
}),
buildMediaOptions: ({ mediaUrl, maxBytes, accountId, replyToId, mediaLocalRoots }) => ({
buildMediaOptions: ({ cfg, mediaUrl, maxBytes, accountId, replyToId, mediaLocalRoots }) => ({
config: cfg,
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,

View File

@@ -13,11 +13,13 @@ export const signalOutbound = createDirectTextMediaOutbound({
channel: "signal",
resolveSender: resolveSignalSender,
resolveMaxBytes: createScopedChannelMediaMaxBytesResolver("signal"),
buildTextOptions: ({ maxBytes, accountId }) => ({
buildTextOptions: ({ cfg, maxBytes, accountId }) => ({
cfg,
maxBytes,
accountId: accountId ?? undefined,
}),
buildMediaOptions: ({ mediaUrl, maxBytes, accountId, mediaLocalRoots }) => ({
buildMediaOptions: ({ cfg, mediaUrl, maxBytes, accountId, mediaLocalRoots }) => ({
cfg,
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,

View File

@@ -58,11 +58,13 @@ const expectSlackSendCalledWith = (
};
},
) => {
expect(sendMessageSlack).toHaveBeenCalledWith("C123", text, {
const expected = {
threadTs: "1111.2222",
accountId: "default",
...options,
});
cfg: expect.any(Object),
...(options?.identity ? { identity: expect.objectContaining(options.identity) } : {}),
};
expect(sendMessageSlack).toHaveBeenCalledWith("C123", text, expect.objectContaining(expected));
};
describe("slack outbound hook wiring", () => {

View File

@@ -48,6 +48,7 @@ async function applySlackMessageSendingHooks(params: {
}
async function sendSlackOutboundMessage(params: {
cfg: NonNullable<Parameters<typeof sendMessageSlack>[2]>["cfg"];
to: string;
text: string;
mediaUrl?: string;
@@ -80,6 +81,7 @@ async function sendSlackOutboundMessage(params: {
const slackIdentity = resolveSlackSendIdentity(params.identity);
const result = await send(params.to, hookResult.text, {
cfg: params.cfg,
threadTs,
accountId: params.accountId ?? undefined,
...(params.mediaUrl
@@ -96,8 +98,9 @@ export const slackOutbound: ChannelOutboundAdapter = {
textChunkLimit: 4000,
sendPayload: async (ctx) =>
await sendTextMediaPayload({ channel: "slack", ctx, adapter: slackOutbound }),
sendText: async ({ to, text, accountId, deps, replyToId, threadId, identity }) => {
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity }) => {
return await sendSlackOutboundMessage({
cfg,
to,
text,
accountId,
@@ -108,6 +111,7 @@ export const slackOutbound: ChannelOutboundAdapter = {
});
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
@@ -119,6 +123,7 @@ export const slackOutbound: ChannelOutboundAdapter = {
identity,
}) => {
return await sendSlackOutboundMessage({
cfg,
to,
text,
mediaUrl,

View File

@@ -9,6 +9,7 @@ import { sendMessageTelegram } from "../../../telegram/send.js";
import type { ChannelOutboundAdapter } from "../types.js";
function resolveTelegramSendContext(params: {
cfg: NonNullable<Parameters<typeof sendMessageTelegram>[2]>["cfg"];
deps?: OutboundSendDeps;
accountId?: string | null;
replyToId?: string | null;
@@ -16,6 +17,7 @@ function resolveTelegramSendContext(params: {
}): {
send: typeof sendMessageTelegram;
baseOpts: {
cfg: NonNullable<Parameters<typeof sendMessageTelegram>[2]>["cfg"];
verbose: false;
textMode: "html";
messageThreadId?: number;
@@ -29,6 +31,7 @@ function resolveTelegramSendContext(params: {
baseOpts: {
verbose: false,
textMode: "html",
cfg: params.cfg,
messageThreadId: parseTelegramThreadId(params.threadId),
replyToMessageId: parseTelegramReplyToMessageId(params.replyToId),
accountId: params.accountId ?? undefined,
@@ -41,8 +44,9 @@ export const telegramOutbound: ChannelOutboundAdapter = {
chunker: markdownToTelegramHtmlChunks,
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId }) => {
const { send, baseOpts } = resolveTelegramSendContext({
cfg,
deps,
accountId,
replyToId,
@@ -54,6 +58,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
return { channel: "telegram", ...result };
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
@@ -64,6 +69,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
threadId,
}) => {
const { send, baseOpts } = resolveTelegramSendContext({
cfg,
deps,
accountId,
replyToId,
@@ -76,8 +82,18 @@ export const telegramOutbound: ChannelOutboundAdapter = {
});
return { channel: "telegram", ...result };
},
sendPayload: async ({ to, payload, mediaLocalRoots, accountId, deps, replyToId, threadId }) => {
sendPayload: async ({
cfg,
to,
payload,
mediaLocalRoots,
accountId,
deps,
replyToId,
threadId,
}) => {
const { send, baseOpts: contextOpts } = resolveTelegramSendContext({
cfg,
deps,
accountId,
replyToId,

View File

@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
const hoisted = vi.hoisted(() => ({
sendPollWhatsApp: vi.fn(async () => ({ messageId: "poll-1", toJid: "1555@s.whatsapp.net" })),
}));
vi.mock("../../../globals.js", () => ({
shouldLogVerbose: () => false,
}));
vi.mock("../../../web/outbound.js", () => ({
sendPollWhatsApp: hoisted.sendPollWhatsApp,
}));
import { whatsappOutbound } from "./whatsapp.js";
describe("whatsappOutbound sendPoll", () => {
it("threads cfg through poll send options", async () => {
const cfg = { marker: "resolved-cfg" } as OpenClawConfig;
const poll = {
question: "Lunch?",
options: ["Pizza", "Sushi"],
maxSelections: 1,
};
const result = await whatsappOutbound.sendPoll!({
cfg,
to: "+1555",
poll,
accountId: "work",
});
expect(hoisted.sendPollWhatsApp).toHaveBeenCalledWith("+1555", poll, {
verbose: false,
accountId: "work",
cfg,
});
expect(result).toEqual({ messageId: "poll-1", toJid: "1555@s.whatsapp.net" });
});
});

View File

@@ -15,21 +15,23 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
resolveWhatsAppOutboundTarget({ to, allowFrom, mode }),
sendPayload: async (ctx) =>
await sendTextMediaPayload({ channel: "whatsapp", ctx, adapter: whatsappOutbound }),
sendText: async ({ to, text, accountId, deps, gifPlayback }) => {
sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => {
const send =
deps?.sendWhatsApp ?? (await import("../../../web/outbound.js")).sendMessageWhatsApp;
const result = await send(to, text, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
gifPlayback,
});
return { channel: "whatsapp", ...result };
},
sendMedia: async ({ to, text, mediaUrl, mediaLocalRoots, accountId, deps, gifPlayback }) => {
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, gifPlayback }) => {
const send =
deps?.sendWhatsApp ?? (await import("../../../web/outbound.js")).sendMessageWhatsApp;
const result = await send(to, text, {
verbose: false,
cfg,
mediaUrl,
mediaLocalRoots,
accountId: accountId ?? undefined,
@@ -37,9 +39,10 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
});
return { channel: "whatsapp", ...result };
},
sendPoll: async ({ to, poll, accountId }) =>
sendPoll: async ({ cfg, to, poll, accountId }) =>
await sendPollWhatsApp(to, poll, {
verbose: shouldLogVerbose(),
accountId: accountId ?? undefined,
cfg,
}),
};