mirror of
https://github.com/yuangyaa/openclaw.git
synced 2026-07-14 11:07:29 +08:00
fix(outbound): unify resolved cfg threading across send paths (#33987)
This commit is contained in:
@@ -302,10 +302,11 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
textChunkLimit: 2000,
|
||||
pollMaxOptions: 10,
|
||||
resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to),
|
||||
sendText: async ({ to, text, accountId, deps, replyToId, silent }) => {
|
||||
sendText: async ({ cfg, to, text, accountId, deps, replyToId, silent }) => {
|
||||
const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
|
||||
const result = await send(to, text, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
replyTo: replyToId ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
silent: silent ?? undefined,
|
||||
@@ -313,6 +314,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
return { channel: "discord", ...result };
|
||||
},
|
||||
sendMedia: async ({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
mediaUrl,
|
||||
@@ -325,6 +327,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
|
||||
const result = await send(to, text, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
replyTo: replyToId ?? undefined,
|
||||
@@ -333,8 +336,9 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
});
|
||||
return { channel: "discord", ...result };
|
||||
},
|
||||
sendPoll: async ({ to, poll, accountId, silent }) =>
|
||||
sendPoll: async ({ cfg, to, poll, accountId, silent }) =>
|
||||
await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, {
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
silent: silent ?? undefined,
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { installCommonResolveTargetErrorCases } from "../../shared/resolve-target-test-helpers.js";
|
||||
|
||||
const runtimeMocks = vi.hoisted(() => ({
|
||||
chunkMarkdownText: vi.fn((text: string) => [text]),
|
||||
fetchRemoteMedia: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk", () => ({
|
||||
getChatChannelMeta: () => ({ id: "googlechat", label: "Google Chat" }),
|
||||
missingTargetError: (provider: string, hint: string) =>
|
||||
@@ -47,7 +52,8 @@ vi.mock("./onboarding.js", () => ({
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getGoogleChatRuntime: vi.fn(() => ({
|
||||
channel: {
|
||||
text: { chunkMarkdownText: vi.fn() },
|
||||
text: { chunkMarkdownText: runtimeMocks.chunkMarkdownText },
|
||||
media: { fetchRemoteMedia: runtimeMocks.fetchRemoteMedia },
|
||||
},
|
||||
})),
|
||||
}));
|
||||
@@ -66,7 +72,11 @@ vi.mock("./targets.js", () => ({
|
||||
resolveGoogleChatOutboundSpace: vi.fn(),
|
||||
}));
|
||||
|
||||
import { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk";
|
||||
import { resolveGoogleChatAccount } from "./accounts.js";
|
||||
import { sendGoogleChatMessage, uploadGoogleChatAttachment } from "./api.js";
|
||||
import { googlechatPlugin } from "./channel.js";
|
||||
import { resolveGoogleChatOutboundSpace } from "./targets.js";
|
||||
|
||||
const resolveTarget = googlechatPlugin.outbound!.resolveTarget!;
|
||||
|
||||
@@ -104,3 +114,118 @@ describe("googlechat resolveTarget", () => {
|
||||
implicitAllowFrom: ["spaces/BBB"],
|
||||
});
|
||||
});
|
||||
|
||||
describe("googlechat outbound cfg threading", () => {
|
||||
beforeEach(() => {
|
||||
runtimeMocks.fetchRemoteMedia.mockReset();
|
||||
runtimeMocks.chunkMarkdownText.mockClear();
|
||||
vi.mocked(resolveGoogleChatAccount).mockReset();
|
||||
vi.mocked(resolveGoogleChatOutboundSpace).mockReset();
|
||||
vi.mocked(resolveChannelMediaMaxBytes).mockReset();
|
||||
vi.mocked(uploadGoogleChatAttachment).mockReset();
|
||||
vi.mocked(sendGoogleChatMessage).mockReset();
|
||||
});
|
||||
|
||||
it("threads resolved cfg into sendText account resolution", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const account = {
|
||||
accountId: "default",
|
||||
config: {},
|
||||
credentialSource: "inline",
|
||||
};
|
||||
vi.mocked(resolveGoogleChatAccount).mockReturnValue(account as any);
|
||||
vi.mocked(resolveGoogleChatOutboundSpace).mockResolvedValue("spaces/AAA");
|
||||
vi.mocked(sendGoogleChatMessage).mockResolvedValue({
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
} as any);
|
||||
|
||||
await googlechatPlugin.outbound!.sendText!({
|
||||
cfg: cfg as any,
|
||||
to: "users/123",
|
||||
text: "hello",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolveGoogleChatAccount).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
});
|
||||
expect(sendGoogleChatMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "hello",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("threads resolved cfg into sendMedia account and media loading path", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
},
|
||||
mediaMaxMb: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
const account = {
|
||||
accountId: "default",
|
||||
config: { mediaMaxMb: 20 },
|
||||
credentialSource: "inline",
|
||||
};
|
||||
vi.mocked(resolveGoogleChatAccount).mockReturnValue(account as any);
|
||||
vi.mocked(resolveGoogleChatOutboundSpace).mockResolvedValue("spaces/AAA");
|
||||
vi.mocked(resolveChannelMediaMaxBytes).mockReturnValue(1024);
|
||||
runtimeMocks.fetchRemoteMedia.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("file"),
|
||||
fileName: "file.png",
|
||||
contentType: "image/png",
|
||||
});
|
||||
vi.mocked(uploadGoogleChatAttachment).mockResolvedValue({
|
||||
attachmentUploadToken: "token-1",
|
||||
} as any);
|
||||
vi.mocked(sendGoogleChatMessage).mockResolvedValue({
|
||||
messageName: "spaces/AAA/messages/msg-2",
|
||||
} as any);
|
||||
|
||||
await googlechatPlugin.outbound!.sendMedia!({
|
||||
cfg: cfg as any,
|
||||
to: "users/123",
|
||||
text: "photo",
|
||||
mediaUrl: "https://example.com/file.png",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolveGoogleChatAccount).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
});
|
||||
expect(runtimeMocks.fetchRemoteMedia).toHaveBeenCalledWith({
|
||||
url: "https://example.com/file.png",
|
||||
maxBytes: 1024,
|
||||
});
|
||||
expect(uploadGoogleChatAttachment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
filename: "file.png",
|
||||
}),
|
||||
);
|
||||
expect(sendGoogleChatMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
account,
|
||||
attachments: [{ attachmentUploadToken: "token-1", contentName: "file.png" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,6 +69,7 @@ async function sendIMessageOutbound(params: {
|
||||
accountId: params.accountId,
|
||||
});
|
||||
return await send(params.to, params.text, {
|
||||
config: params.cfg,
|
||||
...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}),
|
||||
...(params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}),
|
||||
maxBytes,
|
||||
|
||||
@@ -296,16 +296,18 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
|
||||
chunker: (text, limit) => getIrcRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 350,
|
||||
sendText: async ({ to, text, accountId, replyToId }) => {
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
|
||||
const result = await sendMessageIrc(to, text, {
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId: accountId ?? undefined,
|
||||
replyTo: replyToId ?? undefined,
|
||||
});
|
||||
return { channel: "irc", ...result };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => {
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => {
|
||||
const combined = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text;
|
||||
const result = await sendMessageIrc(to, combined, {
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId: accountId ?? undefined,
|
||||
replyTo: replyToId ?? undefined,
|
||||
});
|
||||
|
||||
116
extensions/irc/src/send.test.ts
Normal file
116
extensions/irc/src/send.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { IrcClient } from "./client.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const loadConfig = vi.fn();
|
||||
const resolveMarkdownTableMode = vi.fn(() => "preserve");
|
||||
const convertMarkdownTables = vi.fn((text: string) => text);
|
||||
const record = vi.fn();
|
||||
return {
|
||||
loadConfig,
|
||||
resolveMarkdownTableMode,
|
||||
convertMarkdownTables,
|
||||
record,
|
||||
resolveIrcAccount: vi.fn(() => ({
|
||||
configured: true,
|
||||
accountId: "default",
|
||||
host: "irc.example.com",
|
||||
nick: "openclaw",
|
||||
port: 6697,
|
||||
tls: true,
|
||||
})),
|
||||
normalizeIrcMessagingTarget: vi.fn((value: string) => value.trim()),
|
||||
connectIrcClient: vi.fn(),
|
||||
buildIrcConnectOptions: vi.fn(() => ({})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getIrcRuntime: () => ({
|
||||
config: {
|
||||
loadConfig: hoisted.loadConfig,
|
||||
},
|
||||
channel: {
|
||||
text: {
|
||||
resolveMarkdownTableMode: hoisted.resolveMarkdownTableMode,
|
||||
convertMarkdownTables: hoisted.convertMarkdownTables,
|
||||
},
|
||||
activity: {
|
||||
record: hoisted.record,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveIrcAccount: hoisted.resolveIrcAccount,
|
||||
}));
|
||||
|
||||
vi.mock("./normalize.js", () => ({
|
||||
normalizeIrcMessagingTarget: hoisted.normalizeIrcMessagingTarget,
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
connectIrcClient: hoisted.connectIrcClient,
|
||||
}));
|
||||
|
||||
vi.mock("./connect-options.js", () => ({
|
||||
buildIrcConnectOptions: hoisted.buildIrcConnectOptions,
|
||||
}));
|
||||
|
||||
vi.mock("./protocol.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./protocol.js")>("./protocol.js");
|
||||
return {
|
||||
...actual,
|
||||
makeIrcMessageId: () => "irc-msg-1",
|
||||
};
|
||||
});
|
||||
|
||||
import { sendMessageIrc } from "./send.js";
|
||||
|
||||
describe("sendMessageIrc cfg threading", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses explicitly provided cfg without loading runtime config", async () => {
|
||||
const providedCfg = { source: "provided" } as unknown as CoreConfig;
|
||||
const client = {
|
||||
isReady: vi.fn(() => true),
|
||||
sendPrivmsg: vi.fn(),
|
||||
} as unknown as IrcClient;
|
||||
|
||||
const result = await sendMessageIrc("#room", "hello", {
|
||||
cfg: providedCfg,
|
||||
client,
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(hoisted.loadConfig).not.toHaveBeenCalled();
|
||||
expect(hoisted.resolveIrcAccount).toHaveBeenCalledWith({
|
||||
cfg: providedCfg,
|
||||
accountId: "work",
|
||||
});
|
||||
expect(client.sendPrivmsg).toHaveBeenCalledWith("#room", "hello");
|
||||
expect(result).toEqual({ messageId: "irc-msg-1", target: "#room" });
|
||||
});
|
||||
|
||||
it("falls back to runtime config when cfg is omitted", async () => {
|
||||
const runtimeCfg = { source: "runtime" } as unknown as CoreConfig;
|
||||
hoisted.loadConfig.mockReturnValueOnce(runtimeCfg);
|
||||
const client = {
|
||||
isReady: vi.fn(() => true),
|
||||
sendPrivmsg: vi.fn(),
|
||||
} as unknown as IrcClient;
|
||||
|
||||
await sendMessageIrc("#ops", "ping", { client });
|
||||
|
||||
expect(hoisted.loadConfig).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.resolveIrcAccount).toHaveBeenCalledWith({
|
||||
cfg: runtimeCfg,
|
||||
accountId: undefined,
|
||||
});
|
||||
expect(client.sendPrivmsg).toHaveBeenCalledWith("#ops", "ping");
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { getIrcRuntime } from "./runtime.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
type SendIrcOptions = {
|
||||
cfg?: CoreConfig;
|
||||
accountId?: string;
|
||||
replyTo?: string;
|
||||
target?: string;
|
||||
@@ -37,7 +38,7 @@ export async function sendMessageIrc(
|
||||
opts: SendIrcOptions = {},
|
||||
): Promise<SendIrcResult> {
|
||||
const runtime = getIrcRuntime();
|
||||
const cfg = runtime.config.loadConfig() as CoreConfig;
|
||||
const cfg = (opts.cfg ?? runtime.config.loadConfig()) as CoreConfig;
|
||||
const account = resolveIrcAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
|
||||
@@ -117,6 +117,7 @@ describe("linePlugin outbound.sendPayload", () => {
|
||||
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:group:1", "Now playing:", {
|
||||
verbose: false,
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,6 +155,7 @@ describe("linePlugin outbound.sendPayload", () => {
|
||||
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:user:1", "Choose one:", {
|
||||
verbose: false,
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,7 +195,7 @@ describe("linePlugin outbound.sendPayload", () => {
|
||||
quickReply: { items: ["One", "Two"] },
|
||||
},
|
||||
],
|
||||
{ verbose: false, accountId: "default" },
|
||||
{ verbose: false, accountId: "default", cfg },
|
||||
);
|
||||
expect(mocks.createQuickReplyItems).toHaveBeenCalledWith(["One", "Two"]);
|
||||
});
|
||||
@@ -225,12 +227,13 @@ describe("linePlugin outbound.sendPayload", () => {
|
||||
verbose: false,
|
||||
mediaUrl: "https://example.com/img.jpg",
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
expect(mocks.pushTextMessageWithQuickReplies).toHaveBeenCalledWith(
|
||||
"line:user:3",
|
||||
"Hello",
|
||||
["One", "Two"],
|
||||
{ verbose: false, accountId: "default" },
|
||||
{ verbose: false, accountId: "default", cfg },
|
||||
);
|
||||
const mediaOrder = mocks.sendMessageLine.mock.invocationCallOrder[0];
|
||||
const quickReplyOrder = mocks.pushTextMessageWithQuickReplies.mock.invocationCallOrder[0];
|
||||
|
||||
@@ -372,6 +372,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
const batch = messages.slice(i, i + 5) as unknown as Parameters<typeof sendBatch>[1];
|
||||
const result = await sendBatch(to, batch, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
lastResult = { messageId: result.messageId, chatId: result.chatId };
|
||||
@@ -399,6 +400,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
const flexContents = lineData.flexMessage.contents as Parameters<typeof sendFlex>[2];
|
||||
lastResult = await sendFlex(to, lineData.flexMessage.altText, flexContents, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
@@ -408,6 +410,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
if (template) {
|
||||
lastResult = await sendTemplate(to, template, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
@@ -416,6 +419,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
if (lineData.location) {
|
||||
lastResult = await sendLocation(to, lineData.location, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
@@ -425,6 +429,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
const flexContents = flexMsg.contents as Parameters<typeof sendFlex>[2];
|
||||
lastResult = await sendFlex(to, flexMsg.altText, flexContents, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
@@ -436,6 +441,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
|
||||
verbose: false,
|
||||
mediaUrl: url,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
@@ -447,11 +453,13 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
if (isLast && hasQuickReplies) {
|
||||
lastResult = await sendQuickReplies(to, chunks[i], quickReplies, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
} else {
|
||||
lastResult = await sendText(to, chunks[i], {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
@@ -513,6 +521,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
|
||||
verbose: false,
|
||||
mediaUrl: url,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
@@ -523,7 +532,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
}
|
||||
return { channel: "line", messageId: "empty", chatId: to };
|
||||
},
|
||||
sendText: async ({ to, text, accountId }) => {
|
||||
sendText: async ({ cfg, to, text, accountId }) => {
|
||||
const runtime = getLineRuntime();
|
||||
const sendText = runtime.channel.line.pushMessageLine;
|
||||
const sendFlex = runtime.channel.line.pushFlexMessage;
|
||||
@@ -536,6 +545,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
if (processed.text.trim()) {
|
||||
result = await sendText(to, processed.text, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
} else {
|
||||
@@ -549,17 +559,19 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
const flexContents = flexMsg.contents as Parameters<typeof sendFlex>[2];
|
||||
await sendFlex(to, flexMsg.altText, flexContents, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return { channel: "line", ...result };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId }) => {
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => {
|
||||
const send = getLineRuntime().channel.line.sendMessageLine;
|
||||
const result = await send(to, text, {
|
||||
verbose: false,
|
||||
mediaUrl,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
return { channel: "line", ...result };
|
||||
|
||||
@@ -34,6 +34,7 @@ const loadWebMediaMock = vi.fn().mockResolvedValue({
|
||||
contentType: "image/png",
|
||||
kind: "image",
|
||||
});
|
||||
const runtimeLoadConfigMock = vi.fn(() => ({}));
|
||||
const mediaKindFromMimeMock = vi.fn(() => "image");
|
||||
const isVoiceCompatibleAudioMock = vi.fn(() => false);
|
||||
const getImageMetadataMock = vi.fn().mockResolvedValue(null);
|
||||
@@ -41,7 +42,7 @@ const resizeToJpegMock = vi.fn();
|
||||
|
||||
const runtimeStub = {
|
||||
config: {
|
||||
loadConfig: () => ({}),
|
||||
loadConfig: runtimeLoadConfigMock,
|
||||
},
|
||||
media: {
|
||||
loadWebMedia: loadWebMediaMock as unknown as PluginRuntime["media"]["loadWebMedia"],
|
||||
@@ -65,6 +66,7 @@ const runtimeStub = {
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
|
||||
let resolveMediaMaxBytes: typeof import("./send/client.js").resolveMediaMaxBytes;
|
||||
|
||||
const makeClient = () => {
|
||||
const sendMessage = vi.fn().mockResolvedValue("evt1");
|
||||
@@ -80,11 +82,14 @@ const makeClient = () => {
|
||||
beforeAll(async () => {
|
||||
setMatrixRuntime(runtimeStub);
|
||||
({ sendMessageMatrix } = await import("./send.js"));
|
||||
({ resolveMediaMaxBytes } = await import("./send/client.js"));
|
||||
});
|
||||
|
||||
describe("sendMessageMatrix media", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
runtimeLoadConfigMock.mockReset();
|
||||
runtimeLoadConfigMock.mockReturnValue({});
|
||||
mediaKindFromMimeMock.mockReturnValue("image");
|
||||
isVoiceCompatibleAudioMock.mockReturnValue(false);
|
||||
setMatrixRuntime(runtimeStub);
|
||||
@@ -214,6 +219,8 @@ describe("sendMessageMatrix media", () => {
|
||||
describe("sendMessageMatrix threads", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
runtimeLoadConfigMock.mockReset();
|
||||
runtimeLoadConfigMock.mockReturnValue({});
|
||||
setMatrixRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
@@ -240,3 +247,80 @@ describe("sendMessageMatrix threads", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessageMatrix cfg threading", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
runtimeLoadConfigMock.mockReset();
|
||||
runtimeLoadConfigMock.mockReturnValue({
|
||||
channels: {
|
||||
matrix: {
|
||||
mediaMaxMb: 7,
|
||||
},
|
||||
},
|
||||
});
|
||||
setMatrixRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
it("does not call runtime loadConfig when cfg is provided", async () => {
|
||||
const { client } = makeClient();
|
||||
const providedCfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
mediaMaxMb: 4,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "hello cfg", {
|
||||
client,
|
||||
cfg: providedCfg as any,
|
||||
});
|
||||
|
||||
expect(runtimeLoadConfigMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to runtime loadConfig when cfg is omitted", async () => {
|
||||
const { client } = makeClient();
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "hello runtime", { client });
|
||||
|
||||
expect(runtimeLoadConfigMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMediaMaxBytes cfg threading", () => {
|
||||
beforeEach(() => {
|
||||
runtimeLoadConfigMock.mockReset();
|
||||
runtimeLoadConfigMock.mockReturnValue({
|
||||
channels: {
|
||||
matrix: {
|
||||
mediaMaxMb: 9,
|
||||
},
|
||||
},
|
||||
});
|
||||
setMatrixRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
it("uses provided cfg and skips runtime loadConfig", () => {
|
||||
const providedCfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
mediaMaxMb: 3,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const maxBytes = resolveMediaMaxBytes(undefined, providedCfg as any);
|
||||
|
||||
expect(maxBytes).toBe(3 * 1024 * 1024);
|
||||
expect(runtimeLoadConfigMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to runtime loadConfig when cfg is omitted", () => {
|
||||
const maxBytes = resolveMediaMaxBytes();
|
||||
|
||||
expect(maxBytes).toBe(9 * 1024 * 1024);
|
||||
expect(runtimeLoadConfigMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,11 +47,12 @@ export async function sendMessageMatrix(
|
||||
client: opts.client,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId: opts.accountId,
|
||||
cfg: opts.cfg,
|
||||
});
|
||||
const cfg = opts.cfg ?? getCore().config.loadConfig();
|
||||
try {
|
||||
const roomId = await resolveMatrixRoomId(client, to);
|
||||
return await enqueueSend(roomId, async () => {
|
||||
const cfg = getCore().config.loadConfig();
|
||||
const tableMode = getCore().channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "matrix",
|
||||
@@ -81,7 +82,7 @@ export async function sendMessageMatrix(
|
||||
|
||||
let lastMessageId = "";
|
||||
if (opts.mediaUrl) {
|
||||
const maxBytes = resolveMediaMaxBytes(opts.accountId);
|
||||
const maxBytes = resolveMediaMaxBytes(opts.accountId, cfg);
|
||||
const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes);
|
||||
const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, {
|
||||
contentType: media.contentType,
|
||||
@@ -171,6 +172,7 @@ export async function sendPollMatrix(
|
||||
client: opts.client,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId: opts.accountId,
|
||||
cfg: opts.cfg,
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -32,19 +32,19 @@ function findAccountConfig(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveMediaMaxBytes(accountId?: string): number | undefined {
|
||||
const cfg = getCore().config.loadConfig() as CoreConfig;
|
||||
export function resolveMediaMaxBytes(accountId?: string, cfg?: CoreConfig): number | undefined {
|
||||
const resolvedCfg = cfg ?? (getCore().config.loadConfig() as CoreConfig);
|
||||
// Check account-specific config first (case-insensitive key matching)
|
||||
const accountConfig = findAccountConfig(
|
||||
cfg.channels?.matrix?.accounts as Record<string, unknown> | undefined,
|
||||
resolvedCfg.channels?.matrix?.accounts as Record<string, unknown> | undefined,
|
||||
accountId ?? "",
|
||||
);
|
||||
if (typeof accountConfig?.mediaMaxMb === "number") {
|
||||
return (accountConfig.mediaMaxMb as number) * 1024 * 1024;
|
||||
}
|
||||
// Fall back to top-level config
|
||||
if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") {
|
||||
return cfg.channels.matrix.mediaMaxMb * 1024 * 1024;
|
||||
if (typeof resolvedCfg.channels?.matrix?.mediaMaxMb === "number") {
|
||||
return resolvedCfg.channels.matrix.mediaMaxMb * 1024 * 1024;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -53,6 +53,7 @@ export async function resolveMatrixClient(opts: {
|
||||
client?: MatrixClient;
|
||||
timeoutMs?: number;
|
||||
accountId?: string;
|
||||
cfg?: CoreConfig;
|
||||
}): Promise<{ client: MatrixClient; stopOnDone: boolean }> {
|
||||
ensureNodeRuntime();
|
||||
if (opts.client) {
|
||||
@@ -84,10 +85,11 @@ export async function resolveMatrixClient(opts: {
|
||||
const client = await resolveSharedMatrixClient({
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId,
|
||||
cfg: opts.cfg,
|
||||
});
|
||||
return { client, stopOnDone: false };
|
||||
}
|
||||
const auth = await resolveMatrixAuth({ accountId });
|
||||
const auth = await resolveMatrixAuth({ accountId, cfg: opts.cfg });
|
||||
const client = await createPreparedMatrixClient({
|
||||
auth,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
|
||||
@@ -85,6 +85,7 @@ export type MatrixSendResult = {
|
||||
};
|
||||
|
||||
export type MatrixSendOpts = {
|
||||
cfg?: import("../../types.js").CoreConfig;
|
||||
client?: import("@vector-im/matrix-bot-sdk").MatrixClient;
|
||||
mediaUrl?: string;
|
||||
accountId?: string;
|
||||
|
||||
159
extensions/matrix/src/outbound.test.ts
Normal file
159
extensions/matrix/src/outbound.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
sendMessageMatrix: vi.fn(),
|
||||
sendPollMatrix: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./matrix/send.js", () => ({
|
||||
sendMessageMatrix: mocks.sendMessageMatrix,
|
||||
sendPollMatrix: mocks.sendPollMatrix,
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getMatrixRuntime: () => ({
|
||||
channel: {
|
||||
text: {
|
||||
chunkMarkdownText: (text: string) => [text],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { matrixOutbound } from "./outbound.js";
|
||||
|
||||
describe("matrixOutbound cfg threading", () => {
|
||||
beforeEach(() => {
|
||||
mocks.sendMessageMatrix.mockReset();
|
||||
mocks.sendPollMatrix.mockReset();
|
||||
mocks.sendMessageMatrix.mockResolvedValue({ messageId: "evt-1", roomId: "!room:example" });
|
||||
mocks.sendPollMatrix.mockResolvedValue({ eventId: "$poll", roomId: "!room:example" });
|
||||
});
|
||||
|
||||
it("passes resolved cfg to sendMessageMatrix for text sends", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accessToken: "resolved-token",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
await matrixOutbound.sendText!({
|
||||
cfg,
|
||||
to: "room:!room:example",
|
||||
text: "hello",
|
||||
accountId: "default",
|
||||
threadId: "$thread",
|
||||
replyToId: "$reply",
|
||||
});
|
||||
|
||||
expect(mocks.sendMessageMatrix).toHaveBeenCalledWith(
|
||||
"room:!room:example",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
threadId: "$thread",
|
||||
replyToId: "$reply",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes resolved cfg to sendMessageMatrix for media sends", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accessToken: "resolved-token",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
await matrixOutbound.sendMedia!({
|
||||
cfg,
|
||||
to: "room:!room:example",
|
||||
text: "caption",
|
||||
mediaUrl: "file:///tmp/cat.png",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(mocks.sendMessageMatrix).toHaveBeenCalledWith(
|
||||
"room:!room:example",
|
||||
"caption",
|
||||
expect.objectContaining({
|
||||
cfg,
|
||||
mediaUrl: "file:///tmp/cat.png",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes resolved cfg through injected deps.sendMatrix", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accessToken: "resolved-token",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const sendMatrix = vi.fn(async () => ({
|
||||
messageId: "evt-injected",
|
||||
roomId: "!room:example",
|
||||
}));
|
||||
|
||||
await matrixOutbound.sendText!({
|
||||
cfg,
|
||||
to: "room:!room:example",
|
||||
text: "hello via deps",
|
||||
deps: { sendMatrix },
|
||||
accountId: "default",
|
||||
threadId: "$thread",
|
||||
replyToId: "$reply",
|
||||
});
|
||||
|
||||
expect(sendMatrix).toHaveBeenCalledWith(
|
||||
"room:!room:example",
|
||||
"hello via deps",
|
||||
expect.objectContaining({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
threadId: "$thread",
|
||||
replyToId: "$reply",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes resolved cfg to sendPollMatrix", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accessToken: "resolved-token",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
await matrixOutbound.sendPoll!({
|
||||
cfg,
|
||||
to: "room:!room:example",
|
||||
poll: {
|
||||
question: "Snack?",
|
||||
options: ["Pizza", "Sushi"],
|
||||
},
|
||||
accountId: "default",
|
||||
threadId: "$thread",
|
||||
});
|
||||
|
||||
expect(mocks.sendPollMatrix).toHaveBeenCalledWith(
|
||||
"room:!room:example",
|
||||
expect.objectContaining({
|
||||
question: "Snack?",
|
||||
options: ["Pizza", "Sushi"],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
threadId: "$thread",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,11 +7,12 @@ export const matrixOutbound: ChannelOutboundAdapter = {
|
||||
chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ to, text, deps, replyToId, threadId, accountId }) => {
|
||||
sendText: async ({ cfg, to, text, deps, replyToId, threadId, accountId }) => {
|
||||
const send = deps?.sendMatrix ?? sendMessageMatrix;
|
||||
const resolvedThreadId =
|
||||
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
|
||||
const result = await send(to, text, {
|
||||
cfg,
|
||||
replyToId: replyToId ?? undefined,
|
||||
threadId: resolvedThreadId,
|
||||
accountId: accountId ?? undefined,
|
||||
@@ -22,11 +23,12 @@ export const matrixOutbound: ChannelOutboundAdapter = {
|
||||
roomId: result.roomId,
|
||||
};
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, deps, replyToId, threadId, accountId }) => {
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, deps, replyToId, threadId, accountId }) => {
|
||||
const send = deps?.sendMatrix ?? sendMessageMatrix;
|
||||
const resolvedThreadId =
|
||||
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
|
||||
const result = await send(to, text, {
|
||||
cfg,
|
||||
mediaUrl,
|
||||
replyToId: replyToId ?? undefined,
|
||||
threadId: resolvedThreadId,
|
||||
@@ -38,10 +40,11 @@ export const matrixOutbound: ChannelOutboundAdapter = {
|
||||
roomId: result.roomId,
|
||||
};
|
||||
},
|
||||
sendPoll: async ({ to, poll, threadId, accountId }) => {
|
||||
sendPoll: async ({ cfg, to, poll, threadId, accountId }) => {
|
||||
const resolvedThreadId =
|
||||
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
|
||||
const result = await sendPollMatrix(to, poll, {
|
||||
cfg,
|
||||
threadId: resolvedThreadId,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
|
||||
@@ -240,6 +240,37 @@ describe("mattermostPlugin", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("threads resolved cfg on sendText", async () => {
|
||||
const sendText = mattermostPlugin.outbound?.sendText;
|
||||
if (!sendText) {
|
||||
return;
|
||||
}
|
||||
const cfg = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
botToken: "resolved-bot-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
await sendText({
|
||||
cfg,
|
||||
to: "channel:CHAN1",
|
||||
text: "hello",
|
||||
accountId: "default",
|
||||
} as any);
|
||||
|
||||
expect(sendMessageMattermostMock).toHaveBeenCalledWith(
|
||||
"channel:CHAN1",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("config", () => {
|
||||
|
||||
@@ -273,15 +273,17 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
||||
}
|
||||
return { ok: true, to: trimmed };
|
||||
},
|
||||
sendText: async ({ to, text, accountId, replyToId }) => {
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
|
||||
const result = await sendMessageMattermost(to, text, {
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToId: replyToId ?? undefined,
|
||||
});
|
||||
return { channel: "mattermost", ...result };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, mediaLocalRoots, accountId, replyToId }) => {
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, replyToId }) => {
|
||||
const result = await sendMessageMattermost(to, text, {
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
|
||||
@@ -2,7 +2,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { sendMessageMattermost } from "./send.js";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
loadOutboundMediaFromUrl: vi.fn(),
|
||||
resolveMattermostAccount: vi.fn(() => ({
|
||||
accountId: "default",
|
||||
botToken: "bot-token",
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
})),
|
||||
createMattermostClient: vi.fn(),
|
||||
createMattermostDirectChannel: vi.fn(),
|
||||
createMattermostPost: vi.fn(),
|
||||
@@ -17,11 +23,7 @@ vi.mock("openclaw/plugin-sdk", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveMattermostAccount: () => ({
|
||||
accountId: "default",
|
||||
botToken: "bot-token",
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
}),
|
||||
resolveMattermostAccount: mockState.resolveMattermostAccount,
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
@@ -37,7 +39,7 @@ vi.mock("./client.js", () => ({
|
||||
vi.mock("../runtime.js", () => ({
|
||||
getMattermostRuntime: () => ({
|
||||
config: {
|
||||
loadConfig: () => ({}),
|
||||
loadConfig: mockState.loadConfig,
|
||||
},
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
@@ -57,6 +59,14 @@ vi.mock("../runtime.js", () => ({
|
||||
|
||||
describe("sendMessageMattermost", () => {
|
||||
beforeEach(() => {
|
||||
mockState.loadConfig.mockReset();
|
||||
mockState.loadConfig.mockReturnValue({});
|
||||
mockState.resolveMattermostAccount.mockReset();
|
||||
mockState.resolveMattermostAccount.mockReturnValue({
|
||||
accountId: "default",
|
||||
botToken: "bot-token",
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
});
|
||||
mockState.loadOutboundMediaFromUrl.mockReset();
|
||||
mockState.createMattermostClient.mockReset();
|
||||
mockState.createMattermostDirectChannel.mockReset();
|
||||
@@ -69,6 +79,46 @@ describe("sendMessageMattermost", () => {
|
||||
mockState.uploadMattermostFile.mockResolvedValue({ id: "file-1" });
|
||||
});
|
||||
|
||||
it("uses provided cfg and skips runtime loadConfig", async () => {
|
||||
const providedCfg = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
botToken: "provided-token",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await sendMessageMattermost("channel:town-square", "hello", {
|
||||
cfg: providedCfg as any,
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(mockState.loadConfig).not.toHaveBeenCalled();
|
||||
expect(mockState.resolveMattermostAccount).toHaveBeenCalledWith({
|
||||
cfg: providedCfg,
|
||||
accountId: "work",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to runtime loadConfig when cfg is omitted", async () => {
|
||||
const runtimeCfg = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
botToken: "runtime-token",
|
||||
},
|
||||
},
|
||||
};
|
||||
mockState.loadConfig.mockReturnValueOnce(runtimeCfg);
|
||||
|
||||
await sendMessageMattermost("channel:town-square", "hello");
|
||||
|
||||
expect(mockState.loadConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.resolveMattermostAccount).toHaveBeenCalledWith({
|
||||
cfg: runtimeCfg,
|
||||
accountId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("loads outbound media with trusted local roots before upload", async () => {
|
||||
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("media-bytes"),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk";
|
||||
import { loadOutboundMediaFromUrl, type OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { getMattermostRuntime } from "../runtime.js";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "./client.js";
|
||||
|
||||
export type MattermostSendOpts = {
|
||||
cfg?: OpenClawConfig;
|
||||
botToken?: string;
|
||||
baseUrl?: string;
|
||||
accountId?: string;
|
||||
@@ -146,7 +147,7 @@ export async function sendMessageMattermost(
|
||||
): Promise<MattermostSendResult> {
|
||||
const core = getCore();
|
||||
const logger = core.logging.getChildLogger({ module: "mattermost" });
|
||||
const cfg = core.config.loadConfig();
|
||||
const cfg = opts.cfg ?? core.config.loadConfig();
|
||||
const account = resolveMattermostAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
|
||||
131
extensions/msteams/src/outbound.test.ts
Normal file
131
extensions/msteams/src/outbound.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
sendMessageMSTeams: vi.fn(),
|
||||
sendPollMSTeams: vi.fn(),
|
||||
createPoll: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageMSTeams: mocks.sendMessageMSTeams,
|
||||
sendPollMSTeams: mocks.sendPollMSTeams,
|
||||
}));
|
||||
|
||||
vi.mock("./polls.js", () => ({
|
||||
createMSTeamsPollStoreFs: () => ({
|
||||
createPoll: mocks.createPoll,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getMSTeamsRuntime: () => ({
|
||||
channel: {
|
||||
text: {
|
||||
chunkMarkdownText: (text: string) => [text],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { msteamsOutbound } from "./outbound.js";
|
||||
|
||||
describe("msteamsOutbound cfg threading", () => {
|
||||
beforeEach(() => {
|
||||
mocks.sendMessageMSTeams.mockReset();
|
||||
mocks.sendPollMSTeams.mockReset();
|
||||
mocks.createPoll.mockReset();
|
||||
mocks.sendMessageMSTeams.mockResolvedValue({
|
||||
messageId: "msg-1",
|
||||
conversationId: "conv-1",
|
||||
});
|
||||
mocks.sendPollMSTeams.mockResolvedValue({
|
||||
pollId: "poll-1",
|
||||
messageId: "msg-poll-1",
|
||||
conversationId: "conv-1",
|
||||
});
|
||||
mocks.createPoll.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("passes resolved cfg to sendMessageMSTeams for text sends", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
msteams: {
|
||||
appId: "resolved-app-id",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
await msteamsOutbound.sendText!({
|
||||
cfg,
|
||||
to: "conversation:abc",
|
||||
text: "hello",
|
||||
});
|
||||
|
||||
expect(mocks.sendMessageMSTeams).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
to: "conversation:abc",
|
||||
text: "hello",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes resolved cfg and media roots for media sends", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
msteams: {
|
||||
appId: "resolved-app-id",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
await msteamsOutbound.sendMedia!({
|
||||
cfg,
|
||||
to: "conversation:abc",
|
||||
text: "photo",
|
||||
mediaUrl: "file:///tmp/photo.png",
|
||||
mediaLocalRoots: ["/tmp"],
|
||||
});
|
||||
|
||||
expect(mocks.sendMessageMSTeams).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
to: "conversation:abc",
|
||||
text: "photo",
|
||||
mediaUrl: "file:///tmp/photo.png",
|
||||
mediaLocalRoots: ["/tmp"],
|
||||
});
|
||||
});
|
||||
|
||||
it("passes resolved cfg to sendPollMSTeams and stores poll metadata", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
msteams: {
|
||||
appId: "resolved-app-id",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
await msteamsOutbound.sendPoll!({
|
||||
cfg,
|
||||
to: "conversation:abc",
|
||||
poll: {
|
||||
question: "Snack?",
|
||||
options: ["Pizza", "Sushi"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(mocks.sendPollMSTeams).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
to: "conversation:abc",
|
||||
question: "Snack?",
|
||||
options: ["Pizza", "Sushi"],
|
||||
maxSelections: 1,
|
||||
});
|
||||
expect(mocks.createPoll).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "poll-1",
|
||||
question: "Snack?",
|
||||
options: ["Pizza", "Sushi"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -262,18 +262,20 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
|
||||
chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ to, text, accountId, replyToId }) => {
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
|
||||
const result = await sendMessageNextcloudTalk(to, text, {
|
||||
accountId: accountId ?? undefined,
|
||||
replyTo: replyToId ?? undefined,
|
||||
cfg: cfg as CoreConfig,
|
||||
});
|
||||
return { channel: "nextcloud-talk", ...result };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => {
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => {
|
||||
const messageWithMedia = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text;
|
||||
const result = await sendMessageNextcloudTalk(to, messageWithMedia, {
|
||||
accountId: accountId ?? undefined,
|
||||
replyTo: replyToId ?? undefined,
|
||||
cfg: cfg as CoreConfig,
|
||||
});
|
||||
return { channel: "nextcloud-talk", ...result };
|
||||
},
|
||||
|
||||
104
extensions/nextcloud-talk/src/send.test.ts
Normal file
104
extensions/nextcloud-talk/src/send.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(),
|
||||
resolveMarkdownTableMode: vi.fn(() => "preserve"),
|
||||
convertMarkdownTables: vi.fn((text: string) => text),
|
||||
record: vi.fn(),
|
||||
resolveNextcloudTalkAccount: vi.fn(() => ({
|
||||
accountId: "default",
|
||||
baseUrl: "https://nextcloud.example.com",
|
||||
secret: "secret-value",
|
||||
})),
|
||||
generateNextcloudTalkSignature: vi.fn(() => ({
|
||||
random: "r",
|
||||
signature: "s",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getNextcloudTalkRuntime: () => ({
|
||||
config: {
|
||||
loadConfig: hoisted.loadConfig,
|
||||
},
|
||||
channel: {
|
||||
text: {
|
||||
resolveMarkdownTableMode: hoisted.resolveMarkdownTableMode,
|
||||
convertMarkdownTables: hoisted.convertMarkdownTables,
|
||||
},
|
||||
activity: {
|
||||
record: hoisted.record,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveNextcloudTalkAccount: hoisted.resolveNextcloudTalkAccount,
|
||||
}));
|
||||
|
||||
vi.mock("./signature.js", () => ({
|
||||
generateNextcloudTalkSignature: hoisted.generateNextcloudTalkSignature,
|
||||
}));
|
||||
|
||||
import { sendMessageNextcloudTalk, sendReactionNextcloudTalk } from "./send.js";
|
||||
|
||||
describe("nextcloud-talk send cfg threading", () => {
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("uses provided cfg for sendMessage and skips runtime loadConfig", async () => {
|
||||
const cfg = { source: "provided" } as const;
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ocs: { data: { id: 12345, timestamp: 1_706_000_000 } },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
const result = await sendMessageNextcloudTalk("room:abc123", "hello", {
|
||||
cfg,
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(hoisted.loadConfig).not.toHaveBeenCalled();
|
||||
expect(hoisted.resolveNextcloudTalkAccount).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
accountId: "work",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({
|
||||
messageId: "12345",
|
||||
roomToken: "abc123",
|
||||
timestamp: 1_706_000_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to runtime cfg for sendReaction when cfg is omitted", async () => {
|
||||
const runtimeCfg = { source: "runtime" } as const;
|
||||
hoisted.loadConfig.mockReturnValueOnce(runtimeCfg);
|
||||
fetchMock.mockResolvedValueOnce(new Response("{}", { status: 200 }));
|
||||
|
||||
const result = await sendReactionNextcloudTalk("room:ops", "m-1", "👍", {
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(hoisted.loadConfig).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.resolveNextcloudTalkAccount).toHaveBeenCalledWith({
|
||||
cfg: runtimeCfg,
|
||||
accountId: "default",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ type NextcloudTalkSendOpts = {
|
||||
accountId?: string;
|
||||
replyTo?: string;
|
||||
verbose?: boolean;
|
||||
cfg?: CoreConfig;
|
||||
};
|
||||
|
||||
function resolveCredentials(
|
||||
@@ -60,7 +61,7 @@ export async function sendMessageNextcloudTalk(
|
||||
text: string,
|
||||
opts: NextcloudTalkSendOpts = {},
|
||||
): Promise<NextcloudTalkSendResult> {
|
||||
const cfg = getNextcloudTalkRuntime().config.loadConfig() as CoreConfig;
|
||||
const cfg = (opts.cfg ?? getNextcloudTalkRuntime().config.loadConfig()) as CoreConfig;
|
||||
const account = resolveNextcloudTalkAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
@@ -175,7 +176,7 @@ export async function sendReactionNextcloudTalk(
|
||||
reaction: string,
|
||||
opts: Omit<NextcloudTalkSendOpts, "replyTo"> = {},
|
||||
): Promise<{ ok: true }> {
|
||||
const cfg = getNextcloudTalkRuntime().config.loadConfig() as CoreConfig;
|
||||
const cfg = (opts.cfg ?? getNextcloudTalkRuntime().config.loadConfig()) as CoreConfig;
|
||||
const account = resolveNextcloudTalkAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
|
||||
88
extensions/nostr/src/channel.outbound.test.ts
Normal file
88
extensions/nostr/src/channel.outbound.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStartAccountContext } from "../../test-utils/start-account-context.js";
|
||||
import { nostrPlugin } from "./channel.js";
|
||||
import { setNostrRuntime } from "./runtime.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
normalizePubkey: vi.fn((value: string) => `normalized-${value.toLowerCase()}`),
|
||||
startNostrBus: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./nostr-bus.js", () => ({
|
||||
DEFAULT_RELAYS: ["wss://relay.example.com"],
|
||||
getPublicKeyFromPrivate: vi.fn(() => "pubkey"),
|
||||
normalizePubkey: mocks.normalizePubkey,
|
||||
startNostrBus: mocks.startNostrBus,
|
||||
}));
|
||||
|
||||
describe("nostr outbound cfg threading", () => {
|
||||
afterEach(() => {
|
||||
mocks.normalizePubkey.mockClear();
|
||||
mocks.startNostrBus.mockReset();
|
||||
});
|
||||
|
||||
it("uses resolved cfg when converting markdown tables before send", async () => {
|
||||
const resolveMarkdownTableMode = vi.fn(() => "off");
|
||||
const convertMarkdownTables = vi.fn((text: string) => `converted:${text}`);
|
||||
setNostrRuntime({
|
||||
channel: {
|
||||
text: {
|
||||
resolveMarkdownTableMode,
|
||||
convertMarkdownTables,
|
||||
},
|
||||
},
|
||||
reply: {},
|
||||
} as unknown as PluginRuntime);
|
||||
|
||||
const sendDm = vi.fn(async () => {});
|
||||
const bus = {
|
||||
sendDm,
|
||||
close: vi.fn(),
|
||||
getMetrics: vi.fn(() => ({ counters: {} })),
|
||||
publishProfile: vi.fn(),
|
||||
getProfileState: vi.fn(async () => null),
|
||||
};
|
||||
mocks.startNostrBus.mockResolvedValueOnce(bus as any);
|
||||
|
||||
const cleanup = (await nostrPlugin.gateway!.startAccount!(
|
||||
createStartAccountContext({
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
publicKey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
||||
relays: ["wss://relay.example.com"],
|
||||
config: {},
|
||||
},
|
||||
abortSignal: new AbortController().signal,
|
||||
}),
|
||||
)) as { stop: () => void };
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
nostr: {
|
||||
privateKey: "resolved-nostr-private-key",
|
||||
},
|
||||
},
|
||||
};
|
||||
await nostrPlugin.outbound!.sendText!({
|
||||
cfg: cfg as any,
|
||||
to: "NPUB123",
|
||||
text: "|a|b|",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolveMarkdownTableMode).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
channel: "nostr",
|
||||
accountId: "default",
|
||||
});
|
||||
expect(convertMarkdownTables).toHaveBeenCalledWith("|a|b|", "off");
|
||||
expect(mocks.normalizePubkey).toHaveBeenCalledWith("NPUB123");
|
||||
expect(sendDm).toHaveBeenCalledWith("normalized-npub123", "converted:|a|b|");
|
||||
|
||||
cleanup.stop();
|
||||
});
|
||||
});
|
||||
@@ -135,7 +135,7 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ to, text, accountId }) => {
|
||||
sendText: async ({ cfg, to, text, accountId }) => {
|
||||
const core = getNostrRuntime();
|
||||
const aid = accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const bus = activeBuses.get(aid);
|
||||
@@ -143,7 +143,7 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
|
||||
throw new Error(`Nostr bus not running for account ${aid}`);
|
||||
}
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg: core.config.loadConfig(),
|
||||
cfg,
|
||||
channel: "nostr",
|
||||
accountId: aid,
|
||||
});
|
||||
|
||||
63
extensions/signal/src/channel.outbound.test.ts
Normal file
63
extensions/signal/src/channel.outbound.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { signalPlugin } from "./channel.js";
|
||||
|
||||
describe("signal outbound cfg threading", () => {
|
||||
it("threads provided cfg into sendText deps call", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
accounts: {
|
||||
work: {
|
||||
mediaMaxMb: 12,
|
||||
},
|
||||
},
|
||||
mediaMaxMb: 5,
|
||||
},
|
||||
},
|
||||
};
|
||||
const sendSignal = vi.fn(async () => ({ messageId: "sig-1" }));
|
||||
|
||||
const result = await signalPlugin.outbound!.sendText!({
|
||||
cfg,
|
||||
to: "+15551230000",
|
||||
text: "hello",
|
||||
accountId: "work",
|
||||
deps: { sendSignal },
|
||||
});
|
||||
|
||||
expect(sendSignal).toHaveBeenCalledWith("+15551230000", "hello", {
|
||||
cfg,
|
||||
maxBytes: 12 * 1024 * 1024,
|
||||
accountId: "work",
|
||||
});
|
||||
expect(result).toEqual({ channel: "signal", messageId: "sig-1" });
|
||||
});
|
||||
|
||||
it("threads cfg + mediaUrl into sendMedia deps call", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
mediaMaxMb: 7,
|
||||
},
|
||||
},
|
||||
};
|
||||
const sendSignal = vi.fn(async () => ({ messageId: "sig-2" }));
|
||||
|
||||
const result = await signalPlugin.outbound!.sendMedia!({
|
||||
cfg,
|
||||
to: "+15559870000",
|
||||
text: "photo",
|
||||
mediaUrl: "https://example.com/a.jpg",
|
||||
accountId: "default",
|
||||
deps: { sendSignal },
|
||||
});
|
||||
|
||||
expect(sendSignal).toHaveBeenCalledWith("+15559870000", "photo", {
|
||||
cfg,
|
||||
mediaUrl: "https://example.com/a.jpg",
|
||||
maxBytes: 7 * 1024 * 1024,
|
||||
accountId: "default",
|
||||
});
|
||||
expect(result).toEqual({ channel: "signal", messageId: "sig-2" });
|
||||
});
|
||||
});
|
||||
@@ -80,6 +80,7 @@ async function sendSignalOutbound(params: {
|
||||
accountId: params.accountId,
|
||||
});
|
||||
return await send(params.to, params.text, {
|
||||
cfg: params.cfg,
|
||||
...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}),
|
||||
...(params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}),
|
||||
maxBytes,
|
||||
|
||||
@@ -365,6 +365,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
threadId,
|
||||
});
|
||||
const result = await send(to, text, {
|
||||
cfg,
|
||||
threadTs: threadTsValue != null ? String(threadTsValue) : undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
...(tokenOverride ? { token: tokenOverride } : {}),
|
||||
@@ -390,6 +391,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
threadId,
|
||||
});
|
||||
const result = await send(to, text, {
|
||||
cfg,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
threadTs: threadTsValue != null ? String(threadTsValue) : undefined,
|
||||
|
||||
@@ -320,12 +320,13 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
pollMaxOptions: 10,
|
||||
sendText: async ({ to, text, accountId, deps, replyToId, threadId, silent }) => {
|
||||
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) => {
|
||||
const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
|
||||
const replyToMessageId = parseTelegramReplyToMessageId(replyToId);
|
||||
const messageThreadId = parseTelegramThreadId(threadId);
|
||||
const result = await send(to, text, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
messageThreadId,
|
||||
replyToMessageId,
|
||||
accountId: accountId ?? undefined,
|
||||
@@ -334,6 +335,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
return { channel: "telegram", ...result };
|
||||
},
|
||||
sendMedia: async ({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
mediaUrl,
|
||||
@@ -349,6 +351,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
const messageThreadId = parseTelegramThreadId(threadId);
|
||||
const result = await send(to, text, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
messageThreadId,
|
||||
@@ -358,8 +361,9 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
});
|
||||
return { channel: "telegram", ...result };
|
||||
},
|
||||
sendPoll: async ({ to, poll, accountId, threadId, silent, isAnonymous }) =>
|
||||
sendPoll: async ({ cfg, to, poll, accountId, threadId, silent, isAnonymous }) =>
|
||||
await getTelegramRuntime().channel.telegram.sendPollTelegram(to, poll, {
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
messageThreadId: parseTelegramThreadId(threadId),
|
||||
silent: silent ?? undefined,
|
||||
|
||||
46
extensions/whatsapp/src/channel.outbound.test.ts
Normal file
46
extensions/whatsapp/src/channel.outbound.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
sendPollWhatsApp: vi.fn(async () => ({ messageId: "wa-poll-1", toJid: "1555@s.whatsapp.net" })),
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getWhatsAppRuntime: () => ({
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
channel: {
|
||||
whatsapp: {
|
||||
sendPollWhatsApp: hoisted.sendPollWhatsApp,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { whatsappPlugin } from "./channel.js";
|
||||
|
||||
describe("whatsappPlugin outbound sendPoll", () => {
|
||||
it("threads cfg into runtime sendPollWhatsApp call", async () => {
|
||||
const cfg = { marker: "resolved-cfg" } as OpenClawConfig;
|
||||
const poll = {
|
||||
question: "Lunch?",
|
||||
options: ["Pizza", "Sushi"],
|
||||
maxSelections: 1,
|
||||
};
|
||||
|
||||
const result = await whatsappPlugin.outbound!.sendPoll!({
|
||||
cfg,
|
||||
to: "+1555",
|
||||
poll,
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(hoisted.sendPollWhatsApp).toHaveBeenCalledWith("+1555", poll, {
|
||||
verbose: false,
|
||||
accountId: "work",
|
||||
cfg,
|
||||
});
|
||||
expect(result).toEqual({ messageId: "wa-poll-1", toJid: "1555@s.whatsapp.net" });
|
||||
});
|
||||
});
|
||||
@@ -286,19 +286,30 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
pollMaxOptions: 12,
|
||||
resolveTarget: ({ to, allowFrom, mode }) =>
|
||||
resolveWhatsAppOutboundTarget({ to, allowFrom, mode }),
|
||||
sendText: async ({ to, text, accountId, deps, gifPlayback }) => {
|
||||
sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => {
|
||||
const send = deps?.sendWhatsApp ?? getWhatsAppRuntime().channel.whatsapp.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 ?? getWhatsAppRuntime().channel.whatsapp.sendMessageWhatsApp;
|
||||
const result = await send(to, text, {
|
||||
verbose: false,
|
||||
cfg,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
accountId: accountId ?? undefined,
|
||||
@@ -306,10 +317,11 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
});
|
||||
return { channel: "whatsapp", ...result };
|
||||
},
|
||||
sendPoll: async ({ to, poll, accountId }) =>
|
||||
sendPoll: async ({ cfg, to, poll, accountId }) =>
|
||||
await getWhatsAppRuntime().channel.whatsapp.sendPollWhatsApp(to, poll, {
|
||||
verbose: getWhatsAppRuntime().logging.shouldLogVerbose(),
|
||||
accountId: accountId ?? undefined,
|
||||
cfg,
|
||||
}),
|
||||
},
|
||||
auth: {
|
||||
|
||||
Reference in New Issue
Block a user