refactor: deduplicate reply payload helpers

This commit is contained in:
Peter Steinberger
2026-03-18 17:29:54 +00:00
parent 656679e6e0
commit 8d73bc77fa
67 changed files with 2246 additions and 1366 deletions

View File

@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from "vitest";
import {
sendPayloadMediaSequenceAndFinalize,
sendPayloadMediaSequenceOrFallback,
} from "./direct-text-media.js";
describe("sendPayloadMediaSequenceOrFallback", () => {
it("uses the no-media sender when no media entries exist", async () => {
const send = vi.fn();
const sendNoMedia = vi.fn(async () => ({ messageId: "text-1" }));
await expect(
sendPayloadMediaSequenceOrFallback({
text: "hello",
mediaUrls: [],
send,
sendNoMedia,
fallbackResult: { messageId: "" },
}),
).resolves.toEqual({ messageId: "text-1" });
expect(send).not.toHaveBeenCalled();
expect(sendNoMedia).toHaveBeenCalledOnce();
});
it("returns the last media send result and clears text after the first media", async () => {
const calls: Array<{ text: string; mediaUrl: string; isFirst: boolean }> = [];
await expect(
sendPayloadMediaSequenceOrFallback({
text: "caption",
mediaUrls: ["a", "b"],
send: async ({ text, mediaUrl, isFirst }) => {
calls.push({ text, mediaUrl, isFirst });
return { messageId: mediaUrl };
},
fallbackResult: { messageId: "" },
}),
).resolves.toEqual({ messageId: "b" });
expect(calls).toEqual([
{ text: "caption", mediaUrl: "a", isFirst: true },
{ text: "", mediaUrl: "b", isFirst: false },
]);
});
});
describe("sendPayloadMediaSequenceAndFinalize", () => {
it("skips media sends and finalizes directly when no media entries exist", async () => {
const send = vi.fn();
const finalize = vi.fn(async () => ({ messageId: "final-1" }));
await expect(
sendPayloadMediaSequenceAndFinalize({
text: "hello",
mediaUrls: [],
send,
finalize,
}),
).resolves.toEqual({ messageId: "final-1" });
expect(send).not.toHaveBeenCalled();
expect(finalize).toHaveBeenCalledOnce();
});
it("sends the media sequence before the finalizing send", async () => {
const send = vi.fn(async ({ mediaUrl }: { mediaUrl: string }) => ({ messageId: mediaUrl }));
const finalize = vi.fn(async () => ({ messageId: "final-2" }));
await expect(
sendPayloadMediaSequenceAndFinalize({
text: "",
mediaUrls: ["a", "b"],
send,
finalize,
}),
).resolves.toEqual({ messageId: "final-2" });
expect(send).toHaveBeenCalledTimes(2);
expect(finalize).toHaveBeenCalledOnce();
});
});

View File

