mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-01 16:10:22 +00:00
refactor: deduplicate reply payload helpers
This commit is contained in:
82
src/channels/plugins/outbound/direct-text-media.test.ts
Normal file
82
src/channels/plugins/outbound/direct-text-media.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
73
src/channels/plugins/threading-helpers.test.ts
Normal file
73
src/channels/plugins/threading-helpers.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
32
src/channels/plugins/threading-helpers.ts
Normal file
32
src/channels/plugins/threading-helpers.ts
Normal 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";
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user