mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(outbound): unify resolved cfg threading across send paths (#33987)
This commit is contained in:
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
- Outbound/send config threading: pass resolved SecretRef config through outbound adapters and helper send paths so send flows do not reload unresolved runtime config. (#33987) Thanks @joshavant.
|
||||||
- Sessions/subagent attachments: remove `attachments[].content.maxLength` from `sessions_spawn` schema to avoid llama.cpp GBNF repetition overflow, and preflight UTF-8 byte size before buffer allocation while keeping runtime file-size enforcement unchanged. (#33648) Thanks @anisoptera.
|
- Sessions/subagent attachments: remove `attachments[].content.maxLength` from `sessions_spawn` schema to avoid llama.cpp GBNF repetition overflow, and preflight UTF-8 byte size before buffer allocation while keeping runtime file-size enforcement unchanged. (#33648) Thanks @anisoptera.
|
||||||
- Runtime/tool-state stability: recover from dangling Anthropic `tool_use` after compaction, serialize long-running Discord handler runs without blocking new inbound events, and prevent stale busy snapshots from suppressing stuck-channel recovery. (from #33630, #33583) Thanks @kevinWangSheng and @theotarr.
|
- Runtime/tool-state stability: recover from dangling Anthropic `tool_use` after compaction, serialize long-running Discord handler runs without blocking new inbound events, and prevent stale busy snapshots from suppressing stuck-channel recovery. (from #33630, #33583) Thanks @kevinWangSheng and @theotarr.
|
||||||
- Extensions/media local-root propagation: consistently forward `mediaLocalRoots` through extension `sendMedia` adapters (Google Chat, Slack, iMessage, Signal, WhatsApp), preserving non-local media behavior while restoring local attachment resolution from configured roots. Synthesis of #33581, #33545, #33540, #33536, #33528. Thanks @bmendonca3.
|
- Extensions/media local-root propagation: consistently forward `mediaLocalRoots` through extension `sendMedia` adapters (Google Chat, Slack, iMessage, Signal, WhatsApp), preserving non-local media behavior while restoring local attachment resolution from configured roots. Synthesis of #33581, #33545, #33540, #33536, #33528. Thanks @bmendonca3.
|
||||||
|
|||||||
@@ -302,10 +302,11 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
textChunkLimit: 2000,
|
textChunkLimit: 2000,
|
||||||
pollMaxOptions: 10,
|
pollMaxOptions: 10,
|
||||||
resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to),
|
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 send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
|
cfg,
|
||||||
replyTo: replyToId ?? undefined,
|
replyTo: replyToId ?? undefined,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
silent: silent ?? undefined,
|
silent: silent ?? undefined,
|
||||||
@@ -313,6 +314,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
return { channel: "discord", ...result };
|
return { channel: "discord", ...result };
|
||||||
},
|
},
|
||||||
sendMedia: async ({
|
sendMedia: async ({
|
||||||
|
cfg,
|
||||||
to,
|
to,
|
||||||
text,
|
text,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
@@ -325,6 +327,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
|
const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
|
cfg,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
mediaLocalRoots,
|
mediaLocalRoots,
|
||||||
replyTo: replyToId ?? undefined,
|
replyTo: replyToId ?? undefined,
|
||||||
@@ -333,8 +336,9 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
});
|
});
|
||||||
return { channel: "discord", ...result };
|
return { channel: "discord", ...result };
|
||||||
},
|
},
|
||||||
sendPoll: async ({ to, poll, accountId, silent }) =>
|
sendPoll: async ({ cfg, to, poll, accountId, silent }) =>
|
||||||
await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, {
|
await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, {
|
||||||
|
cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
silent: silent ?? 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";
|
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", () => ({
|
vi.mock("openclaw/plugin-sdk", () => ({
|
||||||
getChatChannelMeta: () => ({ id: "googlechat", label: "Google Chat" }),
|
getChatChannelMeta: () => ({ id: "googlechat", label: "Google Chat" }),
|
||||||
missingTargetError: (provider: string, hint: string) =>
|
missingTargetError: (provider: string, hint: string) =>
|
||||||
@@ -47,7 +52,8 @@ vi.mock("./onboarding.js", () => ({
|
|||||||
vi.mock("./runtime.js", () => ({
|
vi.mock("./runtime.js", () => ({
|
||||||
getGoogleChatRuntime: vi.fn(() => ({
|
getGoogleChatRuntime: vi.fn(() => ({
|
||||||
channel: {
|
channel: {
|
||||||
text: { chunkMarkdownText: vi.fn() },
|
text: { chunkMarkdownText: runtimeMocks.chunkMarkdownText },
|
||||||
|
media: { fetchRemoteMedia: runtimeMocks.fetchRemoteMedia },
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
@@ -66,7 +72,11 @@ vi.mock("./targets.js", () => ({
|
|||||||
resolveGoogleChatOutboundSpace: vi.fn(),
|
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 { googlechatPlugin } from "./channel.js";
|
||||||
|
import { resolveGoogleChatOutboundSpace } from "./targets.js";
|
||||||
|
|
||||||
const resolveTarget = googlechatPlugin.outbound!.resolveTarget!;
|
const resolveTarget = googlechatPlugin.outbound!.resolveTarget!;
|
||||||
|
|
||||||
@@ -104,3 +114,118 @@ describe("googlechat resolveTarget", () => {
|
|||||||
implicitAllowFrom: ["spaces/BBB"],
|
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,
|
accountId: params.accountId,
|
||||||
});
|
});
|
||||||
return await send(params.to, params.text, {
|
return await send(params.to, params.text, {
|
||||||
|
config: params.cfg,
|
||||||
...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}),
|
...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}),
|
||||||
...(params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}),
|
...(params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}),
|
||||||
maxBytes,
|
maxBytes,
|
||||||
|
|||||||
@@ -296,16 +296,18 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
|
|||||||
chunker: (text, limit) => getIrcRuntime().channel.text.chunkMarkdownText(text, limit),
|
chunker: (text, limit) => getIrcRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||||
chunkerMode: "markdown",
|
chunkerMode: "markdown",
|
||||||
textChunkLimit: 350,
|
textChunkLimit: 350,
|
||||||
sendText: async ({ to, text, accountId, replyToId }) => {
|
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
|
||||||
const result = await sendMessageIrc(to, text, {
|
const result = await sendMessageIrc(to, text, {
|
||||||
|
cfg: cfg as CoreConfig,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
replyTo: replyToId ?? undefined,
|
replyTo: replyToId ?? undefined,
|
||||||
});
|
});
|
||||||
return { channel: "irc", ...result };
|
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 combined = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text;
|
||||||
const result = await sendMessageIrc(to, combined, {
|
const result = await sendMessageIrc(to, combined, {
|
||||||
|
cfg: cfg as CoreConfig,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
replyTo: replyToId ?? 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";
|
import type { CoreConfig } from "./types.js";
|
||||||
|
|
||||||
type SendIrcOptions = {
|
type SendIrcOptions = {
|
||||||
|
cfg?: CoreConfig;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
replyTo?: string;
|
replyTo?: string;
|
||||||
target?: string;
|
target?: string;
|
||||||
@@ -37,7 +38,7 @@ export async function sendMessageIrc(
|
|||||||
opts: SendIrcOptions = {},
|
opts: SendIrcOptions = {},
|
||||||
): Promise<SendIrcResult> {
|
): Promise<SendIrcResult> {
|
||||||
const runtime = getIrcRuntime();
|
const runtime = getIrcRuntime();
|
||||||
const cfg = runtime.config.loadConfig() as CoreConfig;
|
const cfg = (opts.cfg ?? runtime.config.loadConfig()) as CoreConfig;
|
||||||
const account = resolveIrcAccount({
|
const account = resolveIrcAccount({
|
||||||
cfg,
|
cfg,
|
||||||
accountId: opts.accountId,
|
accountId: opts.accountId,
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ describe("linePlugin outbound.sendPayload", () => {
|
|||||||
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:group:1", "Now playing:", {
|
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:group:1", "Now playing:", {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
|
cfg,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,6 +155,7 @@ describe("linePlugin outbound.sendPayload", () => {
|
|||||||
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:user:1", "Choose one:", {
|
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:user:1", "Choose one:", {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
|
cfg,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -193,7 +195,7 @@ describe("linePlugin outbound.sendPayload", () => {
|
|||||||
quickReply: { items: ["One", "Two"] },
|
quickReply: { items: ["One", "Two"] },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{ verbose: false, accountId: "default" },
|
{ verbose: false, accountId: "default", cfg },
|
||||||
);
|
);
|
||||||
expect(mocks.createQuickReplyItems).toHaveBeenCalledWith(["One", "Two"]);
|
expect(mocks.createQuickReplyItems).toHaveBeenCalledWith(["One", "Two"]);
|
||||||
});
|
});
|
||||||
@@ -225,12 +227,13 @@ describe("linePlugin outbound.sendPayload", () => {
|
|||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl: "https://example.com/img.jpg",
|
mediaUrl: "https://example.com/img.jpg",
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
|
cfg,
|
||||||
});
|
});
|
||||||
expect(mocks.pushTextMessageWithQuickReplies).toHaveBeenCalledWith(
|
expect(mocks.pushTextMessageWithQuickReplies).toHaveBeenCalledWith(
|
||||||
"line:user:3",
|
"line:user:3",
|
||||||
"Hello",
|
"Hello",
|
||||||
["One", "Two"],
|
["One", "Two"],
|
||||||
{ verbose: false, accountId: "default" },
|
{ verbose: false, accountId: "default", cfg },
|
||||||
);
|
);
|
||||||
const mediaOrder = mocks.sendMessageLine.mock.invocationCallOrder[0];
|
const mediaOrder = mocks.sendMessageLine.mock.invocationCallOrder[0];
|
||||||
const quickReplyOrder = mocks.pushTextMessageWithQuickReplies.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 batch = messages.slice(i, i + 5) as unknown as Parameters<typeof sendBatch>[1];
|
||||||
const result = await sendBatch(to, batch, {
|
const result = await sendBatch(to, batch, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
|
cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
});
|
});
|
||||||
lastResult = { messageId: result.messageId, chatId: result.chatId };
|
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];
|
const flexContents = lineData.flexMessage.contents as Parameters<typeof sendFlex>[2];
|
||||||
lastResult = await sendFlex(to, lineData.flexMessage.altText, flexContents, {
|
lastResult = await sendFlex(to, lineData.flexMessage.altText, flexContents, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
|
cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -408,6 +410,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
if (template) {
|
if (template) {
|
||||||
lastResult = await sendTemplate(to, template, {
|
lastResult = await sendTemplate(to, template, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
|
cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -416,6 +419,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
if (lineData.location) {
|
if (lineData.location) {
|
||||||
lastResult = await sendLocation(to, lineData.location, {
|
lastResult = await sendLocation(to, lineData.location, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
|
cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -425,6 +429,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
const flexContents = flexMsg.contents as Parameters<typeof sendFlex>[2];
|
const flexContents = flexMsg.contents as Parameters<typeof sendFlex>[2];
|
||||||
lastResult = await sendFlex(to, flexMsg.altText, flexContents, {
|
lastResult = await sendFlex(to, flexMsg.altText, flexContents, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
|
cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -436,6 +441,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
|
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl: url,
|
mediaUrl: url,
|
||||||
|
cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -447,11 +453,13 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
if (isLast && hasQuickReplies) {
|
if (isLast && hasQuickReplies) {
|
||||||
lastResult = await sendQuickReplies(to, chunks[i], quickReplies, {
|
lastResult = await sendQuickReplies(to, chunks[i], quickReplies, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
|
cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
lastResult = await sendText(to, chunks[i], {
|
lastResult = await sendText(to, chunks[i], {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
|
cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -513,6 +521,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
|
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl: url,
|
mediaUrl: url,
|
||||||
|
cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -523,7 +532,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
}
|
}
|
||||||
return { channel: "line", messageId: "empty", chatId: to };
|
return { channel: "line", messageId: "empty", chatId: to };
|
||||||
},
|
},
|
||||||
sendText: async ({ to, text, accountId }) => {
|
sendText: async ({ cfg, to, text, accountId }) => {
|
||||||
const runtime = getLineRuntime();
|
const runtime = getLineRuntime();
|
||||||
const sendText = runtime.channel.line.pushMessageLine;
|
const sendText = runtime.channel.line.pushMessageLine;
|
||||||
const sendFlex = runtime.channel.line.pushFlexMessage;
|
const sendFlex = runtime.channel.line.pushFlexMessage;
|
||||||
@@ -536,6 +545,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
if (processed.text.trim()) {
|
if (processed.text.trim()) {
|
||||||
result = await sendText(to, processed.text, {
|
result = await sendText(to, processed.text, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
|
cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -549,17 +559,19 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
const flexContents = flexMsg.contents as Parameters<typeof sendFlex>[2];
|
const flexContents = flexMsg.contents as Parameters<typeof sendFlex>[2];
|
||||||
await sendFlex(to, flexMsg.altText, flexContents, {
|
await sendFlex(to, flexMsg.altText, flexContents, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
|
cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { channel: "line", ...result };
|
return { channel: "line", ...result };
|
||||||
},
|
},
|
||||||
sendMedia: async ({ to, text, mediaUrl, accountId }) => {
|
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => {
|
||||||
const send = getLineRuntime().channel.line.sendMessageLine;
|
const send = getLineRuntime().channel.line.sendMessageLine;
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
|
cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
});
|
});
|
||||||
return { channel: "line", ...result };
|
return { channel: "line", ...result };
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const loadWebMediaMock = vi.fn().mockResolvedValue({
|
|||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
});
|
});
|
||||||
|
const runtimeLoadConfigMock = vi.fn(() => ({}));
|
||||||
const mediaKindFromMimeMock = vi.fn(() => "image");
|
const mediaKindFromMimeMock = vi.fn(() => "image");
|
||||||
const isVoiceCompatibleAudioMock = vi.fn(() => false);
|
const isVoiceCompatibleAudioMock = vi.fn(() => false);
|
||||||
const getImageMetadataMock = vi.fn().mockResolvedValue(null);
|
const getImageMetadataMock = vi.fn().mockResolvedValue(null);
|
||||||
@@ -41,7 +42,7 @@ const resizeToJpegMock = vi.fn();
|
|||||||
|
|
||||||
const runtimeStub = {
|
const runtimeStub = {
|
||||||
config: {
|
config: {
|
||||||
loadConfig: () => ({}),
|
loadConfig: runtimeLoadConfigMock,
|
||||||
},
|
},
|
||||||
media: {
|
media: {
|
||||||
loadWebMedia: loadWebMediaMock as unknown as PluginRuntime["media"]["loadWebMedia"],
|
loadWebMedia: loadWebMediaMock as unknown as PluginRuntime["media"]["loadWebMedia"],
|
||||||
@@ -65,6 +66,7 @@ const runtimeStub = {
|
|||||||
} as unknown as PluginRuntime;
|
} as unknown as PluginRuntime;
|
||||||
|
|
||||||
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
|
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
|
||||||
|
let resolveMediaMaxBytes: typeof import("./send/client.js").resolveMediaMaxBytes;
|
||||||
|
|
||||||
const makeClient = () => {
|
const makeClient = () => {
|
||||||
const sendMessage = vi.fn().mockResolvedValue("evt1");
|
const sendMessage = vi.fn().mockResolvedValue("evt1");
|
||||||
@@ -80,11 +82,14 @@ const makeClient = () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
setMatrixRuntime(runtimeStub);
|
setMatrixRuntime(runtimeStub);
|
||||||
({ sendMessageMatrix } = await import("./send.js"));
|
({ sendMessageMatrix } = await import("./send.js"));
|
||||||
|
({ resolveMediaMaxBytes } = await import("./send/client.js"));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sendMessageMatrix media", () => {
|
describe("sendMessageMatrix media", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
runtimeLoadConfigMock.mockReset();
|
||||||
|
runtimeLoadConfigMock.mockReturnValue({});
|
||||||
mediaKindFromMimeMock.mockReturnValue("image");
|
mediaKindFromMimeMock.mockReturnValue("image");
|
||||||
isVoiceCompatibleAudioMock.mockReturnValue(false);
|
isVoiceCompatibleAudioMock.mockReturnValue(false);
|
||||||
setMatrixRuntime(runtimeStub);
|
setMatrixRuntime(runtimeStub);
|
||||||
@@ -214,6 +219,8 @@ describe("sendMessageMatrix media", () => {
|
|||||||
describe("sendMessageMatrix threads", () => {
|
describe("sendMessageMatrix threads", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
runtimeLoadConfigMock.mockReset();
|
||||||
|
runtimeLoadConfigMock.mockReturnValue({});
|
||||||
setMatrixRuntime(runtimeStub);
|
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,
|
client: opts.client,
|
||||||
timeoutMs: opts.timeoutMs,
|
timeoutMs: opts.timeoutMs,
|
||||||
accountId: opts.accountId,
|
accountId: opts.accountId,
|
||||||
|
cfg: opts.cfg,
|
||||||
});
|
});
|
||||||
|
const cfg = opts.cfg ?? getCore().config.loadConfig();
|
||||||
try {
|
try {
|
||||||
const roomId = await resolveMatrixRoomId(client, to);
|
const roomId = await resolveMatrixRoomId(client, to);
|
||||||
return await enqueueSend(roomId, async () => {
|
return await enqueueSend(roomId, async () => {
|
||||||
const cfg = getCore().config.loadConfig();
|
|
||||||
const tableMode = getCore().channel.text.resolveMarkdownTableMode({
|
const tableMode = getCore().channel.text.resolveMarkdownTableMode({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "matrix",
|
channel: "matrix",
|
||||||
@@ -81,7 +82,7 @@ export async function sendMessageMatrix(
|
|||||||
|
|
||||||
let lastMessageId = "";
|
let lastMessageId = "";
|
||||||
if (opts.mediaUrl) {
|
if (opts.mediaUrl) {
|
||||||
const maxBytes = resolveMediaMaxBytes(opts.accountId);
|
const maxBytes = resolveMediaMaxBytes(opts.accountId, cfg);
|
||||||
const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes);
|
const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes);
|
||||||
const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, {
|
const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, {
|
||||||
contentType: media.contentType,
|
contentType: media.contentType,
|
||||||
@@ -171,6 +172,7 @@ export async function sendPollMatrix(
|
|||||||
client: opts.client,
|
client: opts.client,
|
||||||
timeoutMs: opts.timeoutMs,
|
timeoutMs: opts.timeoutMs,
|
||||||
accountId: opts.accountId,
|
accountId: opts.accountId,
|
||||||
|
cfg: opts.cfg,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -32,19 +32,19 @@ function findAccountConfig(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveMediaMaxBytes(accountId?: string): number | undefined {
|
export function resolveMediaMaxBytes(accountId?: string, cfg?: CoreConfig): number | undefined {
|
||||||
const cfg = getCore().config.loadConfig() as CoreConfig;
|
const resolvedCfg = cfg ?? (getCore().config.loadConfig() as CoreConfig);
|
||||||
// Check account-specific config first (case-insensitive key matching)
|
// Check account-specific config first (case-insensitive key matching)
|
||||||
const accountConfig = findAccountConfig(
|
const accountConfig = findAccountConfig(
|
||||||
cfg.channels?.matrix?.accounts as Record<string, unknown> | undefined,
|
resolvedCfg.channels?.matrix?.accounts as Record<string, unknown> | undefined,
|
||||||
accountId ?? "",
|
accountId ?? "",
|
||||||
);
|
);
|
||||||
if (typeof accountConfig?.mediaMaxMb === "number") {
|
if (typeof accountConfig?.mediaMaxMb === "number") {
|
||||||
return (accountConfig.mediaMaxMb as number) * 1024 * 1024;
|
return (accountConfig.mediaMaxMb as number) * 1024 * 1024;
|
||||||
}
|
}
|
||||||
// Fall back to top-level config
|
// Fall back to top-level config
|
||||||
if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") {
|
if (typeof resolvedCfg.channels?.matrix?.mediaMaxMb === "number") {
|
||||||
return cfg.channels.matrix.mediaMaxMb * 1024 * 1024;
|
return resolvedCfg.channels.matrix.mediaMaxMb * 1024 * 1024;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -53,6 +53,7 @@ export async function resolveMatrixClient(opts: {
|
|||||||
client?: MatrixClient;
|
client?: MatrixClient;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
cfg?: CoreConfig;
|
||||||
}): Promise<{ client: MatrixClient; stopOnDone: boolean }> {
|
}): Promise<{ client: MatrixClient; stopOnDone: boolean }> {
|
||||||
ensureNodeRuntime();
|
ensureNodeRuntime();
|
||||||
if (opts.client) {
|
if (opts.client) {
|
||||||
@@ -84,10 +85,11 @@ export async function resolveMatrixClient(opts: {
|
|||||||
const client = await resolveSharedMatrixClient({
|
const client = await resolveSharedMatrixClient({
|
||||||
timeoutMs: opts.timeoutMs,
|
timeoutMs: opts.timeoutMs,
|
||||||
accountId,
|
accountId,
|
||||||
|
cfg: opts.cfg,
|
||||||
});
|
});
|
||||||
return { client, stopOnDone: false };
|
return { client, stopOnDone: false };
|
||||||
}
|
}
|
||||||
const auth = await resolveMatrixAuth({ accountId });
|
const auth = await resolveMatrixAuth({ accountId, cfg: opts.cfg });
|
||||||
const client = await createPreparedMatrixClient({
|
const client = await createPreparedMatrixClient({
|
||||||
auth,
|
auth,
|
||||||
timeoutMs: opts.timeoutMs,
|
timeoutMs: opts.timeoutMs,
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export type MatrixSendResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type MatrixSendOpts = {
|
export type MatrixSendOpts = {
|
||||||
|
cfg?: import("../../types.js").CoreConfig;
|
||||||
client?: import("@vector-im/matrix-bot-sdk").MatrixClient;
|
client?: import("@vector-im/matrix-bot-sdk").MatrixClient;
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
accountId?: 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),
|
chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||||
chunkerMode: "markdown",
|
chunkerMode: "markdown",
|
||||||
textChunkLimit: 4000,
|
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 send = deps?.sendMatrix ?? sendMessageMatrix;
|
||||||
const resolvedThreadId =
|
const resolvedThreadId =
|
||||||
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
|
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
|
cfg,
|
||||||
replyToId: replyToId ?? undefined,
|
replyToId: replyToId ?? undefined,
|
||||||
threadId: resolvedThreadId,
|
threadId: resolvedThreadId,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
@@ -22,11 +23,12 @@ export const matrixOutbound: ChannelOutboundAdapter = {
|
|||||||
roomId: result.roomId,
|
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 send = deps?.sendMatrix ?? sendMessageMatrix;
|
||||||
const resolvedThreadId =
|
const resolvedThreadId =
|
||||||
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
|
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
|
cfg,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
replyToId: replyToId ?? undefined,
|
replyToId: replyToId ?? undefined,
|
||||||
threadId: resolvedThreadId,
|
threadId: resolvedThreadId,
|
||||||
@@ -38,10 +40,11 @@ export const matrixOutbound: ChannelOutboundAdapter = {
|
|||||||
roomId: result.roomId,
|
roomId: result.roomId,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
sendPoll: async ({ to, poll, threadId, accountId }) => {
|
sendPoll: async ({ cfg, to, poll, threadId, accountId }) => {
|
||||||
const resolvedThreadId =
|
const resolvedThreadId =
|
||||||
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
|
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
|
||||||
const result = await sendPollMatrix(to, poll, {
|
const result = await sendPollMatrix(to, poll, {
|
||||||
|
cfg,
|
||||||
threadId: resolvedThreadId,
|
threadId: resolvedThreadId,
|
||||||
accountId: accountId ?? undefined,
|
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", () => {
|
describe("config", () => {
|
||||||
|
|||||||
@@ -273,15 +273,17 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
|||||||
}
|
}
|
||||||
return { ok: true, to: trimmed };
|
return { ok: true, to: trimmed };
|
||||||
},
|
},
|
||||||
sendText: async ({ to, text, accountId, replyToId }) => {
|
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
|
||||||
const result = await sendMessageMattermost(to, text, {
|
const result = await sendMessageMattermost(to, text, {
|
||||||
|
cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
replyToId: replyToId ?? undefined,
|
replyToId: replyToId ?? undefined,
|
||||||
});
|
});
|
||||||
return { channel: "mattermost", ...result };
|
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, {
|
const result = await sendMessageMattermost(to, text, {
|
||||||
|
cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
mediaLocalRoots,
|
mediaLocalRoots,
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { sendMessageMattermost } from "./send.js";
|
import { sendMessageMattermost } from "./send.js";
|
||||||
|
|
||||||
const mockState = vi.hoisted(() => ({
|
const mockState = vi.hoisted(() => ({
|
||||||
|
loadConfig: vi.fn(() => ({})),
|
||||||
loadOutboundMediaFromUrl: vi.fn(),
|
loadOutboundMediaFromUrl: vi.fn(),
|
||||||
|
resolveMattermostAccount: vi.fn(() => ({
|
||||||
|
accountId: "default",
|
||||||
|
botToken: "bot-token",
|
||||||
|
baseUrl: "https://mattermost.example.com",
|
||||||
|
})),
|
||||||
createMattermostClient: vi.fn(),
|
createMattermostClient: vi.fn(),
|
||||||
createMattermostDirectChannel: vi.fn(),
|
createMattermostDirectChannel: vi.fn(),
|
||||||
createMattermostPost: vi.fn(),
|
createMattermostPost: vi.fn(),
|
||||||
@@ -17,11 +23,7 @@ vi.mock("openclaw/plugin-sdk", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./accounts.js", () => ({
|
vi.mock("./accounts.js", () => ({
|
||||||
resolveMattermostAccount: () => ({
|
resolveMattermostAccount: mockState.resolveMattermostAccount,
|
||||||
accountId: "default",
|
|
||||||
botToken: "bot-token",
|
|
||||||
baseUrl: "https://mattermost.example.com",
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./client.js", () => ({
|
vi.mock("./client.js", () => ({
|
||||||
@@ -37,7 +39,7 @@ vi.mock("./client.js", () => ({
|
|||||||
vi.mock("../runtime.js", () => ({
|
vi.mock("../runtime.js", () => ({
|
||||||
getMattermostRuntime: () => ({
|
getMattermostRuntime: () => ({
|
||||||
config: {
|
config: {
|
||||||
loadConfig: () => ({}),
|
loadConfig: mockState.loadConfig,
|
||||||
},
|
},
|
||||||
logging: {
|
logging: {
|
||||||
shouldLogVerbose: () => false,
|
shouldLogVerbose: () => false,
|
||||||
@@ -57,6 +59,14 @@ vi.mock("../runtime.js", () => ({
|
|||||||
|
|
||||||
describe("sendMessageMattermost", () => {
|
describe("sendMessageMattermost", () => {
|
||||||
beforeEach(() => {
|
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.loadOutboundMediaFromUrl.mockReset();
|
||||||
mockState.createMattermostClient.mockReset();
|
mockState.createMattermostClient.mockReset();
|
||||||
mockState.createMattermostDirectChannel.mockReset();
|
mockState.createMattermostDirectChannel.mockReset();
|
||||||
@@ -69,6 +79,46 @@ describe("sendMessageMattermost", () => {
|
|||||||
mockState.uploadMattermostFile.mockResolvedValue({ id: "file-1" });
|
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 () => {
|
it("loads outbound media with trusted local roots before upload", async () => {
|
||||||
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
|
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
|
||||||
buffer: Buffer.from("media-bytes"),
|
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 { getMattermostRuntime } from "../runtime.js";
|
||||||
import { resolveMattermostAccount } from "./accounts.js";
|
import { resolveMattermostAccount } from "./accounts.js";
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "./client.js";
|
} from "./client.js";
|
||||||
|
|
||||||
export type MattermostSendOpts = {
|
export type MattermostSendOpts = {
|
||||||
|
cfg?: OpenClawConfig;
|
||||||
botToken?: string;
|
botToken?: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
@@ -146,7 +147,7 @@ export async function sendMessageMattermost(
|
|||||||
): Promise<MattermostSendResult> {
|
): Promise<MattermostSendResult> {
|
||||||
const core = getCore();
|
const core = getCore();
|
||||||
const logger = core.logging.getChildLogger({ module: "mattermost" });
|
const logger = core.logging.getChildLogger({ module: "mattermost" });
|
||||||
const cfg = core.config.loadConfig();
|
const cfg = opts.cfg ?? core.config.loadConfig();
|
||||||
const account = resolveMattermostAccount({
|
const account = resolveMattermostAccount({
|
||||||
cfg,
|
cfg,
|
||||||
accountId: opts.accountId,
|
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),
|
chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||||
chunkerMode: "markdown",
|
chunkerMode: "markdown",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
sendText: async ({ to, text, accountId, replyToId }) => {
|
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
|
||||||
const result = await sendMessageNextcloudTalk(to, text, {
|
const result = await sendMessageNextcloudTalk(to, text, {
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
replyTo: replyToId ?? undefined,
|
replyTo: replyToId ?? undefined,
|
||||||
|
cfg: cfg as CoreConfig,
|
||||||
});
|
});
|
||||||
return { channel: "nextcloud-talk", ...result };
|
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 messageWithMedia = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text;
|
||||||
const result = await sendMessageNextcloudTalk(to, messageWithMedia, {
|
const result = await sendMessageNextcloudTalk(to, messageWithMedia, {
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
replyTo: replyToId ?? undefined,
|
replyTo: replyToId ?? undefined,
|
||||||
|
cfg: cfg as CoreConfig,
|
||||||
});
|
});
|
||||||
return { channel: "nextcloud-talk", ...result };
|
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;
|
accountId?: string;
|
||||||
replyTo?: string;
|
replyTo?: string;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
|
cfg?: CoreConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveCredentials(
|
function resolveCredentials(
|
||||||
@@ -60,7 +61,7 @@ export async function sendMessageNextcloudTalk(
|
|||||||
text: string,
|
text: string,
|
||||||
opts: NextcloudTalkSendOpts = {},
|
opts: NextcloudTalkSendOpts = {},
|
||||||
): Promise<NextcloudTalkSendResult> {
|
): Promise<NextcloudTalkSendResult> {
|
||||||
const cfg = getNextcloudTalkRuntime().config.loadConfig() as CoreConfig;
|
const cfg = (opts.cfg ?? getNextcloudTalkRuntime().config.loadConfig()) as CoreConfig;
|
||||||
const account = resolveNextcloudTalkAccount({
|
const account = resolveNextcloudTalkAccount({
|
||||||
cfg,
|
cfg,
|
||||||
accountId: opts.accountId,
|
accountId: opts.accountId,
|
||||||
@@ -175,7 +176,7 @@ export async function sendReactionNextcloudTalk(
|
|||||||
reaction: string,
|
reaction: string,
|
||||||
opts: Omit<NextcloudTalkSendOpts, "replyTo"> = {},
|
opts: Omit<NextcloudTalkSendOpts, "replyTo"> = {},
|
||||||
): Promise<{ ok: true }> {
|
): Promise<{ ok: true }> {
|
||||||
const cfg = getNextcloudTalkRuntime().config.loadConfig() as CoreConfig;
|
const cfg = (opts.cfg ?? getNextcloudTalkRuntime().config.loadConfig()) as CoreConfig;
|
||||||
const account = resolveNextcloudTalkAccount({
|
const account = resolveNextcloudTalkAccount({
|
||||||
cfg,
|
cfg,
|
||||||
accountId: opts.accountId,
|
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: {
|
outbound: {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
sendText: async ({ to, text, accountId }) => {
|
sendText: async ({ cfg, to, text, accountId }) => {
|
||||||
const core = getNostrRuntime();
|
const core = getNostrRuntime();
|
||||||
const aid = accountId ?? DEFAULT_ACCOUNT_ID;
|
const aid = accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
const bus = activeBuses.get(aid);
|
const bus = activeBuses.get(aid);
|
||||||
@@ -143,7 +143,7 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
|
|||||||
throw new Error(`Nostr bus not running for account ${aid}`);
|
throw new Error(`Nostr bus not running for account ${aid}`);
|
||||||
}
|
}
|
||||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||||
cfg: core.config.loadConfig(),
|
cfg,
|
||||||
channel: "nostr",
|
channel: "nostr",
|
||||||
accountId: aid,
|
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,
|
accountId: params.accountId,
|
||||||
});
|
});
|
||||||
return await send(params.to, params.text, {
|
return await send(params.to, params.text, {
|
||||||
|
cfg: params.cfg,
|
||||||
...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}),
|
...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}),
|
||||||
...(params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}),
|
...(params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}),
|
||||||
maxBytes,
|
maxBytes,
|
||||||
|
|||||||
@@ -365,6 +365,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
|||||||
threadId,
|
threadId,
|
||||||
});
|
});
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
|
cfg,
|
||||||
threadTs: threadTsValue != null ? String(threadTsValue) : undefined,
|
threadTs: threadTsValue != null ? String(threadTsValue) : undefined,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
...(tokenOverride ? { token: tokenOverride } : {}),
|
...(tokenOverride ? { token: tokenOverride } : {}),
|
||||||
@@ -390,6 +391,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
|||||||
threadId,
|
threadId,
|
||||||
});
|
});
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
|
cfg,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
mediaLocalRoots,
|
mediaLocalRoots,
|
||||||
threadTs: threadTsValue != null ? String(threadTsValue) : undefined,
|
threadTs: threadTsValue != null ? String(threadTsValue) : undefined,
|
||||||
|
|||||||
@@ -320,12 +320,13 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||||||
chunkerMode: "markdown",
|
chunkerMode: "markdown",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
pollMaxOptions: 10,
|
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 send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
|
||||||
const replyToMessageId = parseTelegramReplyToMessageId(replyToId);
|
const replyToMessageId = parseTelegramReplyToMessageId(replyToId);
|
||||||
const messageThreadId = parseTelegramThreadId(threadId);
|
const messageThreadId = parseTelegramThreadId(threadId);
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
|
cfg,
|
||||||
messageThreadId,
|
messageThreadId,
|
||||||
replyToMessageId,
|
replyToMessageId,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
@@ -334,6 +335,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||||||
return { channel: "telegram", ...result };
|
return { channel: "telegram", ...result };
|
||||||
},
|
},
|
||||||
sendMedia: async ({
|
sendMedia: async ({
|
||||||
|
cfg,
|
||||||
to,
|
to,
|
||||||
text,
|
text,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
@@ -349,6 +351,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||||||
const messageThreadId = parseTelegramThreadId(threadId);
|
const messageThreadId = parseTelegramThreadId(threadId);
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
|
cfg,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
mediaLocalRoots,
|
mediaLocalRoots,
|
||||||
messageThreadId,
|
messageThreadId,
|
||||||
@@ -358,8 +361,9 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||||||
});
|
});
|
||||||
return { channel: "telegram", ...result };
|
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, {
|
await getTelegramRuntime().channel.telegram.sendPollTelegram(to, poll, {
|
||||||
|
cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
messageThreadId: parseTelegramThreadId(threadId),
|
messageThreadId: parseTelegramThreadId(threadId),
|
||||||
silent: silent ?? undefined,
|
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,
|
pollMaxOptions: 12,
|
||||||
resolveTarget: ({ to, allowFrom, mode }) =>
|
resolveTarget: ({ to, allowFrom, mode }) =>
|
||||||
resolveWhatsAppOutboundTarget({ 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 send = deps?.sendWhatsApp ?? getWhatsAppRuntime().channel.whatsapp.sendMessageWhatsApp;
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
|
cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
gifPlayback,
|
gifPlayback,
|
||||||
});
|
});
|
||||||
return { channel: "whatsapp", ...result };
|
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 send = deps?.sendWhatsApp ?? getWhatsAppRuntime().channel.whatsapp.sendMessageWhatsApp;
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
|
cfg,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
mediaLocalRoots,
|
mediaLocalRoots,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
@@ -306,10 +317,11 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
|||||||
});
|
});
|
||||||
return { channel: "whatsapp", ...result };
|
return { channel: "whatsapp", ...result };
|
||||||
},
|
},
|
||||||
sendPoll: async ({ to, poll, accountId }) =>
|
sendPoll: async ({ cfg, to, poll, accountId }) =>
|
||||||
await getWhatsAppRuntime().channel.whatsapp.sendPollWhatsApp(to, poll, {
|
await getWhatsAppRuntime().channel.whatsapp.sendPollWhatsApp(to, poll, {
|
||||||
verbose: getWhatsAppRuntime().logging.shouldLogVerbose(),
|
verbose: getWhatsAppRuntime().logging.shouldLogVerbose(),
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
|
cfg,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
import type { DiscordActionConfig } from "../../config/config.js";
|
import type { DiscordActionConfig } from "../../config/config.js";
|
||||||
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
import { readDiscordComponentSpec } from "../../discord/components.js";
|
import { readDiscordComponentSpec } from "../../discord/components.js";
|
||||||
import {
|
import {
|
||||||
createThreadDiscord,
|
createThreadDiscord,
|
||||||
@@ -59,6 +60,7 @@ export async function handleDiscordMessagingAction(
|
|||||||
options?: {
|
options?: {
|
||||||
mediaLocalRoots?: readonly string[];
|
mediaLocalRoots?: readonly string[];
|
||||||
},
|
},
|
||||||
|
cfg?: OpenClawConfig,
|
||||||
): Promise<AgentToolResult<unknown>> {
|
): Promise<AgentToolResult<unknown>> {
|
||||||
const resolveChannelId = () =>
|
const resolveChannelId = () =>
|
||||||
resolveDiscordChannelId(
|
resolveDiscordChannelId(
|
||||||
@@ -67,6 +69,7 @@ export async function handleDiscordMessagingAction(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const accountId = readStringParam(params, "accountId");
|
const accountId = readStringParam(params, "accountId");
|
||||||
|
const cfgOptions = cfg ? { cfg } : {};
|
||||||
const normalizeMessage = (message: unknown) => {
|
const normalizeMessage = (message: unknown) => {
|
||||||
if (!message || typeof message !== "object") {
|
if (!message || typeof message !== "object") {
|
||||||
return message;
|
return message;
|
||||||
@@ -90,22 +93,28 @@ export async function handleDiscordMessagingAction(
|
|||||||
});
|
});
|
||||||
if (remove) {
|
if (remove) {
|
||||||
if (accountId) {
|
if (accountId) {
|
||||||
await removeReactionDiscord(channelId, messageId, emoji, { accountId });
|
await removeReactionDiscord(channelId, messageId, emoji, {
|
||||||
|
...cfgOptions,
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await removeReactionDiscord(channelId, messageId, emoji);
|
await removeReactionDiscord(channelId, messageId, emoji, cfgOptions);
|
||||||
}
|
}
|
||||||
return jsonResult({ ok: true, removed: emoji });
|
return jsonResult({ ok: true, removed: emoji });
|
||||||
}
|
}
|
||||||
if (isEmpty) {
|
if (isEmpty) {
|
||||||
const removed = accountId
|
const removed = accountId
|
||||||
? await removeOwnReactionsDiscord(channelId, messageId, { accountId })
|
? await removeOwnReactionsDiscord(channelId, messageId, { ...cfgOptions, accountId })
|
||||||
: await removeOwnReactionsDiscord(channelId, messageId);
|
: await removeOwnReactionsDiscord(channelId, messageId, cfgOptions);
|
||||||
return jsonResult({ ok: true, removed: removed.removed });
|
return jsonResult({ ok: true, removed: removed.removed });
|
||||||
}
|
}
|
||||||
if (accountId) {
|
if (accountId) {
|
||||||
await reactMessageDiscord(channelId, messageId, emoji, { accountId });
|
await reactMessageDiscord(channelId, messageId, emoji, {
|
||||||
|
...cfgOptions,
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await reactMessageDiscord(channelId, messageId, emoji);
|
await reactMessageDiscord(channelId, messageId, emoji, cfgOptions);
|
||||||
}
|
}
|
||||||
return jsonResult({ ok: true, added: emoji });
|
return jsonResult({ ok: true, added: emoji });
|
||||||
}
|
}
|
||||||
@@ -121,6 +130,7 @@ export async function handleDiscordMessagingAction(
|
|||||||
const limit =
|
const limit =
|
||||||
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
|
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
|
||||||
const reactions = await fetchReactionsDiscord(channelId, messageId, {
|
const reactions = await fetchReactionsDiscord(channelId, messageId, {
|
||||||
|
...cfgOptions,
|
||||||
...(accountId ? { accountId } : {}),
|
...(accountId ? { accountId } : {}),
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
@@ -137,6 +147,7 @@ export async function handleDiscordMessagingAction(
|
|||||||
label: "stickerIds",
|
label: "stickerIds",
|
||||||
});
|
});
|
||||||
await sendStickerDiscord(to, stickerIds, {
|
await sendStickerDiscord(to, stickerIds, {
|
||||||
|
...cfgOptions,
|
||||||
...(accountId ? { accountId } : {}),
|
...(accountId ? { accountId } : {}),
|
||||||
content,
|
content,
|
||||||
});
|
});
|
||||||
@@ -165,7 +176,7 @@ export async function handleDiscordMessagingAction(
|
|||||||
await sendPollDiscord(
|
await sendPollDiscord(
|
||||||
to,
|
to,
|
||||||
{ question, options: answers, maxSelections, durationHours },
|
{ question, options: answers, maxSelections, durationHours },
|
||||||
{ ...(accountId ? { accountId } : {}), content },
|
{ ...cfgOptions, ...(accountId ? { accountId } : {}), content },
|
||||||
);
|
);
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
@@ -276,6 +287,7 @@ export async function handleDiscordMessagingAction(
|
|||||||
? componentSpec
|
? componentSpec
|
||||||
: { ...componentSpec, text: normalizedContent };
|
: { ...componentSpec, text: normalizedContent };
|
||||||
const result = await sendDiscordComponentMessage(to, payload, {
|
const result = await sendDiscordComponentMessage(to, payload, {
|
||||||
|
...cfgOptions,
|
||||||
...(accountId ? { accountId } : {}),
|
...(accountId ? { accountId } : {}),
|
||||||
silent,
|
silent,
|
||||||
replyTo: replyTo ?? undefined,
|
replyTo: replyTo ?? undefined,
|
||||||
@@ -301,6 +313,7 @@ export async function handleDiscordMessagingAction(
|
|||||||
}
|
}
|
||||||
assertMediaNotDataUrl(mediaUrl);
|
assertMediaNotDataUrl(mediaUrl);
|
||||||
const result = await sendVoiceMessageDiscord(to, mediaUrl, {
|
const result = await sendVoiceMessageDiscord(to, mediaUrl, {
|
||||||
|
...cfgOptions,
|
||||||
...(accountId ? { accountId } : {}),
|
...(accountId ? { accountId } : {}),
|
||||||
replyTo,
|
replyTo,
|
||||||
silent,
|
silent,
|
||||||
@@ -309,6 +322,7 @@ export async function handleDiscordMessagingAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await sendMessageDiscord(to, content ?? "", {
|
const result = await sendMessageDiscord(to, content ?? "", {
|
||||||
|
...cfgOptions,
|
||||||
...(accountId ? { accountId } : {}),
|
...(accountId ? { accountId } : {}),
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
mediaLocalRoots: options?.mediaLocalRoots,
|
mediaLocalRoots: options?.mediaLocalRoots,
|
||||||
@@ -422,6 +436,7 @@ export async function handleDiscordMessagingAction(
|
|||||||
const mediaUrl = readStringParam(params, "mediaUrl");
|
const mediaUrl = readStringParam(params, "mediaUrl");
|
||||||
const replyTo = readStringParam(params, "replyTo");
|
const replyTo = readStringParam(params, "replyTo");
|
||||||
const result = await sendMessageDiscord(`channel:${channelId}`, content, {
|
const result = await sendMessageDiscord(`channel:${channelId}`, content, {
|
||||||
|
...cfgOptions,
|
||||||
...(accountId ? { accountId } : {}),
|
...(accountId ? { accountId } : {}),
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
mediaLocalRoots: options?.mediaLocalRoots,
|
mediaLocalRoots: options?.mediaLocalRoots,
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ describe("handleDiscordMessagingAction", () => {
|
|||||||
expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅", expectedOptions);
|
expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅", expectedOptions);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅");
|
expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅", {});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes reactions on empty emoji", async () => {
|
it("removes reactions on empty emoji", async () => {
|
||||||
@@ -120,7 +120,7 @@ describe("handleDiscordMessagingAction", () => {
|
|||||||
},
|
},
|
||||||
enableAllActions,
|
enableAllActions,
|
||||||
);
|
);
|
||||||
expect(removeOwnReactionsDiscord).toHaveBeenCalledWith("C1", "M1");
|
expect(removeOwnReactionsDiscord).toHaveBeenCalledWith("C1", "M1", {});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes reactions when remove flag set", async () => {
|
it("removes reactions when remove flag set", async () => {
|
||||||
@@ -134,7 +134,7 @@ describe("handleDiscordMessagingAction", () => {
|
|||||||
},
|
},
|
||||||
enableAllActions,
|
enableAllActions,
|
||||||
);
|
);
|
||||||
expect(removeReactionDiscord).toHaveBeenCalledWith("C1", "M1", "✅");
|
expect(removeReactionDiscord).toHaveBeenCalledWith("C1", "M1", "✅", {});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects removes without emoji", async () => {
|
it("rejects removes without emoji", async () => {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export async function handleDiscordAction(
|
|||||||
const isActionEnabled = createDiscordActionGate({ cfg, accountId });
|
const isActionEnabled = createDiscordActionGate({ cfg, accountId });
|
||||||
|
|
||||||
if (messagingActions.has(action)) {
|
if (messagingActions.has(action)) {
|
||||||
return await handleDiscordMessagingAction(action, params, isActionEnabled, options);
|
return await handleDiscordMessagingAction(action, params, isActionEnabled, options, cfg);
|
||||||
}
|
}
|
||||||
if (guildActions.has(action)) {
|
if (guildActions.has(action)) {
|
||||||
return await handleDiscordGuildAction(action, params, isActionEnabled);
|
return await handleDiscordGuildAction(action, params, isActionEnabled);
|
||||||
|
|||||||
@@ -847,7 +847,10 @@ describe("signalMessageActions", () => {
|
|||||||
cfg: createSignalAccountOverrideCfg(),
|
cfg: createSignalAccountOverrideCfg(),
|
||||||
accountId: "work",
|
accountId: "work",
|
||||||
params: { to: "+15550001111", messageId: "123", emoji: "👍" },
|
params: { to: "+15550001111", messageId: "123", emoji: "👍" },
|
||||||
expectedArgs: ["+15550001111", 123, "👍", { accountId: "work" }],
|
expectedRecipient: "+15550001111",
|
||||||
|
expectedTimestamp: 123,
|
||||||
|
expectedEmoji: "👍",
|
||||||
|
expectedOptions: { accountId: "work" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "normalizes uuid recipients",
|
name: "normalizes uuid recipients",
|
||||||
@@ -858,7 +861,10 @@ describe("signalMessageActions", () => {
|
|||||||
messageId: "123",
|
messageId: "123",
|
||||||
emoji: "🔥",
|
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",
|
name: "passes groupId and targetAuthor for group reactions",
|
||||||
@@ -870,17 +876,13 @@ describe("signalMessageActions", () => {
|
|||||||
messageId: "123",
|
messageId: "123",
|
||||||
emoji: "✅",
|
emoji: "✅",
|
||||||
},
|
},
|
||||||
expectedArgs: [
|
expectedRecipient: "",
|
||||||
"",
|
expectedTimestamp: 123,
|
||||||
123,
|
expectedEmoji: "✅",
|
||||||
"✅",
|
expectedOptions: {
|
||||||
{
|
groupId: "group-id",
|
||||||
accountId: undefined,
|
targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000",
|
||||||
groupId: "group-id",
|
},
|
||||||
targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000",
|
|
||||||
targetAuthorUuid: undefined,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -890,7 +892,15 @@ describe("signalMessageActions", () => {
|
|||||||
cfg: testCase.cfg,
|
cfg: testCase.cfg,
|
||||||
accountId: testCase.accountId,
|
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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ function resolveSignalReactionTarget(raw: string): { recipient?: string; groupId
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function mutateSignalReaction(params: {
|
async function mutateSignalReaction(params: {
|
||||||
|
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
target: { recipient?: string; groupId?: string };
|
target: { recipient?: string; groupId?: string };
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
@@ -49,6 +50,7 @@ async function mutateSignalReaction(params: {
|
|||||||
targetAuthorUuid?: string;
|
targetAuthorUuid?: string;
|
||||||
}) {
|
}) {
|
||||||
const options = {
|
const options = {
|
||||||
|
cfg: params.cfg,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
groupId: params.target.groupId,
|
groupId: params.target.groupId,
|
||||||
targetAuthor: params.targetAuthor,
|
targetAuthor: params.targetAuthor,
|
||||||
@@ -153,6 +155,7 @@ export const signalMessageActions: ChannelMessageActionAdapter = {
|
|||||||
throw new Error("Emoji required to remove reaction.");
|
throw new Error("Emoji required to remove reaction.");
|
||||||
}
|
}
|
||||||
return await mutateSignalReaction({
|
return await mutateSignalReaction({
|
||||||
|
cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
target,
|
target,
|
||||||
timestamp,
|
timestamp,
|
||||||
@@ -167,6 +170,7 @@ export const signalMessageActions: ChannelMessageActionAdapter = {
|
|||||||
throw new Error("Emoji required to add reaction.");
|
throw new Error("Emoji required to add reaction.");
|
||||||
}
|
}
|
||||||
return await mutateSignalReaction({
|
return await mutateSignalReaction({
|
||||||
|
cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
target,
|
target,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { resolveChannelMediaMaxBytes } from "../media-limits.js";
|
|||||||
import type { ChannelOutboundAdapter } from "../types.js";
|
import type { ChannelOutboundAdapter } from "../types.js";
|
||||||
|
|
||||||
type DirectSendOptions = {
|
type DirectSendOptions = {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
replyToId?: string | null;
|
replyToId?: string | null;
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
@@ -121,6 +122,7 @@ export function createDirectTextMediaOutbound<
|
|||||||
sendParams.to,
|
sendParams.to,
|
||||||
sendParams.text,
|
sendParams.text,
|
||||||
sendParams.buildOptions({
|
sendParams.buildOptions({
|
||||||
|
cfg: sendParams.cfg,
|
||||||
mediaUrl: sendParams.mediaUrl,
|
mediaUrl: sendParams.mediaUrl,
|
||||||
mediaLocalRoots: sendParams.mediaLocalRoots,
|
mediaLocalRoots: sendParams.mediaLocalRoots,
|
||||||
accountId: sendParams.accountId,
|
accountId: sendParams.accountId,
|
||||||
|
|||||||
@@ -143,9 +143,16 @@ describe("discordOutbound", () => {
|
|||||||
|
|
||||||
it("uses webhook persona delivery for bound thread text replies", async () => {
|
it("uses webhook persona delivery for bound thread text replies", async () => {
|
||||||
mockBoundThreadManager();
|
mockBoundThreadManager();
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
discord: {
|
||||||
|
token: "resolved-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const result = await discordOutbound.sendText?.({
|
const result = await discordOutbound.sendText?.({
|
||||||
cfg: {},
|
cfg,
|
||||||
to: "channel:parent-1",
|
to: "channel:parent-1",
|
||||||
text: "hello from persona",
|
text: "hello from persona",
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
@@ -169,6 +176,10 @@ describe("discordOutbound", () => {
|
|||||||
avatarUrl: "https://example.com/avatar.png",
|
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(hoisted.sendMessageDiscordMock).not.toHaveBeenCalled();
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
channel: "discord",
|
channel: "discord",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { OpenClawConfig } from "../../../config/config.js";
|
||||||
import {
|
import {
|
||||||
getThreadBindingManager,
|
getThreadBindingManager,
|
||||||
type ThreadBindingRecord,
|
type ThreadBindingRecord,
|
||||||
@@ -38,6 +39,7 @@ function resolveDiscordWebhookIdentity(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function maybeSendDiscordWebhookText(params: {
|
async function maybeSendDiscordWebhookText(params: {
|
||||||
|
cfg?: OpenClawConfig;
|
||||||
text: string;
|
text: string;
|
||||||
threadId?: string | number | null;
|
threadId?: string | number | null;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
@@ -68,6 +70,7 @@ async function maybeSendDiscordWebhookText(params: {
|
|||||||
webhookToken: binding.webhookToken,
|
webhookToken: binding.webhookToken,
|
||||||
accountId: binding.accountId,
|
accountId: binding.accountId,
|
||||||
threadId: binding.threadId,
|
threadId: binding.threadId,
|
||||||
|
cfg: params.cfg,
|
||||||
replyTo: params.replyToId ?? undefined,
|
replyTo: params.replyToId ?? undefined,
|
||||||
username: persona.username,
|
username: persona.username,
|
||||||
avatarUrl: persona.avatarUrl,
|
avatarUrl: persona.avatarUrl,
|
||||||
@@ -83,9 +86,10 @@ export const discordOutbound: ChannelOutboundAdapter = {
|
|||||||
resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to),
|
resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to),
|
||||||
sendPayload: async (ctx) =>
|
sendPayload: async (ctx) =>
|
||||||
await sendTextMediaPayload({ channel: "discord", ctx, adapter: discordOutbound }),
|
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) {
|
if (!silent) {
|
||||||
const webhookResult = await maybeSendDiscordWebhookText({
|
const webhookResult = await maybeSendDiscordWebhookText({
|
||||||
|
cfg,
|
||||||
text,
|
text,
|
||||||
threadId,
|
threadId,
|
||||||
accountId,
|
accountId,
|
||||||
@@ -103,10 +107,12 @@ export const discordOutbound: ChannelOutboundAdapter = {
|
|||||||
replyTo: replyToId ?? undefined,
|
replyTo: replyToId ?? undefined,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
silent: silent ?? undefined,
|
silent: silent ?? undefined,
|
||||||
|
cfg,
|
||||||
});
|
});
|
||||||
return { channel: "discord", ...result };
|
return { channel: "discord", ...result };
|
||||||
},
|
},
|
||||||
sendMedia: async ({
|
sendMedia: async ({
|
||||||
|
cfg,
|
||||||
to,
|
to,
|
||||||
text,
|
text,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
@@ -126,14 +132,16 @@ export const discordOutbound: ChannelOutboundAdapter = {
|
|||||||
replyTo: replyToId ?? undefined,
|
replyTo: replyToId ?? undefined,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
silent: silent ?? undefined,
|
silent: silent ?? undefined,
|
||||||
|
cfg,
|
||||||
});
|
});
|
||||||
return { channel: "discord", ...result };
|
return { channel: "discord", ...result };
|
||||||
},
|
},
|
||||||
sendPoll: async ({ to, poll, accountId, threadId, silent }) => {
|
sendPoll: async ({ cfg, to, poll, accountId, threadId, silent }) => {
|
||||||
const target = resolveDiscordOutboundTarget({ to, threadId });
|
const target = resolveDiscordOutboundTarget({ to, threadId });
|
||||||
return await sendPollDiscord(target, poll, {
|
return await sendPollDiscord(target, poll, {
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
silent: silent ?? undefined,
|
silent: silent ?? undefined,
|
||||||
|
cfg,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ export const imessageOutbound = createDirectTextMediaOutbound({
|
|||||||
channel: "imessage",
|
channel: "imessage",
|
||||||
resolveSender: resolveIMessageSender,
|
resolveSender: resolveIMessageSender,
|
||||||
resolveMaxBytes: createScopedChannelMediaMaxBytesResolver("imessage"),
|
resolveMaxBytes: createScopedChannelMediaMaxBytesResolver("imessage"),
|
||||||
buildTextOptions: ({ maxBytes, accountId, replyToId }) => ({
|
buildTextOptions: ({ cfg, maxBytes, accountId, replyToId }) => ({
|
||||||
|
config: cfg,
|
||||||
maxBytes,
|
maxBytes,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
replyToId: replyToId ?? undefined,
|
replyToId: replyToId ?? undefined,
|
||||||
}),
|
}),
|
||||||
buildMediaOptions: ({ mediaUrl, maxBytes, accountId, replyToId, mediaLocalRoots }) => ({
|
buildMediaOptions: ({ cfg, mediaUrl, maxBytes, accountId, replyToId, mediaLocalRoots }) => ({
|
||||||
|
config: cfg,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
maxBytes,
|
maxBytes,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ export const signalOutbound = createDirectTextMediaOutbound({
|
|||||||
channel: "signal",
|
channel: "signal",
|
||||||
resolveSender: resolveSignalSender,
|
resolveSender: resolveSignalSender,
|
||||||
resolveMaxBytes: createScopedChannelMediaMaxBytesResolver("signal"),
|
resolveMaxBytes: createScopedChannelMediaMaxBytesResolver("signal"),
|
||||||
buildTextOptions: ({ maxBytes, accountId }) => ({
|
buildTextOptions: ({ cfg, maxBytes, accountId }) => ({
|
||||||
|
cfg,
|
||||||
maxBytes,
|
maxBytes,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
}),
|
}),
|
||||||
buildMediaOptions: ({ mediaUrl, maxBytes, accountId, mediaLocalRoots }) => ({
|
buildMediaOptions: ({ cfg, mediaUrl, maxBytes, accountId, mediaLocalRoots }) => ({
|
||||||
|
cfg,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
maxBytes,
|
maxBytes,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
|
|||||||
@@ -58,11 +58,13 @@ const expectSlackSendCalledWith = (
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
expect(sendMessageSlack).toHaveBeenCalledWith("C123", text, {
|
const expected = {
|
||||||
threadTs: "1111.2222",
|
threadTs: "1111.2222",
|
||||||
accountId: "default",
|
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", () => {
|
describe("slack outbound hook wiring", () => {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ async function applySlackMessageSendingHooks(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sendSlackOutboundMessage(params: {
|
async function sendSlackOutboundMessage(params: {
|
||||||
|
cfg: NonNullable<Parameters<typeof sendMessageSlack>[2]>["cfg"];
|
||||||
to: string;
|
to: string;
|
||||||
text: string;
|
text: string;
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
@@ -80,6 +81,7 @@ async function sendSlackOutboundMessage(params: {
|
|||||||
|
|
||||||
const slackIdentity = resolveSlackSendIdentity(params.identity);
|
const slackIdentity = resolveSlackSendIdentity(params.identity);
|
||||||
const result = await send(params.to, hookResult.text, {
|
const result = await send(params.to, hookResult.text, {
|
||||||
|
cfg: params.cfg,
|
||||||
threadTs,
|
threadTs,
|
||||||
accountId: params.accountId ?? undefined,
|
accountId: params.accountId ?? undefined,
|
||||||
...(params.mediaUrl
|
...(params.mediaUrl
|
||||||
@@ -96,8 +98,9 @@ export const slackOutbound: ChannelOutboundAdapter = {
|
|||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
sendPayload: async (ctx) =>
|
sendPayload: async (ctx) =>
|
||||||
await sendTextMediaPayload({ channel: "slack", ctx, adapter: slackOutbound }),
|
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({
|
return await sendSlackOutboundMessage({
|
||||||
|
cfg,
|
||||||
to,
|
to,
|
||||||
text,
|
text,
|
||||||
accountId,
|
accountId,
|
||||||
@@ -108,6 +111,7 @@ export const slackOutbound: ChannelOutboundAdapter = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
sendMedia: async ({
|
sendMedia: async ({
|
||||||
|
cfg,
|
||||||
to,
|
to,
|
||||||
text,
|
text,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
@@ -119,6 +123,7 @@ export const slackOutbound: ChannelOutboundAdapter = {
|
|||||||
identity,
|
identity,
|
||||||
}) => {
|
}) => {
|
||||||
return await sendSlackOutboundMessage({
|
return await sendSlackOutboundMessage({
|
||||||
|
cfg,
|
||||||
to,
|
to,
|
||||||
text,
|
text,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { sendMessageTelegram } from "../../../telegram/send.js";
|
|||||||
import type { ChannelOutboundAdapter } from "../types.js";
|
import type { ChannelOutboundAdapter } from "../types.js";
|
||||||
|
|
||||||
function resolveTelegramSendContext(params: {
|
function resolveTelegramSendContext(params: {
|
||||||
|
cfg: NonNullable<Parameters<typeof sendMessageTelegram>[2]>["cfg"];
|
||||||
deps?: OutboundSendDeps;
|
deps?: OutboundSendDeps;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
replyToId?: string | null;
|
replyToId?: string | null;
|
||||||
@@ -16,6 +17,7 @@ function resolveTelegramSendContext(params: {
|
|||||||
}): {
|
}): {
|
||||||
send: typeof sendMessageTelegram;
|
send: typeof sendMessageTelegram;
|
||||||
baseOpts: {
|
baseOpts: {
|
||||||
|
cfg: NonNullable<Parameters<typeof sendMessageTelegram>[2]>["cfg"];
|
||||||
verbose: false;
|
verbose: false;
|
||||||
textMode: "html";
|
textMode: "html";
|
||||||
messageThreadId?: number;
|
messageThreadId?: number;
|
||||||
@@ -29,6 +31,7 @@ function resolveTelegramSendContext(params: {
|
|||||||
baseOpts: {
|
baseOpts: {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
textMode: "html",
|
textMode: "html",
|
||||||
|
cfg: params.cfg,
|
||||||
messageThreadId: parseTelegramThreadId(params.threadId),
|
messageThreadId: parseTelegramThreadId(params.threadId),
|
||||||
replyToMessageId: parseTelegramReplyToMessageId(params.replyToId),
|
replyToMessageId: parseTelegramReplyToMessageId(params.replyToId),
|
||||||
accountId: params.accountId ?? undefined,
|
accountId: params.accountId ?? undefined,
|
||||||
@@ -41,8 +44,9 @@ export const telegramOutbound: ChannelOutboundAdapter = {
|
|||||||
chunker: markdownToTelegramHtmlChunks,
|
chunker: markdownToTelegramHtmlChunks,
|
||||||
chunkerMode: "markdown",
|
chunkerMode: "markdown",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
|
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId }) => {
|
||||||
const { send, baseOpts } = resolveTelegramSendContext({
|
const { send, baseOpts } = resolveTelegramSendContext({
|
||||||
|
cfg,
|
||||||
deps,
|
deps,
|
||||||
accountId,
|
accountId,
|
||||||
replyToId,
|
replyToId,
|
||||||
@@ -54,6 +58,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
|
|||||||
return { channel: "telegram", ...result };
|
return { channel: "telegram", ...result };
|
||||||
},
|
},
|
||||||
sendMedia: async ({
|
sendMedia: async ({
|
||||||
|
cfg,
|
||||||
to,
|
to,
|
||||||
text,
|
text,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
@@ -64,6 +69,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
|
|||||||
threadId,
|
threadId,
|
||||||
}) => {
|
}) => {
|
||||||
const { send, baseOpts } = resolveTelegramSendContext({
|
const { send, baseOpts } = resolveTelegramSendContext({
|
||||||
|
cfg,
|
||||||
deps,
|
deps,
|
||||||
accountId,
|
accountId,
|
||||||
replyToId,
|
replyToId,
|
||||||
@@ -76,8 +82,18 @@ export const telegramOutbound: ChannelOutboundAdapter = {
|
|||||||
});
|
});
|
||||||
return { channel: "telegram", ...result };
|
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({
|
const { send, baseOpts: contextOpts } = resolveTelegramSendContext({
|
||||||
|
cfg,
|
||||||
deps,
|
deps,
|
||||||
accountId,
|
accountId,
|
||||||
replyToId,
|
replyToId,
|
||||||
|
|||||||
41
src/channels/plugins/outbound/whatsapp.poll.test.ts
Normal file
41
src/channels/plugins/outbound/whatsapp.poll.test.ts
Normal 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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,21 +15,23 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
|
|||||||
resolveWhatsAppOutboundTarget({ to, allowFrom, mode }),
|
resolveWhatsAppOutboundTarget({ to, allowFrom, mode }),
|
||||||
sendPayload: async (ctx) =>
|
sendPayload: async (ctx) =>
|
||||||
await sendTextMediaPayload({ channel: "whatsapp", ctx, adapter: whatsappOutbound }),
|
await sendTextMediaPayload({ channel: "whatsapp", ctx, adapter: whatsappOutbound }),
|
||||||
sendText: async ({ to, text, accountId, deps, gifPlayback }) => {
|
sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => {
|
||||||
const send =
|
const send =
|
||||||
deps?.sendWhatsApp ?? (await import("../../../web/outbound.js")).sendMessageWhatsApp;
|
deps?.sendWhatsApp ?? (await import("../../../web/outbound.js")).sendMessageWhatsApp;
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
|
cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
gifPlayback,
|
gifPlayback,
|
||||||
});
|
});
|
||||||
return { channel: "whatsapp", ...result };
|
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 =
|
const send =
|
||||||
deps?.sendWhatsApp ?? (await import("../../../web/outbound.js")).sendMessageWhatsApp;
|
deps?.sendWhatsApp ?? (await import("../../../web/outbound.js")).sendMessageWhatsApp;
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
|
cfg,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
mediaLocalRoots,
|
mediaLocalRoots,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
@@ -37,9 +39,10 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
|
|||||||
});
|
});
|
||||||
return { channel: "whatsapp", ...result };
|
return { channel: "whatsapp", ...result };
|
||||||
},
|
},
|
||||||
sendPoll: async ({ to, poll, accountId }) =>
|
sendPoll: async ({ cfg, to, poll, accountId }) =>
|
||||||
await sendPollWhatsApp(to, poll, {
|
await sendPollWhatsApp(to, poll, {
|
||||||
verbose: shouldLogVerbose(),
|
verbose: shouldLogVerbose(),
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
|
cfg,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -169,6 +169,199 @@ const createTelegramSendPluginRegistration = () => ({
|
|||||||
const { messageCommand } = await import("./message.js");
|
const { messageCommand } = await import("./message.js");
|
||||||
|
|
||||||
describe("messageCommand", () => {
|
describe("messageCommand", () => {
|
||||||
|
it("threads resolved SecretRef config into outbound send actions", async () => {
|
||||||
|
const rawConfig = {
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
token: { $secret: "vault://telegram/token" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const resolvedConfig = {
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
token: "12345:resolved-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
testConfig = rawConfig;
|
||||||
|
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
|
||||||
|
resolvedConfig: resolvedConfig as unknown as Record<string, unknown>,
|
||||||
|
diagnostics: ["resolved channels.telegram.token"],
|
||||||
|
});
|
||||||
|
await setRegistry(
|
||||||
|
createTestRegistry([
|
||||||
|
{
|
||||||
|
...createTelegramSendPluginRegistration(),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const deps = makeDeps();
|
||||||
|
await messageCommand(
|
||||||
|
{
|
||||||
|
action: "send",
|
||||||
|
channel: "telegram",
|
||||||
|
target: "123456",
|
||||||
|
message: "hi",
|
||||||
|
},
|
||||||
|
deps,
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
config: rawConfig,
|
||||||
|
commandName: "message",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(handleTelegramAction).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: "send", to: "123456", accountId: undefined }),
|
||||||
|
resolvedConfig,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("threads resolved SecretRef config into outbound adapter sends", async () => {
|
||||||
|
const rawConfig = {
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
token: { $secret: "vault://telegram/token" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const resolvedConfig = {
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
token: "12345:resolved-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
testConfig = rawConfig;
|
||||||
|
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
|
||||||
|
resolvedConfig: resolvedConfig as unknown as Record<string, unknown>,
|
||||||
|
diagnostics: ["resolved channels.telegram.token"],
|
||||||
|
});
|
||||||
|
const sendText = vi.fn(async (_ctx: { cfg?: unknown; to: string; text: string }) => ({
|
||||||
|
channel: "telegram" as const,
|
||||||
|
messageId: "msg-1",
|
||||||
|
chatId: "123456",
|
||||||
|
}));
|
||||||
|
const sendMedia = vi.fn(async (_ctx: { cfg?: unknown }) => ({
|
||||||
|
channel: "telegram" as const,
|
||||||
|
messageId: "msg-2",
|
||||||
|
chatId: "123456",
|
||||||
|
}));
|
||||||
|
await setRegistry(
|
||||||
|
createTestRegistry([
|
||||||
|
{
|
||||||
|
pluginId: "telegram",
|
||||||
|
source: "test",
|
||||||
|
plugin: createStubPlugin({
|
||||||
|
id: "telegram",
|
||||||
|
label: "Telegram",
|
||||||
|
outbound: {
|
||||||
|
deliveryMode: "direct",
|
||||||
|
sendText,
|
||||||
|
sendMedia,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const deps = makeDeps();
|
||||||
|
await messageCommand(
|
||||||
|
{
|
||||||
|
action: "send",
|
||||||
|
channel: "telegram",
|
||||||
|
target: "123456",
|
||||||
|
message: "hi",
|
||||||
|
},
|
||||||
|
deps,
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sendText).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
cfg: resolvedConfig,
|
||||||
|
to: "123456",
|
||||||
|
text: "hi",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(sendText.mock.calls[0]?.[0]?.cfg).not.toBe(rawConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps local-fallback resolved cfg in outbound adapter sends", async () => {
|
||||||
|
const rawConfig = {
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
token: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const locallyResolvedConfig = {
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
token: "12345:local-fallback-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
testConfig = rawConfig;
|
||||||
|
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
|
||||||
|
resolvedConfig: locallyResolvedConfig as unknown as Record<string, unknown>,
|
||||||
|
diagnostics: ["gateway secrets.resolve unavailable; used local resolver fallback."],
|
||||||
|
});
|
||||||
|
const sendText = vi.fn(async (_ctx: { cfg?: unknown }) => ({
|
||||||
|
channel: "telegram" as const,
|
||||||
|
messageId: "msg-3",
|
||||||
|
chatId: "123456",
|
||||||
|
}));
|
||||||
|
const sendMedia = vi.fn(async (_ctx: { cfg?: unknown }) => ({
|
||||||
|
channel: "telegram" as const,
|
||||||
|
messageId: "msg-4",
|
||||||
|
chatId: "123456",
|
||||||
|
}));
|
||||||
|
await setRegistry(
|
||||||
|
createTestRegistry([
|
||||||
|
{
|
||||||
|
pluginId: "telegram",
|
||||||
|
source: "test",
|
||||||
|
plugin: createStubPlugin({
|
||||||
|
id: "telegram",
|
||||||
|
label: "Telegram",
|
||||||
|
outbound: {
|
||||||
|
deliveryMode: "direct",
|
||||||
|
sendText,
|
||||||
|
sendMedia,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const deps = makeDeps();
|
||||||
|
await messageCommand(
|
||||||
|
{
|
||||||
|
action: "send",
|
||||||
|
channel: "telegram",
|
||||||
|
target: "123456",
|
||||||
|
message: "hi",
|
||||||
|
},
|
||||||
|
deps,
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sendText).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
cfg: locallyResolvedConfig,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(sendText.mock.calls[0]?.[0]?.cfg).not.toBe(rawConfig);
|
||||||
|
expect(runtime.log).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("[secrets] gateway secrets.resolve unavailable"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults channel when only one configured", async () => {
|
it("defaults channel when only one configured", async () => {
|
||||||
process.env.TELEGRAM_BOT_TOKEN = "token-abc";
|
process.env.TELEGRAM_BOT_TOKEN = "token-abc";
|
||||||
await setRegistry(
|
await setRegistry(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
type RequestClient,
|
type RequestClient,
|
||||||
} from "@buape/carbon";
|
} from "@buape/carbon";
|
||||||
import { ChannelType, Routes } from "discord-api-types/v10";
|
import { ChannelType, Routes } from "discord-api-types/v10";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig, type OpenClawConfig } from "../config/config.js";
|
||||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
import { recordChannelActivity } from "../infra/channel-activity.js";
|
||||||
import { loadWebMedia } from "../web/media.js";
|
import { loadWebMedia } from "../web/media.js";
|
||||||
import { resolveDiscordAccount } from "./accounts.js";
|
import { resolveDiscordAccount } from "./accounts.js";
|
||||||
@@ -41,6 +41,7 @@ function extractComponentAttachmentNames(spec: DiscordComponentMessageSpec): str
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DiscordComponentSendOpts = {
|
type DiscordComponentSendOpts = {
|
||||||
|
cfg?: OpenClawConfig;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
token?: string;
|
token?: string;
|
||||||
rest?: RequestClient;
|
rest?: RequestClient;
|
||||||
@@ -58,10 +59,10 @@ export async function sendDiscordComponentMessage(
|
|||||||
spec: DiscordComponentMessageSpec,
|
spec: DiscordComponentMessageSpec,
|
||||||
opts: DiscordComponentSendOpts = {},
|
opts: DiscordComponentSendOpts = {},
|
||||||
): Promise<DiscordSendResult> {
|
): Promise<DiscordSendResult> {
|
||||||
const cfg = loadConfig();
|
const cfg = opts.cfg ?? loadConfig();
|
||||||
const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId });
|
const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId });
|
||||||
const { token, rest, request } = createDiscordClient(opts, cfg);
|
const { token, rest, request } = createDiscordClient(opts, cfg);
|
||||||
const recipient = await parseAndResolveRecipient(to, opts.accountId);
|
const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg);
|
||||||
const { channelId } = await resolveChannelId(rest, recipient, request);
|
const { channelId } = await resolveChannelId(rest, recipient, request);
|
||||||
|
|
||||||
const channelType = await resolveDiscordChannelType(rest, channelId);
|
const channelType = await resolveDiscordChannelType(rest, channelId);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
|||||||
import { serializePayload, type MessagePayloadObject, type RequestClient } from "@buape/carbon";
|
import { serializePayload, type MessagePayloadObject, type RequestClient } from "@buape/carbon";
|
||||||
import { ChannelType, Routes } from "discord-api-types/v10";
|
import { ChannelType, Routes } from "discord-api-types/v10";
|
||||||
import { resolveChunkMode } from "../auto-reply/chunk.js";
|
import { resolveChunkMode } from "../auto-reply/chunk.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig, type OpenClawConfig } from "../config/config.js";
|
||||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
import { recordChannelActivity } from "../infra/channel-activity.js";
|
||||||
import type { RetryConfig } from "../infra/retry.js";
|
import type { RetryConfig } from "../infra/retry.js";
|
||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
} from "./voice-message.js";
|
} from "./voice-message.js";
|
||||||
|
|
||||||
type DiscordSendOpts = {
|
type DiscordSendOpts = {
|
||||||
|
cfg?: OpenClawConfig;
|
||||||
token?: string;
|
token?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
@@ -121,9 +122,9 @@ async function resolveDiscordSendTarget(
|
|||||||
to: string,
|
to: string,
|
||||||
opts: DiscordSendOpts,
|
opts: DiscordSendOpts,
|
||||||
): Promise<{ rest: RequestClient; request: DiscordClientRequest; channelId: string }> {
|
): Promise<{ rest: RequestClient; request: DiscordClientRequest; channelId: string }> {
|
||||||
const cfg = loadConfig();
|
const cfg = opts.cfg ?? loadConfig();
|
||||||
const { rest, request } = createDiscordClient(opts, cfg);
|
const { rest, request } = createDiscordClient(opts, cfg);
|
||||||
const recipient = await parseAndResolveRecipient(to, opts.accountId);
|
const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg);
|
||||||
const { channelId } = await resolveChannelId(rest, recipient, request);
|
const { channelId } = await resolveChannelId(rest, recipient, request);
|
||||||
return { rest, request, channelId };
|
return { rest, request, channelId };
|
||||||
}
|
}
|
||||||
@@ -133,7 +134,7 @@ export async function sendMessageDiscord(
|
|||||||
text: string,
|
text: string,
|
||||||
opts: DiscordSendOpts = {},
|
opts: DiscordSendOpts = {},
|
||||||
): Promise<DiscordSendResult> {
|
): Promise<DiscordSendResult> {
|
||||||
const cfg = loadConfig();
|
const cfg = opts.cfg ?? loadConfig();
|
||||||
const accountInfo = resolveDiscordAccount({
|
const accountInfo = resolveDiscordAccount({
|
||||||
cfg,
|
cfg,
|
||||||
accountId: opts.accountId,
|
accountId: opts.accountId,
|
||||||
@@ -149,7 +150,7 @@ export async function sendMessageDiscord(
|
|||||||
accountId: accountInfo.accountId,
|
accountId: accountInfo.accountId,
|
||||||
});
|
});
|
||||||
const { token, rest, request } = createDiscordClient(opts, cfg);
|
const { token, rest, request } = createDiscordClient(opts, cfg);
|
||||||
const recipient = await parseAndResolveRecipient(to, opts.accountId);
|
const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg);
|
||||||
const { channelId } = await resolveChannelId(rest, recipient, request);
|
const { channelId } = await resolveChannelId(rest, recipient, request);
|
||||||
|
|
||||||
// Forum/Media channels reject POST /messages; auto-create a thread post instead.
|
// Forum/Media channels reject POST /messages; auto-create a thread post instead.
|
||||||
@@ -310,6 +311,7 @@ export async function sendMessageDiscord(
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DiscordWebhookSendOpts = {
|
type DiscordWebhookSendOpts = {
|
||||||
|
cfg?: OpenClawConfig;
|
||||||
webhookId: string;
|
webhookId: string;
|
||||||
webhookToken: string;
|
webhookToken: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
@@ -385,7 +387,7 @@ export async function sendWebhookMessageDiscord(
|
|||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const account = resolveDiscordAccount({
|
const account = resolveDiscordAccount({
|
||||||
cfg: loadConfig(),
|
cfg: opts.cfg ?? loadConfig(),
|
||||||
accountId: opts.accountId,
|
accountId: opts.accountId,
|
||||||
});
|
});
|
||||||
recordChannelActivity({
|
recordChannelActivity({
|
||||||
@@ -464,6 +466,7 @@ export async function sendPollDiscord(
|
|||||||
}
|
}
|
||||||
|
|
||||||
type VoiceMessageOpts = {
|
type VoiceMessageOpts = {
|
||||||
|
cfg?: OpenClawConfig;
|
||||||
token?: string;
|
token?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
@@ -509,7 +512,7 @@ export async function sendVoiceMessageDiscord(
|
|||||||
let channelId: string | undefined;
|
let channelId: string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cfg = loadConfig();
|
const cfg = opts.cfg ?? loadConfig();
|
||||||
const accountInfo = resolveDiscordAccount({
|
const accountInfo = resolveDiscordAccount({
|
||||||
cfg,
|
cfg,
|
||||||
accountId: opts.accountId,
|
accountId: opts.accountId,
|
||||||
@@ -518,7 +521,7 @@ export async function sendVoiceMessageDiscord(
|
|||||||
token = client.token;
|
token = client.token;
|
||||||
rest = client.rest;
|
rest = client.rest;
|
||||||
const request = client.request;
|
const request = client.request;
|
||||||
const recipient = await parseAndResolveRecipient(to, opts.accountId);
|
const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg);
|
||||||
channelId = (await resolveChannelId(rest, recipient, request)).channelId;
|
channelId = (await resolveChannelId(rest, recipient, request)).channelId;
|
||||||
|
|
||||||
// Convert to OGG/Opus if needed
|
// Convert to OGG/Opus if needed
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
createDiscordClient,
|
createDiscordClient,
|
||||||
formatReactionEmoji,
|
formatReactionEmoji,
|
||||||
normalizeReactionEmoji,
|
normalizeReactionEmoji,
|
||||||
resolveDiscordRest,
|
|
||||||
} from "./send.shared.js";
|
} from "./send.shared.js";
|
||||||
import type { DiscordReactionSummary, DiscordReactOpts } from "./send.types.js";
|
import type { DiscordReactionSummary, DiscordReactOpts } from "./send.types.js";
|
||||||
|
|
||||||
@@ -15,7 +14,7 @@ export async function reactMessageDiscord(
|
|||||||
emoji: string,
|
emoji: string,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const cfg = loadConfig();
|
const cfg = opts.cfg ?? loadConfig();
|
||||||
const { rest, request } = createDiscordClient(opts, cfg);
|
const { rest, request } = createDiscordClient(opts, cfg);
|
||||||
const encoded = normalizeReactionEmoji(emoji);
|
const encoded = normalizeReactionEmoji(emoji);
|
||||||
await request(
|
await request(
|
||||||
@@ -31,7 +30,8 @@ export async function removeReactionDiscord(
|
|||||||
emoji: string,
|
emoji: string,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const rest = resolveDiscordRest(opts);
|
const cfg = opts.cfg ?? loadConfig();
|
||||||
|
const { rest } = createDiscordClient(opts, cfg);
|
||||||
const encoded = normalizeReactionEmoji(emoji);
|
const encoded = normalizeReactionEmoji(emoji);
|
||||||
await rest.delete(Routes.channelMessageOwnReaction(channelId, messageId, encoded));
|
await rest.delete(Routes.channelMessageOwnReaction(channelId, messageId, encoded));
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@@ -42,7 +42,8 @@ export async function removeOwnReactionsDiscord(
|
|||||||
messageId: string,
|
messageId: string,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<{ ok: true; removed: string[] }> {
|
): Promise<{ ok: true; removed: string[] }> {
|
||||||
const rest = resolveDiscordRest(opts);
|
const cfg = opts.cfg ?? loadConfig();
|
||||||
|
const { rest } = createDiscordClient(opts, cfg);
|
||||||
const message = (await rest.get(Routes.channelMessage(channelId, messageId))) as {
|
const message = (await rest.get(Routes.channelMessage(channelId, messageId))) as {
|
||||||
reactions?: Array<{ emoji: { id?: string | null; name?: string | null } }>;
|
reactions?: Array<{ emoji: { id?: string | null; name?: string | null } }>;
|
||||||
};
|
};
|
||||||
@@ -73,7 +74,8 @@ export async function fetchReactionsDiscord(
|
|||||||
messageId: string,
|
messageId: string,
|
||||||
opts: DiscordReactOpts & { limit?: number } = {},
|
opts: DiscordReactOpts & { limit?: number } = {},
|
||||||
): Promise<DiscordReactionSummary[]> {
|
): Promise<DiscordReactionSummary[]> {
|
||||||
const rest = resolveDiscordRest(opts);
|
const cfg = opts.cfg ?? loadConfig();
|
||||||
|
const { rest } = createDiscordClient(opts, cfg);
|
||||||
const message = (await rest.get(Routes.channelMessage(channelId, messageId))) as {
|
const message = (await rest.get(Routes.channelMessage(channelId, messageId))) as {
|
||||||
reactions?: Array<{
|
reactions?: Array<{
|
||||||
count: number;
|
count: number;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { PollLayoutType } from "discord-api-types/payloads/v10";
|
|||||||
import type { RESTAPIPoll } from "discord-api-types/rest/v10";
|
import type { RESTAPIPoll } from "discord-api-types/rest/v10";
|
||||||
import { Routes, type APIChannel, type APIEmbed } from "discord-api-types/v10";
|
import { Routes, type APIChannel, type APIEmbed } from "discord-api-types/v10";
|
||||||
import type { ChunkMode } from "../auto-reply/chunk.js";
|
import type { ChunkMode } from "../auto-reply/chunk.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig, type OpenClawConfig } from "../config/config.js";
|
||||||
import type { RetryRunner } from "../infra/retry-policy.js";
|
import type { RetryRunner } from "../infra/retry-policy.js";
|
||||||
import { buildOutboundMediaLoadOptions } from "../media/load-options.js";
|
import { buildOutboundMediaLoadOptions } from "../media/load-options.js";
|
||||||
import { normalizePollDurationHours, normalizePollInput, type PollInput } from "../polls.js";
|
import { normalizePollDurationHours, normalizePollInput, type PollInput } from "../polls.js";
|
||||||
@@ -80,9 +80,10 @@ function parseRecipient(raw: string): DiscordRecipient {
|
|||||||
export async function parseAndResolveRecipient(
|
export async function parseAndResolveRecipient(
|
||||||
raw: string,
|
raw: string,
|
||||||
accountId?: string,
|
accountId?: string,
|
||||||
|
cfg?: OpenClawConfig,
|
||||||
): Promise<DiscordRecipient> {
|
): Promise<DiscordRecipient> {
|
||||||
const cfg = loadConfig();
|
const resolvedCfg = cfg ?? loadConfig();
|
||||||
const accountInfo = resolveDiscordAccount({ cfg, accountId });
|
const accountInfo = resolveDiscordAccount({ cfg: resolvedCfg, accountId });
|
||||||
|
|
||||||
// First try to resolve using directory lookup (handles usernames)
|
// First try to resolve using directory lookup (handles usernames)
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
@@ -93,7 +94,7 @@ export async function parseAndResolveRecipient(
|
|||||||
const resolved = await resolveDiscordTarget(
|
const resolved = await resolveDiscordTarget(
|
||||||
raw,
|
raw,
|
||||||
{
|
{
|
||||||
cfg,
|
cfg: resolvedCfg,
|
||||||
accountId: accountInfo.accountId,
|
accountId: accountInfo.accountId,
|
||||||
},
|
},
|
||||||
parseOptions,
|
parseOptions,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { RequestClient } from "@buape/carbon";
|
import type { RequestClient } from "@buape/carbon";
|
||||||
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import type { RetryConfig } from "../infra/retry.js";
|
import type { RetryConfig } from "../infra/retry.js";
|
||||||
|
|
||||||
export class DiscordSendError extends Error {
|
export class DiscordSendError extends Error {
|
||||||
@@ -28,6 +29,7 @@ export type DiscordSendResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type DiscordReactOpts = {
|
export type DiscordReactOpts = {
|
||||||
|
cfg?: OpenClawConfig;
|
||||||
token?: string;
|
token?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
rest?: RequestClient;
|
rest?: RequestClient;
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { sendWebhookMessageDiscord } from "./send.js";
|
import { sendWebhookMessageDiscord } from "./send.js";
|
||||||
|
|
||||||
const recordChannelActivityMock = vi.hoisted(() => vi.fn());
|
const recordChannelActivityMock = vi.hoisted(() => vi.fn());
|
||||||
|
const loadConfigMock = vi.hoisted(() => vi.fn(() => ({ channels: { discord: {} } })));
|
||||||
|
|
||||||
|
vi.mock("../config/config.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
loadConfig: () => loadConfigMock(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("../infra/channel-activity.js", async (importOriginal) => {
|
vi.mock("../infra/channel-activity.js", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../infra/channel-activity.js")>();
|
const actual = await importOriginal<typeof import("../infra/channel-activity.js")>();
|
||||||
@@ -14,6 +23,7 @@ vi.mock("../infra/channel-activity.js", async (importOriginal) => {
|
|||||||
describe("sendWebhookMessageDiscord activity", () => {
|
describe("sendWebhookMessageDiscord activity", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
recordChannelActivityMock.mockClear();
|
recordChannelActivityMock.mockClear();
|
||||||
|
loadConfigMock.mockClear();
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn(async () => {
|
vi.fn(async () => {
|
||||||
@@ -30,7 +40,15 @@ describe("sendWebhookMessageDiscord activity", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("records outbound channel activity for webhook sends", async () => {
|
it("records outbound channel activity for webhook sends", async () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
discord: {
|
||||||
|
token: "resolved-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
const result = await sendWebhookMessageDiscord("hello world", {
|
const result = await sendWebhookMessageDiscord("hello world", {
|
||||||
|
cfg,
|
||||||
webhookId: "wh-1",
|
webhookId: "wh-1",
|
||||||
webhookToken: "tok-1",
|
webhookToken: "tok-1",
|
||||||
accountId: "runtime",
|
accountId: "runtime",
|
||||||
@@ -46,5 +64,6 @@ describe("sendWebhookMessageDiscord activity", () => {
|
|||||||
accountId: "runtime",
|
accountId: "runtime",
|
||||||
direction: "outbound",
|
direction: "outbound",
|
||||||
});
|
});
|
||||||
|
expect(loadConfigMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
179
src/infra/outbound/cfg-threading.guard.test.ts
Normal file
179
src/infra/outbound/cfg-threading.guard.test.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const thisFilePath = fileURLToPath(import.meta.url);
|
||||||
|
const thisDir = path.dirname(thisFilePath);
|
||||||
|
const repoRoot = path.resolve(thisDir, "../../..");
|
||||||
|
const loadConfigPattern = /\b(?:loadConfig|config\.loadConfig)\s*\(/;
|
||||||
|
|
||||||
|
function toPosix(relativePath: string): string {
|
||||||
|
return relativePath.split(path.sep).join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRepoFile(relativePath: string): string {
|
||||||
|
const absolute = path.join(repoRoot, relativePath);
|
||||||
|
return readFileSync(absolute, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function listCoreOutboundEntryFiles(): string[] {
|
||||||
|
const outboundDir = path.join(repoRoot, "src/channels/plugins/outbound");
|
||||||
|
return readdirSync(outboundDir)
|
||||||
|
.filter((name) => name.endsWith(".ts") && !name.endsWith(".test.ts"))
|
||||||
|
.map((name) => toPosix(path.join("src/channels/plugins/outbound", name)))
|
||||||
|
.toSorted();
|
||||||
|
}
|
||||||
|
|
||||||
|
function listExtensionFiles(): {
|
||||||
|
adapterEntrypoints: string[];
|
||||||
|
inlineChannelEntrypoints: string[];
|
||||||
|
} {
|
||||||
|
const extensionsRoot = path.join(repoRoot, "extensions");
|
||||||
|
const adapterEntrypoints: string[] = [];
|
||||||
|
const inlineChannelEntrypoints: string[] = [];
|
||||||
|
|
||||||
|
for (const entry of readdirSync(extensionsRoot, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const srcDir = path.join(extensionsRoot, entry.name, "src");
|
||||||
|
const outboundPath = path.join(srcDir, "outbound.ts");
|
||||||
|
if (existsSync(outboundPath)) {
|
||||||
|
adapterEntrypoints.push(toPosix(path.join("extensions", entry.name, "src/outbound.ts")));
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelPath = path.join(srcDir, "channel.ts");
|
||||||
|
if (!existsSync(channelPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const source = readFileSync(channelPath, "utf8");
|
||||||
|
if (source.includes("outbound:")) {
|
||||||
|
inlineChannelEntrypoints.push(toPosix(path.join("extensions", entry.name, "src/channel.ts")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
adapterEntrypoints: adapterEntrypoints.toSorted(),
|
||||||
|
inlineChannelEntrypoints: inlineChannelEntrypoints.toSorted(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOutboundBlock(source: string, file: string): string {
|
||||||
|
const outboundKeyIndex = source.indexOf("outbound:");
|
||||||
|
expect(outboundKeyIndex, `${file} should define outbound:`).toBeGreaterThanOrEqual(0);
|
||||||
|
const braceStart = source.indexOf("{", outboundKeyIndex);
|
||||||
|
expect(braceStart, `${file} should define outbound object`).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
let depth = 0;
|
||||||
|
let state: "code" | "single" | "double" | "template" | "lineComment" | "blockComment" = "code";
|
||||||
|
for (let i = braceStart; i < source.length; i += 1) {
|
||||||
|
const current = source[i];
|
||||||
|
const next = source[i + 1];
|
||||||
|
|
||||||
|
if (state === "lineComment") {
|
||||||
|
if (current === "\n") {
|
||||||
|
state = "code";
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (state === "blockComment") {
|
||||||
|
if (current === "*" && next === "/") {
|
||||||
|
state = "code";
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (state === "single") {
|
||||||
|
if (current === "\\" && next) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current === "'") {
|
||||||
|
state = "code";
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (state === "double") {
|
||||||
|
if (current === "\\" && next) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current === '"') {
|
||||||
|
state = "code";
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (state === "template") {
|
||||||
|
if (current === "\\" && next) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current === "`") {
|
||||||
|
state = "code";
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current === "/" && next === "/") {
|
||||||
|
state = "lineComment";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current === "/" && next === "*") {
|
||||||
|
state = "blockComment";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current === "'") {
|
||||||
|
state = "single";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current === '"') {
|
||||||
|
state = "double";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current === "`") {
|
||||||
|
state = "template";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current === "{") {
|
||||||
|
depth += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current === "}") {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) {
|
||||||
|
return source.slice(braceStart, i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unable to parse outbound block in ${file}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("outbound cfg-threading guard", () => {
|
||||||
|
it("keeps outbound adapter entrypoints free of loadConfig calls", () => {
|
||||||
|
const coreAdapterFiles = listCoreOutboundEntryFiles();
|
||||||
|
const extensionAdapterFiles = listExtensionFiles().adapterEntrypoints;
|
||||||
|
const adapterFiles = [...coreAdapterFiles, ...extensionAdapterFiles];
|
||||||
|
|
||||||
|
for (const file of adapterFiles) {
|
||||||
|
const source = readRepoFile(file);
|
||||||
|
expect(source, `${file} must not call loadConfig in outbound entrypoint`).not.toMatch(
|
||||||
|
loadConfigPattern,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps inline channel outbound blocks free of loadConfig calls", () => {
|
||||||
|
const inlineFiles = listExtensionFiles().inlineChannelEntrypoints;
|
||||||
|
for (const file of inlineFiles) {
|
||||||
|
const source = readRepoFile(file);
|
||||||
|
const outboundBlock = extractOutboundBlock(source, file);
|
||||||
|
expect(outboundBlock, `${file} outbound block must not call loadConfig`).not.toMatch(
|
||||||
|
loadConfigPattern,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -53,7 +53,13 @@ const TELEGRAM_TEXT_LIMIT = 4096;
|
|||||||
type SendMatrixMessage = (
|
type SendMatrixMessage = (
|
||||||
to: string,
|
to: string,
|
||||||
text: string,
|
text: string,
|
||||||
opts?: { mediaUrl?: string; replyToId?: string; threadId?: string; timeoutMs?: number },
|
opts?: {
|
||||||
|
cfg?: OpenClawConfig;
|
||||||
|
mediaUrl?: string;
|
||||||
|
replyToId?: string;
|
||||||
|
threadId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
},
|
||||||
) => Promise<{ messageId: string; roomId: string }>;
|
) => Promise<{ messageId: string; roomId: string }>;
|
||||||
|
|
||||||
export type OutboundSendDeps = {
|
export type OutboundSendDeps = {
|
||||||
@@ -600,6 +606,7 @@ async function deliverOutboundPayloadsCore(
|
|||||||
return {
|
return {
|
||||||
channel: "signal" as const,
|
channel: "signal" as const,
|
||||||
...(await sendSignal(to, text, {
|
...(await sendSignal(to, text, {
|
||||||
|
cfg,
|
||||||
maxBytes: signalMaxBytes,
|
maxBytes: signalMaxBytes,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
textMode: "plain",
|
textMode: "plain",
|
||||||
@@ -636,6 +643,7 @@ async function deliverOutboundPayloadsCore(
|
|||||||
return {
|
return {
|
||||||
channel: "signal" as const,
|
channel: "signal" as const,
|
||||||
...(await sendSignal(to, formatted.text, {
|
...(await sendSignal(to, formatted.text, {
|
||||||
|
cfg,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
maxBytes: signalMaxBytes,
|
maxBytes: signalMaxBytes,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
|
|||||||
@@ -27,6 +27,76 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("sendMessage channel normalization", () => {
|
describe("sendMessage channel normalization", () => {
|
||||||
|
it("threads resolved cfg through alias + target normalization in outbound dispatch", async () => {
|
||||||
|
const resolvedCfg = {
|
||||||
|
__resolvedCfgMarker: "cfg-from-secret-resolution",
|
||||||
|
channels: {},
|
||||||
|
} as Record<string, unknown>;
|
||||||
|
const seen: {
|
||||||
|
resolveCfg?: unknown;
|
||||||
|
sendCfg?: unknown;
|
||||||
|
to?: string;
|
||||||
|
} = {};
|
||||||
|
const imessageAliasPlugin: ChannelPlugin = {
|
||||||
|
id: "imessage",
|
||||||
|
meta: {
|
||||||
|
id: "imessage",
|
||||||
|
label: "iMessage",
|
||||||
|
selectionLabel: "iMessage",
|
||||||
|
docsPath: "/channels/imessage",
|
||||||
|
blurb: "iMessage test stub.",
|
||||||
|
aliases: ["imsg"],
|
||||||
|
},
|
||||||
|
capabilities: { chatTypes: ["direct"] },
|
||||||
|
config: {
|
||||||
|
listAccountIds: () => [],
|
||||||
|
resolveAccount: () => ({}),
|
||||||
|
},
|
||||||
|
outbound: {
|
||||||
|
deliveryMode: "direct",
|
||||||
|
resolveTarget: ({ to, cfg }) => {
|
||||||
|
seen.resolveCfg = cfg;
|
||||||
|
const normalized = String(to ?? "")
|
||||||
|
.trim()
|
||||||
|
.replace(/^imessage:/i, "");
|
||||||
|
return { ok: true, to: normalized };
|
||||||
|
},
|
||||||
|
sendText: async ({ cfg, to }) => {
|
||||||
|
seen.sendCfg = cfg;
|
||||||
|
seen.to = to;
|
||||||
|
return { channel: "imessage", messageId: "i-resolved" };
|
||||||
|
},
|
||||||
|
sendMedia: async ({ cfg, to }) => {
|
||||||
|
seen.sendCfg = cfg;
|
||||||
|
seen.to = to;
|
||||||
|
return { channel: "imessage", messageId: "i-resolved-media" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
setRegistry(
|
||||||
|
createTestRegistry([
|
||||||
|
{
|
||||||
|
pluginId: "imessage",
|
||||||
|
source: "test",
|
||||||
|
plugin: imessageAliasPlugin,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await sendMessage({
|
||||||
|
cfg: resolvedCfg,
|
||||||
|
to: " imessage:+15551234567 ",
|
||||||
|
content: "hi",
|
||||||
|
channel: "imsg",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.channel).toBe("imessage");
|
||||||
|
expect(seen.resolveCfg).toBe(resolvedCfg);
|
||||||
|
expect(seen.sendCfg).toBe(resolvedCfg);
|
||||||
|
expect(seen.to).toBe("+15551234567");
|
||||||
|
});
|
||||||
|
|
||||||
it("normalizes Teams alias", async () => {
|
it("normalizes Teams alias", async () => {
|
||||||
const sendMSTeams = vi.fn(async () => ({
|
const sendMSTeams = vi.fn(async () => ({
|
||||||
messageId: "m1",
|
messageId: "m1",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { messagingApi } from "@line/bot-sdk";
|
import { messagingApi } from "@line/bot-sdk";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||||
import { logVerbose } from "../globals.js";
|
import { logVerbose } from "../globals.js";
|
||||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
import { recordChannelActivity } from "../infra/channel-activity.js";
|
||||||
import { resolveLineAccount } from "./accounts.js";
|
import { resolveLineAccount } from "./accounts.js";
|
||||||
@@ -25,6 +26,7 @@ const userProfileCache = new Map<
|
|||||||
const PROFILE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
const PROFILE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
interface LineSendOpts {
|
interface LineSendOpts {
|
||||||
|
cfg?: OpenClawConfig;
|
||||||
channelAccessToken?: string;
|
channelAccessToken?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
@@ -32,8 +34,8 @@ interface LineSendOpts {
|
|||||||
replyToken?: string;
|
replyToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type LineClientOpts = Pick<LineSendOpts, "channelAccessToken" | "accountId">;
|
type LineClientOpts = Pick<LineSendOpts, "cfg" | "channelAccessToken" | "accountId">;
|
||||||
type LinePushOpts = Pick<LineSendOpts, "channelAccessToken" | "accountId" | "verbose">;
|
type LinePushOpts = Pick<LineSendOpts, "cfg" | "channelAccessToken" | "accountId" | "verbose">;
|
||||||
|
|
||||||
interface LinePushBehavior {
|
interface LinePushBehavior {
|
||||||
errorContext?: string;
|
errorContext?: string;
|
||||||
@@ -68,7 +70,7 @@ function createLineMessagingClient(opts: LineClientOpts): {
|
|||||||
account: ReturnType<typeof resolveLineAccount>;
|
account: ReturnType<typeof resolveLineAccount>;
|
||||||
client: messagingApi.MessagingApiClient;
|
client: messagingApi.MessagingApiClient;
|
||||||
} {
|
} {
|
||||||
const cfg = loadConfig();
|
const cfg = opts.cfg ?? loadConfig();
|
||||||
const account = resolveLineAccount({
|
const account = resolveLineAccount({
|
||||||
cfg,
|
cfg,
|
||||||
accountId: opts.accountId,
|
accountId: opts.accountId,
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import { resolveSignalAccount } from "./accounts.js";
|
import { resolveSignalAccount } from "./accounts.js";
|
||||||
import { signalRpcRequest } from "./client.js";
|
import { signalRpcRequest } from "./client.js";
|
||||||
import { resolveSignalRpcContext } from "./rpc-context.js";
|
import { resolveSignalRpcContext } from "./rpc-context.js";
|
||||||
|
|
||||||
export type SignalReactionOpts = {
|
export type SignalReactionOpts = {
|
||||||
|
cfg?: OpenClawConfig;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
account?: string;
|
account?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
@@ -75,8 +77,9 @@ async function sendReactionSignalCore(params: {
|
|||||||
opts: SignalReactionOpts;
|
opts: SignalReactionOpts;
|
||||||
errors: SignalReactionErrorMessages;
|
errors: SignalReactionErrorMessages;
|
||||||
}): Promise<SignalReactionResult> {
|
}): Promise<SignalReactionResult> {
|
||||||
|
const cfg = params.opts.cfg ?? loadConfig();
|
||||||
const accountInfo = resolveSignalAccount({
|
const accountInfo = resolveSignalAccount({
|
||||||
cfg: loadConfig(),
|
cfg,
|
||||||
accountId: params.opts.accountId,
|
accountId: params.opts.accountId,
|
||||||
});
|
});
|
||||||
const { baseUrl, account } = resolveSignalRpcContext(params.opts, accountInfo);
|
const { baseUrl, account } = resolveSignalRpcContext(params.opts, accountInfo);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig, type OpenClawConfig } from "../config/config.js";
|
||||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||||
import { kindFromMime } from "../media/mime.js";
|
import { kindFromMime } from "../media/mime.js";
|
||||||
import { resolveOutboundAttachmentFromUrl } from "../media/outbound-attachment.js";
|
import { resolveOutboundAttachmentFromUrl } from "../media/outbound-attachment.js";
|
||||||
@@ -8,6 +8,7 @@ import { markdownToSignalText, type SignalTextStyleRange } from "./format.js";
|
|||||||
import { resolveSignalRpcContext } from "./rpc-context.js";
|
import { resolveSignalRpcContext } from "./rpc-context.js";
|
||||||
|
|
||||||
export type SignalSendOpts = {
|
export type SignalSendOpts = {
|
||||||
|
cfg?: OpenClawConfig;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
account?: string;
|
account?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
@@ -100,7 +101,7 @@ export async function sendMessageSignal(
|
|||||||
text: string,
|
text: string,
|
||||||
opts: SignalSendOpts = {},
|
opts: SignalSendOpts = {},
|
||||||
): Promise<SignalSendResult> {
|
): Promise<SignalSendResult> {
|
||||||
const cfg = loadConfig();
|
const cfg = opts.cfg ?? loadConfig();
|
||||||
const accountInfo = resolveSignalAccount({
|
const accountInfo = resolveSignalAccount({
|
||||||
cfg,
|
cfg,
|
||||||
accountId: opts.accountId,
|
accountId: opts.accountId,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
resolveTextChunkLimit,
|
resolveTextChunkLimit,
|
||||||
} from "../auto-reply/chunk.js";
|
} from "../auto-reply/chunk.js";
|
||||||
import { isSilentReplyText } from "../auto-reply/tokens.js";
|
import { isSilentReplyText } from "../auto-reply/tokens.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig, type OpenClawConfig } from "../config/config.js";
|
||||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||||
import { logVerbose } from "../globals.js";
|
import { logVerbose } from "../globals.js";
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +45,7 @@ export type SlackSendIdentity = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type SlackSendOpts = {
|
type SlackSendOpts = {
|
||||||
|
cfg?: OpenClawConfig;
|
||||||
token?: string;
|
token?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
@@ -262,7 +263,7 @@ export async function sendMessageSlack(
|
|||||||
if (!trimmedMessage && !opts.mediaUrl && !blocks) {
|
if (!trimmedMessage && !opts.mediaUrl && !blocks) {
|
||||||
throw new Error("Slack send requires text, blocks, or media");
|
throw new Error("Slack send requires text, blocks, or media");
|
||||||
}
|
}
|
||||||
const cfg = loadConfig();
|
const cfg = opts.cfg ?? loadConfig();
|
||||||
const account = resolveSlackAccount({
|
const account = resolveSlackAccount({
|
||||||
cfg,
|
cfg,
|
||||||
accountId: opts.accountId,
|
accountId: opts.accountId,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ type TelegramApi = Bot["api"];
|
|||||||
type TelegramApiOverride = Partial<TelegramApi>;
|
type TelegramApiOverride = Partial<TelegramApi>;
|
||||||
|
|
||||||
type TelegramSendOpts = {
|
type TelegramSendOpts = {
|
||||||
|
cfg?: ReturnType<typeof loadConfig>;
|
||||||
token?: string;
|
token?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
@@ -1038,6 +1039,7 @@ export async function sendStickerTelegram(
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TelegramPollOpts = {
|
type TelegramPollOpts = {
|
||||||
|
cfg?: ReturnType<typeof loadConfig>;
|
||||||
token?: string;
|
token?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig, type OpenClawConfig } from "../config/config.js";
|
||||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||||
import { generateSecureUuid } from "../infra/secure-random.js";
|
import { generateSecureUuid } from "../infra/secure-random.js";
|
||||||
import { getChildLogger } from "../logging/logger.js";
|
import { getChildLogger } from "../logging/logger.js";
|
||||||
@@ -18,6 +18,7 @@ export async function sendMessageWhatsApp(
|
|||||||
body: string,
|
body: string,
|
||||||
options: {
|
options: {
|
||||||
verbose: boolean;
|
verbose: boolean;
|
||||||
|
cfg?: OpenClawConfig;
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
mediaLocalRoots?: readonly string[];
|
mediaLocalRoots?: readonly string[];
|
||||||
gifPlayback?: boolean;
|
gifPlayback?: boolean;
|
||||||
@@ -30,7 +31,7 @@ export async function sendMessageWhatsApp(
|
|||||||
const { listener: active, accountId: resolvedAccountId } = requireActiveWebListener(
|
const { listener: active, accountId: resolvedAccountId } = requireActiveWebListener(
|
||||||
options.accountId,
|
options.accountId,
|
||||||
);
|
);
|
||||||
const cfg = loadConfig();
|
const cfg = options.cfg ?? loadConfig();
|
||||||
const tableMode = resolveMarkdownTableMode({
|
const tableMode = resolveMarkdownTableMode({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "whatsapp",
|
channel: "whatsapp",
|
||||||
@@ -150,7 +151,7 @@ export async function sendReactionWhatsApp(
|
|||||||
export async function sendPollWhatsApp(
|
export async function sendPollWhatsApp(
|
||||||
to: string,
|
to: string,
|
||||||
poll: PollInput,
|
poll: PollInput,
|
||||||
options: { verbose: boolean; accountId?: string },
|
options: { verbose: boolean; accountId?: string; cfg?: OpenClawConfig },
|
||||||
): Promise<{ messageId: string; toJid: string }> {
|
): Promise<{ messageId: string; toJid: string }> {
|
||||||
const correlationId = generateSecureUuid();
|
const correlationId = generateSecureUuid();
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
|||||||
Reference in New Issue
Block a user