@@ -58,6 +58,41 @@ export async function sendPayloadMediaSequence<TResult>(params: {
return lastResult;
}
export async function sendPayloadMediaSequenceOrFallback<TResult>(params: {
text: string;
mediaUrls: readonly string[];
send: (input: {
text: string;
mediaUrl: string;
index: number;
isFirst: boolean;
}) => Promise<TResult>;
fallbackResult: TResult;
sendNoMedia?: () => Promise<TResult>;
}): Promise<TResult> {
if (params.mediaUrls.length === 0) {
return params.sendNoMedia ? await params.sendNoMedia() : params.fallbackResult;
}
return (await sendPayloadMediaSequence(params)) ?? params.fallbackResult;
}
export async function sendPayloadMediaSequenceAndFinalize<TMediaResult, TResult>(params: {
text: string;
mediaUrls: readonly string[];
send: (input: {
text: string;
mediaUrl: string;
index: number;
isFirst: boolean;
}) => Promise<TMediaResult>;
finalize: () => Promise<TResult>;
}): Promise<TResult> {
if (params.mediaUrls.length > 0) {
await sendPayloadMediaSequence(params);
}
return await params.finalize();
}
export async function sendTextMediaPayload(params: {
channel: string;
ctx: SendPayloadContext;

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import {
createScopedAccountReplyToModeResolver,
createStaticReplyToModeResolver,
createTopLevelChannelReplyToModeResolver,
} from "./threading-helpers.js";
describe("createStaticReplyToModeResolver", () => {
it("always returns the configured mode", () => {
expect(createStaticReplyToModeResolver("off")({ cfg: {} as OpenClawConfig })).toBe("off");
expect(createStaticReplyToModeResolver("all")({ cfg: {} as OpenClawConfig })).toBe("all");
});
});
describe("createTopLevelChannelReplyToModeResolver", () => {
it("reads the top-level channel config", () => {
const resolver = createTopLevelChannelReplyToModeResolver("discord");
expect(
resolver({
cfg: { channels: { discord: { replyToMode: "first" } } } as OpenClawConfig,
}),
).toBe("first");
});
it("falls back to off", () => {
const resolver = createTopLevelChannelReplyToModeResolver("discord");
expect(resolver({ cfg: {} as OpenClawConfig })).toBe("off");
});
});
describe("createScopedAccountReplyToModeResolver", () => {
it("reads the scoped account reply mode", () => {
const resolver = createScopedAccountReplyToModeResolver({
resolveAccount: (cfg, accountId) =>
((
cfg.channels as {
matrix?: { accounts?: Record<string, { replyToMode?: "off" | "first" | "all" }> };
}
).matrix?.accounts?.[accountId?.toLowerCase() ?? "default"] ?? {}) as {
replyToMode?: "off" | "first" | "all";
},
resolveReplyToMode: (account) => account.replyToMode,
});
const cfg = {
channels: {
matrix: {
accounts: {
assistant: { replyToMode: "all" },
},
},
},
} as OpenClawConfig;
expect(resolver({ cfg, accountId: "assistant" })).toBe("all");
expect(resolver({ cfg, accountId: "default" })).toBe("off");
});
it("passes chatType through", () => {
const seen: Array<string | null | undefined> = [];
const resolver = createScopedAccountReplyToModeResolver({
resolveAccount: () => ({ replyToMode: "first" as const }),
resolveReplyToMode: (account, chatType) => {
seen.push(chatType);
return account.replyToMode;
},
});
expect(resolver({ cfg: {} as OpenClawConfig, chatType: "group" })).toBe("first");
expect(seen).toEqual(["group"]);
});
});

View File

@@ -0,0 +1,32 @@
import type { OpenClawConfig } from "../../config/config.js";
import type { ReplyToMode } from "../../config/types.base.js";
import type { ChannelThreadingAdapter } from "./types.core.js";
type ReplyToModeResolver = NonNullable<ChannelThreadingAdapter["resolveReplyToMode"]>;
export function createStaticReplyToModeResolver(mode: ReplyToMode): ReplyToModeResolver {
return () => mode;
}
export function createTopLevelChannelReplyToModeResolver(channelId: string): ReplyToModeResolver {
return ({ cfg }) => {
const channelConfig = (
cfg.channels as Record<string, { replyToMode?: ReplyToMode }> | undefined
)?.[channelId];
return channelConfig?.replyToMode ?? "off";
};
}
export function createScopedAccountReplyToModeResolver<TAccount>(params: {
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => TAccount;
resolveReplyToMode: (
account: TAccount,
chatType?: string | null,
) => ReplyToMode | null | undefined;
fallback?: ReplyToMode;
}): ReplyToModeResolver {
return ({ cfg, accountId, chatType }) =>
params.resolveReplyToMode(params.resolveAccount(cfg, accountId), chatType) ??
params.fallback ??
"off";
}

View File

@@ -1,4 +1,5 @@
import { resolveOutboundSendDep } from "../../infra/outbound/send-deps.js";
import { createAttachedChannelResultAdapter } from "../../plugin-sdk/channel-send-result.js";
import type { PluginRuntimeChannel } from "../../plugins/runtime/types-channel.js";
import { escapeRegExp } from "../../utils.js";
import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js";
@@ -62,48 +63,49 @@ export function createWhatsAppOutboundBase({
textChunkLimit: 4000,
pollMaxOptions: 12,
resolveTarget,
sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => {
const normalizedText = normalizeText(text);
if (skipEmptyText && !normalizedText) {
return { channel: "whatsapp", messageId: "" };
}
const send =
resolveOutboundSendDep<WhatsAppSendMessage>(deps, "whatsapp") ?? sendMessageWhatsApp;
const result = await send(to, normalizedText, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
gifPlayback,
});
return { channel: "whatsapp", ...result };
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
deps,
gifPlayback,
}) => {
const send =
resolveOutboundSendDep<WhatsAppSendMessage>(deps, "whatsapp") ?? sendMessageWhatsApp;
const result = await send(to, normalizeText(text), {
verbose: false,
...createAttachedChannelResultAdapter({
channel: "whatsapp",
sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => {
const normalizedText = normalizeText(text);
if (skipEmptyText && !normalizedText) {
return { messageId: "" };
}
const send =
resolveOutboundSendDep<WhatsAppSendMessage>(deps, "whatsapp") ?? sendMessageWhatsApp;
return await send(to, normalizedText, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
gifPlayback,
});
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId: accountId ?? undefined,
accountId,
deps,
gifPlayback,
});
return { channel: "whatsapp", ...result };
},
sendPoll: async ({ cfg, to, poll, accountId }) =>
await sendPollWhatsApp(to, poll, {
verbose: shouldLogVerbose(),
accountId: accountId ?? undefined,
cfg,
}),
}) => {
const send =
resolveOutboundSendDep<WhatsAppSendMessage>(deps, "whatsapp") ?? sendMessageWhatsApp;
return await send(to, normalizeText(text), {
verbose: false,
cfg,
mediaUrl,
mediaLocalRoots,
accountId: accountId ?? undefined,
gifPlayback,
});
},
sendPoll: async ({ cfg, to, poll, accountId }) =>
await sendPollWhatsApp(to, poll, {
verbose: shouldLogVerbose(),
accountId: accountId ?? undefined,
cfg,
}),
}),
};
}